Files
skate-3-rust-engine/tools/asset_pipeline/customisation_library.py
T
Hails 8327e94090 Setup performance: skip duplicate stream copies, parallel customiser
- Skip decoding identical streamed asset copies (DownTown streams 107.8 -> 24.8 s)
- Run the character customiser beside the map stage (memory-gated)
- Build.ps1 builds the native RefPack DLL for dev checkouts
- Clothing library texture warm-up and pro roster run in worker processes
  (library 126.5 -> 51.2 s, roster 89.6 -> 15.9 s); shared caches are
  published first-writer-wins to avoid Windows rename races
- pipeline-equivalence.json: combined pairs (committed -> PR F+G)

Outputs proven identical (8,288 customiser files; all maps/spawns).
One-time customiser rebuild. See docs/hails-additions/08-setup-performance.md (PR G).
2026-10-01 00:13:55 -06:00

259 lines
17 KiB
Python

"""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
from tools.asset_pipeline.optional_content import CONTENT_ERRORS
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 prune_unavailable(models, materials, errors):
"""Never expose a model selection that points at an absent material."""
for key, model in list(models.items()):
model['groups'] = [[mid for mid in group if mid in materials] for group in model['groups']]
if not model['groups'] or any(not group for group in model['groups']):
del models[key]
errors.append(dict(model=key, error='No available material for a mesh group'))
else:
model['materials'] = model['groups'][0]
TEXTURE_PATH='data/content/createacharacter/texture/0x{}.rx2'
def requested_textures(catalog):
"""Texture ids prepare() decodes into decoded/, with its skips: no Misc
models, LOD 0, each material once, 'diffuse' required. Error paths can make
prepare() request fewer; prepare() removes warmed files it did not use."""
seen=set();tids=[]
for part in catalog['components']:
if part['slot']=='Misc':continue
for model in part['models']:
lod=next((l for l in model['lods'] if l['index']==0),None)
if lod is None:continue
for v in (v for group in lod['material_instances'] for v in group):
mat=catalog['materials'].get(v['id'])
if v['id'] in seen or mat is None:continue
tex={t['channel']:t['id'] for t in mat['textures']}
if 'diffuse' not in tex:continue
seen.add(v['id'])
tids+=[tex[c] for c in ('diffuse','alpha','normal','specular') if c in tex]
misc=next((p for p in catalog['components'] if p['slot']=='Misc'),None)
for m in (misc or {}).get('models',[]):
for v in (v for lod in m.get('lods',[]) for group in lod.get('material_instances',[]) for v in group):
mat=catalog['materials'].get(v['id'])
if mat and mat['flags'].get('cas.TattooCategory'):
tex={t['channel']:t['id'] for t in mat['textures']}
if 'decal' in tex:tids.append(tex['decal'])
return list(dict.fromkeys(tids))
def warm_textures(config,tids):
"""Worker body (customisation_workers.py): decode textures into decoded/
exactly as prepare()'s texture() does. Failures are left for prepare()."""
from tools.extract_default_skater import import_rx2_parser,decode_texture
cache=Path(config['directory']);archive=BigArchive(Path(config['game_root'])/'data/content/createacharacter.big')
entries={e.path.lower():e for e in archive.entries}
parser=import_rx2_parser(ROOT/'tools/vendor/utt');decoded=cache/'decoded';decoded.mkdir(exist_ok=True)
for tid in tids:
src=decoded/(tid+'.png')
if src.exists():continue
path=TEXTURE_PATH.format(tid);dest=cache/'source'/path
temporary=decoded/(tid+'.warm.png')
try:
if not dest.exists():write_private(dest,archive.read(entries[path.lower()]))
decode_texture(parser,dest,temporary);temporary.replace(src)
except CONTENT_ERRORS:
temporary.unlink(missing_ok=True)
def warm_up(config,workers):
"""Decode the requested textures in parallel processes.
Returns (warmed ids, ids whose source file the warm-up created) so that
prepare() can remove exactly what a serial run would not have produced."""
cache=Path(config['directory'])
tids=[t for t in requested_textures(json.loads((cache/'catalog.json').read_text())) if not (cache/'decoded'/(t+'.png')).exists()]
if workers<2 or len(tids)<2:return set(),set()
fresh={t for t in tids if not (cache/'source'/TEXTURE_PATH.format(t)).exists()}
import tempfile
from tools.asset_pipeline.customisation_workers import run_parallel
with tempfile.TemporaryDirectory(prefix='customiser-warm-') as tmp:
requests=[]
for index in range(workers):
request=Path(tmp)/f'{index}.json'
request.write_text(json.dumps(dict(config=config,textures=tids[index::workers])))
requests.append(['--warm-textures',request])
run_parallel(requests,workers) # a failed worker only leaves work for prepare()
return set(tids),fresh
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,AnimSource,SkeletonSet
from tools.asset_pipeline.character_glb import convert
assets=Path(config['assets']);cache=Path(config.get('directory',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)
warmed,fresh_sources=warm_up(config,config.get('workers',1));used=set()
def texture(tid,kind='raw'):
used.add(tid)
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']}
# Read-only shared rig inputs live only for this preparation invocation.
# Per-item meshes and morphs are discarded after conversion, bounding RAM.
animation=AnimSource(assets/'private/stock/data/anim/OnBoard.abin')
reference_skeleton=SkeletonSet(str(reference))
for part in catalog['components']:
for model in part['models']:
if part['slot']=='Misc':continue # Non-geometric stamp catalogue, indexed below.
slot=part['slot'];key=model['id'];path=out/(key+'_v4.glb')
try:
lod=next(l for l in model['lods'] if l['index']==0)
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]}
temporary=path.with_suffix('.tmp')
convert(folder.parent,assets/'private',recipe,output=temporary,geometry_only=True,live_morphs=True,reference_models=reference,
source=animation,reference_skeleton=reference_skeleton,
parsed_models={str(mesh_file):parsed},decoded_morphs={str(mesh_file):morphs})
temporary.replace(path)
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 (v for group in lod['material_instances'] for v in group):
try:
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 and slot!='Hair':
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,
opacity=texture(tex['alpha']) if slot=='Hair' and 'alpha' 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 CONTENT_ERRORS as error:
errors.append(dict(model=key,material=v.get('id'),slot=slot,error=str(error)))
except CONTENT_ERRORS 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'),None)
variants=(v for m in (misc or {}).get('models',[]) for lod in m.get('lods',[])
for group in lod.get('material_instances',[]) for v in group)
for variant in variants:
try:
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(',')])
except CONTENT_ERRORS as error:
errors.append(dict(tattoo=variant.get('id'),error=str(error)))
# Warm-up must not add receipted files a serial run would not have made.
for tid in warmed-used:
(decoded/(tid+'.png')).unlink(missing_ok=True)
if tid in fresh_sources:(cache/'source'/TEXTURE_PATH.format(tid)).unlink(missing_ok=True)
prune_unavailable(model_data,mat_data,errors)
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/config.get('library_index','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()))