diff --git a/crates/skate-game/src/config.rs b/crates/skate-game/src/config.rs index 3c56c2a6..6ef0f984 100644 --- a/crates/skate-game/src/config.rs +++ b/crates/skate-game/src/config.rs @@ -9,6 +9,7 @@ pub(crate) struct Config { pub map_path: Option, pub difficulty: crate::difficulty::Difficulty, pub check_assets: bool, + pub teleport: Option, } impl Config { @@ -20,6 +21,7 @@ impl Config { map_path: None, difficulty: crate::difficulty::Difficulty::default(), check_assets: false, + teleport: None, }; let mut difficulty_override = None; let mut explicit_map = false; @@ -37,6 +39,7 @@ impl Config { } Some("--test-world") => { explicit_map = true; config.map = None; config.map_path = None; } Some("--check-assets") => config.check_assets = true, + Some("--teleport") => config.teleport = Some(args.next().ok_or("--teleport requires a destination ID")?.to_string_lossy().into_owned()), Some("--difficulty") => { let value = args.next().ok_or("--difficulty requires easy, normal or hardcore")?; difficulty_override = Some(crate::difficulty::Difficulty::parse(&value.to_string_lossy())?); @@ -69,6 +72,13 @@ impl Config { config.map_path = Some(path.canonicalize().map_err(|e| e.to_string())?); } } + if let Some(id) = &config.teleport { + let destinations = crate::teleport_menu::load(&config.asset_root)?; + let target = destinations.iter().find(|d| &d.id == id).ok_or("Unknown teleport destination")?; + if target.matrix.is_none() || !config.map_path.as_ref().is_some_and(|p| crate::teleport_menu::same_map(p, &target.map)) { + return Err("Teleport destination is unavailable or belongs to a different map".into()); + } + } if let Some(path) = &mut config.verification_capture { if path.extension().and_then(|x| x.to_str()) != Some("png") { return Err("--verify output must be a PNG file".into()); diff --git a/crates/skate-game/src/graphics_menu.rs b/crates/skate-game/src/graphics_menu.rs index 158b54d9..78ef7970 100644 --- a/crates/skate-game/src/graphics_menu.rs +++ b/crates/skate-game/src/graphics_menu.rs @@ -84,6 +84,8 @@ pub(crate) struct Menu { status: String, maps: Vec, selected_map: usize, + destinations: Vec, + travel_page: Option, } pub(crate) fn gameplay_active(menu: Option>) -> bool { menu.is_none_or(|m| !m.open) @@ -117,6 +119,7 @@ fn setup( mut window: Single<&mut Window, With>, cameras: Query>, adapter: Res, + mut skater: ResMut, ) { let path = config .asset_root @@ -194,23 +197,32 @@ fn setup( display: Display::None, width:percent(100), height:percent(100), align_items:AlignItems::Center, justify_content:JustifyContent::Center, position_type:PositionType::Absolute, ..default() }, BackgroundColor(Color::srgba(0.015,0.025,0.04,0.88)))).with_children(|root| { - root.spawn((Node { width:px(560),max_width:percent(95),padding:UiRect::all(px(18)),flex_direction:FlexDirection::Column,row_gap:px(6),border_radius:BorderRadius::all(px(12)),..default() }, + root.spawn((Node { width:px(720),max_width:percent(95),padding:UiRect::all(px(18)),flex_direction:FlexDirection::Column,row_gap:px(6),border_radius:BorderRadius::all(px(12)),..default() }, BackgroundColor(Color::srgb(0.035,0.055,0.08)))).with_children(|panel| { - panel.spawn((Text::new("PAUSED"),TextFont {font_size:32.,..default()},TextColor(Color::WHITE))); - panel.spawn((Text::new("GAMEPLAY & GRAPHICS"),TextFont {font_size:16.,..default()},TextColor(Color::srgb(0.4,0.85,0.85)))); - for i in 0..10 { + panel.spawn((MenuLabel(usize::MAX),Text::new("PAUSED"),TextFont {font_size:32.,..default()},TextColor(Color::WHITE))); + panel.spawn((MenuLabel(usize::MAX-1),Text::new("GAMEPLAY & GRAPHICS"),TextFont {font_size:16.,..default()},TextColor(Color::srgb(0.4,0.85,0.85)))); + for i in 0..11 { panel.spawn((Button, MenuRow(i), Node {width:percent(100),min_height:px(36),padding:UiRect::all(px(8)),align_items:AlignItems::Center,border_radius:BorderRadius::all(px(5)),..default()}, BackgroundColor(Color::srgb(0.08,0.11,0.15)))).with_children(|row| { row.spawn((MenuLabel(i),Text::new(""),TextFont {font_size:18.,..default()},TextColor(Color::WHITE))); }); } panel.spawn((StatusLabel,Text::new(""),TextFont {font_size:15.,..default()},TextColor(Color::srgb(0.65,0.75,0.8)))); - panel.spawn((Text::new("Click to cycle | Up/Down select | Left/Right change\nEsc resume | Changes save automatically"),TextFont {font_size:14.,..default()},TextColor(Color::srgb(0.65,0.75,0.8)))); + panel.spawn((MenuLabel(usize::MAX-2),Text::new(""),TextFont {font_size:14.,..default()},TextColor(Color::srgb(0.65,0.75,0.8)))); }); }); commands.insert_resource(SceneTarget(target)); let maps = crate::map_library::discover(&config.asset_root); let selected_map = maps.iter().position(|m| m.path.as_ref() == config.map_path.as_ref()).unwrap_or(0); + let (destinations, mut status) = match crate::teleport_menu::load(&config.asset_root) { + Ok(locations) => (locations, String::new()), + Err(e) => (Vec::new(), e), + }; + if let Some(id) = &config.teleport { + if let Some(target) = destinations.iter().find(|d| &d.id == id).and_then(|d| d.matrix) { + if let Err(e) = skater.travel_to(target) { status = e; } + } + } commands.insert_resource(Menu { open: false, selected: 0, @@ -218,9 +230,11 @@ fn setup( path, supported_msaa, difficulty: config.difficulty, - status: String::new(), + status, maps, selected_map, + destinations, + travel_page: None, }); } fn msaa(samples: u32) -> Msaa { @@ -238,6 +252,7 @@ fn cycle(values: &[T], value: T, direction: i32) -> T { fn interact( config: Res, mut physics: ResMut, + mut skater: ResMut, keys: Res>, mut menu: ResMut, mut time: ResMut>, @@ -245,20 +260,25 @@ fn interact( mut exit: MessageWriter, ) { if keys.just_pressed(KeyCode::Escape) { - menu.open = !menu.open; + if menu.open && menu.travel_page.is_some() { + menu.travel_page = None; + menu.selected = 8; + } else { menu.open = !menu.open; } } let mut action = None; if menu.open { if keys.just_pressed(KeyCode::ArrowUp) { - menu.selected = (menu.selected + 9) % 10; + menu.selected = (menu.selected + 10) % 11; } if keys.just_pressed(KeyCode::ArrowDown) { - menu.selected = (menu.selected + 1) % 10; + menu.selected = (menu.selected + 1) % 11; } - if keys.just_pressed(KeyCode::ArrowLeft) { + if menu.travel_page.is_some() && (keys.just_pressed(KeyCode::ArrowLeft) || keys.just_pressed(KeyCode::ArrowRight)) { + action = Some((if keys.just_pressed(KeyCode::ArrowLeft) { 8 } else { 9 }, 1)); + } else if keys.just_pressed(KeyCode::ArrowLeft) { action = Some((menu.selected, -1)); } - if keys.just_pressed(KeyCode::ArrowRight) || keys.just_pressed(KeyCode::Enter) { + if (menu.travel_page.is_none() && keys.just_pressed(KeyCode::ArrowRight)) || keys.just_pressed(KeyCode::Enter) { action = Some((menu.selected, 1)); } for (interaction, row) in &buttons { @@ -269,6 +289,30 @@ fn interact( } } if let Some((row, direction)) = action { + if let Some(page) = menu.travel_page { + match row { + 0..=7 => { + if let Some(destination) = menu.destinations.get(page * 8 + row).cloned() { + if let Some(transform) = destination.matrix { + if config.map_path.as_ref().is_some_and(|p| crate::teleport_menu::same_map(p, &destination.map)) { + match skater.travel_to(transform) { + Ok(()) => { menu.open = false; menu.travel_page = None; menu.status = format!("Arrived at {}", destination.name); } + Err(e) => menu.status = e, + } + } else if let Some(entry) = menu.maps.iter().find(|m| m.path.as_ref().is_some_and(|p| crate::teleport_menu::same_map(p, &destination.map))) { + match crate::map_library::switch_to(&config.asset_root, entry, Some(&destination.id)) { + Ok(()) => { exit.write(AppExit::Success); } + Err(e) => menu.status = e, + } + } else { menu.status = format!("{} is not installed", destination.map); } + } else { menu.status = destination.unavailable_reason.unwrap_or_else(|| "Destination unavailable".into()); } + } + } + 8 => menu.travel_page = Some(page.saturating_sub(1)), + 9 => menu.travel_page = Some((page + 1).min(menu.destinations.len().saturating_sub(1) / 8)), + _ => { menu.travel_page = None; menu.selected = 8; } + } + } else { match row { 0 => { let size = cycle( @@ -304,8 +348,9 @@ fn interact( Err(e) => menu.status = e, } } - 8 => menu.open = false, - 9 => { + 8 => { menu.travel_page = Some(0); menu.selected = 0; menu.status = "Select a destination | Left/Right: pages | Esc: back".into(); }, + 9 => menu.open = false, + 10 => { exit.write(AppExit::Success); } _ => {} @@ -324,6 +369,7 @@ fn interact( Err(e) => format!("Could not save: {e}"), }; } + } } if menu.open { time.pause(); @@ -397,6 +443,26 @@ fn labels( let s = &menu.settings; let size = s.internal_size(window.physical_size()); for (label, mut text) in &mut labels { + if label.0 >= usize::MAX-2 { + let travel = menu.travel_page.is_some(); + **text = if label.0 == usize::MAX { + if travel { "TELEPORT" } else { "PAUSED" } + } else if label.0 == usize::MAX-1 { + if travel { "ORIGINAL GAME LOCATIONS" } else { "GAMEPLAY & GRAPHICS" } + } else if travel { + "Click or Enter to travel | Up/Down select | Left/Right pages\nEsc back | Travelling to another map restarts the session" + } else { "Click to cycle | Up/Down select | Left/Right change\nEsc resume | Changes save automatically" }.into(); + continue; + } + if let Some(page) = menu.travel_page { + **text = match label.0 { + 0..=7 => menu.destinations.get(page * 8 + label.0).map(|d| format!("{} — {}{}", d.name, d.map, if d.matrix.is_none() { " (unavailable)" } else { "" })).unwrap_or_default(), + 8 => "Previous page".into(), + 9 => format!("Next page ({}/{})", page + 1, menu.destinations.len().div_ceil(8).max(1)), + _ => "Back to pause menu".into(), + }; + continue; + } **text = match label.0 { 0 => format!("Resolution {} x {}", s.width, s.height), 1 => format!( @@ -423,7 +489,8 @@ fn labels( 5 => format!("Difficulty {}", menu.difficulty.label()), 6 => format!("Map {}", menu.maps[menu.selected_map].label), 7 => "Load map (restarts session)".into(), - 8 => "Resume".into(), + 8 => "Teleport…".into(), + 9 => "Resume".into(), _ => "Quit game".into(), }; } @@ -462,7 +529,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, + maps: Vec::new(), selected_map: 0, destinations: Vec::new(), travel_page: None, }) .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 8345f00f..14b01d49 100644 --- a/crates/skate-game/src/main.rs +++ b/crates/skate-game/src/main.rs @@ -15,6 +15,7 @@ mod skater_animation; mod verification; mod performance; mod graphics_menu; +mod teleport_menu; mod render_capacity; mod retail_render; mod retail_character; diff --git a/crates/skate-game/src/map_library.rs b/crates/skate-game/src/map_library.rs index fcfc03c4..5d0943c2 100644 --- a/crates/skate-game/src/map_library.rs +++ b/crates/skate-game/src/map_library.rs @@ -46,12 +46,17 @@ pub(crate) fn default_map(assets: &Path) -> Result, String> { } pub(crate) fn switch(assets: &Path, entry: &Entry) -> Result<(), String> { + switch_to(assets, entry, None) +} + +pub(crate) fn switch_to(assets: &Path, entry: &Entry, destination: Option<&str>) -> Result<(), String> { if let Some(path) = &entry.path { let map = skate_data::skate_map::SkateMap::load(path)?; crate::skate_world::validate_runtime(&map)?; } let mut command = Command::new(std::env::current_exe().map_err(|e| e.to_string())?); command.arg("--assets").arg(assets); + if let Some(id) = destination { command.arg("--teleport").arg(id); } if let Some(path) = &entry.path { command.arg("--map").arg(path); } else { diff --git a/crates/skate-game/src/physics/skater.rs b/crates/skate-game/src/physics/skater.rs index 532ac365..8f90b174 100644 --- a/crates/skate-game/src/physics/skater.rs +++ b/crates/skate-game/src/physics/skater.rs @@ -82,6 +82,11 @@ pub(crate) struct SkaterRuntime { } impl SkaterRuntime { + pub(crate) fn travel_to(&mut self, transform: [[f32; 4]; 4]) -> Result<(), String> { + self.player_input.request_teleport(transform)?; + self.teleport_state.set_checkpoint(super::teleport_state::Checkpoint { transform, on_board: true }); + Ok(()) + } pub fn load( asset_root: &Path, graphs: &StockGraphs, diff --git a/crates/skate-game/src/physics/teleport_state.rs b/crates/skate-game/src/physics/teleport_state.rs index c2079894..8e438fbc 100644 --- a/crates/skate-game/src/physics/teleport_state.rs +++ b/crates/skate-game/src/physics/teleport_state.rs @@ -24,6 +24,10 @@ pub(crate) struct Runtime { pending_reply: Option, } impl Runtime { + pub(super) fn set_checkpoint(&mut self, checkpoint: Checkpoint) { + self.checkpoint = checkpoint; + self.pending_reply = None; + } pub fn new(checkpoint: Checkpoint) -> Self { Self { state: TeleportState::default(), diff --git a/crates/skate-game/src/retail_exposure.rs b/crates/skate-game/src/retail_exposure.rs index fac3fcf0..e4cddc5a 100644 --- a/crates/skate-game/src/retail_exposure.rs +++ b/crates/skate-game/src/retail_exposure.rs @@ -72,11 +72,15 @@ fn load(config: Res, mut settings: ResMut) { info!("RETAIL_EXPOSURE: fixed 2.5 comparison mode"); return; } - let data = std::fs::read(config.asset_root.join("private/exposure.json")); - if let Some(a) = data + let profile = config.map_path.as_ref().and_then(|p| p.file_stem()).and_then(|name| { + let bytes = std::fs::read(config.asset_root.join("private/exposure-profiles.json")).ok()?; + let mut profiles: std::collections::BTreeMap = serde_json::from_slice(&bytes).ok()?; + profiles.remove(&name.to_string_lossy().to_lowercase()) + }); + let fallback = || std::fs::read(config.asset_root.join("private/exposure.json")) .ok() - .and_then(|b| serde_json::from_slice::(&b).ok()) - { + .and_then(|b| serde_json::from_slice::(&b).ok()); + if let Some(a) = profile.or_else(fallback) { if [a.target_luminance, a.min, a.max, a.damping] .iter() .all(|x| x.is_finite()) diff --git a/crates/skate-game/src/retail_render.rs b/crates/skate-game/src/retail_render.rs index 347bf9e1..b6ffdbf4 100644 --- a/crates/skate-game/src/retail_render.rs +++ b/crates/skate-game/src/retail_render.rs @@ -208,7 +208,10 @@ impl Definition { let mut r = Reader(bytes); r.take(16)?; let shader = r.text()?; - let family = r.u32()?; + let stored_family = r.u32()?; + // Older map packages retained the complete bindings but classified + // non-flowing water as unknown. Upgrade without re-exporting geometry. + let family = if matches!(shader.as_str(), "water.default" | "water.alpha" | "water.skatepark") { 33 } else { stored_family }; let flags = r.u32()?; let mut bindings = BTreeMap::new(); for _ in 0..r.u32()? { @@ -258,6 +261,7 @@ impl Definition { 14 | 32 => tuning.rows.get(&self.shader).is_some_and(|r| !r.is_empty()), 31 => tuning.pca_available && tuning.rows.get(&self.shader).is_some_and(|r| r.len() == 3), 30 => tuning.rows.get(&self.shader).is_some_and(|r| r.len() == 4), + 33 => tuning.pca_available && self.bindings.contains_key("normal") && self.bindings.contains_key("normal2") && tuning.rows.get(&self.shader).is_some_and(|r| r.len() == 4), _ => false, } } @@ -281,7 +285,7 @@ impl Definition { let diffuse = fetch("diffuse", m.textures[0], false); let lightmap = fetch("lightmap", m.textures[1], true); let normal = fetch("normal", m.textures[2], false); - let detail = fetch(if self.family == 31 { "normal2" } else { "detail" }, 0, false); + let detail = fetch(if matches!(self.family, 31 | 33) { "normal2" } else { "detail" }, 0, false); let macro_map = fetch("macrooverlay", 0, false); let decal = fetch("decal", 0, self.family == 3); let specular = fetch("specular", 0, false); @@ -310,7 +314,7 @@ impl Definition { (_, 2) => AlphaMode::Blend, _ => AlphaMode::Opaque, }; - let alpha = if self.family == 32 || (self.family == 30 && self.shader.ends_with("alpha")) { + let alpha = if self.family == 32 || (matches!(self.family, 30 | 33) && self.shader.ends_with("alpha")) { AlphaMode::Blend } else { alpha }; let mut water = [Vec4::ZERO; 4]; diff --git a/crates/skate-game/src/retail_world.wgsl b/crates/skate-game/src/retail_world.wgsl index a62f79a2..04eada51 100644 --- a/crates/skate-game/src/retail_world.wgsl +++ b/crates/skate-game/src/retail_world.wgsl @@ -132,29 +132,68 @@ fn fragment(i: VertexOutput) -> @location(0) vec4 { let ward=exp(-2.0*(eu*eu+ev*ev)/(1.0+dot(h,n)))/max(den,1e-6); lin=(cube*olm*olm*fres+ward*p.water[0].rgb)*p.water[1].y; alpha=1.0; - } else if fam==30u { + } else if fam==30u || fam==33u { let t=frame_state.clock.x; // Convert to original UVs for scale/scroll, then back to flipped rows. let raw_uv=vec2(i.uv.x,1.0-i.uv.y); - let uv1=raw_uv*p.water[2].xy+p.water[1].xy*t; - let uv2=raw_uv*p.water[2].zw+p.water[1].zw*t; - let n1=textureSample(normal_map,normal_sampler,vec2(uv1.x,1.0-uv1.y)).rgb; - let n2=textureSample(normal_map,normal_sampler,vec2(uv2.x,1.0-uv2.y)).rgb; - let vn=normalize((2.0*n1+2.0*n2-2.0)*p.water[0].xzw); - let water_n=normalize(vn.x*kt+vn.y*kb+vn.z*wn); - let wlm=textureSampleLevel(lightmap,lm_sampler,i.uv_b+0.01*vn.xz*vec2(1.0,-1.0),0.0).rgb; + let uv_scale=select(1.0,p.water[3].x,fam==33u); + let uv1=raw_uv*p.water[2].xy*uv_scale+p.water[1].xy*t; + let uv2=raw_uv*p.water[2].zw*uv_scale+p.water[1].zw*t; + let n1=textureSample(normal_map,normal_sampler,vec2(uv1.x,1.0-uv1.y)); + let n2=textureSample(normal_map,normal_sampler,vec2(uv2.x,1.0-uv2.y)); + var vn=normalize((2.0*n1.rgb+2.0*n2.rgb-2.0)*p.water[0].xzw); + var water_n=normalize(vn.x*kt+vn.y*kb+vn.z*wn); + var sample_uv=i.uv; + if fam==33u { + let c1=textureSample(detail_map,detail_sampler,vec2(uv1.x,1.0-uv1.y))*2.0-1.0; + let c2=textureSample(detail_map,detail_sampler,vec2(uv2.x,1.0-uv2.y))*2.0-1.0; + let a1=n1*2.0-1.0; + let a2=n2*2.0-1.0; + // water_defaultPS instructions 22..54: the native mean is XYZ, + // weights are R/G/B pairs. FrameState stores the ocean's R/B/G + // arrangement; recover those original registers here. + let mean=frame_state.pca[0].xzy; + let pca1=vec3(dot(a1,frame_state.pca[1])+dot(c1,frame_state.pca[2])+mean.x, + dot(a1,frame_state.pca[5])+dot(c1,frame_state.pca[6])+mean.y, + dot(a1,frame_state.pca[3])+dot(c1,frame_state.pca[4])+mean.z); + let pca2=vec3(dot(a2,frame_state.pca[1])+dot(c2,frame_state.pca[2])+mean.x, + dot(a2,frame_state.pca[5])+dot(c2,frame_state.pca[6])+mean.y, + dot(a2,frame_state.pca[3])+dot(c2,frame_state.pca[4])+mean.z); + let first=vec3((pca2.x*2.0-1.0)*p.water[0].z, + 1.0+2.0*(pca2.y-1.0)*p.water[0].z,(pca2.z*2.0-1.0)*p.water[0].z); + let second=vec3((pca1.x*2.0-1.0)*p.water[0].w, + 1.0+2.0*(pca1.y-1.0)*p.water[0].w,(pca1.z*2.0-1.0)*p.water[0].w); + water_n=first*inverseSqrt(max(dot(first,first),1e-12)); + let refract=second*inverseSqrt(max(dot(second,second),1e-12)); + sample_uv+=0.02*refract.xz*vec2(1.0,-1.0); + d=textureSample(diffuse,diffuse_sampler,sample_uv).rgb; + d*=d; + vn=water_n; + } + let water_lm_uv=i.uv_b+0.01*vn.xz*vec2(1.0,-1.0); + var wlm=textureSampleLevel(lightmap,lm_sampler,water_lm_uv,0.0).rgb; + if fam==33u { + // Native tf3 fetches at all four half-texel corners, averages, + // then squares. Squaring each tap would change baked lighting. + let texel=0.5/vec2(textureDimensions(lightmap)); + wlm=(textureSampleLevel(lightmap,lm_sampler,water_lm_uv+texel,0.0).rgb + +textureSampleLevel(lightmap,lm_sampler,water_lm_uv-texel,0.0).rgb + +textureSampleLevel(lightmap,lm_sampler,water_lm_uv+texel*vec2(-1.0,1.0),0.0).rgb + +textureSampleLevel(lightmap,lm_sampler,water_lm_uv+texel*vec2(1.0,-1.0),0.0).rgb)*0.25; + } var lml=wlm*wlm; if frame_state.shadow.w>0.0 { let vz=(frame::view.view_from_world*i.world_position).z; for(var id=0u;id(fetch_directional_shadow(id,i.world_position,wn,vz))+frame_state.shadow.rgb); break; + let floor=select(frame_state.shadow.rgb,vec3(0.09,0.13,0.05),fam==33u); + lml=min(lml,vec3(fetch_directional_shadow(id,i.world_position,wn,vz))+floor); break; } } } let kd=dot(water_n,vec3(0.58*sign(sun.x),0.62*sign(sun.y),0.39))*2.39562; var wm=vec2(0.0); - if (flags & 16u)!=0u { wm=saturate(textureSample(specular_map,specular_sampler,i.uv).xz-p.water[3].y); } + if (flags & 16u)!=0u { wm=saturate(textureSample(specular_map,specular_sampler,sample_uv).xz-p.water[3].y); } let reflected_light=2.0*water_n*dot(water_n,sun)-sun; let ks=pow(max(saturate(dot(vd,reflected_light)),1e-6),p.water[3].z); var spec=ks*wm.x*vec3(2.1,1.8,1.5)*saturate(lml.g-0.1); diff --git a/crates/skate-game/src/teleport_menu.rs b/crates/skate-game/src/teleport_menu.rs new file mode 100644 index 00000000..06ec2333 --- /dev/null +++ b/crates/skate-game/src/teleport_menu.rs @@ -0,0 +1,60 @@ +//! Authored FE travel destinations; private content is produced by the extractor. +use std::path::Path; +use serde::Deserialize; + +#[derive(Clone, Deserialize)] +pub(crate) struct Destination { + pub id: String, + pub name: String, + pub map: String, + pub matrix: Option<[[f32; 4]; 4]>, + #[serde(default)] + pub unavailable_reason: Option, +} +#[derive(Deserialize)] +struct Catalog { version: u32, destinations: Vec } + +pub(crate) fn load(assets: &Path) -> Result, String> { + let path = assets.join("private/teleports.json"); + let bytes = std::fs::read(&path).map_err(|e| format!("Teleport locations: {e}"))?; + let catalog: Catalog = serde_json::from_slice(&bytes).map_err(|e| e.to_string())?; + if catalog.version != 1 { return Err("Unsupported teleport catalog version".into()); } + let mut ids = std::collections::HashSet::new(); + for d in &catalog.destinations { + if d.name.is_empty() || d.id.is_empty() || !ids.insert(&d.id) + || d.map.is_empty() || d.map.contains(['/', '\\', ':']) || matches!(d.map.as_str(), "." | "..") { + return Err("Invalid teleport destination identity".into()); + } + if let Some(m) = d.matrix { + if !valid_matrix(m) { return Err(format!("Invalid teleport transform: {}", d.name)); } + } + } + Ok(catalog.destinations) +} + +fn valid_matrix(m: [[f32; 4]; 4]) -> bool { + m.iter().flatten().all(|v| v.is_finite()) + && (0..3).all(|i| m[i][3].abs() < 1e-5) + && (m[3][3] - 1.).abs() < 1e-5 + && m[2][0] * m[2][0] + m[2][2] * m[2][2] > 1e-6 +} + +pub(crate) fn same_map(path: &Path, name: &str) -> bool { + path.file_stem().is_some_and(|stem| stem.to_string_lossy().eq_ignore_ascii_case(name)) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn rejects_invalid_destinations_and_preserves_authored_heading() { + let mut m = skate_core::physics::skeleton_animation_record::IDENTITY; + m[2] = [1., 0., 0., 0.]; + m[3] = [350.5, 140.2, -725.3, 1.]; + assert!(valid_matrix(m)); + m[3][1] = f32::NAN; + assert!(!valid_matrix(m)); + assert!(same_map(Path::new("maps/DownTown.skate"), "Downtown")); + assert!(!same_map(Path::new("maps/MegaPark.skate"), "University")); + } +} diff --git a/docs/teleports-and-still-water.md b/docs/teleports-and-still-water.md new file mode 100644 index 00000000..03bd78a6 --- /dev/null +++ b/docs/teleports-and-still-water.md @@ -0,0 +1,76 @@ +# Travel menu and still-water follow-up + +## Travel + +Esc → Teleport lists the original English frontend location names. The installer +now extracts `private/teleports.json` by joining `fe_locations`, the shipped label +and English language tables, and global RX2 locator records. Names are joined by +their label hashes, not string-table order. The special skate wordmark byte is +represented as `skate.` in plain text. + +The current owned installation yields 42 frontend destinations, 41 with unique +authored transforms. `Stadium - Monster Park` remains visible but unavailable: +its reference `vert_skp5_01_challengelocator_01` was not found in the global +locators or the inspected mission payload. No substitute position is invented. +Frontend templates and unnamed park-layout aliases are excluded. + +Same-map travel queues the existing player-input teleport/reset service and +updates the checkpoint. Cross-map travel uses the existing map-switch process +with `--teleport ID`; startup checks that the destination belongs to that map. +Authored height and orientation are preserved. Runtime arrivals and menu layout +have not been checked in a running game. + +Evidence: TU3 `cGlobalFileLocationManager::LoadGlobalLocations` at `0x828A3D50` +loads `data/content/global_locators/*.rx2`. The RX2 `0xEB0009` section contains +128-byte records with the transform at +0, name pointer at +104, tag pointer at ++108 and identifier at +112. The parser validates record and string boundaries. +All 41 resolved positions have collision surfaces beneath their authored X/Z in +the exported maps; vertical offsets range from approximately -0.008 to 4.536 m. +This is an offline placement check, not a gameplay arrival test. + +## Rendering + +Still-water materials (`water.default`, `water.alpha`, `water.skatepark`) now +have a dedicated family. Retained shader names upgrade older map packages at +load time. The shader implements the decoded two-layer PCA normal reconstruction, +UV scrolling, refraction, four-tap lightmap sampling and literal shadow floor. +Flowing-water materials keep their existing family. Still water requires the +private ocean PCA sidecar; automatic extraction of that sidecar from a fresh +installation remains outstanding, and missing data uses the existing fallback. + +The default and alpha pixel shaders have 128 bytes of literal constants before +their instructions. SHA-256 of their instruction streams: + +- Default: `B5F39F3080C89A220250E87643C5AD15F4A94B42B2C3DCB6BAF090E036FE3B5A` +- Alpha: `81D9905165B28B03C3623D18887E7E9D9303D3FFDA072993B8C183D24BD3A547` + +An offline register evaluator compared 600 normal/refraction cases across all 30 +owned PCA frames with the implemented algebra (maximum error 2.22e-16). This +does not establish full pixel-shader or GPU visual parity. Private disassembly +and validation output are under `logs/renderer-work/shader-inspect`. + +Exposure extraction now follows each map's render-location reference and its +inherited exposure settings into `private/exposure-profiles.json`. Runtime +selects the active map's profile, with the existing global fallback. The ten +current profiles resolve to the same values, so selection alone need not produce +a visible change. + +## Remaining parity work + +- Spatial lighting/colour transition volumes and their native controller. +- Exact colour grading: profiles reference `cc_financial.rgb`, which was not + found in the inspected content. The available `cc_petes.rgb` is not evidence + that this different lookup table should be applied. +- Native bloom composition, console shadow-atlas filtering and the per-frame + shadow-colour controller. +- Fresh-install ocean PCA extraction and in-game water/teleport validation. + +Movable-object work is deferred at the user's request. Hair is unchanged. + +## Checks + +Release build, four filtered Rust menu tests, eight Python extraction/environment +tests, and composed world/depth shader validation passed. All ten map packages +passed the offline reader; their geometry/collision tails remain byte-identical +to the installed inputs. No game or recomp was launched. Owned assets and private +inspection artifacts are not committed. diff --git a/tools/asset_pipeline/install.py b/tools/asset_pipeline/install.py index 38f0c3a0..62c55ce1 100644 --- a/tools/asset_pipeline/install.py +++ b/tools/asset_pipeline/install.py @@ -205,6 +205,9 @@ def install(iso,base,game_exe,report,game_root=None): write_skies(game_root,stage/'assets',converted) from .render_parameters import convert as write_render_parameters write_render_parameters(stage/'assets',converted) + report('Extracting original travel destinations and location names') + from .teleports import convert as write_teleports + write_teleports(game_root,stage/'assets',converted) report('Preparing global foliage backdrops') from .backdrop import convert as write_backdrops write_backdrops(game_root,stage/'assets',converted) diff --git a/tools/asset_pipeline/render_parameters.py b/tools/asset_pipeline/render_parameters.py index 4a34939a..0bf4f782 100644 --- a/tools/asset_pipeline/render_parameters.py +++ b/tools/asset_pipeline/render_parameters.py @@ -4,6 +4,15 @@ import struct from .environment import Collections, key_hash +def exposure_parameters(collections, location): + fields, chain = collections.resolve('render_locations', location) + key = int(fields[key_hash('Hash_C345507C4B9B6F62')]['data'][:16], 16) + rendering, rendering_chain = collections.resolve('rendering', key) + values = {n: struct.unpack('>f', bytes.fromhex(rendering[key_hash('auto_exposure_'+n)]['data']))[0] + for n in ('target_luminance', 'min', 'max', 'damping')} + return dict(values, location_chain=chain, rendering_chain=rendering_chain) + + def convert(assets, converted): collections = Collections(converted) result = {} @@ -26,4 +35,15 @@ def convert(assets, converted): exposure = {n: struct.unpack('>f', bytes.fromhex(fields[key_hash('auto_exposure_'+n)]['data']))[0] for n in ('target_luminance', 'min', 'max', 'damping')} (assets / 'private/exposure.json').write_text(json.dumps(exposure), encoding='utf-8') + profiles = {} + for (cls, key), 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 not own_stream or not own_stream.startswith('DIST_'): + continue + world, _ = collections.resolve(cls, key) + location = int(world[key_hash('Hash_7E23D10785C43717')]['data'][:16], 16) + profiles[own_stream.removeprefix('DIST_').casefold()] = exposure_parameters(collections, location) + (assets / 'private/exposure-profiles.json').write_text(json.dumps(profiles, indent=2), encoding='utf-8') return len(result) diff --git a/tools/asset_pipeline/retail_material.py b/tools/asset_pipeline/retail_material.py index cb3bcd07..839a5ea0 100644 --- a/tools/asset_pipeline/retail_material.py +++ b/tools/asset_pipeline/retail_material.py @@ -31,6 +31,8 @@ def _retail_shader_family(shader_name: str) -> int: return 12 if shader.startswith("water.flowing"): return 30 + if shader in ("water.default", "water.alpha", "water.skatepark"): + return 33 if shader.startswith("ocean.default"): return 31 if shader.startswith("ocean.reflection"): diff --git a/tools/asset_pipeline/teleports.py b/tools/asset_pipeline/teleports.py new file mode 100644 index 00000000..4e4f0ee1 --- /dev/null +++ b/tools/asset_pipeline/teleports.py @@ -0,0 +1,120 @@ +"""Join retail FE destinations to localized names and RX2 location matrices. + +Owned content stays in private sidecars. No guessed coordinates or renamed spots. +LocationManager 828A3D50 loads data/content/global_locators; EB0009 records +contain a 64-byte matrix and a section-relative name pointer at +104. +""" +import hashlib +import json +import struct +import tempfile +from pathlib import Path + +from tools.owned_game.big import BigArchive +from .dynamic_props import sections, matrix +from .environment import Collections, key_hash +from .vlt import vault + + +def cstring(raw, offset, limit=None): + limit = len(raw) if limit is None else limit + if not 0 <= offset < limit <= len(raw): + raise ValueError('Invalid location string offset') + return raw[offset:raw.index(b'\0', offset, limit)] + + +def language_table(raw): + if len(raw) < 36 or struct.unpack_from(' len(raw): + raise ValueError('Invalid retail language index') + result = {} + for index in range(count): + key, offset = struct.unpack_from('<2I', raw, start + index * 8) + if key in result: + raise ValueError('Duplicate language key') + result[key] = cstring(raw, strings + offset) + return result + + +def location_records(raw): + result = [] + for base, _, size, _, _, kind in sections(raw): + if kind != 0xEB0009: + continue + count, _, _, start, end = struct.unpack_from('>5I', raw, base) + if base + size > len(raw) or start != 32 or end != start + count * 128 or end > size: + raise ValueError('Invalid location record table') + for index in range(count): + at = base + start + index * 128 + pointer = struct.unpack_from('>I', raw, at + 104)[0] + if not end <= pointer < size: + raise ValueError('Location name overlaps records') + result.append({'locator': cstring(raw, base + pointer, base + size).decode('utf-8'), + 'matrix': matrix(raw, at).tolist(), 'source_offset': at}) + return result + + +def convert(game_root, assets, converted): + game_root, assets = Path(game_root), Path(assets) + archive = BigArchive(game_root / 'data/big/miscboot.big') + tables = {} + for language in ('english', 'labels'): + entry = next(e for e in archive.entries if f'/{language}/' in e.path and '_Global_' in e.path) + tables[language] = language_table(archive.read(entry)) + labels = {value.strip().decode('ascii'): key for key, value in tables['labels'].items()} + database = BigArchive(game_root / 'data/big/db.big') + with tempfile.TemporaryDirectory(prefix='skate-teleports-') as tmp: + root = Path(tmp) + entries = [e for e in database.entries if e.path in + ('data/db/skatercollections.bin', 'data/db/skatercollections.vlt')] + database.extract_entries(entries, root) + _, binary, _ = vault(root / 'data/db/skatercollections') + locators = {} + for path in sorted((game_root / 'data/content/global_locators').rglob('*.rx2')): + raw = path.read_bytes() + stream = path.parent.name + for record in location_records(raw): + record.update(map=stream.removeprefix('DIST_'), source=str(path.relative_to(game_root)), + source_sha256=hashlib.sha256(raw).hexdigest()) + locators.setdefault((stream.casefold(), record['locator']), []).append(record) + collections = Collections(converted) + destinations = [] + # Own location artwork distinguishes selectable FE destinations from base + # templates, startup checkpoints and park-layout aliases. + for (cls, key), row in collections.rows.items(): + if cls != key_hash('fe_locations') or not any(key_hash(k) == key_hash('Hash_786A2D24F475342B') for k in row['fields']): + continue + fields, _ = collections.resolve(cls, key) + label = fields[key_hash('Hash_174B301910A902C9')]['data'] + if label not in labels: + continue # Template placeholders have no shipped localized name. + text = tables['english'][labels[label]] + # 0xAB is the retail font's skate wordmark glyph. The portable UI has + # no such font glyph; spell out the same brand instead of showing «. + name = text.replace(b'\xab', b'skate.').decode('ascii').strip() + locator = cstring(binary, int(fields[key_hash('location')]['data'], 16)).decode('utf-8') + world_key = int(fields[key_hash('World')]['data'][:16], 16) + world, _ = collections.resolve('world', world_key) + stream = world[key_hash('WorldStream')]['data'] + if not (game_root / 'data/content' / ('world' + stream + '.big')).exists(): + continue + matches = locators.get((stream.casefold(), locator), []) + unique = {json.dumps(m['matrix']) for m in matches} + if len(unique) > 1: + raise ValueError('Ambiguous authored teleport: ' + locator) + item = dict(id=row['key'], name=name, map=stream.removeprefix('DIST_'), + label_key=label, locator=locator, matrix=None) + if matches: + item.update(matches[0]) + else: + item['unavailable_reason'] = 'Authored destination locator was not found in the extracted content' + destinations.append(item) + destinations.sort(key=lambda d: (d['map'].casefold(), d['name'].casefold())) + output = assets / 'private/teleports.json' + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(json.dumps({'version': 1, 'destinations': destinations}, indent=2), encoding='utf-8') + return destinations diff --git a/tools/asset_pipeline/test_environment.py b/tools/asset_pipeline/test_environment.py index 42a7d7f3..a1e28e88 100644 --- a/tools/asset_pipeline/test_environment.py +++ b/tools/asset_pipeline/test_environment.py @@ -2,6 +2,7 @@ import struct import unittest from .environment import Collections, field, key_hash, sky_parameters, world_environment, fog_parameters +from .render_parameters import exposure_parameters def raw(*values): @@ -14,6 +15,14 @@ def row(cls, key, parent='', **fields): class EnvironmentTests(unittest.TestCase): + def test_exposure_follows_location_reference_and_inheritance(self): + rows = [row('render_locations', 'park', Hash_C345507C4B9B6F62=struct.pack('>2Q', key_hash('park_exposure'), 0).hex()), + row('rendering', 'base', auto_exposure_target_luminance=raw(.25), auto_exposure_min=raw(.75), auto_exposure_max=raw(2.5), auto_exposure_damping=raw(.5)), + row('rendering', 'park_exposure', 'base', auto_exposure_max=raw(1.5))] + result = exposure_parameters(Collections({'collections': rows}), 'park') + self.assertEqual(result['max'], 1.5) + self.assertEqual(result['min'], .75) + self.assertEqual(result['rendering_chain'], ['park_exposure', 'base']) def test_world_reference_and_inheritance_select_default_fog(self): rows = [ row('world', 'base', Hash_C7A0A84F018E87BA='world/models/sky', diff --git a/tools/asset_pipeline/test_teleports.py b/tools/asset_pipeline/test_teleports.py new file mode 100644 index 00000000..7835c567 --- /dev/null +++ b/tools/asset_pipeline/test_teleports.py @@ -0,0 +1,36 @@ +import struct +import unittest +import numpy as np +from .teleports import language_table, location_records +from .test_dynamic_props import resource + + +class TeleportTests(unittest.TestCase): + def test_language_join_uses_keys_not_storage_order(self): + # Offsets are relative to the chunk payload; strings need not follow + # the same physical order as the sorted hash index. + raw = bytearray(36 + 16 + 11) + struct.pack_into('<5I', raw, 0, 0x39000, len(raw)-8, 2, 28, 44) + struct.pack_into('<4I', raw, 36, 123, 6, 456, 0) + raw[52:] = b'First\0Last\0' + self.assertEqual(language_table(raw), {123: b'Last', 456: b'First'}) + struct.pack_into('5I', payload, 0, 1, 0, 0, 32, 160) + m = np.array([[0, 0, -1, 0], [0, 1, 0, 0], [1, 0, 0, 0], [350, 140, -725, 1]], dtype=float) + struct.pack_into('>16f', payload, 32, *m.ravel()) + struct.pack_into('>I', payload, 136, 160) + payload[160:] = b'spot\0' + rows = location_records(resource(0xEB0009, payload)) + self.assertEqual(rows[0]['locator'], 'spot') + np.testing.assert_array_equal(rows[0]['matrix'], m) + struct.pack_into('>I', payload, 136, 32) + with self.assertRaisesRegex(ValueError, 'overlaps'): location_records(resource(0xEB0009, payload)) + + def test_bad_record_count_is_rejected(self): + payload = bytearray(32) + struct.pack_into('>5I', payload, 0, 1, 0, 0, 32, 160) + with self.assertRaises(ValueError): location_records(resource(0xEB0009, payload))