Simplify character menus and add resident live customisation

This commit is contained in:
chazmm committed 2026-09-08 17:08:34 +01:00
1 parent a2ead83471
commit 6e686edb77
19 files changed
+2688 -544

No files matched your search

+32 -6
View File
@@ -32,9 +32,12 @@ class Glb:
path.write_bytes(struct.pack('<III',0x46546c67,2,28+len(text)+len(self.data))+
struct.pack('<I4s',len(text),b'JSON')+text+struct.pack('<I4s',len(self.data),b'BIN\0')+self.data)
def convert(models,private,recipe,*,stock=None,output=None,materials=None):
def convert(models,private,recipe,*,stock=None,output=None,materials=None,live_morphs=False,geometry_only=False,reference_models=None):
source=AnimSource((stock or private/'stock')/'data/anim/OnBoard.abin')
skeleton=SkeletonSet(str(models))
if reference_models:
reference_skeleton=SkeletonSet(str(reference_models))
skeleton.bind={**reference_skeleton.bind,**skeleton.bind}
if skeleton.errors:raise ValueError(str(skeleton.errors))
mapping=skeleton.index_map(ABIN.BONE_NAMES,source.parents)
worlds={mapping[n]:m for n,m in skeleton.bind.items() if n in mapping}
@@ -76,8 +79,15 @@ def convert(models,private,recipe,*,stock=None,output=None,materials=None):
raw=raw[0];positions=np.asarray(mesh['pos'],dtype=np.float64)
morphs=decode_dense_morphs(path,parsed,len(positions),RX2)
if [m['name'] for m in morphs]!=recipe['morph_assembly']['expected_targets'][slot]:raise ValueError('Unexpected morph set '+slot)
normals=np.asarray(raw['normals'],dtype=np.float64)
for morph in morphs:
if 'normals' in raw:
normals=np.asarray(raw['normals'],dtype=np.float64)
else:
normals=np.zeros_like(positions)
for triangle in mesh['tris']:
a,b,c=triangle;normal=np.cross(positions[b]-positions[a],positions[c]-positions[a])
for index in triangle:normals[index]+=normal
normals/=np.maximum(np.linalg.norm(normals,axis=1,keepdims=True),1e-20)
for morph in ([] if live_morphs else morphs):
weight=morph_weight(morph['name'],recipe)
positions+=np.asarray(morph['deltas'])*weight
if weight:
@@ -93,28 +103,44 @@ def convert(models,private,recipe,*,stock=None,output=None,materials=None):
weights/=weights.sum(axis=1,keepdims=True)
folder=materials or private/'default_skater/textures/materials'
pbr={'baseColorFactor':[*component['tint'],1.0],
'baseColorTexture':glb.texture(folder/(slot+'_base_color.png')),
'metallicFactor':0.65 if slot=='SkateTruck' else 0.0,
'roughnessFactor':0.48 if slot in {'SkateTruck','SkateWheel'} else 0.72}
if not geometry_only:pbr['baseColorTexture']=glb.texture(folder/(slot+'_base_color.png'))
rough=folder/(slot+'_roughness.png')
if rough.is_file():
if not geometry_only and rough.is_file():
channel=Image.open(rough).convert('RGB').getchannel('R')
white=Image.new('L',channel.size,255);packed=Image.merge('RGB',(white,channel,white));buffer=io.BytesIO();packed.save(buffer,format='PNG')
pbr['metallicRoughnessTexture']=glb.texture(buffer.getvalue());pbr['roughnessFactor']=1.0
material={'name':'Retail_'+slot,'pbrMetallicRoughness':pbr,'doubleSided':False,
'alphaMode':component.get('alpha_mode','OPAQUE')}
if material['alphaMode']=='MASK':material['alphaCutoff']=component.get('alpha_cutoff',0.5)
if component['textures'].get('normal'):material['normalTexture']=glb.texture(folder/(slot+'_normal.png'))
if not geometry_only and component['textures'].get('normal'):material['normalTexture']=glb.texture(folder/(slot+'_normal.png'))
glb.doc['materials'].append(material)
attributes={'POSITION':glb.accessor(positions,'VEC3',bounds=True),'NORMAL':glb.accessor(normals,'VEC3'),
'TEXCOORD_0':glb.accessor(raw['uvs'],'VEC2'),'JOINTS_0':glb.accessor(joints,'VEC4',5123),
'WEIGHTS_0':glb.accessor(weights,'VEC4')}
if live_morphs and 'uvs2' in raw:
attributes['TEXCOORD_1']=glb.accessor(raw['uvs2'],'VEC2')
primitives.append({'attributes':attributes,'indices':glb.accessor(np.asarray(mesh['tris']).ravel(),'SCALAR',5125),
'material':len(glb.doc['materials'])-1})
if live_morphs:
names_morph=recipe['morph_assembly'].get('live_targets',['fat','thin']+recipe['morph_assembly']['face_targets'])
zero=glb.accessor(np.zeros_like(positions),'VEC3',bounds=True)
targets={}
for morph in morphs:
name=morph['name']
key='fat' if name.startswith('fat') else 'thin' if name.startswith('thin') else name
if key not in names_morph:raise ValueError('Unmapped live morph '+name)
targets[key]={'POSITION':glb.accessor(morph['deltas'],'VEC3',bounds=True),
'NORMAL':glb.accessor(morph['normal_deltas'],'VEC3')}
primitives[-1]['targets']=[targets.get(n,{'POSITION':zero,'NORMAL':zero}) for n in names_morph]
mesh_node=len(nodes);nodes.append({'name':'Skate3_SkaterAndBoard','mesh':0,'skin':0})
root=len(nodes);nodes.append({'name':'Skate3_RX2_Rig','children':root_children+[mesh_node]})
glb.doc.update(nodes=nodes,meshes=[{'primitives':primitives}],skins=[{'joints':list(range(len(indices))),
'inverseBindMatrices':inverse}],scenes=[{'nodes':[root]}],scene=0)
if live_morphs:
glb.doc['meshes'][0].update(weights=[.25 if n in recipe['morph_assembly']['face_targets'] else 0. for n in names_morph],
extras={'targetNames':names_morph})
glb.save(output or private/'skater.glb')
if __name__=='__main__':
@@ -0,0 +1,155 @@
"""Prepare resident CAC geometry and shared textures outside the game loop."""
from pathlib import Path
import copy,json,shutil,sys,re,struct
import numpy as np
from PIL import Image,ImageChops
from tools.asset_pipeline.customisation_worker import ROOT,default_profile,flag
from tools.asset_pipeline.customisation_catalog import write_private
from tools.owned_game.big import BigArchive
def friendly(name):
text=name.lower().replace('_',' ')
replacements=[('shortsleeve','short sleeve'),('longsleeve','long sleeve'),('tshirt','tee'),
('t-shirt','tee'),('buttonup','button-up'),('zipup','zip-up'),('flippedup','flipped up'),
('kneedown',''),('thighdown',''),('medhem',''),('lowhem',''),('highhem',''),
('sleeves pushed up','rolled sleeves'),('sleeves up','rolled sleeves'),('sleevup','rolled sleeves'),
('colourizable',''),('colorizable',''),('stampable',''),('logoable',''),('material',''),
('lambert1','original'),('newera','new era'),('45right','angled right'),('45left','angled left'),
('long sleeve tee','long-sleeve tee'),('short sleeve tee','tee'),
('short sleeve button-up shirt','short-sleeve shirt'),('long sleeve button-up shirt','long-sleeve shirt'),
('shirt ls button-up','long-sleeve shirt'),('top button only','top button'),
('long sleeve mens shirt vneck','long-sleeve v-neck'),('jeans straight leg baggy waistline','baggy straight jeans'),
('jeans straight leg baggy waistline','baggy straight jeans'),('sunglasses','shades'),('necklaces',''),('tattoo',''),('biglizard','big lizard'),
('blueflame','blue flame'),('radialsun','sunburst'),('tribalbody','tribal'),('pinstripe','pinstripe ')]
for a,b in replacements:text=text.replace(a,b)
text=re.sub(r'\b(male\d*|female\d*|unisex|mesh|mat|hmat|h|l|high|low|ropa|hem|inner)\b','',text)
text=re.sub(r'\b(?:0x)?[a-f0-9]{8,}\b','',text)
text=re.sub(r'([a-z])([0-9]+)\b',r'\1 \2',text)
text=re.sub(r'\s+',' ',text).strip(' -')
text=text.replace('jeans straight leg baggy waistline','baggy straight jeans').replace('long-sleeve tee rolled sleeves','rolled-sleeve tee')
return text.title() or 'Original'
def variant_label(slot, model_name, native_name):
label=friendly(native_name)
worn=bool(re.search(r'(?:_| )d(?:_| |$)|dirty',native_name.lower()))
label=re.sub(r'\b(Diffuse|Tga|D|Malemat|Skateboard|Featured Artist|Instance)\b','',label)
label=re.sub(r'\b0\b','',label)
if slot=='Feet': return 'Worn' if worn else 'New'
model_words=set(friendly(model_name).lower().split())
words=[w for w in label.split() if w.lower() not in model_words]
label=' '.join(words).strip()
for a,b in [('Ozzie Wright','Ozzie'),('Grass Roots Pullover Hoodie','Grass Roots'),
('Full Back New Era',''),('Hoodie Hooddown Pullover',''),('Hood Up',''),
('Hooddown',''),('Closed',''),('Open',''),('In4Mation Colab Zip','In4Mation'),
('Nightmare Catcher','Nightmare'),('Gonz Animal Bullfish','Bullfish')]: label=label.replace(a,b)
label=re.sub(r'\s+',' ',label).strip()
if not label: label='Original'
return label+(' (Worn)' if worn else '')
def prepare(config):
from tools.extract_default_skater import import_rx2_parser,decode_texture,parse_fallback_recipe
from tools.asset_pipeline.retail_character import RX2,decode_dense_morphs
from tools.asset_pipeline.character_glb import convert
assets=Path(config['assets']);cache=assets/'private/customisation';out=cache/'library';out.mkdir(exist_ok=True)
catalog=json.loads((cache/'catalog.json').read_text());archive=BigArchive(Path(config['game_root'])/'data/content/createacharacter.big')
entries={e.path.lower():e for e in archive.entries}
def extract(path):
dest=cache/'source'/path
if not dest.exists():write_private(dest,archive.read(entries[path.lower()]))
return dest
parser=import_rx2_parser(ROOT/'tools/vendor/utt');decoded=cache/'decoded';decoded.mkdir(exist_ok=True)
def texture(tid,kind='raw'):
src=decoded/(tid+'.png')
if not src.exists():decode_texture(parser,extract('data/content/createacharacter/texture/0x'+tid+'.rx2'),src)
if kind=='raw':return src.relative_to(assets).as_posix()
dest=out/'textures'/(tid+'_'+kind+'.png');dest.parent.mkdir(exist_ok=True)
if not dest.exists():
image=Image.open(src).convert('RGBA');a=np.array(image,dtype=np.float64)
if kind=='normal':
x=a[:,:,3]/127.5-1.;y=a[:,:,1]/127.5-1.;scale=np.maximum(np.sqrt(x*x+y*y),1.);x/=scale;y/=scale
z=np.sqrt(np.maximum(0.,1.-x*x-y*y));image=Image.fromarray(np.rint((np.stack((x,y,z),axis=2)*.5+.5)*255.).astype('uint8'))
elif kind=='rough':
grey=ImageChops.invert(image.convert('L'));white=Image.new('L',image.size,255);image=Image.merge('RGB',(white,grey,white))
image.save(dest)
return dest.relative_to(assets).as_posix()
model_data={};mat_data={};errors=[]
recipe_base=json.loads((ROOT/'tools/default_skater_retail_manifest.json').read_text())
recipe_base['morph_assembly']['live_targets']=['fat','thin']+recipe_base['morph_assembly']['face_targets']+['global_ethnicity_african','global_ethnicity_asian','global_ethnicity_caucasian']
reference=out/'reference'
for component in recipe_base['components']:
slot=component['slot'];model=next(m for p in catalog['components'] if p['slot']==slot for m in p['models'] if m['id']==component['asset_id'])
src=extract(next(l for l in model['lods'] if l['index']==0)['path']);dest=reference/slot/src.name
dest.parent.mkdir(parents=True,exist_ok=True)
if not dest.exists():shutil.copyfile(src,dest)
defaults={x['material_id']:x for x in recipe_base['components']}
for part in catalog['components']:
for model in part['models']:
if part['slot']=='Misc':continue # Non-geometric stamp catalogue, indexed below.
slot=part['slot'];lod=next(l for l in model['lods'] if l['index']==0);key=model['id'];path=out/(key+'_v4.glb')
try:
if not path.exists():
source=extract(lod['path']);folder=out/'work'/key/'models'/slot;folder.mkdir(parents=True,exist_ok=True)
mesh_file=folder/source.name
if not mesh_file.exists():shutil.copyfile(source,mesh_file)
parsed=RX2.parse_rx2(str(mesh_file));mesh=next(m for m in parsed['meshes'] if m.get('positions') and m.get('indices'))
morphs=decode_dense_morphs(mesh_file,parsed,len(mesh['positions']),RX2)
recipe=copy.deepcopy(recipe_base);recipe['components']=[dict(slot=slot,textures={},tint=[1.,1.,1.])]
recipe['morph_assembly']['expected_targets']={slot:[m['name'] for m in morphs]}
convert(folder.parent,assets/'private',recipe,output=path,geometry_only=True,live_morphs=True,reference_models=reference)
groups=[[v['id'] for v in group] for group in lod['material_instances']]
model_data[key]=dict(slot=slot,name=friendly(model['name']),flags={k[4:]:v for k,v in model['flags'].items() if v},
materials=groups[0],groups=groups,scene=path.relative_to(assets).as_posix())
for v in lod['material_instances'][0]:
mid=v['id']
if mid in mat_data:continue
mat=catalog['materials'][mid];tex={t['channel']:t['id'] for t in mat['textures']}
if 'diffuse' not in tex:continue
diffuse=texture(tex['diffuse'])
if 'alpha' in tex:
alpha_path=assets/texture(tex['alpha']);combined=out/'textures'/(tex['diffuse']+'_'+tex['alpha']+'.png')
if not combined.exists():
im=Image.open(assets/diffuse).convert('RGBA');alpha=Image.open(alpha_path).convert('RGBA')
alpha=alpha.resize(im.size,Image.Resampling.LANCZOS);mask=alpha.getchannel('A')
if mask.getextrema()==(255,255):mask=alpha.convert('L')
im.putalpha(ImageChops.multiply(im.getchannel('A'),mask));im.save(combined)
diffuse=combined.relative_to(assets).as_posix()
mat_data[mid]=dict(name=variant_label(slot,model['name'],v['name']),flags={k[4:]:val for k,val in mat['flags'].items() if val},
diffuse=diffuse,normal=texture(tex['normal'],'normal') if 'normal' in tex else None,
rough=texture(tex['specular'],'rough') if 'specular' in tex else None,
alpha='alpha' in tex,tint=defaults.get(mid,{}).get('tint',
[0.72,0.57,0.49] if mat['flags'].get('cas.SkinTone')=='light' else
[0.33,0.26,0.23] if mat['flags'].get('cas.SkinTone')=='dark' else
[0.02,0.01,0.01] if slot=='Hair' else [1.,1.,1.]),
metallic=.65 if slot=='SkateTruck' else 0.,roughness=.48 if slot in {'SkateTruck','SkateWheel'} else .72)
except Exception as e:errors.append(dict(model=key,slot=slot,error=str(e)))
print('Prepared',part['slot'],len(model_data),'models',len(mat_data),'materials',flush=True)
tattoos={}
misc=next(p for p in catalog['components'] if p['slot']=='Misc')
for variant in misc['models'][0]['lods'][0]['material_instances'][0]:
mid=variant['id'];mat=catalog['materials'][mid]
if not mat['flags'].get('cas.TattooCategory'):continue
tex={t['channel']:t['id'] for t in mat['textures']}
tattoos[mid]=dict(name=friendly(variant['name']),texture=texture(tex['decal']),
bounds=[float(v) for v in mat['flags']['cas.StampBorderConstraint'].split(',')])
male=default_profile();male['gender']='male'
female=copy.deepcopy(male);female['gender']='female';female['selections']={}
raw=parse_fallback_recipe(assets/'private/stock/data/cacrecipes/SavedRecipeFallbackFemale.bin',16)
for slot,items in raw['asset_lists'].items():
if items:
item=items[0];female['selections'][slot]={'asset_id':item['asset_id'],'material_id':item['models'][0]['material_id']}
colours=[]
native=json.loads((cache/'native.json').read_text())
for row in native['collections']:
if row['class']=='cac_colour' and row['key'].endswith('_clothing') and row['key']!='default_clothing':
colours.append(dict(name=row['key'][:-9].replace('_',' ').title(),
rgb=struct.unpack('>4f',bytes.fromhex(row['fields']['Hash_7827ED970A88B70C']['data']))[:3]))
colours.sort(key=lambda c:c['name'])
library=dict(version=3,colours=colours,tattoos=tattoos,models=model_data,materials=mat_data,defaults={'male':male,'female':female},
morphs=recipe_base['morph_assembly']['live_targets'],errors=errors)
(cache/'library.json').write_text(json.dumps(library,separators=(',',':')))
print('LIBRARY_READY',len(model_data),len(mat_data),'errors',errors,flush=True)
return library
if __name__=='__main__':prepare(json.loads(Path(sys.argv[1]).read_text()))
@@ -0,0 +1,58 @@
"""Map owned native CAC profiles into named, directly applicable menu choices."""
import json,struct,re
from pathlib import Path
from tools.asset_pipeline.customisation_native import key_hash
from tools.asset_pipeline.customisation_worker import ROOT
def generate(native):
def page(name,children):return dict(label=name,children=children)
def choice(name,patch,**extra):return dict(label=name,patch=patch,**extra)
targets={key_hash(m['key']):m for m in native['morphs']}
presets=[]
for row in sorted(native['collections'],key=lambda r:r['key']):
if row['class']!='cac_presets' or not re.fullmatch(r'(male|female)_\d+',row['key']):continue
values={}
for field in row['fields'].values():
if field['type']!='Sk8::CAC::MorphPreset':continue
for item in field['array']['items']:
raw=bytes.fromhex(item);target=targets[struct.unpack_from('>Q',raw,8)[0]]
normalized=struct.unpack_from('>f',raw,24)[0]
if not 0<=normalized<=1:raise ValueError('Preset is outside normalized range')
bounds=target['branch_a']
# Inverse of CASMorph normalization8253CD18, preserving target
# hashes rather than assuming VLT array order matches UI order.
values[target['target']]=bounds['min']+normalized*(bounds['max']-bounds['min'])
if len(values)!=19:raise ValueError('Incomplete native face preset')
gender,number=row['key'].split('_')
presets.append(choice('Face '+str(int(number)),{'morphs':values},gender=gender))
source=(ROOT/'crates/skate-game/src/graph_host/motion_character_gesture.rs').read_text()
names=re.findall(r'"B_GSTR_([A-Z_]+)"',source)
if len(names)!=37:raise ValueError('Native gesture table changed')
labels=['Air guitar','Airplane','Boxing','Bruce Lee','Check the time','Devil horns','Finger guns','Dunno',
'Finger wag','Fists','Flex','Flip the table','The Fonz','Freedom','Raised fist','Get away','Get outta here',
'Handcuffs','High fist pump','Low fist pump','Wind up','Peace','Point','Raise the roof','Shaka','Shrug',
'Point to the sky','Snap','Soul arch','Spock','Surf\'s up','Swing high','Swing low','Throw your arms',
'Thumbs down','Wings','Yard sale']
gestures=page('Gestures',[page(direction,[choice(label,{'gestures':{str(i):j}}) for j,label in enumerate(labels)])
for i,direction in enumerate(['Up','Down','Left','Right'])])
skin_labels={'skin_light_3':'Porcelain','skin_light_2':'Fair','skin_light_4':'Warm',
'skin_light_6':'Tan','skin_dark_1':'Brown','skin_dark_3':'Deep brown','skin_dark_2':'Dark'}
skin=[];hair=[]
for row in native['collections']:
if row['class']!='cac_colour':continue
fields=row['fields'];key=row['key']
if key in skin_labels:
rgb=struct.unpack('>4f',bytes.fromhex(fields['Hash_7827ED970A88B70C']['data']))[:3]
skin.append(choice(skin_labels[key],{'skin':fields['Hash_AB0E65FA2F485ED8']['data'],'skin_tint':rgb}))
elif key.endswith('_hair') and not key.startswith('default') and not key.endswith('_facial_hair'):
rgb=struct.unpack('>4f',bytes.fromhex(fields['Hash_97462E6ABC78A423']['data']))[:3]
hair.append(choice(key[:-5].replace('_',' ').title(),{'hair_tint':rgb}))
skin.sort(key=lambda e: -sum(e['patch']['skin_tint']))
hair.sort(key=lambda e:e['label'])
return [page('Skin tone',skin),page('Hair colour',hair),page('Face presets',presets),page('Style',[gestures,
page('Stance',[choice('Regular',{'stance':0}),choice('Goofy',{'stance':1})]),
page('Skating style',[choice(label,{'style':i}) for i,label in enumerate(['Standard','Loose','Gonzo','Aggressive'])])])]
if __name__=='__main__':
import sys
root=Path(sys.argv[1]);(root/'extra-menu.json').write_text(json.dumps(generate(json.loads((root/'native.json').read_text())),indent=2))
@@ -0,0 +1,78 @@
"""Offline CAC preparation checks; no window, game process, or renderer."""
import json
import os
import struct
import unittest
from pathlib import Path
import numpy as np
from tools.asset_pipeline.customisation_library import friendly, variant_label
from tools.asset_pipeline.retail_character import RX2
class LibraryTests(unittest.TestCase):
def test_secondary_uv_is_float2_not_four_normalized_shorts(self):
data = struct.pack('>4f', .125, .75, .875, .625)
description = {'stride': 8, 'elements': [{'usage_name': 'TEXCOORD',
'usage_index': 1, 'format_name': RX2.VTX_FORMATS[0x002C23A5][0], 'offset': 0}]}
self.assertEqual(RX2._decode_vertices(data, 0, 2, description)['uvs2'],
[(.125, .75), (.875, .625)])
def test_menu_names_remove_exporter_words_and_explain_wear(self):
self.assertEqual(friendly('male_longsleeve_tshirt_stampable_sleeves_up'), 'Rolled-Sleeve Tee')
self.assertEqual(variant_label('Feet', 'Busenitz Pro Shoes', 'busenitz_pro_material_d'), 'Worn')
self.assertEqual(variant_label('Feet', 'Busenitz Pro Shoes', 'busenitz_pro_material'), 'New')
self.assertEqual(friendly('tattoo_biglizard_mat'), 'Big Lizard')
@unittest.skipUnless(os.environ.get('SKATE_CAC_TEST_LIBRARY'), 'owned library is opt-in')
def test_owned_geometry_and_dependencies(self):
path = Path(os.environ['SKATE_CAC_TEST_LIBRARY'])
library = json.loads(path.read_text(encoding='utf-8'))
assets = path.parents[2]
self.assertEqual(library['errors'], [])
self.assertEqual(len(library['models']), 480)
self.assertEqual(len(library['tattoos']), 170)
for part in library['models'].values():
data = (assets / part['scene']).read_bytes()
magic, version, size, text_size = struct.unpack_from('<4I', data)
self.assertEqual((magic, version, size), (0x46546C67, 2, len(data)))
doc = json.loads(data[20:20+text_size])
blob = data[28+text_size:]
def array(index):
accessor = doc['accessors'][index]
view = doc['bufferViews'][accessor['bufferView']]
width = {'SCALAR':1, 'VEC2':2, 'VEC3':3, 'VEC4':4, 'MAT4':16}[accessor['type']]
result = np.frombuffer(blob, dtype={5126:'<f4', 5123:'<u2', 5125:'<u4'}[accessor['componentType']],
count=accessor['count']*width, offset=view.get('byteOffset',0)+accessor.get('byteOffset',0))
self.assertTrue(np.isfinite(result).all())
return result.reshape(-1,width)
for mesh in doc['meshes']:
self.assertEqual(mesh['extras']['targetNames'], library['morphs'])
self.assertEqual(mesh['weights'], [0.,0.]+[.25]*17+[0.]*3)
for primitive in mesh['primitives']:
a = primitive['attributes']; count = len(array(a['POSITION']))
self.assertLess(int(array(primitive['indices']).max()), count)
self.assertLess(int(array(a['JOINTS_0']).max()), len(doc['skins'][0]['joints']))
np.testing.assert_allclose(array(a['WEIGHTS_0']).sum(1), 1., atol=1e-6)
self.assertEqual(len(primitive['targets']),22)
for target in primitive['targets']:
self.assertEqual(len(array(target['POSITION'])),count)
self.assertEqual(len(array(target['NORMAL'])),count)
if (part['slot']=='Arm' and part['flags'].get('ArmModelType')=='shoulder') or (part['slot']=='Leg' and part['flags'].get('LegModelType') in ['thigh','thighdown'] and part['flags'].get('RequiresSockStyle','none') in ['','none']):
uv = array(a['TEXCOORD_1'])
for side in ['Q3','Q4']:
top,bottom,left,right = map(float,part['flags']['StampUVConstraint'+side].split(','))
inside=(uv[:,0]>=left)&(uv[:,0]<=right)&(uv[:,1]>=1-top)&(uv[:,1]<=1-bottom)
self.assertTrue(inside.any(),part['name']+' '+side)
for material in library['materials'].values():
for channel in ['diffuse','normal','rough']:
if material[channel]: self.assertTrue((assets/material[channel]).is_file())
for tattoo in library['tattoos'].values():
self.assertTrue((assets/tattoo['texture']).is_file())
if __name__ == '__main__':
unittest.main()
+14 -9
View File
@@ -1,21 +1,26 @@
@echo off
setlocal
cd /d "%~dp0"
if not exist "%~dp0skate3rust-customiser.exe" (
echo The dedicated customiser executable is missing.
if not exist "%~dp0skate3rust-customiser-v2.exe" (
echo The V2 customiser executable is missing.
pause
exit /b 1
)
echo Skate 3 Character Customiser Test
echo Open Escape / Start, then choose Character customiser.
echo Save: F5 / controller X. Back: Escape / controller B.
echo First-time clothing changes can take a few seconds.
echo Diagnostic output is written to customiser-session.log.
"%~dp0skate3rust-customiser.exe" --assets "%~dp0assets" --map "%~dp0maps\SkateSchool.skate" > "%~dp0customiser-session.log" 2>&1
if not exist "%~dp0assets\private\customisation\library.json" (
echo The prepared character library is missing.
pause
exit /b 1
)
echo Skate 3 Character Customiser V2
echo Open Escape / Start, then Character customiser.
echo Browse with arrows / D-pad. Change with left-right or the minus-plus buttons.
echo Type to search an item list. Escape / B goes back. Done saves and resumes.
echo Diagnostic output: customiser-v2-session.log
"%~dp0skate3rust-customiser-v2.exe" --assets "%~dp0assets" --map "%~dp0maps\SkateSchool.skate" > "%~dp0customiser-v2-session.log" 2>&1
set "skate_customiser_exit=%errorlevel%"
if not "%skate_customiser_exit%"=="0" (
echo The test exited with code %skate_customiser_exit%.
echo See %~dp0customiser-session.log
echo See %~dp0customiser-v2-session.log
pause
)
exit /b %skate_customiser_exit%
+5
View File
@@ -12,3 +12,8 @@ OffBoard clips to the same armature and action bank.
They decode animation and skeleton files supplied locally by the user. No
retail payload is included.
Local CAC correction: vertex format `0x002C23A5` now decodes as two big-endian
float32 values. Its Xenos surface-format field is 37 (32_32_FLOAT); the previous
SHORT4N interpretation corrupted secondary tattoo UVs. Source declarations,
owned-mesh UV bounds and synthetic decoding tests corroborate the change.
+4 -1
View File
@@ -398,7 +398,7 @@ VTX_FORMATS: Dict[int, Tuple[str, int]] = {
0x001A215A: ("SHORT4", 8), # raw int16[4] -- position
0x001A2286: ("UBYTE4", 4), # raw uint8[4] -- blend indices / weights
0x002C2159: ("SHORT2N", 4), # int16[2] / 32768 -- texcoord
0x002C23A5: ("SHORT4N", 8), # int16[4] / 32768 -- texcoord pair
0x002C23A5: ("FLOAT2", 8), # Xenos format37: two big-endian float32 UVs
0x002A2190: ("PACKED11_11_10N", 4), # signed 11/11/10 -- tangent, binormal
}
@@ -514,6 +514,9 @@ def _decode_vertices(d: bytes, vb: int, count: int, vdesc: dict) -> dict:
for v in range(count):
u, w = struct.unpack_from(">2h", d, vb + v * stride + off)
vals.append((u / 32768.0, w / 32768.0))
elif fmt == "FLOAT2":
for v in range(count):
vals.append(struct.unpack_from(">2f", d, vb + v * stride + off))
elif fmt == "SHORT4N":
for v in range(count):
q = struct.unpack_from(">4h", d, vb + v * stride + off)