mirror of
https://github.com/SK8-ENGINE/skate-3-rust-engine.git
synced 2026-10-26 19:47:44 +00:00
Simplify character menus and add resident live customisation
This commit is contained in:
1 parent
a2ead83471
commit
6e686edb77
19 files changed
+2688
-544
No files matched your search
@@ -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()
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user