Extract original travel destinations and add teleport menu with still-water rendering

This commit is contained in:
chazmm committed 2026-09-08 20:31:57 +01:00
1 parent 79ea829ca9
commit 38d4f30ef0
17 files changed
+497 -32

No files matched your search

+10
View File
@@ -9,6 +9,7 @@ pub(crate) struct Config {
pub map_path: Option<PathBuf>,
pub difficulty: crate::difficulty::Difficulty,
pub check_assets: bool,
pub teleport: Option<String>,
}
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());
+82 -15
View File
@@ -84,6 +84,8 @@ pub(crate) struct Menu {
status: String,
maps: Vec<crate::map_library::Entry>,
selected_map: usize,
destinations: Vec<crate::teleport_menu::Destination>,
travel_page: Option<usize>,
}
pub(crate) fn gameplay_active(menu: Option<Res<Menu>>) -> bool {
menu.is_none_or(|m| !m.open)
@@ -117,6 +119,7 @@ fn setup(
mut window: Single<&mut Window, With<PrimaryWindow>>,
cameras: Query<Entity, With<Camera3d>>,
adapter: Res<RenderAdapter>,
mut skater: ResMut<crate::physics::SkaterRuntime>,
) {
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<T: PartialEq + Copy>(values: &[T], value: T, direction: i32) -> T {
fn interact(
config: Res<crate::config::Config>,
mut physics: ResMut<crate::physics::GamePhysics>,
mut skater: ResMut<crate::physics::SkaterRuntime>,
keys: Res<ButtonInput<KeyCode>>,
mut menu: ResMut<Menu>,
mut time: ResMut<Time<Virtual>>,
@@ -245,20 +260,25 @@ fn interact(
mut exit: MessageWriter<AppExit>,
) {
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));
+1
View File
@@ -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;
+5
View File
@@ -46,12 +46,17 @@ pub(crate) fn default_map(assets: &Path) -> Result<Option<PathBuf>, 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 {
+5
View File
@@ -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,
@@ -24,6 +24,10 @@ pub(crate) struct Runtime {
pending_reply: Option<Target>,
}
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(),
+8 -4
View File
@@ -72,11 +72,15 @@ fn load(config: Res<crate::config::Config>, mut settings: ResMut<Settings>) {
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<String, Authored> = 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::<Authored>(&b).ok())
{
.and_then(|b| serde_json::from_slice::<Authored>(&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())
+7 -3
View File
@@ -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];
+49 -10
View File
@@ -132,29 +132,68 @@ fn fragment(i: VertexOutput) -> @location(0) vec4<f32> {
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<f32>(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<f32>(uv1.x,1.0-uv1.y)).rgb;
let n2=textureSample(normal_map,normal_sampler,vec2<f32>(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<f32>(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<f32>(uv1.x,1.0-uv1.y));
let n2=textureSample(normal_map,normal_sampler,vec2<f32>(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<f32>(uv1.x,1.0-uv1.y))*2.0-1.0;
let c2=textureSample(detail_map,detail_sampler,vec2<f32>(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<f32>(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<f32>(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<f32>((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<f32>((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<f32>(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<f32>(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<f32>(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<f32>(-1.0,1.0),0.0).rgb
+textureSampleLevel(lightmap,lm_sampler,water_lm_uv+texel*vec2<f32>(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<frame::lights.n_directional_lights;id+=1u) {
if (frame::lights.directional_lights[id].flags & 5u)==5u {
lml=min(lml,vec3<f32>(fetch_directional_shadow(id,i.world_position,wn,vz))+frame_state.shadow.rgb); break;
let floor=select(frame_state.shadow.rgb,vec3<f32>(0.09,0.13,0.05),fam==33u);
lml=min(lml,vec3<f32>(fetch_directional_shadow(id,i.world_position,wn,vz))+floor); break;
}
}
}
let kd=dot(water_n,vec3<f32>(0.58*sign(sun.x),0.62*sign(sun.y),0.39))*2.39562;
var wm=vec2<f32>(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<f32>(2.1,1.8,1.5)*saturate(lml.g-0.1);
+60
View File
@@ -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<String>,
}
#[derive(Deserialize)]
struct Catalog { version: u32, destinations: Vec<Destination> }
pub(crate) fn load(assets: &Path) -> Result<Vec<Destination>, 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"));
}
}
+76
View File
@@ -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.
+3
View File
@@ -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)
+20
View File
@@ -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)
+2
View File
@@ -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"):
+120
View File
@@ -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('<I', raw)[0] != 0x39000:
raise ValueError('Unsupported retail language table')
count, start, strings = struct.unpack_from('<3I', raw, 8)
start += 8
strings += 8
if start + count * 8 != strings or strings > 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
+9
View File
@@ -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',
+36
View File
@@ -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('<I', raw, 40, 999)
with self.assertRaises(ValueError): language_table(raw)
def test_locator_preserves_heading_and_world_space_position(self):
payload = bytearray(165)
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))