diff --git a/crates/skate-game/src/retail_backdrop.rs b/crates/skate-game/src/retail_backdrop.rs index 9f597770..5a3c976e 100644 --- a/crates/skate-game/src/retail_backdrop.rs +++ b/crates/skate-game/src/retail_backdrop.rs @@ -1,4 +1,4 @@ -//! Global authored foliage that lives outside the district stream. +//! Authored presentation supplements outside the static district geometry. use bevy::prelude::*; use skate_data::skate_map::SkateMap; use std::path::Path; @@ -12,7 +12,22 @@ pub(crate) fn spawn_backdrop( retail_materials: &mut Assets, images: &mut Assets, ) { - let path = asset_root.join("private/native-backdrops").join(format!("{name}.skate")); + for folder in ["native-backdrops", "native-props"] { + spawn_package(name, asset_root, folder, commands, meshes, materials, retail_materials, images); + } +} + +fn spawn_package( + name: &str, + asset_root: &Path, + folder: &str, + commands: &mut Commands, + meshes: &mut Assets, + materials: &mut Assets, + retail_materials: &mut Assets, + images: &mut Assets, +) { + let path = asset_root.join("private").join(folder).join(format!("{name}.skate")); if !path.is_file() { return; } @@ -31,6 +46,6 @@ pub(crate) fn spawn_backdrop( error!("SKATE_BACKDROP: invalid render-only package {}", path.display()); return; } - info!("SKATE_BACKDROP: {name} authored foliage triangles={}", map.geometry.indices.len() / 3); + info!("SKATE_BACKDROP: {name} {folder} triangles={}", map.geometry.indices.len() / 3); crate::skate_world::spawn(&map, commands, meshes, materials, retail_materials, images, &super::MaterialTuning::load(asset_root)); } diff --git a/crates/skate-game/src/retail_character.rs b/crates/skate-game/src/retail_character.rs index 49be936c..4af39118 100644 --- a/crates/skate-game/src/retail_character.rs +++ b/crates/skate-game/src/retail_character.rs @@ -26,7 +26,6 @@ struct MaterialData { shader: String, params: Vec<[f32; 4]>, specular: Option, - coverage: Option, } #[derive(Deserialize)] struct LightingData { @@ -62,9 +61,6 @@ struct CharacterMaterial { #[texture(5)] #[sampler(6)] mask: Option>, - #[texture(7)] - #[sampler(8)] - coverage: Option>, alpha: AlphaMode, } impl Material for CharacterMaterial { @@ -224,8 +220,8 @@ fn bind( options: Vec4::new( f32::from(m.normal_map_texture.is_some()), f32::from(data.specular.is_some()), - if data.coverage.is_some() { -1. } else { alpha_cutoff }, - if data.coverage.is_some() { 2. } else { f32::from(data.shader == "character.hair") }, + alpha_cutoff, + f32::from(data.shader == "character.hair"), ), rows: std::array::from_fn(|i| Vec4::from_array(data.params[i])), sh: lighting @@ -243,9 +239,7 @@ fn bind( }, ) }), - coverage: data.coverage.as_ref().map(|path| server.load_with_settings( - path.clone(), |settings: &mut bevy::image::ImageLoaderSettings| { settings.is_srgb = false; })), - alpha: if data.coverage.is_some() { AlphaMode::Blend } else { m.alpha_mode }, + alpha: m.alpha_mode, }); commands .entity(entity) diff --git a/crates/skate-game/src/retail_character.wgsl b/crates/skate-game/src/retail_character.wgsl index a4a3fdd7..f6de4d00 100644 --- a/crates/skate-game/src/retail_character.wgsl +++ b/crates/skate-game/src/retail_character.wgsl @@ -12,8 +12,6 @@ struct CharacterParams { @group(#{MATERIAL_BIND_GROUP}) @binding(5) var mask_map: texture_2d; @group(#{MATERIAL_BIND_GROUP}) @binding(6) var mask_sampler: sampler; -@group(#{MATERIAL_BIND_GROUP}) @binding(7) var coverage: texture_2d; -@group(#{MATERIAL_BIND_GROUP}) @binding(8) var coverage_sampler: sampler; @fragment fn fragment(i: VertexOutput) -> @location(0) vec4 { let albedo = textureSample(diffuse,diffuse_sampler,i.uv); @@ -31,12 +29,7 @@ fn fragment(i: VertexOutput) -> @location(0) vec4 { let hl=p.rows[1].rgb*(saturate(ndl*0.75+0.25)+p.rows[5].w*0.25) +p.rows[6].rgb*fres*saturate(ndl*1.75+0.25); if albedo.a1.5 { strand_alpha=saturate(textureSample(coverage,coverage_sampler,i.uv_b).r*p.rows[6].x); } -#endif - return vec4(d*hl*p.light.w,strand_alpha*p.tint.a); + return vec4(d*hl*p.light.w,albedo.a*p.tint.a); } let dp1=dpdx(rpos); let dp2=dpdy(rpos); let du1=dpdx(i.uv); let du2=dpdy(i.uv); diff --git a/crates/skate-game/src/retail_character_depth.wgsl b/crates/skate-game/src/retail_character_depth.wgsl index 634abb97..b1ea6e8e 100644 --- a/crates/skate-game/src/retail_character_depth.wgsl +++ b/crates/skate-game/src/retail_character_depth.wgsl @@ -9,8 +9,6 @@ struct CharacterParams { light: vec4, tint: vec4, options: vec4, @group(#{MATERIAL_BIND_GROUP}) @binding(0) var p: CharacterParams; @group(#{MATERIAL_BIND_GROUP}) @binding(1) var diffuse: texture_2d; @group(#{MATERIAL_BIND_GROUP}) @binding(2) var diffuse_sampler: sampler; -@group(#{MATERIAL_BIND_GROUP}) @binding(7) var coverage: texture_2d; -@group(#{MATERIAL_BIND_GROUP}) @binding(8) var coverage_sampler: sampler; @fragment fn fragment(i: VertexOutput) #ifdef PREPASS_FRAGMENT @@ -18,10 +16,6 @@ fn fragment(i: VertexOutput) #endif { if textureSample(diffuse,diffuse_sampler,i.uv).a < p.options.z { discard; } -#ifdef VERTEX_UVS_B - // Retain strand holes in depth-only passes. Blended colour uses continuous coverage. - if p.options.w>1.5 && textureSample(coverage,coverage_sampler,i.uv_b).r*p.rows[6].x < 30.0/255.0 { discard; } -#endif #ifdef PREPASS_FRAGMENT var out: FragmentOutput; #ifdef NORMAL_PREPASS diff --git a/crates/skate-game/src/retail_world.wgsl b/crates/skate-game/src/retail_world.wgsl index c6aaee1b..8ea8bdeb 100644 --- a/crates/skate-game/src/retail_world.wgsl +++ b/crates/skate-game/src/retail_world.wgsl @@ -25,6 +25,12 @@ struct WorldParams { struct FrameState { shadow: vec4, clock: vec4, pca: array, 7> } @group(#{MATERIAL_BIND_GROUP}) @binding(16) var frame_state: FrameState; +// Textures and base UVs both have V flipped by the exporter. Scale in the +// authored coordinate system, then return to the flipped texture rows. +fn scaled_uv(uv: vec2, scale: f32) -> vec2 { + return vec2(uv.x*scale, 1.0-(1.0-uv.y)*scale); +} + @fragment fn fragment(i: VertexOutput) -> @location(0) vec4 { let fam = u32(p.mode.x); @@ -41,13 +47,13 @@ fn fragment(i: VertexOutput) -> @location(0) vec4 { var masks = vec3(0.0); if (flags & 1u) != 0u && (fam <= 6u || fam == 13u) { nm = textureSample(normal_map,normal_sampler,i.uv).rgb; } if (flags & 2u) != 0u && fam != 2u { - detail = textureSample(detail_map,detail_sampler,i.uv*p.surface.z).rg; + detail = textureSample(detail_map,detail_sampler,scaled_uv(i.uv,p.surface.z)).rg; } if fam == 5u || fam == 6u || fam == 13u { // The reference folds the reflective material's constant detail texel. if (flags & 128u) != 0u { detail = textureLoad(detail_map,vec2(0),0).rg; } } - if (flags & 4u) != 0u { overlay_sample = textureSample(macro_map,macro_sampler,i.uv*p.surface.x).rgb; } + if (flags & 4u) != 0u { overlay_sample = textureSample(macro_map,macro_sampler,scaled_uv(i.uv,p.surface.x)).rgb; } if (flags & 8u) != 0u && (fam == 3u || fam == 4u) { art = textureSample(decal_map,decal_sampler,i.color.xy); } if (flags & 16u) != 0u { masks = textureSample(specular_map,specular_sampler,i.uv).rgb; } var wn = normalize(i.world_normal); diff --git a/docs/dynamic-props-and-grime.md b/docs/dynamic-props-and-grime.md new file mode 100644 index 00000000..155518b5 --- /dev/null +++ b/docs/dynamic-props-and-grime.md @@ -0,0 +1,80 @@ +# Authored props and grime follow-up + +## Hair rollback + +Removed the hair-specific changes from 96adff5 at the user's request: coverage +texture bindings/blending, depth coverage, exported secondary UVs, FLOAT2 vendor +decoder correction and its test. The private character package was regenerated +with the previous exporter. Character probe smoothing remains. The other +worktree's hair implementation was not touched or imported. + +## Grime coordinates + +The map writer flips both texture rows and base UV V. The world shader formerly +sampled macro/detail maps at `flippedUV * scale`. For a scale of 0.3 this gives +`0.3 - 0.3*v`, but the matching texture-row coordinate is `1 - 0.3*v`: a 0.7 +texture-period displacement. MegaPark's authored macro scale is approximately +0.3. This shifts stains onto different parts of the ramps and over decal paint. + +Macro and detail UVs now scale in the original coordinate system, then flip V. +The authored opacity and native-reference overlay equation are unchanged: +`saturate((macro.rgb - 0.5) * opacity + 0.5)`, multiplied over the linear +diffuse/decal composite. Reference: scene.hlsl in native renderer commit +f6e0ae87fdfecbadb5c1e36c55d66a744187a3cd. No arbitrary global grime reduction +was introduced. Perceived strength still requires an in-game comparison after +the coordinate correction; adaptive exposure also changes overall brightness. + +## Dynamic-object initial placement + +Observed source: district Sim RX2 `0xEB001D` records have a 32-byte header, +128-byte records, row-vector affine matrix at +0, world bounds at +64, +instance/locator IDs at +96/+104, template ID at +112 and name offset at +124. +Native TU3 `0x825876D0` (community symbol SimManager::SpawnStatic) loads the +translation at +48 and template ID at +112 before the DMO model lookup. +Mapped image SHA256: +F4AA113EB541BFBA03DBC108CF5AB43F58C965B20FA3B82F9C40938A0AD841C4. + +`worlddmo.big` supplies reusable templates. Their EB000D 160-byte tInstance +records bind template IDs to EB0001 model sections. Model mesh tables reference +EB0023 sections, which reference the vertex declarations. The exporter follows +those indices, never mesh order or material display names. Material texture +GUIDs come from the binary channel bindings: the DMO texture namespace's high +bit is absent from display-name suffixes, so suffix matching loses textures. + +The exporter composes model, template and locator matrices in row-vector order; +normals use the inverse transpose. Reflections reverse triangle winding. It +neither snaps objects to terrain nor changes their authored matrices. Structured +JSON alongside each presentation package retains IDs, matrices, source hashes, +offsets and unresolved locators for a future dynamic runtime. + +Offline restored placements: + +| Map | Placed | Unresolved | +| --- | ---: | ---: | +| University | 479 | 0 | +| DownTown | 786 | 14 | +| Industrial | 162 | 18 | +| BlackBoxPark | 12 | 0 | +| MaloofMoneyCup | 31 | 0 | +| Remaining five maps | 0 | 0 | + +All University placements resolve. 478 of 479 transformed render bounds agree +with their authored locator bounds within 5 cm (most within millimetres). One +open dumpster differs by about 14.5 cm; its authored matrix is retained, since +render bounds and assembly/animated bounds need not coincide. + +The 32 unresolved Downtown/Industrial locators reference six template IDs absent +from the supplied DMO catalog. The named assets are also absent from parkassets +by those IDs, and district presentation template tables do not define them. +They remain explicit unresolved records; no substitute geometry is invented. + +Runtime loads private/native-props presentation supplements alongside the +existing backdrop packages. This restores visible initial placement only: +objects are not yet pushable, droppable or collidable. Their current materials +use the existing PBR fallback, not the native dynamicobject lighting shader. +No static district collision, gameplay or animation logic was changed. + +Validation: synthetic record, rotation/scale/normal and invalid-reference tests; +original-data template resolution and placement-bound comparison; offline map +readers and shader composition. Game/recomp was not launched. GPU execution, +appearance, performance and gameplay interaction are not validated here. diff --git a/docs/visual-parity-status.md b/docs/visual-parity-status.md index 21758273..662efe39 100644 --- a/docs/visual-parity-status.md +++ b/docs/visual-parity-status.md @@ -1,4 +1,8 @@ -# Visual parity status — 2026-09-08 +# Visual parity status – 2026-09-08 + +Follow-up: the hair changes below were rolled back at the user's request. +See [dynamic-props-and-grime.md](dynamic-props-and-grime.md) for the current +hair rollback, grime coordinate correction and authored prop placement status. Implemented and compiled offline; game/recomp not launched. User-run checks are still needed for the previously reported crash, shadow silhouette and performance. diff --git a/tools/asset_pipeline/character_glb.py b/tools/asset_pipeline/character_glb.py index c7072fba..f6c58937 100644 --- a/tools/asset_pipeline/character_glb.py +++ b/tools/asset_pipeline/character_glb.py @@ -16,9 +16,6 @@ class Glb: return len(self.doc['bufferViews'])-1 def accessor(self,values,kind,component=5126,bounds=False): a=np.asarray(values,dtype={5126:'4f', bytes.fromhex(row)) for row in rows]) - coverage = components[slot.name]['textures'].get('alpha') - if shader == 'character.hair' and coverage: - result['materials']['Retail_'+slot.name]['coverage'] = ( - 'private/default_skater/textures/decoded/'+coverage+'.png') specular = components[slot.name]['textures'].get('specular') if specular: # The native skin shader consumes R, not luminance or roughness. diff --git a/tools/asset_pipeline/dynamic_props.py b/tools/asset_pipeline/dynamic_props.py new file mode 100644 index 00000000..f919dd71 --- /dev/null +++ b/tools/asset_pipeline/dynamic_props.py @@ -0,0 +1,221 @@ +"""Export authored district DMO locators and their referenced model geometry. + +This preserves initial placement, not DMO simulation. Native 0x825876D0 reads +the locator matrix and template ID at +112; RX2 EB001D records are 128 bytes. +No name matching, random placement, ground snapping or collision synthesis. +""" +import argparse +import copy +import hashlib +import json +import struct +import sys +import tempfile +from pathlib import Path + +import numpy as np +from .map_writer import write +from tools.owned_game.big import BigArchive + + +def sections(raw): + if raw[:7] != b'\x89RW4xb2': + raise ValueError('Expected Xbox RX2') + count = struct.unpack_from('>I', raw, 32)[0] + table = struct.unpack_from('>I', raw, 48)[0] + if table + count*24 > len(raw): + raise ValueError('Truncated RX2 section table') + return [struct.unpack_from('>6I', raw, table+i*24) for i in range(count)] + + +def matrix(raw, at): + value = np.array(struct.unpack_from('>16f', raw, at)).reshape(4, 4) + if not np.isfinite(value).all() or not np.allclose(value[:, 3], [0, 0, 0, 1], atol=1e-5): + raise ValueError('Invalid DMO affine matrix') + if abs(np.linalg.det(value[:3, :3])) < 1e-8: + raise ValueError('Singular DMO matrix') + return value + + +def records(raw, kind, stride): + for offset, _, size, _, _, type_id in sections(raw): + if type_id != kind: + continue + if offset+size > len(raw) or size < 32: + raise ValueError('Invalid DMO section extent') + count = struct.unpack_from('>I', raw, offset+4)[0] + start, strings = struct.unpack_from('>2I', raw, offset+12) + if start != 32 or strings != start+count*stride or strings > size: + raise ValueError('Unexpected DMO record layout') + for index in range(count): + yield offset, offset+start+index*stride, size + + +def locators(raw): + result = [] + for base, at, size in records(raw, 0xEB001D, 128): + name_offset = struct.unpack_from('>I', raw, at+124)[0] + if not 32 <= name_offset < size: + raise ValueError('Invalid DMO locator name offset') + end = raw.index(b'\0', base+name_offset, base+size) + result.append(dict(instance_id=f'{struct.unpack_from(">Q", raw, at+96)[0]:016X}', + locator_id=f'{struct.unpack_from(">Q", raw, at+104)[0]:016X}', + template_id=f'{struct.unpack_from(">Q", raw, at+112)[0]:016X}', + matrix=matrix(raw, at).tolist(), name=raw[base+name_offset:end].decode('utf-8'), + source_offset=at, bounds=np.array(struct.unpack_from('>8f', raw, at+64)).reshape(2, 4)[:, :3].tolist())) + return result + + +def template_meshes(raw): + """Resolve EB000D tInstance -> EB0001 model -> EB0023 mesh -> declaration.""" + table = sections(raw) + result = {} + for _, at, _ in records(raw, 0xEB000D, 160): + key = f'{struct.unpack_from(">Q", raw, at+104)[0]:016X}' + index = struct.unpack_from('>I', raw, at+128)[0] + if index >= len(table) or table[index][5] != 0xEB0001: + raise ValueError('Unresolved DMO model reference') + base, _, size, _, _, _ = table[index] + start = struct.unpack_from('>I', raw, base+36)[0] + count = struct.unpack_from('>H', raw, base+48)[0] + if base+size > len(raw) or start+count*8 > size: + raise ValueError('Invalid DMO model mesh table') + mesh_info = [] + for j in range(count): + mesh_index = struct.unpack_from('>I', raw, base+start+j*8)[0] + if mesh_index >= len(table) or table[mesh_index][5] != 0xEB0023: + raise ValueError('Unresolved DMO mesh reference') + descriptor = struct.unpack_from('>I', raw, table[mesh_index][0]+40)[0] + if descriptor >= len(table) or table[descriptor][5] != 0x200E9: + raise ValueError('Unresolved DMO vertex declaration') + mesh_info.append(table[descriptor][0]) + if key in result: + raise ValueError('Duplicate DMO template ID') + result[key] = dict(mesh_info=mesh_info, matrix=matrix(raw, at), + model_matrix=matrix(raw, base+struct.unpack_from('>I', raw, base+32)[0])) + return result + + +def transform_mesh(arrays, index, transform): + values = {key: np.array(value, copy=True) for key, value in arrays.items() if key.endswith('_'+str(index))} + key = f'vertices_{index}' + values[key] = (values[key] @ transform[:3, :3] + transform[3, :3]).astype('f4') + normal_matrix = np.linalg.inv(transform[:3, :3]).T + for prefix in ('normals', 'retail_normals'): + key = f'{prefix}_{index}' + if key in values: + n = values[key] @ normal_matrix + values[key] = (n/np.maximum(np.linalg.norm(n, axis=1, keepdims=True), 1e-20)).astype('f4') + if np.linalg.det(transform[:3, :3]) < 0: + values[f'faces_{index}'] = values[f'faces_{index}'][:, [0, 2, 1]] + return values + + +def catalog(cache_roots): + sys.path.insert(0, str(Path(__file__).resolve().parents[1]/'vendor/utt')) + import rx2_parser + from .backdrop import texture_groups + templates, textures = {}, {} + for root in cache_roots: + m = json.loads((root/'manifest.json').read_text()) + for key, value in m['textures'].items(): + entry = dict(value) + for path_key in ('rgba', 'png'): + if path_key in entry: + entry[path_key] = str((root/entry[path_key]).resolve()) + if key in textures and Path(textures[key]['rgba']).read_bytes() != Path(entry['rgba']).read_bytes(): + raise ValueError('Conflicting DMO texture '+key) + textures[key] = entry + for model in m['models']: + raw = (root/model['rx2']).read_bytes() + table = rx2_parser.RX2File(raw) + table.parse() + roles = set(role for mesh in model['meshes'] for role in mesh['retail_texture_ids']) + bindings = texture_groups(raw, table, roles) + for key, definition in template_meshes(raw).items(): + meshes = [copy.deepcopy(mesh) for mesh in model['meshes'] if mesh['source_offsets']['mesh_info'] in definition['mesh_info']] + for mesh in meshes: + # DMO resources have a high-bit namespace that display names + # omit. Bind the binary channel GUID; never strip that bit. + group = bindings[mesh['retail_material_group_index']] + mesh['retail_texture_ids'] = {role:f'0x{group[role]:016x}' for role in mesh['retail_texture_ids']} + mesh['texture_id'] = mesh['retail_texture_ids'].get('diffuse', mesh['retail_texture_ids'].get('transparent')) + if len(meshes) != len(definition['mesh_info']): + raise ValueError('DMO model geometry omitted '+key) + if key in templates: + raise ValueError('Conflicting DMO template '+key) + templates[key] = dict(definition, meshes=meshes, npz=root/model['npz'], asset_id=model['asset_id']) + return templates, textures + + +def export(manifest_path, cache_roots, output): + district = json.loads(manifest_path.read_text()) + templates, textures = catalog(cache_roots) + placements = {} + for source in district['simulation_assets']: + raw = (manifest_path.parent/source['rx2']).read_bytes() + for item in locators(raw): + item['source_asset'] = source['asset_id'] + item['source_sha256'] = hashlib.sha256(raw).hexdigest() + key = item['instance_id'] + if key in placements and placements[key] != item: + raise ValueError('Conflicting DMO locator '+key) + placements[key] = item + report = dict(map=district['map_name'], instances=[], unresolved=[], simulation='initial placement only') + with tempfile.TemporaryDirectory(prefix='skate-dmo-') as work: + root = Path(work); models = []; used = set() + for number, item in enumerate(placements.values()): + template = templates.get(item['template_id']) + if template is None: + report['unresolved'].append(item) + continue + transform = template['model_matrix'] @ template['matrix'] @ np.array(item['matrix']) + arrays = {}; meshes = copy.deepcopy(template['meshes']) + with np.load(template['npz'], allow_pickle=False) as original: + for mesh in meshes: + arrays.update(transform_mesh(original, mesh['index'], transform)) + used.update(mesh['retail_texture_ids'].values()) + path = f'{number}.npz'; np.savez(root/path, **arrays) + models.append(dict(asset_id=item['instance_id'], meshes=meshes, npz=path)) + report['instances'].append(dict(item, model_asset=template['asset_id'], meshes=len(meshes))) + if models: + manifest = dict(map_name=district['map_name'], district_name=district['district_name'], + models=models, textures={key:textures[key] for key in used}, + normal_texture_policy=dict(excluded_texture_ids=[]), grind_splines=[]) + (root/'manifest.json').write_text(json.dumps(manifest)) + # Publish only a complete package; preserve the previous one on failure. + temporary = output.with_suffix('.skate.new') + try: + write(root/'manifest.json', temporary, None, render_only=True) + temporary.replace(output) + finally: + temporary.unlink(missing_ok=True) + else: + output.unlink(missing_ok=True) + output.parent.mkdir(parents=True, exist_ok=True) + output.with_suffix('.json').write_text(json.dumps(report, indent=2)) + return len(report['instances']), len(report['unresolved']) + + +def prepare_catalog(game_root, work): + vendor = Path(__file__).resolve().parents[1]/'vendor' + sys.path.insert(0, str(vendor/'university/tools/vanilla_map_extraction/tools')) + from prepare_hawaiian_dream import prepare + archive = BigArchive(game_root/'data/content/worlddmo.big') + archive.extract_entries(archive.entries, work/'raw') + roots = [] + for stream in (work/'raw/data/content/world/dmo').iterdir(): + root = work/'cache'/stream.name + prepare(stream_directory=stream, output_root=root, utt_root=vendor/'utt', + district_name=stream.name, map_name=stream.name, raw_texture_cache=True) + roots.append(root) + return roots + + +if __name__ == '__main__': + p = argparse.ArgumentParser(description=__doc__) + p.add_argument('--manifest', type=Path, required=True) + p.add_argument('--cache', type=Path, nargs='+', required=True) + p.add_argument('--output', type=Path, required=True) + a = p.parse_args() + print('DMO placed/unresolved:', export(a.manifest, a.cache, a.output)) diff --git a/tools/asset_pipeline/install.py b/tools/asset_pipeline/install.py index efb278e7..38f0c3a0 100644 --- a/tools/asset_pipeline/install.py +++ b/tools/asset_pipeline/install.py @@ -120,6 +120,12 @@ def convert_map(archive,work,maps,stage,game_exe,log,report): build_archive(manifest_path,collision) final=maps/(label+'.skate') write_map(manifest_path,final,collision,report) + from .dynamic_props import export as write_props + caches=list((work/'dmo/cache').glob('DMO_*')) + if not caches: + raise RuntimeError('Missing prepared DMO catalog') + placed, unresolved=write_props(manifest_path,caches,stage/'assets/private/native-props'/(label+'.skate')) + report(f'{label}: placed {placed} authored DMO instances, {unresolved} unresolved templates') report('Checking converted map: '+label) run([game_exe,'--assets',stage/'assets','--map',final,'--check-assets'],log,report) entry={'name':label,'path':'maps/'+final.name,'sha256':digest(final)} @@ -202,6 +208,9 @@ def install(iso,base,game_exe,report,game_root=None): report('Preparing global foliage backdrops') from .backdrop import convert as write_backdrops write_backdrops(game_root,stage/'assets',converted) + report('Preparing authored movable-object models') + from .dynamic_props import prepare_catalog + prepare_catalog(game_root,work/'dmo') report('Validating skater, input and animation data') run([game_exe,'--assets',stage/'assets','--test-world','--check-assets'],log,report) archives=list((game_root/'data/content').glob('worldDIST_*.big')) diff --git a/tools/asset_pipeline/test_character_uv.py b/tools/asset_pipeline/test_character_uv.py deleted file mode 100644 index b68ee103..00000000 --- a/tools/asset_pipeline/test_character_uv.py +++ /dev/null @@ -1,31 +0,0 @@ -"""Regressions for the RX2 hair float UV declaration and glTF layout.""" -import struct -import unittest - -from tools.asset_pipeline.character_glb import Glb, RX2 - - -class CharacterUvTests(unittest.TestCase): - def test_float_uv_preserves_signed_values_and_stride(self): - fmt, size = RX2.VTX_FORMATS[0x002C23A5] - self.assertEqual((fmt, size), ('FLOAT2', 8)) - source = b'head' + struct.pack('>I2fI2f', 0, -0.25, 1.5, 0, 0.125, 0.75) - descriptor = {'stride': 12, 'elements': [ - {'usage_name': 'TEXCOORD', 'usage_index': 1, - 'format_name': fmt, 'offset': 4}]} - uv = RX2._decode_vertices(source, 4, 2, descriptor)['uvs2'] - self.assertEqual(uv, [(-0.25, 1.5), (0.125, 0.75)]) - glb = Glb() - index = glb.accessor(uv, 'VEC2') - accessor = glb.doc['accessors'][index] - self.assertEqual(accessor['count'], 2) - self.assertEqual(glb.doc['bufferViews'][accessor['bufferView']]['byteLength'], 16) - self.assertEqual(struct.unpack('<4f', glb.data), (-0.25, 1.5, 0.125, 0.75)) - - def test_accessor_rejects_wrong_component_count(self): - with self.assertRaisesRegex(ValueError, 'shape'): - Glb().accessor([(1, 2, 3, 4)], 'VEC2') - - -if __name__ == '__main__': - unittest.main() diff --git a/tools/asset_pipeline/test_dynamic_props.py b/tools/asset_pipeline/test_dynamic_props.py new file mode 100644 index 00000000..fe433594 --- /dev/null +++ b/tools/asset_pipeline/test_dynamic_props.py @@ -0,0 +1,52 @@ +import struct +import unittest +import numpy as np + +from .dynamic_props import locators, transform_mesh, template_meshes + + +def resource(kind, payload): + raw = bytearray(88+len(payload)) + raw[:7] = b'\x89RW4xb2' + struct.pack_into('>I', raw, 32, 1) + struct.pack_into('>I', raw, 48, 64) + struct.pack_into('>6I', raw, 64, 88, 0, len(payload), 0, 0, kind) + raw[88:] = payload + return raw + + +class DynamicPropsTests(unittest.TestCase): + def test_locator_id_and_row_matrix(self): + payload = bytearray(166) + struct.pack_into('>5I', payload, 0, 0, 1, 1, 32, 160) + m = np.eye(4); m[3, :3] = [10, 20, -30] + struct.pack_into('>16f', payload, 32, *m.ravel()) + struct.pack_into('>3Q2I', payload, 128, 123, 456, 789, 0, 160) + payload[160:] = b'bench\0' + rows = locators(resource(0xEB001D, payload)) + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0]['template_id'], '0000000000000315') + self.assertEqual(rows[0]['matrix'][3], [10, 20, -30, 1]) + self.assertEqual(rows[0]['name'], 'bench') + struct.pack_into('>I', payload, 16, 159) + with self.assertRaisesRegex(ValueError, 'layout'): + locators(resource(0xEB001D, payload)) + + def test_scaled_rotation_and_normal_transform(self): + m = np.array([[0, 0, -2, 0], [0, 3, 0, 0], [4, 0, 0, 0], [10, 20, 30, 1]], dtype=float) + arrays = {'vertices_0': np.array([[1, 2, 3]]), 'normals_0': np.array([[0, 0, 1]]), 'faces_0': np.array([[0, 0, 0]])} + transformed = transform_mesh(arrays, 0, m) + np.testing.assert_allclose(transformed['vertices_0'], [[22, 26, 28]]) + np.testing.assert_allclose(transformed['normals_0'], [[1, 0, 0]]) + np.testing.assert_array_equal(arrays['vertices_0'], [[1, 2, 3]]) + + def test_missing_template_reference_is_rejected(self): + payload = bytearray(192) + struct.pack_into('>5I', payload, 0, 0, 1, 1, 32, 192) + struct.pack_into('>I', payload, 160, 100) + with self.assertRaisesRegex(ValueError, 'model reference'): + template_meshes(resource(0xEB000D, payload)) + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/vendor/skate3_anim/PROVENANCE.md b/tools/vendor/skate3_anim/PROVENANCE.md index a92762c6..5db56627 100644 --- a/tools/vendor/skate3_anim/PROVENANCE.md +++ b/tools/vendor/skate3_anim/PROVENANCE.md @@ -4,12 +4,6 @@ `https://github.com/chasmlol/skate-3-trick-toolkit` commit `85d9d679bc35462ce66fdbba95007b56a94579f0`. -Local correction (2026-09-08): `rx2_skeleton.py` decodes vertex declaration -`0x002C23A5` as big-endian FLOAT2 (format 37), replacing the incorrect SHORT4N -interpretation. This preserves the original secondary UV coordinates used by -hair strand coverage. See `docs/visual-parity-status.md` and the synthetic -`tools/asset_pipeline/test_character_uv.py` regression. - `blender_rx2_abin_export.py` is the project-maintained extension of that exporter (SHA-256 `58BF997204228E6C25942AFCB7BACE48E19BD478EBBD2E2E8C83695BB8417363`). diff --git a/tools/vendor/skate3_anim/rx2_skeleton.py b/tools/vendor/skate3_anim/rx2_skeleton.py index cd2c8c8a..6362112c 100644 --- a/tools/vendor/skate3_anim/rx2_skeleton.py +++ b/tools/vendor/skate3_anim/rx2_skeleton.py @@ -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: ("FLOAT2", 8), # format 37: big-endian float2, hair strand UV + 0x002C23A5: ("SHORT4N", 8), # int16[4] / 32768 -- texcoord pair 0x002A2190: ("PACKED11_11_10N", 4), # signed 11/11/10 -- tangent, binormal } @@ -514,9 +514,10 @@ 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": + elif fmt == "SHORT4N": for v in range(count): - vals.append(struct.unpack_from(">2f", d, vb + v * stride + off)) + q = struct.unpack_from(">4h", d, vb + v * stride + off) + vals.append(tuple(c / 32768.0 for c in q)) elif fmt == "UBYTE4" and e["usage_name"] == "BLENDWEIGHT": for v in range(count): q = struct.unpack_from(">4B", d, vb + v * stride + off) diff --git a/tools/vendor/university/tools/vanilla_map_extraction/tools/skate3_streams.py b/tools/vendor/university/tools/vanilla_map_extraction/tools/skate3_streams.py index e824899d..f352f87f 100644 --- a/tools/vendor/university/tools/vanilla_map_extraction/tools/skate3_streams.py +++ b/tools/vendor/university/tools/vanilla_map_extraction/tools/skate3_streams.py @@ -315,6 +315,8 @@ def load_district_stream( root = Path(stream_directory) records = read_atoc(root / f"{district_name}_{stream_name}.xst") stream_files = sorted(root.glob(f"c{stream_name}_*.xsf")) + if not records and not stream_files: + return [] if not stream_files: raise StreamFormatError( f"{root} contains no c{stream_name}_*.xsf stream files"