From 1888e48a99f8ad84741f4f4cbfce5fd3fd37072c Mon Sep 17 00:00:00 2001 From: chazmm Date: Tue, 8 Sep 2026 15:14:45 +0100 Subject: [PATCH 01/16] Preserve complete aligned VLT arrays alongside legacy field data --- tools/asset_pipeline/test_vlt.py | 37 ++++++++++++++++++++++++++++++++ tools/asset_pipeline/vlt.py | 26 ++++++++++++++++++++++ 2 files changed, 63 insertions(+) create mode 100644 tools/asset_pipeline/test_vlt.py diff --git a/tools/asset_pipeline/test_vlt.py b/tools/asset_pipeline/test_vlt.py new file mode 100644 index 00000000..bbc25a62 --- /dev/null +++ b/tools/asset_pipeline/test_vlt.py @@ -0,0 +1,37 @@ +"""Synthetic fixtures for the binary array layout; contains no game data.""" +import struct +import unittest +from .vlt import array_items + + +class ArrayLayoutTests(unittest.TestCase): + def test_vector4_header_alignment(self): + first = struct.pack('>4f', .75, .35, 0, 0) + second = struct.pack('>4f', 1, 1, 1, 1) + raw = struct.pack('>4H', 2, 2, 16, 0x8000) + bytes(8) + first + second + result = array_items(raw, 0, 16, 4) + self.assertEqual(result['items'], [first.hex().upper(), second.hex().upper()]) + self.assertEqual(result['alignment'], 16) + + def test_each_element_is_aligned_at_absolute_offset(self): + a, b = struct.pack('>3f', 2, 3, 4), struct.pack('>3f', 5, 6, 7) + raw = bytes(4) + struct.pack('>4H', 3, 2, 12, 0) + bytes(4) + raw += a + bytes(4) + b + bytes(4) + bytes(12) + result = array_items(raw, 4, 12, 4) + self.assertEqual(result['capacity'], 3) + self.assertEqual(result['items'], [a.hex().upper(), b.hex().upper()]) + + def test_empty_and_pointer_arrays(self): + self.assertEqual(array_items(struct.pack('>4H', 0, 0, 4, 0), 0, 4, 2)['items'], []) + raw = struct.pack('>4H2I', 2, 2, 4, 0, 0x1234, 0) + self.assertEqual(array_items(raw, 0, 4, 2)['items'], ['00001234', '00000000']) + + def test_invalid_header_or_truncated_capacity_fails(self): + for raw in [struct.pack('>4H', 1, 2, 4, 0), struct.pack('>4H', 1, 1, 8, 0), + struct.pack('>4HI', 2, 1, 4, 0, 123)]: + with self.assertRaises(ValueError): + array_items(raw, 0, 4, 2) + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/asset_pipeline/vlt.py b/tools/asset_pipeline/vlt.py index 930adaa9..0c10b185 100644 --- a/tools/asset_pipeline/vlt.py +++ b/tools/asset_pipeline/vlt.py @@ -43,6 +43,28 @@ def take(data, at, size): def unpack(fmt,data,at): return struct.unpack('>'+fmt,take(data,at,struct.calcsize('>'+fmt))) +def array_items(data, pos, size, alignment): + """ModernBase array: four u16 header lanes, then aligned elements. + + Alignment in the schema is log2(bytes), not the array header's mask. + Retain each element separately: padding is not part of its value. + """ + capacity, count, stride, _ = unpack('4H', data, pos) + if count > capacity or stride != size or not stride or alignment > 16: + raise ValueError(f'Invalid VLT array at {pos:#x}') + boundary = 1 << alignment + cursor = pos + 8 + items = [] + for index in range(capacity): + cursor = (cursor + boundary - 1) & -boundary + raw = take(data, cursor, stride) + if index < count: + items.append(raw.hex().upper()) + cursor += stride + return {'capacity': capacity, 'element_size': stride, + 'alignment': boundary, 'items': items} + + def chunks(data): at=0 while at Date: Tue, 8 Sep 2026 15:15:20 +0100 Subject: [PATCH 02/16] Resolve VLT text array values before discarding the binary bank --- tools/asset_pipeline/test_vlt.py | 9 ++++++++- tools/asset_pipeline/vlt.py | 17 +++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/tools/asset_pipeline/test_vlt.py b/tools/asset_pipeline/test_vlt.py index bbc25a62..39e3583d 100644 --- a/tools/asset_pipeline/test_vlt.py +++ b/tools/asset_pipeline/test_vlt.py @@ -1,7 +1,7 @@ """Synthetic fixtures for the binary array layout; contains no game data.""" import struct import unittest -from .vlt import array_items +from .vlt import array_items, array_text_items class ArrayLayoutTests(unittest.TestCase): @@ -32,6 +32,13 @@ class ArrayLayoutTests(unittest.TestCase): with self.assertRaises(ValueError): array_items(raw, 0, 4, 2) + def test_text_array_resolves_relocated_pointers(self): + binary = bytes(8) + 'test\0caf\u00e9\0'.encode() + self.assertEqual(array_text_items(binary, ['00000008', '0000000D', '00000000']), + ['test', 'caf\u00e9', '']) + with self.assertRaises(ValueError): + array_text_items(binary, ['0000FFFF']) + if __name__ == '__main__': unittest.main() diff --git a/tools/asset_pipeline/vlt.py b/tools/asset_pipeline/vlt.py index 0c10b185..dda19732 100644 --- a/tools/asset_pipeline/vlt.py +++ b/tools/asset_pipeline/vlt.py @@ -65,6 +65,20 @@ def array_items(data, pos, size, alignment): 'alignment': boundary, 'items': items} +def array_text_items(binary, items): + values = [] + for raw in items: + ptr, = unpack('I', bytes.fromhex(raw), 0) + if not ptr: + values.append('') + continue + end = binary.find(b'\0', ptr) + if end < 0: + raise ValueError(f'Unterminated VLT array text at {ptr:#x}') + values.append(take(binary, ptr, end-ptr).decode('utf-8')) + return values + + def chunks(data): at=0 while at Date: Tue, 8 Sep 2026 15:25:59 +0100 Subject: [PATCH 03/16] Resolve authored world skies, sun gradients and location directions --- crates/skate-game/src/retail_depth.wgsl | 2 +- crates/skate-game/src/retail_render.rs | 122 +------------ crates/skate-game/src/retail_sky.rs | 209 +++++++++++++++++++++++ crates/skate-game/src/retail_sky.wgsl | 28 ++- crates/skate-game/src/retail_world.wgsl | 9 +- crates/skate-game/src/world.rs | 2 +- docs/retail-renderer.md | 89 ++++++++-- tools/asset_pipeline/environment.py | 83 +++++++++ tools/asset_pipeline/install.py | 2 +- tools/asset_pipeline/sky.py | 146 +++++++++++----- tools/asset_pipeline/test_environment.py | 61 +++++++ 11 files changed, 573 insertions(+), 180 deletions(-) create mode 100644 crates/skate-game/src/retail_sky.rs create mode 100644 tools/asset_pipeline/environment.py create mode 100644 tools/asset_pipeline/test_environment.py diff --git a/crates/skate-game/src/retail_depth.wgsl b/crates/skate-game/src/retail_depth.wgsl index ba943a42..4730bf54 100644 --- a/crates/skate-game/src/retail_depth.wgsl +++ b/crates/skate-game/src/retail_depth.wgsl @@ -5,7 +5,7 @@ #ifdef MOTION_VECTOR_PREPASS #import bevy_pbr::pbr_prepass_functions::calculate_motion_vector #endif -struct WorldParams { mode: vec4, surface: vec4, family: vec4, fog_ramp: vec4, fog_color: vec4, shadow_color: vec4 } +struct WorldParams { mode: vec4, surface: vec4, family: vec4, fog_ramp: vec4, fog_color: vec4, shadow_color: vec4, sun_direction: vec4 } @group(#{MATERIAL_BIND_GROUP}) @binding(0) var p: WorldParams; @group(#{MATERIAL_BIND_GROUP}) @binding(1) var diffuse: texture_2d; @group(#{MATERIAL_BIND_GROUP}) @binding(2) var diffuse_sampler: sampler; diff --git a/crates/skate-game/src/retail_render.rs b/crates/skate-game/src/retail_render.rs index ed162da5..cc2cb68d 100644 --- a/crates/skate-game/src/retail_render.rs +++ b/crates/skate-game/src/retail_render.rs @@ -33,123 +33,9 @@ impl Plugin for RetailRenderPlugin { #[derive(Resource)] pub(crate) struct RetailScene(pub bool); -#[derive(Asset, TypePath, AsBindGroup, Debug, Clone)] -pub(crate) struct RetailSkyMaterial { - #[uniform(0)] - pub params: Vec4, - #[texture(1)] - #[sampler(2)] - pub diffuse: Handle, -} -impl Material for RetailSkyMaterial { - fn enable_prepass() -> bool { - false - } - fn enable_shadows() -> bool { - false - } - fn vertex_shader() -> ShaderRef { - "embedded://skate3rust/retail_sky.wgsl".into() - } - fn fragment_shader() -> ShaderRef { - Self::vertex_shader() - } - fn specialize( - _: &bevy::pbr::MaterialPipeline, - descriptor: &mut bevy::render::render_resource::RenderPipelineDescriptor, - _: &bevy::mesh::MeshVertexBufferLayoutRef, - _: bevy::pbr::MaterialPipelineKey, - ) -> Result<(), bevy::render::render_resource::SpecializedMeshPipelineError> { - descriptor.primitive.cull_mode = None; - if let Some(depth) = &mut descriptor.depth_stencil { - depth.depth_write_enabled = false; - } - Ok(()) - } -} - -pub(crate) fn spawn_sky( - name: &str, - root: &std::path::Path, - commands: &mut Commands, - meshes: &mut Assets, - images: &mut Assets, - materials: &mut Assets, -) { - // Parks need their authored sky selection from the environment controller; - // only these three district identities are currently resolved. - if !matches!(name, "University" | "DownTown" | "Industrial") { - return; - } - #[derive(serde::Deserialize)] - struct Sky { - width: u32, - height: u32, - positions: Vec<[f32; 3]>, - uvs: Vec<[f32; 2]>, - indices: Vec, - } - let load = || -> Result<(Sky, Vec), String> { - let base = root.join("private/native-skies"); - let sky: Sky = serde_json::from_slice( - &std::fs::read(base.join(format!("{name}.json"))).map_err(|e| e.to_string())?, - ) - .map_err(|e| e.to_string())?; - let rgba = std::fs::read(base.join(format!("{name}.rgba"))).map_err(|e| e.to_string())?; - if sky.width == 0 - || sky.height == 0 - || u64::from(sky.width) * u64::from(sky.height) * 4 != rgba.len() as u64 - || sky.positions.len() != sky.uvs.len() - || sky - .indices - .iter() - .any(|&i| i as usize >= sky.positions.len()) - { - return Err("Invalid retail sky dimensions/geometry".into()); - } - Ok((sky, rgba)) - }; - let (sky, rgba) = match load() { - Ok(v) => v, - Err(e) => { - warn!("Retail sky unavailable: {e}"); - return; - } - }; - let mut image = Image::new( - bevy::render::render_resource::Extent3d { - width: sky.width, - height: sky.height, - depth_or_array_layers: 1, - }, - bevy::render::render_resource::TextureDimension::D2, - rgba, - bevy::render::render_resource::TextureFormat::Rgba8Unorm, - bevy::asset::RenderAssetUsages::RENDER_WORLD, - ); - let mut sampler = bevy::image::ImageSamplerDescriptor::linear(); - sampler.address_mode_u = bevy::image::ImageAddressMode::Repeat; - image.sampler = bevy::image::ImageSampler::Descriptor(sampler); - let mesh = Mesh::new( - bevy::mesh::PrimitiveTopology::TriangleList, - bevy::asset::RenderAssetUsages::RENDER_WORLD, - ) - .with_inserted_attribute(Mesh::ATTRIBUTE_POSITION, sky.positions) - .with_inserted_attribute(Mesh::ATTRIBUTE_UV_0, sky.uvs) - .with_inserted_indices(bevy::mesh::Indices::U32(sky.indices)); - commands.spawn(( - Name::new("Retail sky dome"), - Mesh3d(meshes.add(mesh)), - MeshMaterial3d(materials.add(RetailSkyMaterial { - params: Vec4::new(165., 2.5, 1., 0.), - diffuse: images.add(image), - })), - Transform::default(), - bevy::camera::visibility::NoFrustumCulling, - bevy::light::NotShadowCaster, - bevy::light::NotShadowReceiver, - )); -} +#[path = "retail_sky.rs"] +mod sky; +pub(crate) use sky::{spawn_sky, RetailSkyMaterial}; #[derive(Component, ExtractComponent, Clone, Copy, ShaderType, Default)] pub(crate) struct RetailTone { @@ -179,6 +65,7 @@ pub(crate) struct WorldParams { pub fog_ramp: Vec4, pub fog_color: Vec4, pub shadow_color: Vec4, + pub sun_direction: Vec4, } #[derive(Asset, TypePath, AsBindGroup, Debug, Clone)] @@ -385,6 +272,7 @@ impl Definition { fog_ramp: Vec4::new(0., 0., 1., 0.), fog_color: Vec4::ZERO, shadow_color: Vec4::ZERO, + sun_direction: Vec3::new(4., 7., 4.).normalize().extend(0.), }, diffuse, lightmap, diff --git a/crates/skate-game/src/retail_sky.rs b/crates/skate-game/src/retail_sky.rs new file mode 100644 index 00000000..61671d5e --- /dev/null +++ b/crates/skate-game/src/retail_sky.rs @@ -0,0 +1,209 @@ +//! Authored camera-relative sky and its radial sun gradient. +use super::RetailWorldMaterial; +use bevy::{ + prelude::*, + render::render_resource::{AsBindGroup, ShaderType}, + shader::ShaderRef, +}; + +#[derive(Clone, Debug, ShaderType)] +pub(crate) struct SkyParams { + // anchor height, scene exposure, sky multiplier, sun angular scale (0 disables). + pub settings: Vec4, + pub sun_direction: Vec4, +} + +#[derive(Asset, TypePath, AsBindGroup, Debug, Clone)] +pub(crate) struct RetailSkyMaterial { + #[uniform(0)] + pub params: SkyParams, + #[texture(1)] + #[sampler(2)] + pub diffuse: Handle, + #[texture(3)] + #[sampler(4)] + pub sun: Option>, +} +impl Material for RetailSkyMaterial { + fn enable_prepass() -> bool { + false + } + fn enable_shadows() -> bool { + false + } + fn vertex_shader() -> ShaderRef { + "embedded://skate3rust/retail_sky.wgsl".into() + } + fn fragment_shader() -> ShaderRef { + Self::vertex_shader() + } + fn specialize( + _: &bevy::pbr::MaterialPipeline, + descriptor: &mut bevy::render::render_resource::RenderPipelineDescriptor, + _: &bevy::mesh::MeshVertexBufferLayoutRef, + _: bevy::pbr::MaterialPipelineKey, + ) -> Result<(), bevy::render::render_resource::SpecializedMeshPipelineError> { + descriptor.primitive.cull_mode = None; + if let Some(depth) = &mut descriptor.depth_stencil { + depth.depth_write_enabled = false; + } + Ok(()) + } +} + +#[derive(serde::Deserialize)] +struct Environment { + anchor_height: f32, + sun_direction: [f32; 3], + sun_scale: f32, + multiplier: f32, + location_chain: Vec, +} +#[derive(serde::Deserialize)] +struct Sky { + width: u32, + height: u32, + positions: Vec<[f32; 3]>, + uvs: Vec<[f32; 2]>, + indices: Vec, + #[serde(default)] + sun_width: u32, + #[serde(default)] + sun_height: u32, + environment: Option, +} +fn load(root: &std::path::Path, name: &str) -> Result<(Sky, Vec, Option>), String> { + if name.is_empty() || name.contains(['/', '\\', ':']) || matches!(name, "." | "..") { + return Err("Invalid retail sky name".into()); + } + let base = root.join("private/native-skies"); + let read = |suffix: &str| { + std::fs::read(base.join(format!("{name}{suffix}"))).map_err(|e| e.to_string()) + }; + let sky: Sky = serde_json::from_slice(&read(".json")?).map_err(|e| e.to_string())?; + let rgba = read(".rgba")?; + if sky.width == 0 + || sky.height == 0 + || u64::from(sky.width) * u64::from(sky.height) * 4 != rgba.len() as u64 + || sky.positions.is_empty() + || sky.positions.len() != sky.uvs.len() + || sky.indices.is_empty() + || sky.indices.len() % 3 != 0 + || sky + .indices + .iter() + .any(|&i| i as usize >= sky.positions.len()) + || sky + .positions + .iter() + .flatten() + .chain(sky.uvs.iter().flatten()) + .any(|v| !v.is_finite()) + { + return Err("Invalid retail sky dimensions/geometry".into()); + } + let sun = if let Some(env) = &sky.environment { + let direction = Vec3::from_array(env.sun_direction); + if !env.anchor_height.is_finite() + || !direction.is_finite() + || !(0.99..=1.01).contains(&direction.length_squared()) + || !env.sun_scale.is_finite() + || env.sun_scale <= 0. + || !env.multiplier.is_finite() + || env.multiplier < 0. + || sky.sun_width == 0 + || sky.sun_height != 16 + { + return Err("Invalid authored sky environment".into()); + } + let bytes = read(".sun.rgba")?; + if u64::from(sky.sun_width) * u64::from(sky.sun_height) * 4 != bytes.len() as u64 { + return Err("Invalid sun-gradient payload".into()); + } + Some(bytes) + } else { + None + }; + Ok((sky, rgba, sun)) +} +fn image(rgba: Vec, width: u32, height: u32, repeat: bool) -> Image { + use bevy::render::render_resource::*; + let mut image = Image::new( + Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + TextureDimension::D2, + rgba, + TextureFormat::Rgba8Unorm, + bevy::asset::RenderAssetUsages::RENDER_WORLD, + ); + let mut sampler = bevy::image::ImageSamplerDescriptor::linear(); + if repeat { + sampler.address_mode_u = bevy::image::ImageAddressMode::Repeat; + } + image.sampler = bevy::image::ImageSampler::Descriptor(sampler); + image +} +pub(crate) fn spawn_sky( + name: &str, + root: &std::path::Path, + commands: &mut Commands, + meshes: &mut Assets, + images: &mut Assets, + materials: &mut Assets, + world_materials: &mut Assets, +) { + let (sky, rgba, sun) = match load(root, name) { + Ok(v) => v, + Err(e) => { + warn!("Retail sky unavailable for {name}: {e}"); + return; + } + }; + // Legacy sky packages retain their original parameters. The scene exposure + // remains the adapter's capture default until auto-exposure is recovered. + let mut params = SkyParams { + settings: Vec4::new(165., 2.5, 1., 0.), + sun_direction: Vec4::ZERO, + }; + if let Some(env) = &sky.environment { + params.settings = Vec4::new(env.anchor_height, 2.5, env.multiplier, env.sun_scale); + params.sun_direction = Vec3::from_array(env.sun_direction).extend(0.); + for (_, material) in world_materials.iter_mut() { + // Only the existing tangent-sign term uses this vector. No added + // directional light energy or imported-world shadow map. + material.params.sun_direction = params.sun_direction; + } + info!( + "SKATE_SKY_READY map={name:?} location={:?} anchor={} sun_scale={} multiplier={} authored_direction={:?}", + env.location_chain, env.anchor_height, env.sun_scale, env.multiplier, env.sun_direction + ); + } else { + warn!( + "Retail sky {name} has legacy metadata; reconvert skies for authored parameters and sun" + ); + } + let sun = sun.map(|bytes| images.add(image(bytes, sky.sun_width, sky.sun_height, false))); + let mesh = Mesh::new( + bevy::mesh::PrimitiveTopology::TriangleList, + bevy::asset::RenderAssetUsages::RENDER_WORLD, + ) + .with_inserted_attribute(Mesh::ATTRIBUTE_POSITION, sky.positions) + .with_inserted_attribute(Mesh::ATTRIBUTE_UV_0, sky.uvs) + .with_inserted_indices(bevy::mesh::Indices::U32(sky.indices)); + commands.spawn(( + Name::new("Retail sky dome"), + Mesh3d(meshes.add(mesh)), + MeshMaterial3d(materials.add(RetailSkyMaterial { + params, + diffuse: images.add(image(rgba, sky.width, sky.height, true)), + sun, + })), + Transform::default(), + bevy::camera::visibility::NoFrustumCulling, + bevy::light::NotShadowCaster, + bevy::light::NotShadowReceiver, + )); +} diff --git a/crates/skate-game/src/retail_sky.wgsl b/crates/skate-game/src/retail_sky.wgsl index a9631d95..2a296ba2 100644 --- a/crates/skate-game/src/retail_sky.wgsl +++ b/crates/skate-game/src/retail_sky.wgsl @@ -1,20 +1,38 @@ #import bevy_pbr::{forward_io::Vertex, mesh_view_bindings::view} -@group(#{MATERIAL_BIND_GROUP}) @binding(0) var settings: vec4; +struct SkyParams { settings: vec4, sun_direction: vec4 } +@group(#{MATERIAL_BIND_GROUP}) @binding(0) var p: SkyParams; @group(#{MATERIAL_BIND_GROUP}) @binding(1) var panorama: texture_2d; @group(#{MATERIAL_BIND_GROUP}) @binding(2) var panorama_sampler: sampler; -struct SkyOut { @builtin(position) position: vec4, @location(0) uv: vec2 } +@group(#{MATERIAL_BIND_GROUP}) @binding(3) var sun_gradient: texture_2d; +@group(#{MATERIAL_BIND_GROUP}) @binding(4) var sun_sampler: sampler; +struct SkyOut { + @builtin(position) position: vec4, + @location(0) uv: vec2, + @location(1) dome_position: vec3, +} @vertex fn vertex(v: Vertex) -> SkyOut { var out: SkyOut; - let world = v.position + vec3(view.world_position.x,settings.x,view.world_position.z); + let world = v.position + vec3(view.world_position.x,p.settings.x,view.world_position.z); out.position = view.clip_from_world*vec4(world,1.0); - // Reverse-Z far plane: the authored dome is larger than the world camera's far distance. + // Reverse-Z far plane: the authored dome is larger than the camera's far distance. out.position.z = 0.0000001*out.position.w; out.uv = v.uv; + out.dome_position = v.position; return out; } @fragment fn fragment(i: SkyOut) -> @location(0) vec4 { let d = textureSample(panorama,panorama_sampler,i.uv).rgb; - return vec4(d*d*settings.y*settings.z,1.0); + var linear = d*d; + if p.settings.w > 0.0 { + // Native sky_defaultPS: sine-angle lookup in the material's specular + // gradient, alpha controls the HDR core. World position minus the sky + // anchor is exactly the local dome position used here. + let dot_pl = saturate(dot(normalize(i.dome_position),p.sun_direction.xyz)); + let sin_angle = sqrt(saturate(1.0-dot_pl*dot_pl)); + let sun = textureSample(sun_gradient,sun_sampler,vec2(sin_angle/p.settings.w,0.5/16.0)); + linear += sun.rgb*sun.rgb/saturate(sun.a+0.01); + } + return vec4(linear*p.settings.y*p.settings.z,1.0); } diff --git a/crates/skate-game/src/retail_world.wgsl b/crates/skate-game/src/retail_world.wgsl index e0cb4dc0..4f0d740f 100644 --- a/crates/skate-game/src/retail_world.wgsl +++ b/crates/skate-game/src/retail_world.wgsl @@ -2,7 +2,7 @@ struct WorldParams { mode: vec4, surface: vec4, family: vec4, - fog_ramp: vec4, fog_color: vec4, shadow_color: vec4, + fog_ramp: vec4, fog_color: vec4, shadow_color: vec4, sun_direction: vec4, } @group(#{MATERIAL_BIND_GROUP}) @binding(0) var p: WorldParams; @group(#{MATERIAL_BIND_GROUP}) @binding(1) var diffuse: texture_2d; @@ -64,10 +64,9 @@ fn fragment(i: VertexOutput) -> @location(0) vec4 { #endif let rpos = i.world_position.xyz - frame::view.world_position; let vd = -normalize(rpos); - // Static reference axis for the tangent-space sign terms. This is not - // an additive light or a shadow source. Native frame direction remains - // an extraction gap; preserve the adapter's prior direction for now. - let sun = normalize(vec3(4.0,7.0,4.0)); + // Authored render-location direction for the tangent-space sign terms. + // This does not add directional light energy or a shadow source. + let sun = p.sun_direction.xyz; var d = a.rgb*a.rgb; var alpha = 1.0; var lin = vec3(0.0); diff --git a/crates/skate-game/src/world.rs b/crates/skate-game/src/world.rs index a8debf70..f14639dd 100644 --- a/crates/skate-game/src/world.rs +++ b/crates/skate-game/src/world.rs @@ -39,7 +39,7 @@ fn spawn( // Physics has already consumed the package. Render assets own their // uploaded data; keeping another full city package wastes gigabytes. crate::skate_world::spawn(&map, &mut commands, &mut meshes, &mut materials, &mut retail_materials, &mut images); - crate::retail_render::spawn_sky(&map.name, &config.asset_root, &mut commands, &mut meshes, &mut images, &mut sky_materials); + crate::retail_render::spawn_sky(&map.name, &config.asset_root, &mut commands, &mut meshes, &mut images, &mut sky_materials, &mut retail_materials); return; } let colors = [ diff --git a/docs/retail-renderer.md b/docs/retail-renderer.md index 0f24d5dd..cb3c78c6 100644 --- a/docs/retail-renderer.md +++ b/docs/retail-renderer.md @@ -18,10 +18,19 @@ Implemented: colour and depth passes. Foliage is two-sided. - Lightmaps remain bilinear, clamped and fixed at mip zero. Repeating world textures have mipmaps. -- The three major districts use their actual sky geometry and diffuse texture - from `miscload.big`. Texture selection follows material GUIDs through the - RX2 reference table. Sky X/Z follow the camera; Y uses the reference's - documented 165-metre default. +- All ten installed worlds resolve sky geometry/texture paths through their + authored `world` collection inheritance. The parks inherit the default + panorama; Downtown and Industrial override it. Sky X/Z follow the camera; + Y comes from the selected `render_locations` chain, not camera height. +- The sky uses its material's diffuse and specular GUID bindings. The latter + is the 512x16 radial sun gradient: sine-angle lookup, squared RGB divided + by saturated alpha + 0.01, then the material multiplier and scene exposure. + `material_sky/default.m_params[0]` supplies scale 0.75 and multiplier 0.35; + these are decoded inputs, not constants copied from one frame. This follows + `scene.hlsl`'s sky branch and `skate3_native_scene.cpp`'s sky-bank layout. +- The selected render location supplies the normalized sun vector used by + the sky and existing world tangent-sign terms. This adds no directional + light energy or shadow map to imported worlds. The converter reads scalar material channels at record offset 0x10. Earlier exports incorrectly read their string pointer and saved empty strings. @@ -29,19 +38,77 @@ Reconvert maps to obtain those constants and complete reflection cubes. Old packages still load; missing constants disable the relevant layer and single-face environment textures are not treated as cubes. +The VLT converter now adds `array` to array fields, retaining the existing +`type` and `data` unchanged. `array.items` contains complete big-endian element +hex strings without padding; `capacity`, `element_size` and `alignment` +describe the source layout. Text arrays additionally retain decoded +`text_items` before the binary bank is discarded. The layout follows +VaultLib's `VLTArrayType`: four big-endian u16 header lanes followed by +individually aligned elements. Alignment in the schema is a base-two exponent. + +Skies can be refreshed independently of map/character conversion: + +```text +python -m tools.asset_pipeline.sky --game-root OWNED_EXTRACTED_GAME --assets PRIVATE_ASSET_ROOT +``` + +Use a private staging asset directory for experiments. This reads only the +database pair and sky resources; it does not require an ISO or Blender. +Legacy sky metadata retains its previous multiplier and has no sun gradient. + +## Treeline investigation + +The missing forest in the supplied University / Super Ultra Mega Park +comparison is not resolved by this change. The camera-relative +`WorldPresEntityOptimesh` anchor from `sub_82792900` is consistent with the sky +placement above, but the decoded University panorama has mountains and city +scenery, not a dense forest layer. Its RX2 contains one 380-triangle dome. +The separate `DIST_MegaPark` archive contains 102 mesh parts and no tree mesh; +it is not a source for additional trees at University's park. + +The main University export retains 479 model assets, 8,546 mesh parts and 86 +named TreeWall parts. Those named walls are outside the immediate park area; +their absence at that location does not prove geometry is missing. Nearby +proxy geometry demonstrably overlaps the main trees: in cell 350/-750, +451 of 456 proxy long-tree triangles match triangles in the 912-triangle +main tree after quantization to one millimetre, independent of winding. +The five nonmatching triangles are not evidence for an extra forest. +The main tree's diffuse and transparent channels resolve the same DXT5 +texture, with nonconstant alpha, and its baked lightmap is retained. The +renderer already uses the retail cutoff in colour/depth and two-sided foliage. + +No proxy geometry has been added. `skate3_draw_distance.cpp` documents the +ProxyWorld/full-detail exchange; rendering both whole sets is not a valid +fix. Identifying the specific missing silhouette now requires matched-camera +reference evidence connecting it to a submitted mesh/material or a streaming +collection. The two supplied images have different viewpoints. Native mip +selection and regional/streaming state also remain unverified. + ## Remaining differences This is not a claim of pixel parity. Native frame constants are supplied by game hooks in the reference; this engine does not yet recover that controller. -Exposure 2.5, material multiplier 1, and tree/proxy values use documented -day-capture defaults. Imported maps have no dynamic directional sun or realtime +Scene exposure 2.5, world material multiplier 1, and tree/proxy values still use +documented day-capture defaults. Imported maps have no dynamic directional sun or realtime sun shadows: their world lighting comes from the baked lightmaps. Normal-map -sign terms retain the adapter's fixed direction until native frame data is -available; this adds no light energy or shadow map. The fog inputs -are wired into the shader but remain disabled without authored frame rows. +sign terms use the initial world's authored direction when new sky metadata +is available; old packages retain the previous direction. Regional changes +and the runtime environment controller are not implemented. Fog selection +and its authored near/far/colour/power/max are retained in sky metadata, but +the CPU transformation to native frame rows is not yet proven, so shader fog +remains disabled. The selected `material_fog/default` must not be confused +with the different `fog_default` collection. -Sky sun-gradient shading, park sky selection, native water, character shaders, -SSAO, SSR, bloom and volumetric lighting are not yet ported. The character and +Native water, character shaders, SSAO, SSR, bloom and volumetric lighting are +not yet ported. The water arrays are recoverable now, but flowing-water +normal/tangent unpacking, ocean PCA input bindings and the separate horizon +water resource still need their own adapters. Character parity needs the +native CAC-composed texture/mask inputs plus verified key/rim/specular and +nine SH rows; portable GLB materials do not retain that contract. Reference +post effects need their depth/normal/reflection inputs and native pass ordering; +volumetric sun visibility also conflicts with the currently disabled world +sun-shadow source. These gaps are not enabled using guessed settings. +The character and unsupported world families still use Bevy materials beneath the shared tone curve. Reflection normals use the reference's analytic world-up construction; there has been no matched-camera pixel comparison against the recomp. diff --git a/tools/asset_pipeline/environment.py b/tools/asset_pipeline/environment.py new file mode 100644 index 00000000..e72a2cfa --- /dev/null +++ b/tools/asset_pipeline/environment.py @@ -0,0 +1,83 @@ +"""Resolve authored world/environment references without inventing frame rows.""" +import math +import struct +from .vlt import hash64 + +# Names recovered from schema/debug strings; unnamed fields keep their hashes. +NAMES = ('world', 'render_locations', 'material_sky', 'material_fog', + 'sun_position', 'm_params', 'fog_far', 'fog_near', 'fog_colour', + 'fog_power', 'fog_max') + + +def key_hash(name): + return int(name[5:], 16) if name.startswith('Hash_') else hash64(name) + + +class Collections: + def __init__(self, data): + self.rows = {(key_hash(r['class']), key_hash(r['key'])): r + for r in data['collections']} + + def resolve(self, cls, key): + cls = key_hash(cls) if isinstance(cls, str) else cls + key = key_hash(key) if isinstance(key, str) else key + fields, chain, seen = {}, [], set() + while key: + if key in seen: + raise ValueError('Cyclic environment inheritance') + seen.add(key) + row = self.rows[cls, key] + chain.append(row['key']) + for name, value in row['fields'].items(): + fields.setdefault(key_hash(name), value) + key = key_hash(row['parent']) + return fields, chain + + +def field(fields, name): + return fields[key_hash(name)]['data'] + + +def floats(raw, count): + values = struct.unpack('>' + 'f' * count, bytes.fromhex(raw)) + if not all(math.isfinite(v) for v in values): + raise ValueError('Non-finite environment value') + return list(values) + + +def world_environment(collections, world_key): + world, world_chain = collections.resolve('world', world_key) + location_key = struct.unpack('>Q', bytes.fromhex( + field(world, 'Hash_7E23D10785C43717'))[:8])[0] + location, location_chain = collections.resolve('render_locations', location_key) + sun = floats(field(location, 'sun_position'), 3) + length = math.sqrt(sum(v*v for v in sun)) + if length < 1e-6: + raise ValueError('Invalid authored sun direction') + fog_cls, fog_key, _ = struct.unpack('>3Q', bytes.fromhex(field(location, 'material_fog'))) + fog, fog_chain = collections.resolve(fog_cls, fog_key) + return { + 'world_chain': world_chain, 'location_chain': location_chain, + 'sun_direction': [v/length for v in sun], + 'anchor_height': floats(field(location, 'Hash_2E9AAECD1C29F81F'), 1)[0], + 'sky_model': field(world, 'Hash_C7A0A84F018E87BA'), + 'sky_textures': field(world, 'Hash_2A0BF629355AFB15'), + # These are authored inputs, not shader constants. The controller's + # colour/ramp transformation still needs to be recovered. + 'fog': {'chain': fog_chain, **{n: floats(field(fog, n), 4 if n == 'fog_colour' else 1) + for n in ('fog_near', 'fog_far', 'fog_colour', 'fog_power', 'fog_max')}}, + } + + +def sky_parameters(collections, shader): + effect, key = shader.split('.', 1) + if effect != 'sky': + raise ValueError('Not a sky material: ' + shader) + fields, chain = collections.resolve('material_sky', key) + params = fields[hash64('m_params')].get('array') + if not params or not params['items']: + raise ValueError('Sky parameters require complete VLT array payloads') + row = floats(params['items'][0], 4) + if row[0] <= 0 or row[1] < 0: + raise ValueError('Invalid authored sky parameters') + return {'material_chain': chain, 'sun_scale': row[0], 'multiplier': row[1]} diff --git a/tools/asset_pipeline/install.py b/tools/asset_pipeline/install.py index 1a4c9319..4ad5cd10 100644 --- a/tools/asset_pipeline/install.py +++ b/tools/asset_pipeline/install.py @@ -194,7 +194,7 @@ def install(iso,base,game_exe,report,game_root=None): (private/'game.json').write_text(json.dumps(game_manifest),encoding='utf-8') report('Preparing retail sky domes') from .sky import convert as write_skies - write_skies(game_root,stage/'assets') + write_skies(game_root,stage/'assets',converted) 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/sky.py b/tools/asset_pipeline/sky.py index 3740f277..206cace2 100644 --- a/tools/asset_pipeline/sky.py +++ b/tools/asset_pipeline/sky.py @@ -1,43 +1,111 @@ -"""Extract retail sky geometry and textures through RX2 GUID bindings.""" -import json,struct,sys +"""Extract authored retail skies through world references and RX2 GUID bindings.""" +import json +import struct +import sys from pathlib import Path from tools.owned_game.big import BigArchive +from .environment import Collections, NAMES, field, key_hash, sky_parameters, world_environment -def convert(game_root, assets): - sys.path.insert(0,str(Path(__file__).resolve().parents[1]/'vendor/utt')) - import mdl_parser,rx2_parser - archive=BigArchive(game_root/'data/big/miscload.big') - entries={e.path:e for e in archive.entries} - output=assets/'private/native-skies';output.mkdir(parents=True,exist_ok=True) - for name,suffix in [('University',''),('DownTown','_downtown'),('Industrial','_industrial')]: - prefix='data/content/world/models/DIST_skybox'+suffix - raw=archive.read(entries[prefix+'.rx2']) - model=mdl_parser.parse_rx2(raw) - table=rx2_parser.RX2File(raw);table.parse() - diffuse=None - for section in table.entries: - if section.type_id!=0x00eb0005:continue - o=section.f0;h=struct.unpack_from('>8I',raw,o) - if (h[4]-h[3])//h[1]!=32:raise ValueError('Unexpected sky material layout') - for i in range(h[1]): - v=struct.unpack_from('>8I',raw,o+h[3]+i*32) - at=o+v[0];kind=raw[at:raw.index(b'\0',at)] - if kind==b'diffuse':diffuse=(v[4]<<32)|v[5] - textures=rx2_parser.parse_rx2(archive.read(entries[prefix+'_Textures.rx2'])) - handle=None - for section in textures.entries: - if section.type_id!=0x00eb000b:continue - o=section.f0;n,start=struct.unpack_from('>2I',textures.data,o) - for i in range(n): - _,_,hi,lo,kind,index=struct.unpack_from('>6I',textures.data,o+start+i*24) - if (hi<<32)|lo==diffuse:handle=index - texture=next((t for t in textures.textures if t.index==handle),None) - if texture is None:raise ValueError('Sky diffuse GUID did not resolve: '+name) - if len(model.meshes)!=1 or model.meshes[0].uvs is None:raise ValueError('Unexpected sky mesh layout') - mesh=model.meshes[0] - (output/(name+'.rgba')).write_bytes(texture.rgba) + +def _material(raw, table): + channels = {} + for section in table.entries: + if section.type_id != 0x00eb0005: + continue + o = section.f0 + h = struct.unpack_from('>8I', raw, o) + if not h[1] or (h[4]-h[3])//h[1] != 32: + raise ValueError('Unexpected sky material layout') + for i in range(h[1]): + v = struct.unpack_from('>8I', raw, o+h[3]+i*32) + at = o+v[0] + kind = raw[at:raw.index(b'\0', at)].decode('ascii') + if kind in ('diffuse', 'specular'): + channels[kind] = (v[4]<<32)|v[5] + return channels + + +def _texture(textures, guid): + handle = None + for section in textures.entries: + if section.type_id != 0x00eb000b: + continue + o = section.f0 + n, start = struct.unpack_from('>2I', textures.data, o) + for i in range(n): + _, _, hi, lo, _, index = struct.unpack_from('>6I', textures.data, o+start+i*24) + if (hi<<32)|lo == guid: + handle = index + texture = next((t for t in textures.textures if t.index == handle), None) + if texture is None: + raise ValueError(f'Sky texture GUID did not resolve: {guid:016X}') + return texture + + +def convert(game_root, assets, converted): + sys.path.insert(0, str(Path(__file__).resolve().parents[1]/'vendor/utt')) + import mdl_parser + import rx2_parser + collections = Collections(converted) + archive = BigArchive(game_root/'data/big/miscload.big') + entries = {e.path: e for e in archive.entries} + output = assets/'private/native-skies' + output.mkdir(parents=True, exist_ok=True) + available = {p.stem.removeprefix('world') for p in (game_root/'data/content').glob('worldDIST_*.big')} + count = 0 + for (cls, _), row in collections.rows.items(): + if cls != key_hash('world'): + continue + own_stream = next((v['data'] for k, v in row['fields'].items() + if key_hash(k) == key_hash('WorldStream')), None) + if own_stream not in available: + continue + name = own_stream.removeprefix('DIST_') + env = world_environment(collections, row['key']) + raw = archive.read(entries['data/content/'+env['sky_model']+'.rx2']) + model = mdl_parser.parse_rx2(raw) + table = rx2_parser.RX2File(raw) + table.parse() + channels = _material(raw, table) + textures = rx2_parser.parse_rx2(archive.read(entries['data/content/'+env['sky_textures']+'.rx2'])) + diffuse = _texture(textures, channels['diffuse']) + sun = _texture(textures, channels['specular']) + shader = next(p.value for p in model.materials if p.kind == 'AttribulatorMaterialName') + env.update(sky_parameters(collections, shader)) + if len(model.meshes) != 1 or model.meshes[0].uvs is None: + raise ValueError('Unexpected sky mesh layout') + mesh = model.meshes[0] + if len(sun.rgba) != sun.width*sun.height*4 or sun.height != 16: + raise ValueError('Unexpected sky sun-gradient dimensions') + (output/(name+'.rgba')).write_bytes(diffuse.rgba) + (output/(name+'.sun.rgba')).write_bytes(sun.rgba) (output/(name+'.json')).write_text(json.dumps({ - 'width':texture.width,'height':texture.height, - 'positions':mesh.vertices.tolist(),'uvs':mesh.uvs.tolist(), - 'indices':mesh.faces.reshape(-1).tolist(), - },separators=(',',':')),encoding='utf-8') + 'width': diffuse.width, 'height': diffuse.height, + 'positions': mesh.vertices.tolist(), 'uvs': mesh.uvs.tolist(), + 'indices': mesh.faces.reshape(-1).tolist(), + 'environment': env, 'sun_width': sun.width, 'sun_height': sun.height, + 'source_sha256': row['sha256'], + }, separators=(',', ':')), encoding='utf-8') + count += 1 + if not count: + raise ValueError('No authored world skies resolved') + return count + + +if __name__ == '__main__': + import argparse + import tempfile + from .vlt import convert as convert_vlt + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--game-root', type=Path, required=True) + parser.add_argument('--assets', type=Path, required=True) + args = parser.parse_args() + # A targeted update reads only the small database pair and sky resources. + with tempfile.TemporaryDirectory(prefix='skate-sky-') as work: + database = BigArchive(args.game_root/'data/big/db.big') + needed = {'skaterschema.bin', 'skaterschema.vlt', 'skatercollections.bin', 'skatercollections.vlt'} + database.extract_entries([e for e in database.entries if Path(e.path).name.lower() in needed], Path(work)) + stem = Path(work)/'data/db' + names = (Path(__file__).parent/'names.txt').read_text(encoding='utf-8').splitlines() + converted = convert_vlt(stem/'skaterschema', stem/'skatercollections', [*names, *NAMES]) + print('Prepared', convert(args.game_root, args.assets, converted), 'authored skies') diff --git a/tools/asset_pipeline/test_environment.py b/tools/asset_pipeline/test_environment.py new file mode 100644 index 00000000..34f3b93b --- /dev/null +++ b/tools/asset_pipeline/test_environment.py @@ -0,0 +1,61 @@ +"""Synthetic environment references, independent of owned game assets.""" +import struct +import unittest +from .environment import Collections, field, key_hash, sky_parameters, world_environment + + +def raw(*values): + return struct.pack('>'+'f'*len(values), *values).hex() + + +def row(cls, key, parent='', **fields): + return {'class': cls, 'key': key, 'parent': parent, + 'fields': {k: {'type': 'fixture', 'data': v} for k, v in fields.items()}} + + +class EnvironmentTests(unittest.TestCase): + def test_world_reference_and_inheritance_select_default_fog(self): + rows = [ + row('world', 'base', Hash_C7A0A84F018E87BA='world/models/sky', + Hash_2A0BF629355AFB15='world/models/sky_Textures', + Hash_7E23D10785C43717=struct.pack('>2Q', key_hash('park'), 0).hex()), + row('world', 'test', 'base'), + row('render_locations', 'default', sun_position=raw(1, 0, 0), + Hash_2E9AAECD1C29F81F=raw(150), + material_fog=struct.pack('>3Q', key_hash('material_fog'), key_hash('default'), 0).hex()), + row('render_locations', 'park', 'default', sun_position=raw(0, 3, 4)), + row('material_fog', 'default', fog_near=raw(20), fog_far=raw(1000), + fog_colour=raw(.1, .2, .3, 1), fog_power=raw(2), fog_max=raw(.5)), + row('material_fog', 'fog_default', 'default', fog_far=raw(200)), + ] + env = world_environment(Collections({'collections': rows}), 'test') + self.assertEqual(env['sun_direction'], [0, .6, .8]) + self.assertEqual(env['anchor_height'], 150) + self.assertEqual(env['location_chain'], ['park', 'default']) + self.assertEqual(env['fog']['fog_far'], [1000]) + self.assertEqual(env['sky_model'], 'world/models/sky') + + def test_unnamed_hashes_and_cycle_rejection(self): + cls = f'Hash_{key_hash("world"):016X}' + c = Collections({'collections': [row(cls, 'a', 'b', value='child'), + row(cls, 'b', value='parent')]}) + values, _ = c.resolve('world', 'a') + self.assertEqual(field(values, 'value'), 'child') + c.rows[key_hash('world'), key_hash('b')]['parent'] = 'a' + with self.assertRaises(ValueError): + c.resolve('world', 'a') + + def test_sky_requires_complete_array_and_shader_collection(self): + r = row('material_sky', 'default') + r['fields']['m_params'] = {'type': 'Attrib::Types::Vector4', 'data': 'header'} + c = Collections({'collections': [r]}) + with self.assertRaises(ValueError): + sky_parameters(c, 'sky.default') + r['fields']['m_params']['array'] = {'items': [raw(.5, .25, 0, 0)]} + self.assertEqual(sky_parameters(c, 'sky.default')['multiplier'], .25) + with self.assertRaises(KeyError): + sky_parameters(c, 'sky.missing') + + +if __name__ == '__main__': + unittest.main() From e72be071622adb94ce6aa62cce5e70aa896e8426 Mon Sep 17 00:00:00 2001 From: chazmm Date: Tue, 8 Sep 2026 15:50:42 +0100 Subject: [PATCH 04/16] Add opt-in foliage visibility diagnostic for University treeline --- crates/skate-game/src/retail_depth.wgsl | 2 +- crates/skate-game/src/retail_render.rs | 26 +++++++++++++++++++---- crates/skate-game/src/retail_world.wgsl | 3 ++- docs/retail-renderer.md | 28 +++++++++++++++++++++++++ 4 files changed, 53 insertions(+), 6 deletions(-) diff --git a/crates/skate-game/src/retail_depth.wgsl b/crates/skate-game/src/retail_depth.wgsl index 4730bf54..41aedc41 100644 --- a/crates/skate-game/src/retail_depth.wgsl +++ b/crates/skate-game/src/retail_depth.wgsl @@ -5,7 +5,7 @@ #ifdef MOTION_VECTOR_PREPASS #import bevy_pbr::pbr_prepass_functions::calculate_motion_vector #endif -struct WorldParams { mode: vec4, surface: vec4, family: vec4, fog_ramp: vec4, fog_color: vec4, shadow_color: vec4, sun_direction: vec4 } +struct WorldParams { mode: vec4, foliage_debug: vec4, surface: vec4, family: vec4, fog_ramp: vec4, fog_color: vec4, shadow_color: vec4, sun_direction: vec4 } @group(#{MATERIAL_BIND_GROUP}) @binding(0) var p: WorldParams; @group(#{MATERIAL_BIND_GROUP}) @binding(1) var diffuse: texture_2d; @group(#{MATERIAL_BIND_GROUP}) @binding(2) var diffuse_sampler: sampler; diff --git a/crates/skate-game/src/retail_render.rs b/crates/skate-game/src/retail_render.rs index cc2cb68d..a78a8263 100644 --- a/crates/skate-game/src/retail_render.rs +++ b/crates/skate-game/src/retail_render.rs @@ -18,6 +18,9 @@ use std::collections::BTreeMap; pub(crate) struct RetailRenderPlugin; impl Plugin for RetailRenderPlugin { fn build(&self, app: &mut App) { + if std::env::var_os("SKATE_DEBUG_FOLIAGE").is_some_and(|v| v == "1") { + eprintln!("SKATE_FOLIAGE_DEBUG: solid cyan tree-wall cards, magenta other foliage; alpha rejection disabled for foliage only"); + } embedded_asset!(app, "retail_world.wgsl"); embedded_asset!(app, "retail_tone.wgsl"); embedded_asset!(app, "retail_depth.wgsl"); @@ -58,6 +61,8 @@ impl FullscreenMaterial for RetailTone { pub(crate) struct WorldParams { // family, texture flags, alpha cutoff (-1 for opaque), exposure pub mode: Vec4, + // Diagnostic solid foliage colour; w=0 keeps retail shading. + pub foliage_debug: Vec4, // macro UV scale, opacity, detail UV scale, material multiplier pub surface: Vec4, // tree LM scale/floor/tint, proxy multiplier: reference day capture @@ -254,9 +259,15 @@ impl Definition { | (u32::from(detail.is_some()) << 7); // scene.hlsl's retail world ALPHAREF is 30, not the portable 0.5. let cutoff = 30. / 255.; - let alpha = match m.alpha_mode { - 1 => AlphaMode::Mask(cutoff), - 2 => AlphaMode::Blend, + let debug_foliage = matches!(self.family, 9 | 10) + && std::env::var_os("SKATE_DEBUG_FOLIAGE").is_some_and(|v| v == "1"); + let tree_wall = m.retail_definition.as_deref().is_some_and(|bytes| { + bytes.windows(b"TreeWall".len()).any(|s| s == b"TreeWall") + }); + let alpha = match (debug_foliage, m.alpha_mode) { + (true, _) => AlphaMode::Opaque, + (_, 1) => AlphaMode::Mask(cutoff), + (_, 2) => AlphaMode::Blend, _ => AlphaMode::Opaque, }; RetailWorldMaterial { @@ -264,9 +275,16 @@ impl Definition { mode: Vec4::new( self.family as f32, flags as f32, - if m.alpha_mode == 1 { cutoff } else { -1. }, + if m.alpha_mode == 1 && !debug_foliage { cutoff } else { -1. }, 2.5, ), + foliage_debug: if !debug_foliage { + Vec4::ZERO + } else if tree_wall { + Vec4::new(0., 1., 1., 1.) + } else { + Vec4::new(1., 0., 1., 1.) + }, surface: Vec4::new(macro_scale, macro_opacity, detail_scale, 1.), family: Vec4::new(0.3435, 0.02, 1., 0.45), fog_ramp: Vec4::new(0., 0., 1., 0.), diff --git a/crates/skate-game/src/retail_world.wgsl b/crates/skate-game/src/retail_world.wgsl index 4f0d740f..b9e6b2b7 100644 --- a/crates/skate-game/src/retail_world.wgsl +++ b/crates/skate-game/src/retail_world.wgsl @@ -1,7 +1,7 @@ #import bevy_pbr::{forward_io::VertexOutput, mesh_view_bindings as frame} struct WorldParams { - mode: vec4, surface: vec4, family: vec4, + mode: vec4, foliage_debug: vec4, surface: vec4, family: vec4, fog_ramp: vec4, fog_color: vec4, shadow_color: vec4, sun_direction: vec4, } @group(#{MATERIAL_BIND_GROUP}) @binding(0) var p: WorldParams; @@ -130,6 +130,7 @@ fn fragment(i: VertexOutput) -> @location(0) vec4 { var xe = max((lin*fog_a+p.fog_color.rgb*f)*p.mode.w,vec3(0.0)); // Reduced curve is the full curve with the linear input capped at one. if fam == 8u { xe = min(xe,vec3(1.0)); } + if p.foliage_debug.w != 0.0 { return vec4(p.foliage_debug.rgb, 1.0); } if a.a < p.mode.z { discard; } return vec4(xe,alpha); } diff --git a/docs/retail-renderer.md b/docs/retail-renderer.md index cb3c78c6..ea47ad62 100644 --- a/docs/retail-renderer.md +++ b/docs/retail-renderer.md @@ -115,3 +115,31 @@ there has been no matched-camera pixel comparison against the recomp. The shaders are embedded in the executable. Sky and map payloads are extracted from the user's game and must never be committed or bundled with releases. + +### Treeline investigation and diagnostic (2026-09-08) + +The user's mega-park screenshots refer to the bowl/cliffs in University, not +DIST_MegaPark (the separate create-a-park venue). A read-only decode of the installed +University SKATE14 package found 8,546 materials, 1,645,617 render triangles, +1,180 tree-family mesh materials and all 86 TreeWall meshes from the source cache. +TreeWall diffuse texture 0x2c70170a00053a88 retains its RGBA cutout, and the tree +materials retain masked alpha and two-sided flags. A TOC audit of all 479 full +models found no extra vertex or face buffers omitted by the parser. + +Proxy comparisons must resolve mesh material GUIDs with +`_bind_material_groups_by_guid`; `mdl_parser.Mesh.name` is in material-table order +and can label proxy terrain as trees. With GUID bindings, the 14 proxy foliage +meshes intersecting x=200..550, z=-850..-600 contain 3,878 distinct triangles; +3,813 match full geometry after unordered-vertex quantization to 1 mm. The small +remainder does not establish absent trees (different triangulation/rounding also +changes these keys). DMO_University and DMO_Global contain movable props, not a +second background forest. These checks do not prove visual parity or a fix. + +For a user-run discriminating capture, `SKATE_DEBUG_FOLIAGE=1` renders tree-wall +cards cyan and other tree-family surfaces magenta, opaque and unlit. Geometry, +camera, depth testing and occlusion remain authored. This bypasses texture alpha +in both colour and depth passes without adding proxy geometry. Return to the +reported viewpoint: solid foliage appearing in the missing area implicates the +normal shading/alpha path; an empty area requires checking placement, camera or +source selection. Unset the variable to restore normal rendering. This diagnostic +has been prepared without launching the game. From 616be77a283890ee443996ed6213ce9f5e69f968 Mon Sep 17 00:00:00 2001 From: chazmm Date: Tue, 8 Sep 2026 16:07:30 +0100 Subject: [PATCH 05/16] Restore global tree-wall backdrops omitted from district streams --- crates/skate-data/examples/inspect_skate.rs | 13 +- crates/skate-data/src/skate_map.rs | 17 ++- crates/skate-data/tests/skate_map.rs | 17 +++ crates/skate-game/src/retail_backdrop.rs | 36 +++++ crates/skate-game/src/retail_render.rs | 4 + crates/skate-game/src/world.rs | 1 + docs/retail-renderer.md | 41 ++++++ tools/asset_pipeline/backdrop.py | 138 ++++++++++++++++++++ tools/asset_pipeline/install.py | 3 + tools/asset_pipeline/map_writer.py | 10 +- tools/asset_pipeline/test_backdrop.py | 44 +++++++ 11 files changed, 318 insertions(+), 6 deletions(-) create mode 100644 crates/skate-game/src/retail_backdrop.rs create mode 100644 tools/asset_pipeline/backdrop.py create mode 100644 tools/asset_pipeline/test_backdrop.py diff --git a/crates/skate-data/examples/inspect_skate.rs b/crates/skate-data/examples/inspect_skate.rs index 56cd0792..3d8cfa1f 100644 --- a/crates/skate-data/examples/inspect_skate.rs +++ b/crates/skate-data/examples/inspect_skate.rs @@ -1,7 +1,18 @@ fn main() { let mut failed = false; + let mut render_only = false; for path in std::env::args_os().skip(1) { - match skate_data::skate_map::SkateMap::load(std::path::Path::new(&path)) { + if path == "--render-only" { + render_only = true; + continue; + } + let result = if render_only { + std::fs::read(&path).map_err(|e| e.to_string()) + .and_then(|bytes| skate_data::skate_map::SkateMap::parse_render_only(&bytes)) + } else { + skate_data::skate_map::SkateMap::load(std::path::Path::new(&path)) + }; + match result { Ok(m) => { println!( "{} v{}: vertices={} triangles={} collision={} textures={} rails={} doors={} lights={} routes={} spawn={:?} heading={}", diff --git a/crates/skate-data/src/skate_map.rs b/crates/skate-data/src/skate_map.rs index 6c229971..52d05733 100644 --- a/crates/skate-data/src/skate_map.rs +++ b/crates/skate-data/src/skate_map.rs @@ -343,6 +343,21 @@ impl SkateMap { Self::parse(&data).map_err(|e| format!("{}: {e}", path.display())) } pub fn parse(data: &[u8]) -> Result { + Self::parse_inner(data, true) + } + /// Decode a supplemental presentation package without requiring collision. + /// Playable map loading continues to use `parse`/`load`. + pub fn parse_render_only(data: &[u8]) -> Result { + let map = Self::parse_inner(data, false)?; + if !map.geometry.collision.is_empty() || !map.rails.is_empty() + || !map.doors.is_empty() || !map.lights.is_empty() || !map.routes.is_empty() + || map.extensions.iter().any(|e| e.tag != *b"WMET") + { + return Err("SKATE render-only package contains non-presentation data".into()); + } + Ok(map) + } + fn parse_inner(data: &[u8], require_collision: bool) -> Result { let mut r = Reader { bytes: data, at: 0 }; let magic = r.take(8)?; if &magic[..5] != b"SKATE" @@ -637,7 +652,7 @@ impl SkateMap { }); } } - if geometry.collision.is_empty() && !extensions.iter().any(|e| e.tag == *b"RWCM" && e.schema == 1 && !e.payload.is_empty()) { + if require_collision && geometry.collision.is_empty() && !extensions.iter().any(|e| e.tag == *b"RWCM" && e.schema == 1 && !e.payload.is_empty()) { return Err("SKATE requires triangle collision or an embedded RWCM archive".into()); } if r.at != data.len() { diff --git a/crates/skate-data/tests/skate_map.rs b/crates/skate-data/tests/skate_map.rs index d48ca217..013e49b7 100644 --- a/crates/skate-data/tests/skate_map.rs +++ b/crates/skate-data/tests/skate_map.rs @@ -1,5 +1,22 @@ use skate_data::skate_map::SkateMap; +#[test] +fn presentation_loading_does_not_relax_playable_map_validation() { + let mut data = fixture(1); + let counts = 8 + 4 + 4 + "Fixture".len() + (4 + 12) * 4; + data[counts + 16..counts + 24].fill(0); // No collision or rails. + let needle: Vec = [0u32, 1, 2].into_iter().flat_map(u32::to_le_bytes).collect(); + let indices = data.windows(12).position(|w| w == needle).unwrap(); + data.truncate(indices + 12); + assert!(SkateMap::parse(&data).unwrap_err().contains("requires triangle collision")); + let render = SkateMap::parse_render_only(&data).unwrap(); + assert_eq!(render.geometry.indices, [0, 1, 2]); + assert!(render.geometry.collision.is_empty()); + data[indices..indices+4].copy_from_slice(&99u32.to_le_bytes()); + assert!(SkateMap::parse_render_only(&data).unwrap_err().contains("indices")); + assert!(SkateMap::parse_render_only(&fixture(1)).unwrap_err().contains("non-presentation")); +} + fn u(b: &mut Vec, value: u32) { b.extend(value.to_le_bytes()); } diff --git a/crates/skate-game/src/retail_backdrop.rs b/crates/skate-game/src/retail_backdrop.rs new file mode 100644 index 00000000..598930a3 --- /dev/null +++ b/crates/skate-game/src/retail_backdrop.rs @@ -0,0 +1,36 @@ +//! Global authored foliage that lives outside the district stream. +use bevy::prelude::*; +use skate_data::skate_map::SkateMap; +use std::path::Path; + +pub(crate) fn spawn_backdrop( + name: &str, + asset_root: &Path, + commands: &mut Commands, + meshes: &mut Assets, + materials: &mut Assets, + retail_materials: &mut Assets, + images: &mut Assets, +) { + let path = asset_root.join("private/native-backdrops").join(format!("{name}.skate")); + if !path.is_file() { + return; + } + let map = match std::fs::read(&path).map_err(|e| e.to_string()) + .and_then(|data| SkateMap::parse_render_only(&data)) { + Ok(map) => map, + Err(error) => { + error!("SKATE_BACKDROP: {}: {error}", path.display()); + return; + } + }; + // This package contributes presentation only; never route it into physics. + if map.name != name || !map.geometry.collision.is_empty() + || !map.lights.is_empty() || !map.doors.is_empty() || !map.rails.is_empty() + { + error!("SKATE_BACKDROP: invalid render-only package {}", path.display()); + return; + } + info!("SKATE_BACKDROP: {name} authored foliage triangles={}", map.geometry.indices.len() / 3); + crate::skate_world::spawn(&map, commands, meshes, materials, retail_materials, images); +} diff --git a/crates/skate-game/src/retail_render.rs b/crates/skate-game/src/retail_render.rs index a78a8263..f6e9b922 100644 --- a/crates/skate-game/src/retail_render.rs +++ b/crates/skate-game/src/retail_render.rs @@ -40,6 +40,10 @@ pub(crate) struct RetailScene(pub bool); mod sky; pub(crate) use sky::{spawn_sky, RetailSkyMaterial}; +#[path = "retail_backdrop.rs"] +mod backdrop; +pub(crate) use backdrop::spawn_backdrop; + #[derive(Component, ExtractComponent, Clone, Copy, ShaderType, Default)] pub(crate) struct RetailTone { pub enabled: Vec4, diff --git a/crates/skate-game/src/world.rs b/crates/skate-game/src/world.rs index f14639dd..3681dbc6 100644 --- a/crates/skate-game/src/world.rs +++ b/crates/skate-game/src/world.rs @@ -39,6 +39,7 @@ fn spawn( // Physics has already consumed the package. Render assets own their // uploaded data; keeping another full city package wastes gigabytes. crate::skate_world::spawn(&map, &mut commands, &mut meshes, &mut materials, &mut retail_materials, &mut images); + crate::retail_render::spawn_backdrop(&map.name, &config.asset_root, &mut commands, &mut meshes, &mut materials, &mut retail_materials, &mut images); crate::retail_render::spawn_sky(&map.name, &config.asset_root, &mut commands, &mut meshes, &mut images, &mut sky_materials, &mut retail_materials); return; } diff --git a/docs/retail-renderer.md b/docs/retail-renderer.md index ea47ad62..3a1c8255 100644 --- a/docs/retail-renderer.md +++ b/docs/retail-renderer.md @@ -143,3 +143,44 @@ reported viewpoint: solid foliage appearing in the missing area implicates the normal shading/alpha path; an empty area requires checking placement, camera or source selection. Unset the variable to restore normal rendering. This diagnostic has been prepared without launching the game. + +### Missing global tree-wall backdrop resolved (2026-09-08) + +The user-run opaque-foliage diagnostic left the same empty background. The +missing surfaces are in `world/models/DIST_Water_University.rx2`, not in +`DIST_University` or its proxy stream. Despite its name, this global model has +both an ocean plane and a separate tree material: mesh 1 contains 1,572 vertices +and 786 tree-wall triangles. World fields `951898F6C0FA6856` and +`CA5A157A65E75934` select this model and its texture resource. The global +Industrial model likewise contains 716 foliage triangles. + +`tools.asset_pipeline.backdrop` follows those world references and exports only +the tree-family presentation meshes into `private/native-backdrops/.skate`. +Mesh-to-material and material-to-texture selection use binary GUIDs: texture +name suffixes differ from the global texture resource GUIDs. Original positions, +indices, UVs, masked alpha, two-sided rendering and baked lightmaps are retained. +No water surfaces or synthetic forest placements are added. + +The renderer loads this supplemental package after the district and before sky +setup, through the existing retail mesh/material path. `parse_render_only` is +an explicit data-reader entry point for presentation packages; ordinary playable +map loading still requires collision. Presentation loading rejects collision, +rails, doors, lights, routes and extensions other than WMET. This does not alter +the playable map's collision or gameplay state. Future installation runs prepare +the supplement automatically; existing assets can be updated with: + +```powershell +python -m tools.asset_pipeline.backdrop --game-root --assets +``` + +Static verification: the University supplement contains exactly the source's +786 triangles and matching vertex positions/indices/UVs. Zero of these triangles +match the 86 existing district TreeWall meshes after unordered-vertex 1 mm +quantization. Both University and Industrial supplements pass the runtime data +reader's offline inspector. Synthetic GUID tests and map-reader regression tests +pass. The executable was compiled but not launched; visual confirmation remains +for the user. The normal launcher clears SKATE_DEBUG_FOLIAGE. + +For integration with staged map switching, prepare this supplement alongside the +district, before applying sky lighting. Track its entities and assets in the +same map lifetime. The current adapter is in `retail_backdrop.rs`. diff --git a/tools/asset_pipeline/backdrop.py b/tools/asset_pipeline/backdrop.py new file mode 100644 index 00000000..ff6836c0 --- /dev/null +++ b/tools/asset_pipeline/backdrop.py @@ -0,0 +1,138 @@ +"""Retain foliage in authored global world models outside district streams.""" +import json +import hashlib +import struct +import sys +import tempfile +from pathlib import Path + +from .environment import Collections, field, key_hash +from .map_writer import write +from .sky import _texture +from tools.owned_game.big import BigArchive + + +def texture_groups(raw, table, channels): + """Read binary channel GUIDs; display-name suffixes are not resource IDs.""" + groups = [] + for section in table.entries: + if section.type_id != 0x00eb0005: + continue + o = section.f0 + h = struct.unpack_from('>8I', raw, o) + if not h[1] or (h[4] - h[3]) != h[1] * 32: + raise ValueError('Unexpected global model material layout') + for j in range(h[1]): + v = struct.unpack_from('>8I', raw, o+h[3]+j*32) + at = o+v[0] + kind = raw[at:raw.index(b'\0', at)].decode('ascii') + if kind == 'Name': + groups.append({}) + if kind in channels: + groups[-1][kind] = (v[4] << 32) | v[5] + return groups + + +def convert(game_root, assets, converted): + vendor = Path(__file__).resolve().parents[1] / 'vendor' + sys.path.insert(0, str(vendor / 'utt')) + sys.path.insert(0, str(vendor / 'university/tools/vanilla_map_extraction/tools')) + import numpy as np + import mdl_parser + import rx2_parser + import prepare_hawaiian_dream as prep + from retail_lightmap_uv import decode_lightmap_uvs + from retail_texture_decode import B5G6R5_FORMAT_ID, decode_b5g6r5 + + archive = BigArchive(game_root / 'data/big/miscload.big') + entries = {e.path: e for e in archive.entries} + collections = Collections(converted) + output = assets / 'private/native-backdrops' + output.mkdir(parents=True, exist_ok=True) + available = {p.stem.removeprefix('world') for p in (game_root/'data/content').glob('worldDIST_*.big')} + count = 0 + for (cls, _), row in collections.rows.items(): + if cls != key_hash('world'): + continue + own_stream = next((v['data'] for k, v in row['fields'].items() + if key_hash(k) == key_hash('WorldStream')), None) + if own_stream not in available: + continue + fields, _ = collections.resolve('world', row['key']) + # These global presentation resources are separate from WorldStream. + if key_hash('Hash_951898F6C0FA6856') not in fields: + continue + model_name = field(fields, 'Hash_951898F6C0FA6856') + if not model_name: + continue + texture_name = field(fields, 'Hash_CA5A157A65E75934') + raw = archive.read(entries['data/content/' + model_name + '.rx2']) + model = mdl_parser.parse_rx2(raw) + groups, bindings = prep._bind_material_groups_by_guid( + raw, prep._group_material_parameters(model.materials), len(model.meshes), + allow_import_order_fallback=False) + selected = [(i, prep._material_metadata(groups, i)) for i in range(len(model.meshes))] + selected = [(i, m) for i, m in selected if m['shader_name'] in ('tree.default', 'animated.tree')] + if not selected: + continue + textures = rx2_parser.parse_rx2(archive.read(entries['data/content/' + texture_name + '.rx2'])) + table = rx2_parser.RX2File(raw) + table.parse() + channel_groups = texture_groups(raw, table, prep.RETAIL_TEXTURE_CHANNELS) + with tempfile.TemporaryDirectory(prefix='skate-backdrop-') as temporary: + root = Path(temporary) + arrays, meshes, exported_textures = {}, [], {} + for i, material in selected: + mesh = model.meshes[i] + binding = bindings[i] + meshes.append(dict(material, index=i, name=groups[i]['Name'][0], + retail_material_guid=f"0x{binding['material_guid']:016X}", + retail_material_handle=f"0x{binding['material_handle']:08X}", + retail_material_group_index=binding['group_index'], source_offsets=mesh.source_offsets)) + arrays[f'vertices_{i}'] = mesh.vertices + arrays[f'faces_{i}'] = mesh.faces + arrays[f'uvs_{i}'] = mesh.uvs + arrays[f'normals_{i}'] = mesh.normals + lm = decode_lightmap_uvs(raw, vertex_buffer_offset=mesh.source_offsets['vertex_buffer'], + vertex_count=mesh.vertex_count, vertex_stride=mesh.vertex_stride, attributes=mesh.attributes) + if lm is not None: + arrays[f'lightmap_uvs_{i}'] = lm.values + for role, name in material['retail_texture_ids'].items(): + if name in exported_textures: + continue + texture = _texture(textures, channel_groups[binding['group_index']][role]) + rgba = texture.rgba + if texture.fmt_id == B5G6R5_FORMAT_ID: + rgba = decode_b5g6r5(textures.data[texture.data_offset:texture.data_offset+texture.buffer_size], + texture.width, texture.height) + path = name + '.rgba' + (root/path).write_bytes(rgba) + exported_textures[name] = dict(width=texture.width, height=texture.height, rgba=path) + np.savez(root/'model.npz', **arrays) + name = own_stream.removeprefix('DIST_') + manifest = dict(map_name=name, district_name=own_stream, models=[dict( + asset_id='0x'+hashlib.sha256(raw).hexdigest()[:16], npz='model.npz', meshes=meshes)], + textures=exported_textures, normal_texture_policy=dict(excluded_texture_ids=[]), grind_splines=[], + source_model=model_name, source_textures=texture_name, source_sha256=hashlib.sha256(raw).hexdigest()) + path = root/'manifest.json' + path.write_text(json.dumps(manifest)) + write(path, output/(name+'.skate'), None, render_only=True) + count += 1 + return count + + +if __name__ == '__main__': + import argparse + from .vlt import convert as convert_vlt + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--game-root', type=Path, required=True) + parser.add_argument('--assets', type=Path, required=True) + args = parser.parse_args() + with tempfile.TemporaryDirectory(prefix='skate-backdrop-db-') as work: + database = BigArchive(args.game_root/'data/big/db.big') + needed = {'skaterschema.bin', 'skaterschema.vlt', 'skatercollections.bin', 'skatercollections.vlt'} + database.extract_entries([e for e in database.entries if Path(e.path).name.lower() in needed], Path(work)) + stem = Path(work)/'data/db' + names = (Path(__file__).parent/'names.txt').read_text(encoding='utf-8').splitlines() + converted = convert_vlt(stem/'skaterschema', stem/'skatercollections', names) + print('Prepared', convert(args.game_root, args.assets, converted), 'authored foliage backdrops') diff --git a/tools/asset_pipeline/install.py b/tools/asset_pipeline/install.py index 4ad5cd10..070bbfe0 100644 --- a/tools/asset_pipeline/install.py +++ b/tools/asset_pipeline/install.py @@ -195,6 +195,9 @@ def install(iso,base,game_exe,report,game_root=None): report('Preparing retail sky domes') from .sky import convert as write_skies write_skies(game_root,stage/'assets',converted) + report('Preparing global foliage backdrops') + from .backdrop import convert as write_backdrops + write_backdrops(game_root,stage/'assets',converted) 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/map_writer.py b/tools/asset_pipeline/map_writer.py index a17f776c..22843321 100644 --- a/tools/asset_pipeline/map_writer.py +++ b/tools/asset_pipeline/map_writer.py @@ -59,7 +59,7 @@ def spawn_point(manifest,root): if best is None:raise ValueError('No supported spawn surface in '+manifest['map_name']) return best[1] -def write(manifest_path,output,collision,report=lambda _:None): +def write(manifest_path,output,collision,report=lambda _:None, *, render_only=False): root=manifest_path.parent;m=json.loads(manifest_path.read_text());textures=m['textures'] ids={name:i+1 for i,name in enumerate(sorted(textures))} excluded=set(m['normal_texture_policy']['excluded_texture_ids']) @@ -115,7 +115,7 @@ def write(manifest_path,output,collision,report=lambda _:None): record['frame'][:,:3]=np.rint(np.clip(binormal,-1,1)*127).astype('i1');record['frame'][:,3]=np.rint(sign*127).astype('i1') if not np.isfinite(pos).all():raise ValueError('Non-finite map geometry') vertices.write(record.tobytes());indices.write((faces+nv).astype('8I', raw, 0, 0, len(parameters), 0, 32, + 32 + len(parameters)*32, 0, 0, 0) + at = 256 + for i, (kind, guid) in enumerate(parameters): + word = kind.encode() + b'\0' + raw[at:at+len(word)] = word + struct.pack_into('>8I', raw, 32+i*32, at, 0, 0, 0, + guid >> 32, guid & 0xffffffff, 0, 0) + at += len(word) + table = SimpleNamespace(entries=[SimpleNamespace(type_id=0xeb0005, f0=0)]) + self.assertEqual(texture_groups(bytes(raw), table, ('diffuse', 'normal', 'lightmap')), + [{'normal': 19}, {'diffuse': 0x123456789ABCDEF0, 'lightmap': 23}]) + + def test_texture_guid_uses_handle_not_list_position(self): + raw = bytearray(64) + struct.pack_into('>2I', raw, 0, 1, 8) + struct.pack_into('>6I', raw, 8, 0, 0, 0x12345678, 0x9abcdef0, 0, 27) + wanted = SimpleNamespace(index=27) + textures = SimpleNamespace(data=bytes(raw), + entries=[SimpleNamespace(type_id=0xeb000b, f0=0)], + textures=[SimpleNamespace(index=1), wanted]) + self.assertIs(_texture(textures, 0x123456789ABCDEF0), wanted) + with self.assertRaises(ValueError): + _texture(textures, 123) + + +if __name__ == '__main__': + unittest.main() From 8c31384a120da5fcc17df5c90670b071ccc50013 Mon Sep 17 00:00:00 2001 From: chazmm Date: Tue, 8 Sep 2026 16:13:58 +0100 Subject: [PATCH 06/16] Document native character lighting inputs and spatial lookup evidence --- docs/character-lighting-investigation.md | 43 ++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 docs/character-lighting-investigation.md diff --git a/docs/character-lighting-investigation.md b/docs/character-lighting-investigation.md new file mode 100644 index 00000000..fcffbd14 --- /dev/null +++ b/docs/character-lighting-investigation.md @@ -0,0 +1,43 @@ +# Character lighting investigation + +Status: native character-lighting reconstruction started; no runtime lighting change yet. + +Observed in the local reference renderer (`skate3_native_scene.cpp`, CaptureCharLighting, +and `scene_char.hlsli`): defaultcharacter/CAC shading consumes a directional key, +nine RGB spherical-harmonic rows, ambient multiplier, exposure, rim colour/direction, +key/rim specular colours and powers, material multiplier, and shadow visibility. +The character material path differs from the baked world-lightmap path. Hair uses +its own wrapped diffuse/ambient/rim response. Skin needs its dedicated specular +mask; opaque diffuse alpha is not a substitute. The player currently uses generic +StandardMaterial, with imported-world directional lighting disabled. + +Observed in TU3 generated PPC source: +- 0x827E7280 reads nine Vector3 elements from field F45DFCAB6EC195CD, copies them + into 16-byte rows at manager +16, and retains a default-probe mode byte. +- Collections with class hash 6A2B5946514B8490 have default/dlc/cas/freeskate rows + containing this complete nine-element array. Class 1FFDC8E3ACA07C1F also has it. +- 0x827E7348 computes squared position differences against records at +160 and + copies nine lighting vectors from a selected record. This supports a spatial + lighting lookup, but does not yet establish the complete sampling/blending, + registration, or fallback rule. +- `light_probes` (class 0E902F3F038A483C) contains global directional-light structs, + ambient, and arrays of cSimpleLightData. Do not assume these are the same as + the spatial baked SH samples just because of the class name. + +Implementation work still required: +1. Recover the spatial irradiance resource/registration and sampling path and + resolve default probe selection from world/render state. +2. Extract character frame inputs and per-material parameters through native + references, retaining texture/normal/spec-mask semantics. +3. Add a skinned character material adapter that evaluates native SH, key, rim, + and highlights, updated from character position. Keep world materials and + gameplay/physics unchanged. +4. Supply character-only shadow visibility separately from the world's disabled + additive sun/shadow rendering; establish the native source before choosing + its renderer implementation. +5. Validate in exposed and shaded locations and with skin/clothing/hair. No + game launches are authorized by this investigation; prepare user launchers. + +Reference caveat: community symbol labels are hypotheses. In particular the +82E3B548 label must not be treated as a verified prototype. No copied renderer +capture's lighting constants should become a universal outdoor preset. From 7d44d84f19081f8de7ae9cbaf574a3207f5865e3 Mon Sep 17 00:00:00 2001 From: chazmm Date: Tue, 8 Sep 2026 16:41:23 +0100 Subject: [PATCH 07/16] Add spatial irradiance and character lighting adapter --- crates/skate-game/src/graphics_menu.rs | 1 + crates/skate-game/src/main.rs | 2 + crates/skate-game/src/retail_character.rs | 237 ++++++++++++++++++ crates/skate-game/src/retail_character.wgsl | 72 ++++++ .../src/retail_character_depth.wgsl | 32 +++ crates/skate-game/src/retail_irradiance.rs | 163 ++++++++++++ crates/skate-game/src/retail_render.rs | 1 + crates/skate-game/src/skate_world.rs | 2 + docs/character-lighting-investigation.md | 68 ++++- docs/retail-renderer.md | 9 +- tools/asset_pipeline/character_lighting.py | 55 ++++ tools/asset_pipeline/install.py | 2 + tools/asset_pipeline/irradiance.py | 51 ++++ tools/asset_pipeline/map_writer.py | 3 + tools/asset_pipeline/test_irradiance.py | 37 +++ 15 files changed, 730 insertions(+), 5 deletions(-) create mode 100644 crates/skate-game/src/retail_character.rs create mode 100644 crates/skate-game/src/retail_character.wgsl create mode 100644 crates/skate-game/src/retail_character_depth.wgsl create mode 100644 crates/skate-game/src/retail_irradiance.rs create mode 100644 tools/asset_pipeline/character_lighting.py create mode 100644 tools/asset_pipeline/irradiance.py create mode 100644 tools/asset_pipeline/test_irradiance.py diff --git a/crates/skate-game/src/graphics_menu.rs b/crates/skate-game/src/graphics_menu.rs index a3d77f99..158b54d9 100644 --- a/crates/skate-game/src/graphics_menu.rs +++ b/crates/skate-game/src/graphics_menu.rs @@ -462,6 +462,7 @@ mod tests { .insert_resource(Menu { open: false, selected: 0, settings: GraphicsSettings::default(), difficulty: Difficulty::Easy, path: PathBuf::new(), supported_msaa: vec![1, 2, 4, 8], status: String::new(), + maps: Vec::new(), selected_map: 0, }) .add_systems(Update, apply); app.world_mut().spawn((Window::default(), PrimaryWindow)); diff --git a/crates/skate-game/src/main.rs b/crates/skate-game/src/main.rs index 8d93deda..8345f00f 100644 --- a/crates/skate-game/src/main.rs +++ b/crates/skate-game/src/main.rs @@ -17,6 +17,8 @@ mod performance; mod graphics_menu; mod render_capacity; mod retail_render; +mod retail_character; +mod retail_irradiance; mod presentation; mod replay; mod world; diff --git a/crates/skate-game/src/retail_character.rs b/crates/skate-game/src/retail_character.rs new file mode 100644 index 00000000..b6fefdaf --- /dev/null +++ b/crates/skate-game/src/retail_character.rs @@ -0,0 +1,237 @@ +//! Character SH/key/rim shader, independent of the world's additive lights. +use crate::retail_irradiance::Irradiance; +use bevy::{ + asset::embedded_asset, + gltf::GltfMaterialName, + prelude::*, + render::render_resource::{AsBindGroup, ShaderType}, + shader::ShaderRef, +}; +use serde::Deserialize; +use std::collections::HashMap; + +pub(crate) struct CharacterLightingPlugin; +impl Plugin for CharacterLightingPlugin { + fn build(&self, app: &mut App) { + embedded_asset!(app, "retail_character.wgsl"); + embedded_asset!(app, "retail_character_depth.wgsl"); + app.add_plugins(MaterialPlugin::::default()) + .add_systems(Startup, load) + .add_systems(Update, (bind, update).chain()); + } +} +#[derive(Deserialize)] +struct MaterialData { + shader: String, + params: Vec<[f32; 4]>, + specular: Option, +} +#[derive(Deserialize)] +struct LightingData { + materials: HashMap, + default_sh: [[f32; 3]; 9], +} +#[derive(Resource)] +struct Lighting { + data: LightingData, + probes: Irradiance, + light: Vec4, +} +#[derive(Clone, ShaderType)] +struct CharacterParams { + light: Vec4, + tint: Vec4, + // normal map present, dedicated specular mask, alpha cutoff, hair family + options: Vec4, + rows: [Vec4; 9], + sh: [Vec4; 9], +} +#[derive(Asset, TypePath, AsBindGroup, Clone)] +struct CharacterMaterial { + #[uniform(0)] + params: CharacterParams, + #[texture(1)] + #[sampler(2)] + diffuse: Option>, + #[texture(3)] + #[sampler(4)] + normal: Option>, + #[texture(5)] + #[sampler(6)] + mask: Option>, + alpha: AlphaMode, +} +impl Material for CharacterMaterial { + fn fragment_shader() -> ShaderRef { + "embedded://skate3rust/retail_character.wgsl".into() + } + fn prepass_fragment_shader() -> ShaderRef { + "embedded://skate3rust/retail_character_depth.wgsl".into() + } + fn alpha_mode(&self) -> AlphaMode { + self.alpha + } +} +fn load(mut commands: Commands, config: Res) { + let Some(map_path) = &config.map_path else { + return; + }; + let path = config.asset_root.join("private/character-lighting.json"); + let data = match std::fs::read(&path) + .map_err(|e| e.to_string()) + .and_then(|b| serde_json::from_slice::(&b).map_err(|e| e.to_string())) + { + Ok(data) => data, + Err(e) => { + warn!("SKATE_CHARACTER_LIGHTING: {e}"); + return; + } + }; + let name = map_path.file_stem().unwrap_or_default().to_string_lossy(); + let overlay = config + .asset_root + .join("private/native-lighting") + .join(format!("{name}.irradiance")); + let probe_path = if overlay.is_file() { + overlay + } else { + map_path.with_extension("irradiance") + }; + let probes = match std::fs::read(probe_path) + .map_err(|e| e.to_string()) + .and_then(|b| Irradiance::parse(&b)) + { + Ok(probes) => probes, + Err(e) => { + warn!("SKATE_CHARACTER_LIGHTING: spatial data unavailable: {e}"); + return; + } + }; + let sky_path = config + .asset_root + .join("private/native-skies") + .join(format!("{name}.json")); + let sky: serde_json::Value = match std::fs::read(sky_path) + .ok() + .and_then(|b| serde_json::from_slice(&b).ok()) + { + Some(sky) => sky, + None => { + warn!("SKATE_CHARACTER_LIGHTING: missing authored light direction"); + return; + } + }; + let Some(sun) = sky["environment"]["sun_direction"].as_array() else { + return; + }; + if sun.len() != 3 { + return; + } + let mut light = Vec4::new(0., 0., 0., 2.5); // Existing retail scene exposure. + for i in 0..3 { + let Some(v) = sun[i].as_f64() else { + return; + }; + light[i] = v as f32; + } + info!("SKATE_CHARACTER_LIGHTING: loaded authored {name} irradiance and character parameters"); + // Shadow visibility only. World shaders do not sample this map; zero lux + // also prevents additive energy on any remaining StandardMaterials. + commands.spawn(( + Name::new("Character shadow visibility"), + DirectionalLight { + illuminance: 0., + shadows_enabled: true, + ..default() + }, + Transform::default().looking_to(-light.truncate(), Vec3::Y), + bevy::light::CascadeShadowConfigBuilder { + maximum_distance: 100., + first_cascade_far_bound: 10., + ..default() + } + .build(), + )); + commands.insert_resource(Lighting { + data, + probes, + light, + }); +} +fn bind( + mut commands: Commands, + lighting: Option>, + source: Res>, + server: Res, + mut materials: ResMut>, + entities: Query<(Entity, &GltfMaterialName, &MeshMaterial3d)>, +) { + let Some(lighting) = lighting else { + return; + }; + for (entity, name, handle) in &entities { + let Some(data) = lighting.data.materials.get(&name.0) else { + continue; + }; + if data.params.len() != 9 { + continue; + } + let Some(m) = source.get(&handle.0) else { + continue; + }; + let alpha_cutoff = if let AlphaMode::Mask(cutoff) = m.alpha_mode { + cutoff + } else { + -1. + }; + let material = materials.add(CharacterMaterial { + params: CharacterParams { + light: lighting.light, + tint: Vec4::from_array(m.base_color.to_linear().to_f32_array()), + options: Vec4::new( + f32::from(m.normal_map_texture.is_some()), + f32::from(data.specular.is_some()), + alpha_cutoff, + f32::from(data.shader == "character.hair"), + ), + rows: std::array::from_fn(|i| Vec4::from_array(data.params[i])), + sh: lighting + .data + .default_sh + .map(|v| Vec3::from_array(v).extend(0.)), + }, + diffuse: m.base_color_texture.clone(), + normal: m.normal_map_texture.clone(), + mask: data.specular.as_ref().map(|path| { + server.load_with_settings( + path.clone(), + |settings: &mut bevy::image::ImageLoaderSettings| { + settings.is_srgb = false; + }, + ) + }), + alpha: m.alpha_mode, + }); + commands + .entity(entity) + .remove::>() + .insert(MeshMaterial3d(material)); + } +} +fn update( + lighting: Option>, + root: Query<&Transform, With>, + mut materials: ResMut>, +) { + let (Some(mut lighting), Ok(root)) = (lighting, root.single()) else { + return; + }; + let fallback = lighting + .data + .default_sh + .map(|v| Vec3::from_array(v).extend(0.)); + let sh = lighting.probes.sample(root.translation, fallback); + for (_, material) in materials.iter_mut() { + material.params.sh = sh; + } +} diff --git a/crates/skate-game/src/retail_character.wgsl b/crates/skate-game/src/retail_character.wgsl new file mode 100644 index 00000000..5184f19c --- /dev/null +++ b/crates/skate-game/src/retail_character.wgsl @@ -0,0 +1,72 @@ +#import bevy_pbr::{forward_io::VertexOutput, mesh_view_bindings as frame} +#import bevy_pbr::shadows::fetch_directional_shadow +struct CharacterParams { + light: vec4, tint: vec4, options: vec4, + rows: array,9>, sh: array,9>, +} +@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(3) var normal_map: texture_2d; +@group(#{MATERIAL_BIND_GROUP}) @binding(4) var normal_sampler: sampler; +@group(#{MATERIAL_BIND_GROUP}) @binding(5) var mask_map: texture_2d; +@group(#{MATERIAL_BIND_GROUP}) @binding(6) var mask_sampler: sampler; + +@fragment +fn fragment(i: VertexOutput) -> @location(0) vec4 { + let albedo = textureSample(diffuse,diffuse_sampler,i.uv); + // GLTF diffuse is sampled as sRGB; recover retail's gamma-two decode. + let gamma = select(albedo.rgb*12.92, 1.055*pow(max(albedo.rgb,vec3(0.0)),vec3(1.0/2.4))-0.055, albedo.rgb>vec3(0.0031308)); + let d = gamma*gamma*p.tint.rgb; + let n = normalize(i.world_normal); + let rpos = i.world_position.xyz-frame::view.world_position; + let vd = -normalize(rpos); + if p.options.w>0.0 { + // Native hair response. The installed GLB supplies the selected dye + // and composited strand coverage; live CAC colour rows are not present. + let ndl=saturate(dot(n,p.light.xyz)); + let fres=pow(1.0-saturate(dot(n,vd)),max(p.rows[2].w,1.0)); + 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.a(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); + var tt=cross(dp2,n)*du1.x+cross(n,dp1)*du2.x; + var bb=cross(dp2,n)*du1.y+cross(n,dp1)*du2.y; + tt *= -inverseSqrt(max(dot(tt,tt),1e-12)); + bb *= inverseSqrt(max(dot(bb,bb),1e-12)); + var vn=n; + if p.options.x>0.0 { + // Installed normal PNGs already unpack DXT5nm into RGB. + let nm=textureSample(normal_map,normal_sampler,i.uv).xy*2.0-1.0; + vn=normalize(nm.x*tt+nm.y*bb+n*sqrt(saturate(1.0-dot(nm,nm)))); + } + var smask=albedo.a; + if p.options.y>0.0 { smask=textureSample(mask_map,mask_sampler,i.uv).r; } + let s=p.rows[7].y; + let v=vn*s; + let irr=saturate(p.sh[0].rgb+v.x*p.sh[1].rgb+v.y*p.sh[2].rgb+v.z*p.sh[3].rgb + +v.x*v.z*p.sh[4].rgb+v.z*v.y*p.sh[5].rgb+v.y*v.x*p.sh[6].rgb + +(3.0*v.z*v.z-1.0)*p.sh[7].rgb+(v.x*v.x-v.y*v.y)*p.sh[8].rgb); + let ndl=dot(vn,p.light.xyz); + let view_z=(frame::view.view_from_world*i.world_position).z; + var shadow=1.0; + if frame::lights.n_directional_lights>0u { + shadow=fetch_directional_shadow(0u,i.world_position,vn,view_z); + } + var lit=p.rows[1].rgb*saturate(ndl)*shadow+irr*p.rows[5].w; + let fb=1.0-saturate(dot(vn,vd)); + let kfres=pow(max(fb,1e-6),p.rows[1].w); + let rfres=pow(max(fb,1e-6),p.rows[0].z); + let rd=normalize(p.light.xyz*vec3(-1.,0.2,-1.)-vd); + lit+=saturate(rfres*saturate(dot(vn,rd)))*p.rows[6].rgb; + let kr=p.light.xyz-2.0*ndl*vn; + let ks=pow(saturate(dot(vd,-kr)),p.rows[2].w)*select(0.0,1.0,ndl>=0.0)*shadow; + let rr=rd-2.0*dot(vn,rd)*vn; + let rs=pow(saturate(dot(vd,-rr)),p.rows[3].w)*rfres; + let spec=saturate((ks*p.rows[2].rgb*kfres+rs*p.rows[3].rgb)*smask*smask); + if albedo.a((d*lit+spec)*p.rows[0].y*p.light.w,albedo.a*p.tint.a); +} diff --git a/crates/skate-game/src/retail_character_depth.wgsl b/crates/skate-game/src/retail_character_depth.wgsl new file mode 100644 index 00000000..b1ea6e8e --- /dev/null +++ b/crates/skate-game/src/retail_character_depth.wgsl @@ -0,0 +1,32 @@ +#import bevy_pbr::prepass_io::VertexOutput +#ifdef PREPASS_FRAGMENT +#import bevy_pbr::prepass_io::FragmentOutput +#endif +#ifdef MOTION_VECTOR_PREPASS +#import bevy_pbr::pbr_prepass_functions::calculate_motion_vector +#endif +struct CharacterParams { light: vec4, tint: vec4, options: vec4, rows: array,9>, sh: array,9> } +@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; +@fragment +fn fragment(i: VertexOutput) +#ifdef PREPASS_FRAGMENT + -> FragmentOutput +#endif +{ + if textureSample(diffuse,diffuse_sampler,i.uv).a < p.options.z { discard; } +#ifdef PREPASS_FRAGMENT + var out: FragmentOutput; +#ifdef NORMAL_PREPASS + out.normal = vec4(normalize(i.world_normal)*0.5+0.5,1.0); +#endif +#ifdef MOTION_VECTOR_PREPASS + out.motion_vector = calculate_motion_vector(i.world_position,i.previous_world_position); +#endif +#ifdef UNCLIPPED_DEPTH_ORTHO_EMULATION + out.frag_depth = i.unclipped_depth; +#endif + return out; +#endif +} diff --git a/crates/skate-game/src/retail_irradiance.rs b/crates/skate-game/src/retail_irradiance.rs new file mode 100644 index 00000000..5c49c693 --- /dev/null +++ b/crates/skate-game/src/retail_irradiance.rs @@ -0,0 +1,163 @@ +//! Spatial SH samples from RX2 0xEB0024. TU3 827E6AC8 selects a nearest sample. +use bevy::prelude::*; + +#[derive(Clone)] +pub(crate) struct Probe { + pub position: Vec3, + pub sh: [Vec4; 9], +} +pub(crate) struct ProbeGroup { + min: Vec3, + max: Vec3, + probes: Vec, +} +#[derive(Default)] +pub(crate) struct Irradiance { + groups: Vec, + recent: Vec, +} +impl Irradiance { + pub fn parse(bytes: &[u8]) -> Result { + if bytes.get(..4) != Some(b"SHP1") { + return Err("Invalid irradiance header".into()); + } + let mut at = 4; + fn word(bytes: &[u8], at: &mut usize) -> Result { + let raw = bytes.get(*at..*at + 4).ok_or("Truncated irradiance")?; + *at += 4; + Ok(u32::from_le_bytes(raw.try_into().unwrap())) + } + let count = word(bytes, &mut at)?; + let mut groups = Vec::new(); + for _ in 0..count { + let n = word(bytes, &mut at)?; + let mut probes = Vec::new(); + let mut min = Vec3::splat(f32::INFINITY); + let mut max = Vec3::splat(f32::NEG_INFINITY); + for _ in 0..n { + let mut v = [0.; 39]; + for x in &mut v { + *x = f32::from_bits(word(bytes, &mut at)?); + } + word(bytes, &mut at)?; // Runtime linked-list word, not lighting. + if !v.iter().all(|v| v.is_finite()) { + return Err("Non-finite irradiance".into()); + } + let position = Vec3::from_slice(&v[36..39]); + min = min.min(position); + max = max.max(position); + probes.push(Probe { + position, + sh: std::array::from_fn(|i| Vec4::from_slice(&v[i * 4..i * 4 + 4])), + }); + } + if !probes.is_empty() { + groups.push(ProbeGroup { min, max, probes }); + } + } + if at != bytes.len() { + return Err("Trailing irradiance bytes".into()); + } + Ok(Self { + groups, + recent: Vec::new(), + }) + } + pub fn sample(&mut self, position: Vec3, fallback: [Vec4; 9]) -> [Vec4; 9] { + // Native six-entry cache is accepted inside 2.5 metres of a sample. + if let Some((i, _)) = self + .recent + .iter() + .enumerate() + .map(|(i, p)| (i, p.position.distance_squared(position))) + .filter(|(_, d)| *d < 6.25) + .min_by(|a, b| a.1.total_cmp(&b.1)) + { + let probe = self.recent.remove(i); + let sh = probe.sh; + self.recent.insert(0, probe); + return sh; + } + let mut best = None; + let mut distance = f32::MAX; + for group in &self.groups { + if position.cmplt(group.min - Vec3::splat(15.)).any() + || position.cmpgt(group.max + Vec3::splat(15.)).any() + { + continue; + } + for p in &group.probes { + let delta = (p.position - position).abs(); + if delta.x > 15. || delta.z > 15. { + continue; + } + let d = p.position.distance_squared(position); + if d < distance { + distance = d; + best = Some(p.clone()); + } + } + } + match best { + Some(probe) => { + let sh = probe.sh; + self.recent.insert(0, probe); + self.recent.truncate(6); + sh + } + None => fallback, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn spatial_selection_and_outside_fallback() { + let mut b = b"SHP1".to_vec(); + b.extend(1u32.to_le_bytes()); + b.extend(2u32.to_le_bytes()); + for (x, colour) in [(0f32, 0.2f32), (10., 0.8)] { + let mut v = [0f32; 40]; + v[0] = colour; + v[36] = x; + for f in v { + b.extend(f.to_le_bytes()); + } + } + let mut data = Irradiance::parse(&b).unwrap(); + assert_eq!(data.sample(Vec3::ZERO, [Vec4::ZERO; 9])[0].x, 0.2); + assert_eq!(data.sample(Vec3::X * 9., [Vec4::ZERO; 9])[0].x, 0.8); + assert_eq!(data.sample(Vec3::X * 100., [Vec4::ONE; 9]), [Vec4::ONE; 9]); + assert!(Irradiance::parse(&b[..b.len() - 1]).is_err()); + } + #[test] + fn cache_retains_native_two_point_five_metre_hysteresis() { + let a = Probe { + position: Vec3::ZERO, + sh: [Vec4::ZERO; 9], + }; + let b = Probe { + position: Vec3::X * 3., + sh: [Vec4::ONE; 9], + }; + let mut data = Irradiance { + groups: vec![ProbeGroup { + min: Vec3::ZERO, + max: b.position, + probes: vec![a.clone(), b], + }], + recent: vec![a], + }; + // B is closer, but A remains selected within the native cache radius. + assert_eq!( + data.sample(Vec3::X * 2., [Vec4::splat(2.); 9]), + [Vec4::ZERO; 9] + ); + assert_eq!( + data.sample(Vec3::X * 2.5, [Vec4::splat(2.); 9]), + [Vec4::ONE; 9] + ); + } +} diff --git a/crates/skate-game/src/retail_render.rs b/crates/skate-game/src/retail_render.rs index f6e9b922..38f583a0 100644 --- a/crates/skate-game/src/retail_render.rs +++ b/crates/skate-game/src/retail_render.rs @@ -18,6 +18,7 @@ use std::collections::BTreeMap; pub(crate) struct RetailRenderPlugin; impl Plugin for RetailRenderPlugin { fn build(&self, app: &mut App) { + app.add_plugins(crate::retail_character::CharacterLightingPlugin); if std::env::var_os("SKATE_DEBUG_FOLIAGE").is_some_and(|v| v == "1") { eprintln!("SKATE_FOLIAGE_DEBUG: solid cyan tree-wall cards, magenta other foliage; alpha rejection disabled for foliage only"); } diff --git a/crates/skate-game/src/skate_world.rs b/crates/skate-game/src/skate_world.rs index ee1903d2..b4d98cb7 100644 --- a/crates/skate-game/src/skate_world.rs +++ b/crates/skate-game/src/skate_world.rs @@ -853,12 +853,14 @@ mod tests { let mut queue = bevy::ecs::world::CommandQueue::default(); let mut meshes = Assets::::default(); let mut materials = Assets::::default(); + let mut retail_materials = Assets::::default(); let mut images = Assets::::default(); spawn( &map, &mut Commands::new(&mut queue, &world), &mut meshes, &mut materials, + &mut retail_materials, &mut images, ); queue.apply(&mut world); diff --git a/docs/character-lighting-investigation.md b/docs/character-lighting-investigation.md index fcffbd14..a5572d7c 100644 --- a/docs/character-lighting-investigation.md +++ b/docs/character-lighting-investigation.md @@ -1,6 +1,6 @@ # Character lighting investigation -Status: native character-lighting reconstruction started; no runtime lighting change yet. +Status: position-dependent character lighting implemented; user-run visual validation pending. Observed in the local reference renderer (`skate3_native_scene.cpp`, CaptureCharLighting, and `scene_char.hlsli`): defaultcharacter/CAC shading consumes a directional key, @@ -24,7 +24,7 @@ Observed in TU3 generated PPC source: ambient, and arrays of cSimpleLightData. Do not assume these are the same as the spatial baked SH samples just because of the class name. -Implementation work still required: +Initial investigation checklist (superseded by the implementation status below): 1. Recover the spatial irradiance resource/registration and sampling path and resolve default probe selection from world/render state. 2. Extract character frame inputs and per-material parameters through native @@ -41,3 +41,67 @@ Implementation work still required: Reference caveat: community symbol labels are hypotheses. In particular the 82E3B548 label must not be treated as a verified prototype. No copied renderer capture's lighting constants should become a universal outdoor preset. + +## Implementation, 2026-09-08 + +`tools.asset_pipeline.irradiance` extracts RX2 type `0xEB0024` from the district's +other presentation assets. Its section starts with count and relative array +offset; each 160-byte record contains nine float4 SH rows, XYZ at byte 144 and a +runtime link word at 156. The `SHP1` sidecar preserves groups, values and original +world coordinates, converting only endianness. University contains 17,493 samples +in 64 resources. Future map conversions write `.irradiance` beside the map. + +TU3 `0x827E6E28` registers these resources into a 41-by-41 spatial grid; +`0x827E6AC8` selects the nearest sample in three dimensions from the XZ +neighborhood and falls back to the default coefficients. `0x827E7348` checks a +six-entry MRU cache with a strict squared-distance threshold of 6.25. Raw binary +constant `0x820BD16C` is 15.0 (query margin); `0x822F8F38` is 6.25. The evidence +is the generated PPC instructions corroborated with resource contents and the +TU3 dump with SHA-256 +`F4AA113EB541BFBA03DBC108CF5AB43F58C965B20FA3B82F9C40938A0AD841C4`. +Addresses use the dump's `0x82000000` base. + +`retail_irradiance.rs` retains the native nearest-sample/cache behavior. Its +static-world adaptation searches all loaded groups using expanded bounds and an +XZ 15-metre neighborhood. It does not reproduce the original 12-resource +streaming limit or grid-cell boundary quantization. The freeskate collection +supplies the nine fallback rows; live changes of the manager's default mode are +not implemented. This is not trilinear probe interpolation. + +`tools.asset_pipeline.character_lighting` resolves each selected model's +AttribulatorMaterialName through VLT inheritance and exports its nine complete +material parameter rows. Skin/face specular textures preserve the original red +channel separately; the GLB's grayscale inverse-roughness conversion is not used +as the native specular mask. Source assets remain private. + +`retail_character.rs` replaces the ten named retail GLB materials while retaining +Bevy's skinned vertex path. It updates the SH rows from PlayerRoot's position. +The fragment shader evaluates the native SH basis, authored directional key, +ambient multiplier, rim response, key/rim highlights, normal maps and material +multiplier. Diffuse sampling accounts for the GLB's sRGB texture format before +the retail gamma-two decode. Output remains linear HDR for the existing shared +tone pass. The separate depth shader retains alpha rejection. + +A zero-lux directional shadow source provides world/self occlusion to the +character's key and key specular terms. The baked world shader does not sample +this shadow map; remaining StandardMaterials receive zero additive sunlight. +This uses Bevy cascades/filtering/biases, not the console's exact cascade layout +and 9-tap shadow filter. It does not add a skater-shadow receiver to baked world +surfaces. No gameplay or physics code changes are involved. + +Hair now uses the native wrapped diffuse/ambient/fresnel response, with the +authored material colours and the existing GLB dye/coverage composition. The +live CAC colour-register derivation and separate second-UV blended strand pass +are not reconstructed; hair keeps the installed cutout mode. This is an explicit +adapter limitation, not a claim of exact hair parity. + +Scene exposure remains the existing 2.5 day-capture value shared with the world; +the regional exposure controller, water and fog are still unfinished. Missing +lighting sidecars leave the existing GLB material path active and log the reason. + +Validation: 13 synthetic Python extraction/inheritance/GUID tests, two spatial +selection/cache tests, and the windowless world-render adapter test pass. Character +colour, shadow and normal-depth shader variants pass offline Naga composition +against the vendored Bevy 0.18 shader modules. Release compilation passes. The +game/recomp was not launched. User visual checks should cover exposed terrain, +sheltered spots, skin/clothing highlights, hair coverage and body/board shadows. diff --git a/docs/retail-renderer.md b/docs/retail-renderer.md index 3a1c8255..cdeaa94e 100644 --- a/docs/retail-renderer.md +++ b/docs/retail-renderer.md @@ -99,8 +99,11 @@ the CPU transformation to native frame rows is not yet proven, so shader fog remains disabled. The selected `material_fog/default` must not be confused with the different `fog_default` collection. -Native water, character shaders, SSAO, SSR, bloom and volumetric lighting are -not yet ported. The water arrays are recoverable now, but flowing-water +Native water, SSAO, SSR, bloom and volumetric lighting are +not yet ported. Character lighting now has a dedicated adapter; see +[character-lighting-investigation.md](character-lighting-investigation.md) for +implemented terms, spatial data, validation and remaining parity differences. +The water arrays are recoverable now, but flowing-water normal/tangent unpacking, ocean PCA input bindings and the separate horizon water resource still need their own adapters. Character parity needs the native CAC-composed texture/mask inputs plus verified key/rim/specular and @@ -108,7 +111,7 @@ nine SH rows; portable GLB materials do not retain that contract. Reference post effects need their depth/normal/reflection inputs and native pass ordering; volumetric sun visibility also conflicts with the currently disabled world sun-shadow source. These gaps are not enabled using guessed settings. -The character and +Characters without the new lighting sidecars and unsupported world families still use Bevy materials beneath the shared tone curve. Reflection normals use the reference's analytic world-up construction; there has been no matched-camera pixel comparison against the recomp. diff --git a/tools/asset_pipeline/character_lighting.py b/tools/asset_pipeline/character_lighting.py new file mode 100644 index 00000000..9dd863ee --- /dev/null +++ b/tools/asset_pipeline/character_lighting.py @@ -0,0 +1,55 @@ +"""Export authored character shader parameters and default SH coefficients.""" +import json +import struct +import sys +from PIL import Image +from pathlib import Path +from .environment import Collections, key_hash + + +def convert(models, private, converted): + sys.path.insert(0, str(Path(__file__).resolve().parents[1]/'vendor/utt')) + import mdl_parser + collections = Collections(converted) + result = {'materials': {}} + recipe = json.loads((Path(__file__).resolve().parents[1] / 'default_skater_retail_manifest.json').read_text()) + components = {c['slot']: c for c in recipe['components']} + for slot in sorted(models.iterdir()): + if not slot.is_dir(): + continue + files = list(slot.glob('*.rx2')) + if len(files) != 1: + raise ValueError('Ambiguous character lighting model: '+slot.name) + model = mdl_parser.parse_rx2(files[0].read_bytes()) + shaders = {p.value for p in model.materials if p.kind == 'AttribulatorMaterialName'} + if len(shaders) != 1: + raise ValueError('Ambiguous character shader: '+slot.name) + shader = shaders.pop() + family, key = shader.split('.', 1) + fields, _ = collections.resolve('material_'+family, key) + rows = fields[key_hash('m_params')]['array']['items'] + result['materials']['Retail_'+slot.name] = dict(shader=shader, + params=[struct.unpack('>4f', bytes.fromhex(row)) for row in rows]) + specular = components[slot.name]['textures'].get('specular') + if specular: + # The native skin shader consumes R, not luminance or roughness. + source = private / 'default_skater/textures/decoded' / (specular+'.png') + destination = private / 'native-character' / (slot.name+'_specular.png') + destination.parent.mkdir(parents=True, exist_ok=True) + with Image.open(source) as image: + image.convert('RGB').getchannel('R').save(destination) + result['materials']['Retail_'+slot.name]['specular'] = 'private/native-character/'+destination.name + fields, _ = collections.resolve('Hash_6A2B5946514B8490', 'freeskate') + result['default_sh'] = [struct.unpack('>3f', bytes.fromhex(row)) + for row in fields[key_hash('Hash_F45DFCAB6EC195CD')]['array']['items']] + (private/'character-lighting.json').write_text(json.dumps(result)) + + +if __name__ == '__main__': + import argparse + p = argparse.ArgumentParser(description=__doc__) + p.add_argument('--models', type=Path, required=True) + p.add_argument('--private', type=Path, required=True) + p.add_argument('--collections', type=Path, required=True) + a = p.parse_args() + convert(a.models, a.private, json.loads(a.collections.read_text())) diff --git a/tools/asset_pipeline/install.py b/tools/asset_pipeline/install.py index 070bbfe0..23a2f1bf 100644 --- a/tools/asset_pipeline/install.py +++ b/tools/asset_pipeline/install.py @@ -188,6 +188,8 @@ def install(iso,base,game_exe,report,game_root=None): report('Building the skater model and rig') from .character_glb import convert as write_character write_character(character/'selected/models',private,manifest) + from .character_lighting import convert as write_character_lighting + write_character_lighting(character/'selected/models',private,converted) game_manifest={'version':1,'character_scene':'private/skater.glb','initial_animation':'R_IDLE_HCOM_000', 'action_graph':'private/stock/data/state/ActionGraph_OnBoard.stategraph', 'motion_graph':'private/stock/data/state/MotionGraph_OnBoard.stategraph'} diff --git a/tools/asset_pipeline/irradiance.py b/tools/asset_pipeline/irradiance.py new file mode 100644 index 00000000..8a425536 --- /dev/null +++ b/tools/asset_pipeline/irradiance.py @@ -0,0 +1,51 @@ +"""Export retail RWOBJECTTYPE_IRRADIANCEDATA (0xEB0024) without resampling.""" +import json +import math +import struct +from pathlib import Path + + +def decode(raw): + if raw[:7] != b'\x89RW4xb2': + raise ValueError('Not an Xbox RX2 resource') + count, table = struct.unpack_from('>I', raw, 32)[0], struct.unpack_from('>I', raw, 48)[0] + groups = [] + for i in range(count): + offset, _, size, _, _, kind = struct.unpack_from('>6I', raw, table + i*24) + if kind != 0xEB0024: + continue + n, start = struct.unpack_from('>2I', raw, offset) + if start < 8 or start+n*160 > size or offset+size > len(raw): + raise ValueError('Invalid irradiance record extent') + records = [] + for j in range(n): + values = struct.unpack_from('>39fI', raw, offset+start+j*160) + if not all(math.isfinite(x) for x in values[:39]): + raise ValueError('Non-finite irradiance sample') + records.append(struct.pack('<39fI', *values)) + if records: + groups.append(records) + return groups + + +def write(manifest_path, output): + manifest = json.loads(manifest_path.read_text()) + groups = [] + for asset in manifest['other_presentation_assets']: + groups.extend(decode((manifest_path.parent / asset['rx2']).read_bytes())) + output.parent.mkdir(parents=True, exist_ok=True) + with output.open('wb') as f: + f.write(b'SHP1' + struct.pack('I', data, 32, 1) + struct.pack_into('>I', data, 48, 64) + struct.pack_into('>6I', data, 64, 96, 0, 176, 0, 0, 0xEB0024) + struct.pack_into('>2I', data, 96, 1, 16) + values = [i / 4 for i in range(39)] + struct.pack_into('>39fI', data, 112, *values, 0xDEDEDEDE) + return data, values + + +class IrradianceTests(unittest.TestCase): + def test_preserves_coefficients_position_and_link_word(self): + data, values = resource() + groups = decode(data) + self.assertEqual(len(groups), 1) + self.assertEqual(struct.unpack('<39fI', groups[0][0]), (*values, 0xDEDEDEDE)) + + def test_rejects_records_outside_section(self): + data, _ = resource() + struct.pack_into('>I', data, 96, 2) + with self.assertRaises(ValueError): + decode(data) + + def test_rejects_nonfinite_sh(self): + data, _ = resource() + struct.pack_into('>f', data, 112, float('nan')) + with self.assertRaises(ValueError): + decode(data) From ad992f20017628293c84e9f086bba329046bd835 Mon Sep 17 00:00:00 2001 From: chazmm Date: Tue, 8 Sep 2026 16:56:20 +0100 Subject: [PATCH 08/16] Fix padded Xbox texture rows and BC2/BC3 colour decoding --- docs/retail-renderer.md | 39 +++++++++++++++++ tools/asset_pipeline/test_texture_decode.py | 46 +++++++++++++++++++++ tools/vendor/utt/rx2_fast.py | 8 +++- tools/vendor/utt/rx2_parser.py | 5 ++- 4 files changed, 94 insertions(+), 4 deletions(-) create mode 100644 tools/asset_pipeline/test_texture_decode.py diff --git a/docs/retail-renderer.md b/docs/retail-renderer.md index cdeaa94e..fb3b4378 100644 --- a/docs/retail-renderer.md +++ b/docs/retail-renderer.md @@ -187,3 +187,42 @@ for the user. The normal launcher clears SKATE_DEBUG_FOLIAGE. For integration with staged map switching, prepare this supplement alongside the district, before applying sky lighting. Track its entities and assets in the same map lifetime. The current adapter is in `retail_backdrop.rs`. + +### Black texture/lightmap bands (2026-09-08) + +The University bark rings and mega-park shark logo expose an exporter defect in +the shared Xbox texture decoder. `XGAddress2DTiledX/Y` returns coordinates in the +padded physical tile. `_untile360` flattened `y * logical_width + x` before +rejecting columns outside the logical width. Padding columns consequently aliased +onto later valid rows, overwriting colour blocks with padding or other tile data. +The NumPy implementation reproduced the scalar bug, including last-write wins. +Both implementations now reject `x >= logical_width` before writing. + +The logo's diffuse is `0x2c70170a001d01bb`; its 64x64 DXT1 lightmap is +`0x8b068e5c4f70dd7a`. Re-decoding that lightmap removes the black stripes while +the logo diffuse remains unchanged. Nine lightmaps bound to the spring/fall-tree +bark diffuse textures `0x00007e2903e3870a` and `0x00007dc403e3870a` have the same +corruption. The shared decoder affects colour textures as well: the 64x128 palm +fringe texture `0x00007e3103e3870a` also loses artificial horizontal bands. +This establishes an asset-decoding cause independently of shader lighting. + +A separate colour-palette error was corrected in both decoders: DXT3/DXT5 colour +blocks must retain four interpolated colours even when their endpoints are in +ascending order. The old code reused DXT1's three-colour/black branch and merely +made that black opaque. DXT1's transparent palette remains intact. See Microsoft's +[block compression reference](https://learn.microsoft.com/en-us/windows/win32/direct3d10/d3d10-graphics-programming-guide-resources-block-compression) +for the format layouts and separate BC2/BC3 alpha blocks. + +The private University repair re-decodes the original RX2 sources and changes +723 of 2,046 texture payloads: 720 DXT1 (logical widths 16/32/64), two DXT5 and +one A8R8G8B8. It preserves the exporter's dedicated B5G6R5 decoder output. Every +original payload was checked against its source cache before replacement; +material definitions and the entire geometry/collision/metadata tail remain +byte-identical. The repaired package passes the offline SKATE reader. + +Regression tests cover narrow/wide tile layouts at 2/4/8/16 bytes per unit, +DXT1 transparency, and DXT3/DXT5 reversed colour endpoints with independent alpha, +using both scalar and NumPy paths. All 16 asset-pipeline tests pass. The game was +not launched; visual confirmation remains user-run. Existing exported maps need +their textures regenerated from RX2, since changing the decoder cannot repair +already-baked PNG/RGBA cache files. Fresh conversions use the fix across maps. diff --git a/tools/asset_pipeline/test_texture_decode.py b/tools/asset_pipeline/test_texture_decode.py new file mode 100644 index 00000000..47d95761 --- /dev/null +++ b/tools/asset_pipeline/test_texture_decode.py @@ -0,0 +1,46 @@ +"""Regression fixtures for padded Xbox tiles and BC colour palettes.""" +import importlib.util +from pathlib import Path +import struct +import sys +import unittest +from unittest.mock import patch + +UTT = Path(__file__).resolve().parents[1] / 'vendor/utt' +sys.path.insert(0, str(UTT)) +import rx2_fast + +# Exercise the standalone fallback as well as the NumPy implementation. +spec = importlib.util.spec_from_file_location('rx2_scalar_test', UTT / 'rx2_parser.py') +scalar = importlib.util.module_from_spec(spec) +with patch.dict(sys.modules, {'rx2_fast': None}): + spec.loader.exec_module(scalar) + + +class TextureDecodeTests(unittest.TestCase): + def test_padding_cannot_overwrite_visible_rows(self): + for width, pitch in ((w, p) for w in (4, 8, 16, 32) for p in (2, 4, 8, 16)): + with self.subTest(width=width, pitch=pitch): + units = max(32*32, 4096//pitch) + raw = bytearray(units*pitch) + for offset in range(units): + x = scalar._x360_tiled_x(offset, width, pitch) + y = scalar._x360_tiled_y(offset, width, pitch) + if x < width and y < 32: + raw[offset*pitch:offset*pitch+pitch] = bytes([1+y])*pitch + expected = b''.join(bytes([1+y])*(width*pitch) for y in range(32)) + for decoder in (scalar, rx2_fast): + self.assertEqual(decoder._untile360(raw, width, pitch)[:len(expected)], expected) + + def test_bc1_keeps_transparent_palette(self): + colour = struct.pack('>HH', 0x001f, 0xf800) + b'\xff'*4 + self.assertEqual(scalar._dxt1_block(colour, True), bytes(64)) + self.assertEqual(rx2_fast._decode(colour, 4, 4, 1, False), bytes(64)) + + def test_bc2_bc3_use_four_colours_with_reversed_endpoints(self): + colour = struct.pack('>HH', 0x001f, 0xf800) + b'\xff'*4 + for kind, alpha, block_decoder in ((3, b'\xaa'*8, scalar._dxt3_block), + (5, b'\x00\xaa'+bytes(6), scalar._dxt5_block)): + expected = bytes((170, 0, 85, 170))*16 + self.assertEqual(block_decoder(alpha+colour), expected) + self.assertEqual(rx2_fast._decode(alpha+colour, 4, 4, kind, False), expected) diff --git a/tools/vendor/utt/rx2_fast.py b/tools/vendor/utt/rx2_fast.py index 7110beed..f45f3875 100644 --- a/tools/vendor/utt/rx2_fast.py +++ b/tools/vendor/utt/rx2_fast.py @@ -17,7 +17,9 @@ def _layout(units,width,pitch): y=(((m//(aligned>>5))<<2)+((t>>(6+log))&1)+((b&2048)>>10))<<3 y+=(((t&(((pitch<<6)-1)&~31))+((t&15)<<1))>>(3+log))&~1 y+=(t&16)>>4 - dest=y*width+x;valid=dest>11)&31)*255//31,((v>>5)&63)*255//63,(v&31)*255//31),axis=1) - a=rgb(c0);z=rgb(c1);four=c0>c1 + # BC2/BC3 always use four colours. Only BC1 uses endpoint ordering + # to select the three-colour + transparent-black palette. + a=rgb(c0);z=rgb(c1);four=(c0>c1) | (not transparent) pal=np.zeros((len(b),4,4),dtype=np.uint8);pal[:,:,3]=255 pal[:,0,:3]=a;pal[:,1,:3]=z pal[:,2,:3]=np.where(four[:,None],(2*a+z+1)//3,(a+z)//2) diff --git a/tools/vendor/utt/rx2_parser.py b/tools/vendor/utt/rx2_parser.py index 651c99a6..ffdc6a8a 100644 --- a/tools/vendor/utt/rx2_parser.py +++ b/tools/vendor/utt/rx2_parser.py @@ -426,7 +426,7 @@ def _untile360(src, width_units, texel_pitch): y = _x360_tiled_y(j * width_units + i, width_units, texel_pitch) dst_idx = (y * width_units + x) * texel_pitch src_idx = (j * width_units + i) * texel_pitch - if dst_idx + texel_pitch <= len(dst) and src_idx + texel_pitch <= len(src): + if x < width_units and dst_idx + texel_pitch <= len(dst) and src_idx + texel_pitch <= len(src): dst[dst_idx:dst_idx + texel_pitch] = src[src_idx:src_idx + texel_pitch] return bytes(dst) @@ -473,7 +473,8 @@ def _dxt1_block(blk, transparent): c0, c1 = struct.unpack_from(">HH", blk, 0) r0, g0, b0 = _rgb565(c0) r1, g1, b1 = _rgb565(c1) - if c0 > c1: + # DXT3/DXT5 have independent alpha and always use four colour entries. + if c0 > c1 or not transparent: c2, c3 = _rgb565_interp((c0 >> 11) & 0x1F, (c1 >> 11) & 0x1F, (c0 >> 5) & 0x3F, (c1 >> 5) & 0x3F, c0 & 0x1F, c1 & 0x1F) From 464692f94c7ba1de64f01a6a0f27d3251a5a1c7d Mon Sep 17 00:00:00 2001 From: chazmm Date: Tue, 8 Sep 2026 17:19:45 +0100 Subject: [PATCH 09/16] Project player shadows onto baked world materials --- crates/skate-game/src/retail_character.rs | 108 ++++++++++++++++++-- crates/skate-game/src/retail_character.wgsl | 8 +- crates/skate-game/src/retail_world.wgsl | 15 ++- docs/character-lighting-investigation.md | 36 +++++++ docs/retail-renderer.md | 5 +- 5 files changed, 157 insertions(+), 15 deletions(-) diff --git a/crates/skate-game/src/retail_character.rs b/crates/skate-game/src/retail_character.rs index b6fefdaf..5212d98e 100644 --- a/crates/skate-game/src/retail_character.rs +++ b/crates/skate-game/src/retail_character.rs @@ -2,6 +2,7 @@ use crate::retail_irradiance::Irradiance; use bevy::{ asset::embedded_asset, + camera::visibility::RenderLayers, gltf::GltfMaterialName, prelude::*, render::render_resource::{AsBindGroup, ShaderType}, @@ -17,7 +18,7 @@ impl Plugin for CharacterLightingPlugin { embedded_asset!(app, "retail_character_depth.wgsl"); app.add_plugins(MaterialPlugin::::default()) .add_systems(Startup, load) - .add_systems(Update, (bind, update).chain()); + .add_systems(Update, (bind, shadow_views, update).chain()); } } #[derive(Deserialize)] @@ -36,6 +37,7 @@ struct Lighting { data: LightingData, probes: Irradiance, light: Vec4, + shadow_floor: Option, } #[derive(Clone, ShaderType)] struct CharacterParams { @@ -135,16 +137,44 @@ fn load(mut commands: Commands, config: Res) { light[i] = v as f32; } info!("SKATE_CHARACTER_LIGHTING: loaded authored {name} irradiance and character parameters"); - // Shadow visibility only. World shaders do not sample this map; zero lux - // also prevents additive energy on any remaining StandardMaterials. + spawn_shadow_sources(&mut commands, light.truncate()); + commands.insert_resource(Lighting { + data, + probes, + light, + shadow_floor: None, + }); +} +fn spawn_shadow_sources(commands: &mut Commands, light: Vec3) { + // World + skater casters, sampled only by the character shader. commands.spawn(( Name::new("Character shadow visibility"), DirectionalLight { illuminance: 0., shadows_enabled: true, + affects_lightmapped_mesh_diffuse: false, ..default() }, - Transform::default().looking_to(-light.truncate(), Vec3::Y), + Transform::default().looking_to(-light, Vec3::Y), + bevy::light::CascadeShadowConfigBuilder { + maximum_distance: 100., + first_cascade_far_bound: 10., + ..default() + } + .build(), + )); + // Only skinned player pieces inhabit layer 31. The world receiver samples + // this separate map so baked building/terrain shadows are not re-applied. + commands.spawn(( + Name::new("Player shadow onto baked world"), + DirectionalLight { + illuminance: 0., + shadows_enabled: true, + affects_lightmapped_mesh_diffuse: true, + ..default() + }, + RenderLayers::layer(31), + Transform::default().looking_to(-light, Vec3::Y), bevy::light::CascadeShadowConfigBuilder { maximum_distance: 100., first_cascade_far_bound: 10., @@ -152,12 +182,8 @@ fn load(mut commands: Commands, config: Res) { } .build(), )); - commands.insert_resource(Lighting { - data, - probes, - light, - }); } + fn bind( mut commands: Commands, lighting: Option>, @@ -215,13 +241,32 @@ fn bind( commands .entity(entity) .remove::>() - .insert(MeshMaterial3d(material)); + .insert(( + MeshMaterial3d(material), + RenderLayers::from_layers(&[0, 31]), + )); + } +} +fn shadow_views( + mut commands: Commands, + lighting: Option>, + cameras: Query<(Entity, Option<&RenderLayers>), With>, +) { + if lighting.is_none() { + return; + } + for (entity, layers) in &cameras { + let layers = layers.cloned().unwrap_or_default(); + if !layers.intersects(&RenderLayers::layer(31)) { + commands.entity(entity).insert(layers.with(31)); + } } } fn update( lighting: Option>, root: Query<&Transform, With>, mut materials: ResMut>, + mut world_materials: ResMut>, ) { let (Some(mut lighting), Ok(root)) = (lighting, root.single()) else { return; @@ -234,4 +279,47 @@ fn update( for (_, material) in materials.iter_mut() { material.params.sh = sh; } + // Adapter floor: the local probe's direction-independent ambient term. + // The native per-frame c8 shadow-colour controller remains unrecovered. + let floor = sh[0].truncate().clamp(Vec3::ZERO, Vec3::ONE); + if lighting.shadow_floor != Some(floor) { + for (_, material) in world_materials.iter_mut() { + material.params.shadow_color = floor.extend(1.); + } + lighting.shadow_floor = Some(floor); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn world_receiver_source_excludes_world_casters_and_emits_no_light() { + let mut world = World::new(); + let mut queue = bevy::ecs::world::CommandQueue::default(); + spawn_shadow_sources(&mut Commands::new(&mut queue, &world), Vec3::Y); + queue.apply(&mut world); + let player = RenderLayers::from_layers(&[0, 31]); + let terrain = RenderLayers::default(); + let mut receivers = 0; + let mut character_sources = 0; + for (light, layers) in world + .query::<(&DirectionalLight, Option<&RenderLayers>)>() + .iter(&world) + { + let layers = layers.cloned().unwrap_or_default(); + assert_eq!(light.illuminance, 0.); + assert!(light.shadows_enabled); + assert!(layers.intersects(&player)); + if light.affects_lightmapped_mesh_diffuse { + assert!(!layers.intersects(&terrain)); + receivers += 1; + } else { + assert!(layers.intersects(&terrain)); + character_sources += 1; + } + } + assert_eq!((receivers, character_sources), (1, 1)); + } } diff --git a/crates/skate-game/src/retail_character.wgsl b/crates/skate-game/src/retail_character.wgsl index 5184f19c..f6de4d00 100644 --- a/crates/skate-game/src/retail_character.wgsl +++ b/crates/skate-game/src/retail_character.wgsl @@ -53,8 +53,12 @@ fn fragment(i: VertexOutput) -> @location(0) vec4 { let ndl=dot(vn,p.light.xyz); let view_z=(frame::view.view_from_world*i.world_position).z; var shadow=1.0; - if frame::lights.n_directional_lights>0u { - shadow=fetch_directional_shadow(0u,i.world_position,vn,view_z); + for (var light_id=0u; light_id, foliage_debug: vec4, surface: vec4, family: vec4, @@ -70,6 +71,18 @@ fn fragment(i: VertexOutput) -> @location(0) vec4 { var d = a.rgb*a.rgb; var alpha = 1.0; var lin = vec3(0.0); + var baked = lm*lm; + if p.shadow_color.w>0.0 && (fam<=8u || fam==13u) { + let view_z=(frame::view.view_from_world*i.world_position).z; + for (var light_id=0u; light_id(visibility)+p.shadow_color.rgb); + break; + } + } + } if fam == 9u || fam == 10u { lin = d * max(lm*lm,vec3(p.family.y)) * p.family.x; if fam == 9u { lin *= p.family.z; } @@ -102,7 +115,7 @@ fn fragment(i: VertexOutput) -> @location(0) vec4 { wn = normalize(raw.x*tt+raw.y*bb+wn*max(raw.z,0.05)); kd = (vnd.x*0.58*sign(dot(kt,sun))+vnd.y*0.62*sign(dot(kb,sun))+vnd.z*0.39)*2.39562; } - var lml = lm*lm; + var lml = baked; lin = lml*kd*d; if fam == 13u { lin = lml*d*a.a; } if (flags & 16u) != 0u { diff --git a/docs/character-lighting-investigation.md b/docs/character-lighting-investigation.md index a5572d7c..2af34c13 100644 --- a/docs/character-lighting-investigation.md +++ b/docs/character-lighting-investigation.md @@ -105,3 +105,39 @@ colour, shadow and normal-depth shader variants pass offline Naga composition against the vendored Bevy 0.18 shader modules. Release compilation passes. The game/recomp was not launched. User visual checks should cover exposed terrain, sheltered spots, skin/clothing highlights, hair coverage and body/board shadows. + +## Player shadow receiver, 2026-09-08 + +The baked-world receiver is now connected. Native `scene.hlsl` evaluates +`min(lightmap * lightmap, shadow_visibility + shadow_colour)` before diffuse, +normal/detail lighting and specular. This is a component-wise minimum cap, not +an extra multiplicative darkening pass. The local native reference's caster path +is corroborated by TU3 `0x827EBEF0` (CSM setup), `0x827F82F8` (CSM render) and +the character-family shadow shader names in the executable. The animated mesh +and board are the caster geometry; no ground decal or circular blob is used. + +Two zero-lux shadow sources separate the receiver contracts. The existing source +includes world and player casters, and is sampled by the character. The new +source includes only the ten skinned retail player/board pieces on reserved +render layer 31. Camera views include that layer; ordinary world geometry stays +on layer zero. World families 1–8 and 13 sample the player-only source and apply +the recovered minimum clamp. This prevents terrain/building shadows from being +applied a second time over the baked map. The source's lightmapped-diffuse flag +identifies its role in the GPU light table, so entity ordering does not determine +which shadow map the shaders select. Both lights have zero illuminance. + +This reproduces the projected-mesh and lightmap-combination method, **not full +console parity**. Shadow map projection/filtering/biases still use Bevy cascades. +The original per-frame c8 shadow-colour controller has not been recovered: the +adapter currently uses the local irradiance probe's constant RGB coefficient as +the ambient shadow floor. At University's starting point that is approximately +(0.071711, 0.095925, 0.127945), read from the source sample, not a global preset. +The floor updates when the selected probe changes. Native caster exclusions, +atlas coverage encoding/blur, shadow colour and pixel matching remain separate +parity work. Unsupported world material families still have no custom receiver. + +Offline validation: world, character and character depth shaders pass Naga +composition/validation; a windowless ECS regression test verifies the caster-layer +separation and zero illuminance. Release compilation passes. The prepared user +launcher retains the texture-band repairs and prior character lighting. No game +or recomp was launched, and no physics/animation implementation changed. diff --git a/docs/retail-renderer.md b/docs/retail-renderer.md index fb3b4378..d0eab74b 100644 --- a/docs/retail-renderer.md +++ b/docs/retail-renderer.md @@ -89,8 +89,9 @@ selection and regional/streaming state also remain unverified. This is not a claim of pixel parity. Native frame constants are supplied by game hooks in the reference; this engine does not yet recover that controller. Scene exposure 2.5, world material multiplier 1, and tree/proxy values still use -documented day-capture defaults. Imported maps have no dynamic directional sun or realtime -sun shadows: their world lighting comes from the baked lightmaps. Normal-map +documented day-capture defaults. Imported maps have no additive directional sun: +their world lighting comes from baked lightmaps, with a separate player-only +projected shadow receiver described in the character-lighting notes. Normal-map sign terms use the initial world's authored direction when new sky metadata is available; old packages retain the previous direction. Regional changes and the runtime environment controller are not implemented. Fog selection From b5a00ede436e79158f9905b40a06ebd5f1103c20 Mon Sep 17 00:00:00 2001 From: chazmm Date: Tue, 8 Sep 2026 17:29:48 +0100 Subject: [PATCH 10/16] Stabilize player shadow state without rebuilding world materials --- crates/skate-game/src/retail_character.rs | 27 ++++--- crates/skate-game/src/retail_render.rs | 8 ++ crates/skate-game/src/retail_shadow.rs | 89 +++++++++++++++++++++++ crates/skate-game/src/retail_world.wgsl | 6 +- docs/character-lighting-investigation.md | 33 +++++++++ docs/retail-renderer.md | 6 ++ 6 files changed, 153 insertions(+), 16 deletions(-) create mode 100644 crates/skate-game/src/retail_shadow.rs diff --git a/crates/skate-game/src/retail_character.rs b/crates/skate-game/src/retail_character.rs index 5212d98e..2b1e3b34 100644 --- a/crates/skate-game/src/retail_character.rs +++ b/crates/skate-game/src/retail_character.rs @@ -37,7 +37,6 @@ struct Lighting { data: LightingData, probes: Irradiance, light: Vec4, - shadow_floor: Option, } #[derive(Clone, ShaderType)] struct CharacterParams { @@ -142,7 +141,6 @@ fn load(mut commands: Commands, config: Res) { data, probes, light, - shadow_floor: None, }); } fn spawn_shadow_sources(commands: &mut Commands, light: Vec3) { @@ -171,13 +169,16 @@ fn spawn_shadow_sources(commands: &mut Commands, light: Vec3) { illuminance: 0., shadows_enabled: true, affects_lightmapped_mesh_diffuse: true, + // Receiver-only map: no self-shadow acne to hide with large bias. + shadow_depth_bias: 0.002, + shadow_normal_bias: 0.0, ..default() }, RenderLayers::layer(31), Transform::default().looking_to(-light, Vec3::Y), bevy::light::CascadeShadowConfigBuilder { - maximum_distance: 100., - first_cascade_far_bound: 10., + num_cascades: 1, + maximum_distance: 24., ..default() } .build(), @@ -266,7 +267,8 @@ fn update( lighting: Option>, root: Query<&Transform, With>, mut materials: ResMut>, - mut world_materials: ResMut>, + mut shadow: ResMut, + time: Res