From 2dbe4aaceb27785b211664e6c40b9542a0d03cd4 Mon Sep 17 00:00:00 2001 From: chazmm Date: Tue, 8 Sep 2026 17:44:19 +0100 Subject: [PATCH] Integrate scoring collectors and original APT HUD for interactive testing --- crates/skate-core/src/scoring.rs | 29 +- crates/skate-core/src/scoring/conversions.rs | 335 ++++++++++ crates/skate-data/examples/apt_movie_data.rs | 102 +++ crates/skate-data/examples/hud_data.rs | 79 +++ .../skate-data/examples/scoring_flow_data.rs | 92 +++ crates/skate-data/src/lib.rs | 1 + crates/skate-data/src/scoring.rs | 42 ++ crates/skate-data/src/scoring_fields.rs | 65 ++ crates/skate-game/src/app.rs | 3 +- crates/skate-game/src/apt_display.rs | 1 + crates/skate-game/src/apt_movie.rs | 320 ++++++++++ crates/skate-game/src/apt_scene.rs | 196 ++++++ crates/skate-game/src/apt_text.rs | 119 ++++ crates/skate-game/src/apt_vm.rs | 73 ++- crates/skate-game/src/hud_render.wgsl | 9 + crates/skate-game/src/hud_runtime.rs | 249 ++++++++ crates/skate-game/src/main.rs | 6 + .../skate-game/src/physics/animation_phase.rs | 1 + crates/skate-game/src/physics/frame.rs | 17 + crates/skate-game/src/physics/skater.rs | 2 + crates/skate-game/src/scoring_hud.rs | 293 +++++++++ crates/skate-game/src/scoring_runtime.rs | 597 ++++++++++++++++++ docs/native-scoring-hud.md | 87 ++- tools/prepare_hud.py | 2 + 24 files changed, 2678 insertions(+), 42 deletions(-) create mode 100644 crates/skate-core/src/scoring/conversions.rs create mode 100644 crates/skate-data/examples/apt_movie_data.rs create mode 100644 crates/skate-data/examples/hud_data.rs create mode 100644 crates/skate-data/examples/scoring_flow_data.rs create mode 100644 crates/skate-data/src/scoring_fields.rs create mode 100644 crates/skate-game/src/apt_movie.rs create mode 100644 crates/skate-game/src/apt_scene.rs create mode 100644 crates/skate-game/src/apt_text.rs create mode 100644 crates/skate-game/src/hud_render.wgsl create mode 100644 crates/skate-game/src/hud_runtime.rs create mode 100644 crates/skate-game/src/scoring_hud.rs create mode 100644 crates/skate-game/src/scoring_runtime.rs diff --git a/crates/skate-core/src/scoring.rs b/crates/skate-core/src/scoring.rs index b4cd64e8..2851ccce 100644 --- a/crates/skate-core/src/scoring.rs +++ b/crates/skate-core/src/scoring.rs @@ -8,6 +8,7 @@ pub const SCORABLE_COUNT: usize = 332; pub const SCORE_TYPE_COUNT: usize = 14; pub mod carrier; pub mod catalog; +pub mod conversions; pub mod session; pub mod timer; @@ -64,6 +65,26 @@ impl Default for ScoreHolder { } impl ScoreHolder { + pub fn has_pending_sequence(&self) -> bool { + self.pending_sequence + } + /// Non-air collectors use6198 then add directly to accumulator32. + pub fn credit_trick(&mut self, scorable: Scorable, reward: f32) { + if !scorable.valid() || self.suppressed { + return; + } + self.count_trick(scorable); + self.snapshot.accumulated += reward; + } + fn count_trick(&mut self, scorable: Scorable) { + self.repetitions[scorable.id] = self.repetitions[scorable.id].saturating_add(1); + if scorable.score_type != 0 { + self.sequence_history[scorable.id] = + self.sequence_history[scorable.id].saturating_add(1); + self.type_history[scorable.score_type] = + self.type_history[scorable.score_type].saturating_add(1); + } + } pub fn set_suppressed(&mut self, suppressed: bool) { self.suppressed = suppressed; } @@ -75,13 +96,7 @@ impl ScoreHolder { if !scorable.valid() || self.suppressed { return; } - self.repetitions[scorable.id] = self.repetitions[scorable.id].saturating_add(1); - if scorable.score_type != 0 { - self.sequence_history[scorable.id] = - self.sequence_history[scorable.id].saturating_add(1); - self.type_history[scorable.score_type] = - self.type_history[scorable.score_type].saturating_add(1); - } + self.count_trick(scorable); let s = &mut self.snapshot; if scorable.class != 5 { s.general_pending += s.fingerflip_pending; diff --git a/crates/skate-core/src/scoring/conversions.rs b/crates/skate-core/src/scoring/conversions.rs new file mode 100644 index 00000000..b1ea0703 --- /dev/null +++ b/crates/skate-core/src/scoring/conversions.rs @@ -0,0 +1,335 @@ +//! Scorable descriptor conversion links at820862A8, stride24. +pub const LINKS: [(i32, i32); 332] = [ + (-1, -1), //0 + (-1, -1), //1 + (-1, -1), //2 + (-1, -1), //3 + (-1, -1), //4 + (-1, -1), //5 + (-1, -1), //6 + (-1, -1), //7 + (-1, -1), //8 + (-1, -1), //9 + (-1, -1), //10 + (-1, -1), //11 + (-1, -1), //12 + (-1, -1), //13 + (-1, -1), //14 + (-1, -1), //15 + (-1, -1), //16 + (-1, -1), //17 + (-1, -1), //18 + (-1, -1), //19 + (-1, -1), //20 + (-1, -1), //21 + (-1, -1), //22 + (-1, -1), //23 + (-1, -1), //24 + (-1, -1), //25 + (-1, -1), //26 + (-1, -1), //27 + (-1, -1), //28 + (-1, -1), //29 + (-1, -1), //30 + (-1, -1), //31 + (-1, -1), //32 + (-1, -1), //33 + (-1, -1), //34 + (-1, -1), //35 + (-1, -1), //36 + (-1, -1), //37 + (-1, -1), //38 + (-1, -1), //39 + (-1, -1), //40 + (-1, -1), //41 + (-1, -1), //42 + (-1, -1), //43 + (-1, -1), //44 + (-1, -1), //45 + (-1, -1), //46 + (-1, -1), //47 + (-1, -1), //48 + (-1, -1), //49 + (-1, -1), //50 + (-1, -1), //51 + (-1, -1), //52 + (-1, -1), //53 + (-1, -1), //54 + (-1, -1), //55 + (-1, -1), //56 + (-1, -1), //57 + (-1, -1), //58 + (-1, -1), //59 + (-1, -1), //60 + (-1, -1), //61 + (-1, -1), //62 + (-1, -1), //63 + (-1, -1), //64 + (-1, -1), //65 + (-1, -1), //66 + (-1, -1), //67 + (-1, -1), //68 + (-1, -1), //69 + (-1, -1), //70 + (-1, -1), //71 + (-1, -1), //72 + (-1, -1), //73 + (-1, -1), //74 + (-1, -1), //75 + (-1, -1), //76 + (-1, -1), //77 + (-1, -1), //78 + (-1, -1), //79 + (-1, -1), //80 + (-1, -1), //81 + (-1, -1), //82 + (-1, -1), //83 + (-1, -1), //84 + (-1, -1), //85 + (-1, -1), //86 + (-1, -1), //87 + (-1, -1), //88 + (-1, -1), //89 + (-1, -1), //90 + (-1, -1), //91 + (-1, -1), //92 + (92, 92), //93 + (93, 92), //94 + (-1, -1), //95 + (-1, -1), //96 + (96, 96), //97 + (97, 96), //98 + (-1, -1), //99 + (-1, 128), //100 + (-1, 128), //101 + (-1, 128), //102 + (-1, 128), //103 + (-1, 128), //104 + (-1, 128), //105 + (-1, -1), //106 + (-1, -1), //107 + (-1, -1), //108 + (-1, -1), //109 + (-1, -1), //110 + (-1, -1), //111 + (-1, -1), //112 + (-1, -1), //113 + (113, 113), //114 + (114, 113), //115 + (-1, -1), //116 + (-1, -1), //117 + (117, 117), //118 + (118, 117), //119 + (-1, -1), //120 + (-1, -1), //121 + (-1, -1), //122 + (-1, -1), //123 + (-1, -1), //124 + (-1, -1), //125 + (-1, -1), //126 + (-1, -1), //127 + (-1, -1), //128 + (-1, -1), //129 + (-1, -1), //130 + (-1, -1), //131 + (-1, -1), //132 + (-1, -1), //133 + (94, 92), //134 + (98, 96), //135 + (115, 113), //136 + (119, 117), //137 + (-1, -1), //138 + (-1, -1), //139 + (-1, -1), //140 + (-1, -1), //141 + (-1, -1), //142 + (-1, -1), //143 + (-1, -1), //144 + (-1, -1), //145 + (-1, -1), //146 + (-1, -1), //147 + (-1, -1), //148 + (-1, -1), //149 + (-1, -1), //150 + (-1, -1), //151 + (-1, -1), //152 + (-1, -1), //153 + (-1, -1), //154 + (-1, -1), //155 + (-1, -1), //156 + (-1, -1), //157 + (-1, -1), //158 + (-1, -1), //159 + (-1, -1), //160 + (-1, -1), //161 + (-1, -1), //162 + (-1, -1), //163 + (-1, -1), //164 + (-1, -1), //165 + (-1, -1), //166 + (-1, -1), //167 + (-1, -1), //168 + (-1, -1), //169 + (-1, -1), //170 + (-1, -1), //171 + (-1, -1), //172 + (-1, -1), //173 + (-1, -1), //174 + (-1, -1), //175 + (-1, -1), //176 + (-1, -1), //177 + (-1, -1), //178 + (-1, -1), //179 + (-1, -1), //180 + (-1, -1), //181 + (-1, -1), //182 + (-1, -1), //183 + (-1, -1), //184 + (-1, -1), //185 + (-1, -1), //186 + (-1, -1), //187 + (-1, -1), //188 + (-1, -1), //189 + (-1, -1), //190 + (-1, -1), //191 + (-1, -1), //192 + (-1, -1), //193 + (-1, -1), //194 + (-1, -1), //195 + (-1, -1), //196 + (-1, -1), //197 + (-1, -1), //198 + (-1, -1), //199 + (-1, -1), //200 + (-1, -1), //201 + (-1, -1), //202 + (-1, -1), //203 + (-1, -1), //204 + (-1, -1), //205 + (-1, -1), //206 + (-1, -1), //207 + (-1, -1), //208 + (-1, -1), //209 + (-1, -1), //210 + (-1, -1), //211 + (-1, -1), //212 + (-1, -1), //213 + (-1, -1), //214 + (-1, -1), //215 + (-1, -1), //216 + (-1, -1), //217 + (-1, -1), //218 + (-1, -1), //219 + (-1, -1), //220 + (-1, -1), //221 + (-1, -1), //222 + (-1, -1), //223 + (-1, -1), //224 + (-1, -1), //225 + (-1, -1), //226 + (-1, -1), //227 + (-1, -1), //228 + (-1, -1), //229 + (-1, -1), //230 + (-1, -1), //231 + (-1, -1), //232 + (-1, -1), //233 + (-1, -1), //234 + (-1, -1), //235 + (-1, -1), //236 + (-1, -1), //237 + (-1, -1), //238 + (-1, -1), //239 + (-1, -1), //240 + (-1, -1), //241 + (-1, -1), //242 + (-1, -1), //243 + (-1, -1), //244 + (-1, -1), //245 + (-1, -1), //246 + (-1, -1), //247 + (-1, -1), //248 + (-1, -1), //249 + (-1, -1), //250 + (-1, -1), //251 + (-1, -1), //252 + (-1, -1), //253 + (-1, -1), //254 + (-1, -1), //255 + (-1, -1), //256 + (-1, -1), //257 + (-1, -1), //258 + (-1, -1), //259 + (-1, -1), //260 + (-1, -1), //261 + (-1, -1), //262 + (-1, -1), //263 + (-1, -1), //264 + (-1, -1), //265 + (-1, -1), //266 + (-1, -1), //267 + (-1, -1), //268 + (-1, -1), //269 + (-1, -1), //270 + (-1, -1), //271 + (-1, -1), //272 + (-1, -1), //273 + (-1, -1), //274 + (-1, -1), //275 + (-1, -1), //276 + (-1, -1), //277 + (-1, -1), //278 + (-1, -1), //279 + (-1, -1), //280 + (-1, -1), //281 + (-1, -1), //282 + (-1, -1), //283 + (-1, -1), //284 + (-1, -1), //285 + (-1, -1), //286 + (-1, -1), //287 + (-1, -1), //288 + (-1, -1), //289 + (-1, -1), //290 + (-1, -1), //291 + (-1, -1), //292 + (-1, -1), //293 + (-1, -1), //294 + (-1, -1), //295 + (-1, -1), //296 + (-1, -1), //297 + (-1, -1), //298 + (-1, -1), //299 + (-1, -1), //300 + (-1, -1), //301 + (-1, -1), //302 + (-1, -1), //303 + (-1, -1), //304 + (-1, -1), //305 + (-1, -1), //306 + (-1, -1), //307 + (-1, -1), //308 + (-1, -1), //309 + (-1, -1), //310 + (-1, -1), //311 + (-1, -1), //312 + (-1, -1), //313 + (-1, -1), //314 + (-1, -1), //315 + (-1, -1), //316 + (-1, -1), //317 + (-1, -1), //318 + (-1, -1), //319 + (-1, -1), //320 + (-1, -1), //321 + (-1, -1), //322 + (-1, -1), //323 + (-1, -1), //324 + (-1, -1), //325 + (-1, -1), //326 + (-1, -1), //327 + (-1, -1), //328 + (-1, -1), //329 + (-1, -1), //330 + (-1, -1), //331 +]; diff --git a/crates/skate-data/examples/apt_movie_data.rs b/crates/skate-data/examples/apt_movie_data.rs new file mode 100644 index 00000000..f3e0cc94 --- /dev/null +++ b/crates/skate-data/examples/apt_movie_data.rs @@ -0,0 +1,102 @@ +//! Executes owned movie bytecode against a data-only host. No game or renderer. +#[path = "../../skate-game/src/apt_display.rs"] +mod apt_display; +#[path = "../../skate-game/src/apt_movie.rs"] +mod apt_movie; +#[path = "../../skate-game/src/apt_text.rs"] +mod apt_text; +#[path = "../../skate-game/src/apt_vm.rs"] +mod apt_vm; +use apt_vm::*; +struct Audit { + movie: apt_movie::Movie, +} +impl Host for Audit { + fn call( + &mut self, + vm: &mut Vm, + object: usize, + method: &str, + args: Vec, + ) -> Result { + if self.movie.method(vm, object, method, &args)? { + return Ok(Value::Undefined); + } + match method { + "ASSetPropFlags" => Ok(Value::Undefined), + "GetLineTimerMaxPoints" => Ok(Value::Number(400.0)), + "GetLineTimeRemaining" | "GetLineScore" => Ok(Value::Number(0.0)), + "GetLanguage" => Ok(Value::Text("english".into())), + _ => Err(format!( + "Unimplemented audit binding {object}.{method}({args:?})" + )), + } + } +} +fn drain(vm: &mut Vm, host: &mut Audit) -> Result<(), String> { + let mut count = 0; + while let Some((object, offset)) = host.movie.pending.pop_front() { + count += 1; + if count > 4096 { + return Err("Frame action limit".into()); + } + if !host.movie.instances.contains_key(&object) { + continue; + } + let code = host + .movie + .actions + .get(&offset.to_string()) + .ok_or("Missing movie action block")? + .clone(); + vm.run_on(object, &code, host)?; + } + Ok(()) +} +fn main() -> Result<(), String> { + let path = std::env::args_os() + .nth(1) + .ok_or("Expected owned trickdisplay.json")?; + let json: serde_json::Value = + serde_json::from_slice(&std::fs::read(path).map_err(|e| e.to_string())?) + .map_err(|e| e.to_string())?; + let mut host = Audit { + movie: apt_movie::Movie::load(&json)?, + }; + let mut vm = Vm::new(); + for name in ["MovieClip", "Tricks", "FELanguage"] { + let object = vm.object(ObjectKind::Native(name.into())); + let proto = vm.object(ObjectKind::Plain); + vm.set(object, "prototype", Value::Object(proto))?; + vm.set(vm.global, name, Value::Object(object))?; + } + vm.set(vm.global, "Screen_EdgeOffset", Value::Number(0.0))?; + for c in host.movie.characters.values().cloned().collect::>() { + for frame in c.frames { + for control in frame.controls { + if control.type_name == "do_init_action" { + let code = host.movie.actions[&control.actions_offset.to_string()].clone(); + vm.run(&code, &mut host)?; + } + } + } + } + vm.begin_update(); + host.movie.initialize(&mut vm)?; + drain(&mut vm, &mut host)?; + let Value::Object(controller) = vm.get(host.movie.root, "screen") else { + return Err("Root action did not construct controller".into()); + }; + vm.call_method(controller, "ScreenShow", vec![Value::Bool(true)], &mut host)?; + drain(&mut vm, &mut host)?; + for _ in 0..90 { + vm.begin_update(); + host.movie.advance(&mut vm)?; + drain(&mut vm, &mut host)?; + } + println!( + "Original movie initialized and advanced 90 authored frames; {} retained display instances", + host.movie.instances.len() + ); + Ok(()) +} diff --git a/crates/skate-data/examples/hud_data.rs b/crates/skate-data/examples/hud_data.rs new file mode 100644 index 00000000..5c26120c --- /dev/null +++ b/crates/skate-data/examples/hud_data.rs @@ -0,0 +1,79 @@ +//! Original action-stream validation with supplied data, without game systems. +#[path = "../../skate-game/src/apt_display.rs"] +mod apt_display; +#[path = "../../skate-game/src/apt_movie.rs"] +mod apt_movie; +#[path = "../../skate-game/src/apt_scene.rs"] +mod apt_scene; +#[path = "../../skate-game/src/apt_text.rs"] +mod apt_text; +#[path = "../../skate-game/src/apt_vm.rs"] +mod apt_vm; +#[path = "../../skate-game/src/hud_runtime.rs"] +mod hud_runtime; +use apt_vm::Value; +fn main() -> Result<(), String> { + let path = std::env::args_os() + .nth(1) + .ok_or("Expected owned trickdisplay.json")?; + let json: serde_json::Value = + serde_json::from_slice(&std::fs::read(path).map_err(|e| e.to_string())?) + .map_err(|e| e.to_string())?; + let mut input = hud_runtime::Input { + sequence_score: 0, + line_score: 0, + sequence_timer: 0, + line_time: 0.0, + line_capacity: 400.0, + multiplier: 1.0, + clean: false, + sketchy: false, + stance: [false; 4], + trick_name: String::new(), + trick_metrics: std::array::from_fn(|_| Value::Undefined), + context_tricks: Vec::new(), + }; + let mut runtime = hud_runtime::Runtime::load(&json, input.clone())?; + let shapes: apt_scene::Shapes = + serde_json::from_value(json["shapes"].clone()).map_err(|e| e.to_string())?; + let mut max_draws = 0; + for tick in 0..1800 { + let new_trick = tick % 180 == 10; + if new_trick { + input.trick_name = "Kickflip".into(); + input.trick_metrics = [ + Value::Text(input.trick_name.clone()), + Value::Number(0.0), + Value::Number(0.0), + Value::Bool(false), + Value::Bool(false), + ]; + input.sequence_score += 100; + input.line_score += 100; + input.clean = tick % 360 == 10; + input.sketchy = !input.clean; + input.line_time = 400.0; + input.multiplier = if input.multiplier == 1.5 { 2.0 } else { 1.5 }; + } + input.line_time = (input.line_time - 1.0).max(0.0); + runtime + .update(input.clone(), new_trick, false, tick % 180 == 120) + .map_err(|e| format!("Data frame {tick}: {e}"))?; + let draws = apt_scene::draw(&runtime.bindings.movie, &runtime.vm, &shapes)?; + max_draws = max_draws.max(draws.len()); + if draws + .iter() + .flat_map(|d| &d.vertices) + .any(|v| v.position.iter().chain(v.uv.iter()).any(|x| !x.is_finite())) + { + return Err("Nonfinite authored draw geometry".into()); + } + } + println!( + "Original HUD action audit passed 1800 authored frames; {} object slots, {} display instances", + runtime.vm.objects.len(), + runtime.bindings.movie.instances.len() + ); + println!("Validated glyph/shape traversal: maximum {max_draws} draw batches"); + Ok(()) +} diff --git a/crates/skate-data/examples/scoring_flow_data.rs b/crates/skate-data/examples/scoring_flow_data.rs new file mode 100644 index 00000000..fa8dc4b0 --- /dev/null +++ b/crates/skate-data/examples/scoring_flow_data.rs @@ -0,0 +1,92 @@ +//! Original action-stream validation with supplied data, without game systems. +#[path = "../../skate-game/src/apt_display.rs"] +mod apt_display; +#[path = "../../skate-game/src/apt_movie.rs"] +mod apt_movie; +#[path = "../../skate-game/src/apt_scene.rs"] +mod apt_scene; +#[path = "../../skate-game/src/apt_text.rs"] +mod apt_text; +#[path = "../../skate-game/src/apt_vm.rs"] +mod apt_vm; +#[path = "../../skate-game/src/hud_runtime.rs"] +mod hud_runtime; +#[path = "../../skate-game/src/scoring_runtime.rs"] +mod scoring_runtime; +use skate_core::{ + animation::output::attributes::AttributeName, physics::filtered_state::FilteredCategory, +}; +fn frame( + tick: u32, + category: FilteredCategory, + descriptor: Option, +) -> scoring_runtime::Frame { + scoring_runtime::Frame { + tick, + dt: 1. / 60., + category, + state: 100, + descriptor, + grind_id: -1, + flags: 0, + position: [0., 0., 0.], + velocity: [0., 0., 5.], + forward: [0., 0., 1.], + switch: false, + fakie: false, + nollie: false, + body_flip: false, + landing: Default::default(), + teleported: false, + reverting: false, + } +} +fn main() -> Result<(), String> { + let root = std::env::args_os() + .nth(1) + .ok_or("Expected owned assets directory")?; + let data = skate_data::collections::Collections::load(std::path::Path::new(&root))?; + let mut scoring = scoring_runtime::Runtime::load(&data)?; + let kickflip = scoring + .data + .by_id(96) + .ok_or("Missing kickflip")? + .encoded_name; + scoring.advance(frame(0, FilteredCategory::Ground, None))?; + for tick in 1..61 { + scoring.advance(frame(tick, FilteredCategory::Air, Some(kickflip)))?; + } + for tick in 61..70 { + scoring.advance(frame(tick, FilteredCategory::Ground, None))?; + } + let banked = scoring.session.holder.snapshot.last_reward; + if banked != 100. { + return Err(format!( + "Stationary unswitched kickflip, no landing bonus: expected authored 100, got {banked}" + )); + } + for tick in 70..80 { + scoring.advance(frame(tick, FilteredCategory::Ground, None))?; + } + if scoring.session.holder.snapshot.last_reward != banked { + return Err("Idle frame published the sequence again".into()); + } + for tick in 80..140 { + scoring.advance(frame(tick, FilteredCategory::Air, Some(kickflip)))?; + } + let mut cancelled = frame(140, FilteredCategory::Ground, None); + cancelled.teleported = true; + scoring.advance(cancelled)?; + if scoring.session.holder.snapshot.last_reward != 0. + || scoring.session.holder.has_pending_sequence() + { + return Err("Teleport retained pending trick rewards".into()); + } + if scoring.session.combo.multiplier != 1. { + return Err("Teleport retained multiplier".into()); + } + println!( + "Scoring data audit: authored kickflip credited once; idle publication stable; teleport cancels pending rewards and multiplier" + ); + Ok(()) +} diff --git a/crates/skate-data/src/lib.rs b/crates/skate-data/src/lib.rs index 989540b8..d6674afe 100644 --- a/crates/skate-data/src/lib.rs +++ b/crates/skate-data/src/lib.rs @@ -8,6 +8,7 @@ pub mod animation_frames; pub mod animation_metadata; pub mod collections; pub mod scoring; +mod scoring_fields; pub mod attrib_hash; pub mod input_config; pub mod gesture_patterns; diff --git a/crates/skate-data/src/scoring.rs b/crates/skate-data/src/scoring.rs index 02a005c8..e4520b96 100644 --- a/crates/skate-data/src/scoring.rs +++ b/crates/skate-data/src/scoring.rs @@ -25,6 +25,7 @@ pub struct Definition { #[derive(Clone, Debug)] pub struct ScoringData { + pub collector: CollectorTuning, pub definitions: Vec, pub repetition: PointGraph<8>, pub announcement: PointGraph<8>, @@ -37,6 +38,7 @@ pub struct ScoringData { pub combo_refresh_threshold: f32, pub unannounced_factor: f32, pub bail_factor: f32, + pub sketchy_side_speed: f32, } impl ScoringData { @@ -103,6 +105,7 @@ impl ScoringData { }; let f = |field| data.float(TUNING, "default", field); Ok(Self { + collector: CollectorTuning::load(data)?, definitions, repetition: graph("Hash_59D91EAABF033A24")?, announcement: graph("Hash_263B277F8CA17126")?, @@ -118,6 +121,7 @@ impl ScoringData { combo_refresh_threshold: f("Hash_E36197BFC8EA1CAD")?, unannounced_factor: f("Hash_2577DF0FCE5251E4")?, bail_factor: f("Hash_B0B56FF046508506")?, + sketchy_side_speed: f("Hash_6DC5982591F80A4C")?, }) } pub fn by_name(&self, name: AttributeName) -> Option<&Definition> { @@ -127,3 +131,41 @@ impl ScoringData { self.definitions.iter().find(|d| d.metadata.id == id) } } + +#[derive(Clone, Debug)] +pub struct CollectorTuning { + scalars: std::collections::BTreeMap, + curves: std::collections::BTreeMap>, +} +impl CollectorTuning { + fn load(data: &Collections) -> Result { + let mut out = Self { + scalars: Default::default(), + curves: Default::default(), + }; + for &(offset, field, size) in crate::scoring_fields::FIELDS { + if size == 4 { + out.scalars.insert( + offset, + data.float("Hash_546C36B656038E04", "default", field)?, + ); + } else { + let w = data.words::<20>("Hash_546C36B656038E04", "default", field)?; + out.curves.insert( + offset, + PointGraph { + x: std::array::from_fn(|i| f32::from_bits(w[4 + i])), + y: std::array::from_fn(|i| f32::from_bits(w[12 + i])), + }, + ); + } + } + Ok(out) + } + pub fn scalar(&self, native_offset: u16) -> f32 { + self.scalars[&native_offset] + } + pub fn curve(&self, native_offset: u16, input: f32) -> f32 { + self.curves[&native_offset].evaluate(input) + } +} diff --git a/crates/skate-data/src/scoring_fields.rs b/crates/skate-data/src/scoring_fields.rs new file mode 100644 index 00000000..f9965f98 --- /dev/null +++ b/crates/skate-data/src/scoring_fields.rs @@ -0,0 +1,65 @@ +//! Score collector VLT class546C36B656038E04 schema layout. +pub const FIELDS: &[(u16, &str, usize)] = &[ + (0x000, "Hash_8D544250D732B9A8", 80), + (0x050, "Hash_130B361B9E4D9369", 80), + (0x0a0, "Hash_156F53F56E861668", 80), + (0x0f0, "Hash_6DA2DFA9C8E3D863", 80), + (0x140, "Hash_9C9EFF9DB25BF70A", 80), + (0x190, "Hash_004A238881254FB5", 80), + (0x1e0, "Hash_198F48320EBB7C3A", 80), + (0x230, "Hash_F94C77A0FB410FE3", 80), + (0x280, "Hash_D7FBBD6E5E1EDF37", 80), + (0x2d0, "Hash_E78EDEC321055E09", 80), + (0x320, "Hash_8C24CF7B8227B348", 80), + (0x370, "Hash_5A756D4B3A5036DD", 80), + (0x3c0, "Hash_B552FB2712A897D6", 80), + (0x410, "Hash_205C814AA3ACA8D4", 80), + (0x460, "Hash_94F7B93068FE6FB1", 80), + (0x4b0, "Hash_7E17CC5298BBA36E", 80), + (0x500, "Hash_2D52116C69E3E1BE", 80), + (0x550, "Hash_1F647002596E4157", 80), + (0x5a0, "Hash_91529F3BD9F4DD25", 80), + (0x5f0, "Hash_66216D696EEAD314", 4), + (0x5f4, "Hash_3A444577C4D4661E", 4), + (0x5f8, "Hash_B26A66F7634E760E", 4), + (0x5fc, "Hash_BFDA1A507E2E55A1", 4), + (0x600, "Hash_1CA631042D292121", 4), + (0x604, "Hash_04A1775CB52A8E4D", 4), + (0x608, "Hash_AD14550EFF3920DE", 4), + (0x60c, "Hash_4BEA9C285A3CDA06", 4), + (0x610, "Hash_F813B960D0450300", 4), + (0x614, "Hash_391821526C05A7F1", 4), + (0x618, "Hash_B3C69AAF221F34F9", 4), + (0x61c, "Hash_E2AFE1B06292209D", 4), + (0x620, "Hash_62B9C3DAA846E240", 4), + (0x624, "Hash_B0D7A3876D2D274E", 4), + (0x628, "Hash_77DD0728B4736957", 4), + (0x62c, "Hash_05BBAF2E866E083E", 4), + (0x630, "Hash_5A2D1476F08690EC", 4), + (0x634, "Hash_001BF89D79FA1CCB", 4), + (0x638, "Hash_40E8618821693CD3", 4), + (0x63c, "Hash_25B11E405C140E61", 4), + (0x640, "Hash_694410FC8D57E794", 4), + (0x644, "Hash_2BE55EDC393F3D9B", 4), + (0x648, "Hash_F1F451420D10D2C5", 4), + (0x64c, "Hash_3BD67FAE1E671F26", 4), + (0x650, "Hash_F577378023AADAA2", 4), + (0x654, "Hash_16CB3F5D3D560676", 4), + (0x658, "Hash_67EBCA6DBB3EE9F1", 4), + (0x65c, "Hash_BA0B9725B1F7ABA7", 4), + (0x660, "Hash_C5BEC05471041171", 4), + (0x664, "Hash_BC91E9C84E099F2C", 4), + (0x668, "Hash_C33E4AA507A2CB4C", 4), + (0x66c, "Hash_AB8EACF72B291746", 4), + (0x670, "Hash_E5897F067B3C4F77", 4), + (0x674, "Hash_2B55A2C725912CBF", 4), + (0x678, "Hash_092D5E5FDE988D1B", 4), + (0x67c, "Hash_44746535286E1613", 4), + (0x680, "Hash_0BA6F149F1462EB0", 4), + (0x684, "Hash_EE42BA52A1313FDB", 4), + (0x688, "Hash_945369EBD1E9A062", 4), + (0x68c, "Hash_123E054E8895CAFE", 4), + (0x690, "Hash_63E61D37475E6AB9", 4), + (0x694, "Hash_AC54DE5C98B5DFAD", 4), + (0x698, "Hash_C4BDC0CD02C83A58", 4), +]; diff --git a/crates/skate-game/src/app.rs b/crates/skate-game/src/app.rs index 598aab81..cdd7476f 100644 --- a/crates/skate-game/src/app.rs +++ b/crates/skate-game/src/app.rs @@ -105,6 +105,7 @@ pub(crate) fn build( crate::render_capacity::RenderCapacityPlugin, verification::VerificationPlugin, crate::performance::PerformancePlugin, - )); + )) + .add_plugins(crate::scoring_hud::ScoringHudPlugin); app } diff --git a/crates/skate-game/src/apt_display.rs b/crates/skate-game/src/apt_display.rs index 5a7c8833..8db5ee1e 100644 --- a/crates/skate-game/src/apt_display.rs +++ b/crates/skate-game/src/apt_display.rs @@ -5,6 +5,7 @@ use std::collections::BTreeMap; #[derive(Clone, Debug, Deserialize)] pub struct Control { + pub label: Option, pub type_name: String, #[serde(default)] pub flags: u32, diff --git a/crates/skate-game/src/apt_movie.rs b/crates/skate-game/src/apt_movie.rs new file mode 100644 index 00000000..32bc1a22 --- /dev/null +++ b/crates/skate-game/src/apt_movie.rs @@ -0,0 +1,320 @@ +//! Original APT movie hierarchy and timeline control, independent of Bevy. +use crate::{ + apt_display::{Control, DisplayList, Placement}, + apt_vm::{Instruction, ObjectKind, Value, Vm}, +}; +use serde::Deserialize; +use std::collections::{BTreeMap, VecDeque}; + +#[derive(Clone, Deserialize)] +pub struct Frame { + pub controls: Vec, +} +#[derive(Clone, Deserialize)] +pub struct Character { + pub id: i32, + pub type_name: String, + #[serde(default)] + pub frames: Vec, + pub text: Option, + pub bounds: Option<[f32; 4]>, +} +#[derive(Clone)] +pub struct Instance { + pub character: i32, + pub frame: usize, + pub playing: bool, + pub children: BTreeMap, + pub placement: Option, +} +pub struct Movie { + pub characters: BTreeMap, + pub instances: BTreeMap, + pub actions: BTreeMap>, + pub pending: VecDeque<(usize, u32)>, + pub root: usize, + pub text_assets: crate::apt_text::TextAssets, + states: BTreeMap>, +} +impl Movie { + pub fn load(json: &serde_json::Value) -> Result { + let characters: Vec = + serde_json::from_value(json["characters"].clone()).map_err(|e| e.to_string())?; + let mut states = BTreeMap::new(); + for c in &characters { + let mut list = DisplayList::default(); + let mut frames = Vec::new(); + for frame in &c.frames { + for control in &frame.controls { + list.apply(control)?; + } + frames.push(list.clone()); + } + states.insert(c.id, frames); + } + Ok(Self { + characters: characters.into_iter().map(|c| (c.id, c)).collect(), + instances: BTreeMap::new(), + actions: serde_json::from_value(json["actions"].clone()).map_err(|e| e.to_string())?, + pending: VecDeque::new(), + root: usize::MAX, + text_assets: crate::apt_text::TextAssets::load(json)?, + states, + }) + } + pub fn initialize(&mut self, vm: &mut Vm) -> Result<(), String> { + self.root = self.create(vm, 0, None, 0)?; + vm.set(self.root, "_root", Value::Object(self.root))?; + // Every clip sees the same authored root, including unnamed children. + for id in self.instances.keys() { + vm.set(*id, "_root", Value::Object(self.root))?; + } + Ok(()) + } + fn create( + &mut self, + vm: &mut Vm, + character: i32, + parent: Option, + depth: usize, + ) -> Result { + if depth > 32 || self.instances.len() > 2048 { + return Err("APT movie hierarchy limit".into()); + } + let c = self + .characters + .get(&character) + .ok_or("APT unknown character")? + .clone(); + let id = vm.object(ObjectKind::Native(format!("movie:{character}"))); + if self.root != usize::MAX { + vm.set(id, "_root", Value::Object(self.root))?; + } + if let Some(parent) = parent { + vm.set(id, "_parent", Value::Object(parent))?; + } + vm.set(id, "_x", Value::Number(0.0))?; + vm.set(id, "_y", Value::Number(0.0))?; + vm.set(id, "_visible", Value::Bool(true))?; + vm.set(id, "_alpha", Value::Number(100.0))?; + if let Some(text) = &c.text { + vm.set( + id, + "text", + Value::Text(text["initial_text"].as_str().unwrap_or("").into()), + )?; + } + self.instances.insert( + id, + Instance { + character, + frame: 0, + playing: !c.frames.is_empty(), + children: BTreeMap::new(), + placement: None, + }, + ); + self.text_changed(vm, id)?; + if !c.frames.is_empty() { + self.seek(vm, id, 0, depth + 1)?; + } + Ok(id) + } + pub fn text_changed(&self, vm: &mut Vm, id: usize) -> Result<(), String> { + let Some(instance) = self.instances.get(&id) else { + return Ok(()); + }; + let character = &self.characters[&instance.character]; + let Some(text) = &character.text else { + return Ok(()); + }; + let value = self.text_assets.localize(&vm.get(id, "text").text()); + vm.set(id, "_displayText", Value::Text(value.clone()))?; + let font = self + .text_assets + .fonts + .get(&(text["font_id"].as_i64().ok_or("Invalid text font id")? as i32)) + .ok_or("Missing original text font")?; + let height = text["font_height"].as_f64().ok_or("Invalid text size")? as f32; + let width = font.width(&value, height); + vm.set(id, "textWidth", Value::Number(width as f64))?; + let bounds = character.bounds.ok_or("Missing text bounds")?; + let autosize = vm.get(id, "autoSize").text(); + vm.set( + id, + "_width", + Value::Number( + if autosize == "left" || autosize == "right" || autosize == "center" { + width + } else { + bounds[2] - bounds[0] + } as f64, + ), + )?; + Ok(()) + } + fn remove(&mut self, vm: &mut Vm, id: usize) { + if let Some(instance) = self.instances.remove(&id) { + for child in instance.children.values() { + self.remove(vm, *child); + } + } + // Retired handles can remain referenced by ActionScript, but no longer + // participate in timeline advancement or rendering. + let _ = vm.set(id, "_visible", Value::Bool(false)); + } + pub fn seek( + &mut self, + vm: &mut Vm, + id: usize, + frame: usize, + nesting: usize, + ) -> Result<(), String> { + let instance = self + .instances + .get(&id) + .ok_or("APT absent movie instance")? + .clone(); + let character = self + .characters + .get(&instance.character) + .ok_or("APT absent movie character")?; + if frame >= character.frames.len() { + return Err(format!( + "APT frame {frame} outside character {}", + character.id + )); + } + let frame_count = character.frames.len(); + let actions: Vec<_> = character.frames[frame] + .controls + .iter() + .filter(|c| c.type_name == "do_action" && c.actions_offset != 0) + .map(|c| c.actions_offset) + .collect(); + let list = self.states[&instance.character][frame].clone(); + let mut children = BTreeMap::new(); + for (depth, placement) in list.depths { + let previous = instance.children.get(&depth).copied(); + if let Some(name) = previous + .and_then(|old| self.instances.get(&old)) + .and_then(|i| i.placement.as_ref()) + .map(|p| p.name.clone()) + { + if !name.is_empty() && name != placement.name { + vm.objects[id].fields.remove(&name); + } + } + let child = if let Some(old) = previous.filter(|old| { + self.instances + .get(old) + .is_some_and(|i| i.character == placement.character) + }) { + old + } else { + if let Some(old) = previous { + self.remove(vm, old); + } + self.create(vm, placement.character, Some(id), nesting + 1)? + }; + let old = self.instances[&child].placement.as_ref(); + if old.is_none_or(|old| old.matrix != placement.matrix) { + vm.set(child, "_x", Value::Number(placement.matrix[4] as f64))?; + vm.set(child, "_y", Value::Number(placement.matrix[5] as f64))?; + } + if !placement.name.is_empty() { + vm.set(id, &placement.name, Value::Object(child))?; + } + self.instances.get_mut(&child).unwrap().placement = Some(placement); + children.insert(depth, child); + } + for (depth, child) in &instance.children { + if !children.contains_key(depth) { + if let Some(name) = self + .instances + .get(child) + .and_then(|i| i.placement.as_ref()) + .map(|p| p.name.clone()) + { + if !name.is_empty() { + vm.objects[id].fields.remove(&name); + } + } + self.remove(vm, *child); + } + } + let current = self.instances.get_mut(&id).unwrap(); + current.frame = frame; + current.children = children; + vm.set(id, "_currentframe", Value::Number((frame + 1) as f64))?; + vm.set(id, "_totalframes", Value::Number(frame_count as f64))?; + for offset in actions { + self.pending.push_back((id, offset)); + } + Ok(()) + } + pub fn advance(&mut self, vm: &mut Vm) -> Result<(), String> { + let playing: Vec<_> = self + .instances + .iter() + .filter(|(_, i)| i.playing) + .map(|(id, i)| (*id, i.character, i.frame)) + .collect(); + for (id, character, frame) in playing { + if !self.instances.contains_key(&id) { + continue; + } + let count = self.characters[&character].frames.len(); + if count > 1 { + self.seek(vm, id, (frame + 1) % count, 0)?; + } + } + Ok(()) + } + pub fn method( + &mut self, + vm: &mut Vm, + id: usize, + method: &str, + args: &[Value], + ) -> Result { + if !self.instances.contains_key(&id) { + return Ok(false); + } + match method { + "stop" => self.instances.get_mut(&id).unwrap().playing = false, + "play" => self.instances.get_mut(&id).unwrap().playing = true, + "gotoAndPlay" | "gotoAndStop" => { + let c = &self.characters[&self.instances[&id].character]; + let frame = match args.first().ok_or("APT goto requires frame")? { + Value::Text(label) => c + .frames + .iter() + .position(|f| { + f.controls.iter().any(|control| { + control.type_name == "frame_label" + && control.label.as_deref() == Some(label) + }) + }) + .ok_or_else(|| format!("APT character {} lacks label {label}", c.id))?, + value => { + let frame = value.number(); + if !frame.is_finite() || frame < 0.0 { + return Err(format!( + "Invalid APT frame {frame} in {} on character {}", + method, c.id + )); + } + // The shipped line timer deliberately requests zero at + // full capacity. Frame zero addresses the first frame. + (frame as usize).saturating_sub(1) + } + }; + self.seek(vm, id, frame, 0)?; + self.instances.get_mut(&id).unwrap().playing = method == "gotoAndPlay"; + } + _ => return Ok(false), + } + Ok(true) + } +} diff --git a/crates/skate-game/src/apt_scene.rs b/crates/skate-game/src/apt_scene.rs new file mode 100644 index 00000000..dcfd5dc1 --- /dev/null +++ b/crates/skate-game/src/apt_scene.rs @@ -0,0 +1,196 @@ +//! Retained movie traversal. All geometry and glyphs come from the owned APT. +use crate::{apt_movie::Movie, apt_vm::Vm}; +use serde::Deserialize; +use std::collections::BTreeMap; + +#[derive(Clone, Deserialize)] +pub struct Vertex { + pub position: [f32; 2], + pub uv: [f32; 2], +} +#[derive(Clone, Deserialize)] +pub struct Texture { + pub width: u32, + pub height: u32, + pub rgba: String, +} +#[derive(Clone, Deserialize)] +pub struct Shape { + pub texture: Texture, + pub triangles: Vec<[Vertex; 3]>, + pub color: [f32; 4], +} +pub type Shapes = BTreeMap>; +pub struct Draw { + pub texture: String, + pub size: [u32; 2], + pub vertices: Vec, + pub multiply: [f32; 4], + pub add: [f32; 4], +} +fn compose(a: [f32; 6], b: [f32; 6]) -> [f32; 6] { + [ + a[0] * b[0] + a[2] * b[1], + a[1] * b[0] + a[3] * b[1], + a[0] * b[2] + a[2] * b[3], + a[1] * b[2] + a[3] * b[3], + a[0] * b[4] + a[2] * b[5] + a[4], + a[1] * b[4] + a[3] * b[5] + a[5], + ] +} +fn transform(m: [f32; 6], v: &Vertex) -> Vertex { + Vertex { + position: [ + m[0] * v.position[0] + m[2] * v.position[1] + m[4], + m[1] * v.position[0] + m[3] * v.position[1] + m[5], + ], + uv: v.uv, + } +} +pub fn draw(movie: &Movie, vm: &Vm, shapes: &Shapes) -> Result, String> { + let mut draws = Vec::new(); + visit( + movie, + vm, + shapes, + movie.root, + [1., 0., 0., 1., 0., 0.], + [1.; 4], + [0.; 4], + &mut draws, + )?; + Ok(draws) +} +fn visit( + movie: &Movie, + vm: &Vm, + shapes: &Shapes, + id: usize, + parent: [f32; 6], + pm: [f32; 4], + pa: [f32; 4], + out: &mut Vec, +) -> Result<(), String> { + if !vm.get(id, "_visible").truth() { + return Ok(()); + } + let instance = &movie.instances[&id]; + let mut matrix = instance + .placement + .as_ref() + .map_or([1., 0., 0., 1., 0., 0.], |p| p.matrix); + matrix[4] = vm.get(id, "_x").number() as f32; + matrix[5] = vm.get(id, "_y").number() as f32; + let matrix = compose(parent, matrix); + if matrix.iter().any(|x| !x.is_finite()) { + return Err(format!( + "Nonfinite HUD transform on character {}", + instance.character + )); + } + let color = instance + .placement + .as_ref() + .map_or([255, 255, 255, 255, 0, 0, 0, 0], |p| p.color); + let rgba = [1, 2, 3, 0]; + let mut multiply = std::array::from_fn(|i| pm[i] * color[rgba[i]] as f32 / 255.); + let mut add = std::array::from_fn(|i| pa[i] + pm[i] * color[4 + rgba[i]] as f32 / 255.); + let alpha = (vm.get(id, "_alpha").number() as f32 / 100.).clamp(0., 1.); + multiply[3] *= alpha; + add[3] *= alpha; + if multiply[3] <= 0. && add[3] <= 0. { + return Ok(()); + } + let character = &movie.characters[&instance.character]; + if character.type_name == "shape" { + for shape in shapes + .get(&character.id) + .ok_or("Missing original shape geometry")? + { + out.push(Draw { + texture: shape.texture.rgba.clone(), + size: [shape.texture.width, shape.texture.height], + vertices: shape + .triangles + .iter() + .flatten() + .map(|v| transform(matrix, v)) + .collect(), + multiply: std::array::from_fn(|i| multiply[i] * shape.color[i]), + add, + }); + } + } else if let Some(text) = &character.text { + let font = &movie.text_assets.fonts + [&(text["font_id"].as_i64().ok_or("Invalid text font")? as i32)]; + let height = text["font_height"].as_f64().ok_or("Invalid text height")? as f32; + let sx = font.scale[0] * height; + let sy = font.scale[1] * height; + let value = vm.get(id, "_displayText").text(); + let bounds = character.bounds.ok_or("Missing text bounds")?; + let width = font.width(&value, height); + let alignment = text["alignment"].as_u64().unwrap_or(0); + let autosize = vm.get(id, "autoSize").text(); + let alignment = if autosize == "left" { 0 } else { alignment }; + let mut x = bounds[0] + + font.offset[0] * sx + + match alignment { + 1 => bounds[2] - bounds[0] - width, + 2 => (bounds[2] - bounds[0] - width) * 0.5, + _ => 0., + }; + let y = bounds[1] + font.offset[1] * sy; + let mut vertices = Vec::new(); + for c in value.chars() { + if let Some(g) = font.glyph(c) { + let x0 = x + g.x_offset * sx; + let y0 = y + (font.ascent - g.y_offset) * sy; + let x1 = x0 + g.width * sx; + let y1 = y0 + g.height * sy; + let [u0, v0, u1, v1] = g.atlas_bounds; + let points = [[x0, y0], [x1, y0], [x1, y1], [x0, y1]]; + let uvs = [[u0, v0], [u1, v0], [u1, v1], [u0, v1]]; + for i in [0, 1, 2, 0, 2, 3] { + vertices.push(transform( + matrix, + &Vertex { + position: points[i], + uv: [ + uvs[i][0] / font.size[0] as f32, + uvs[i][1] / font.size[1] as f32, + ], + }, + )); + } + x = g.x_advance.mul_add(sx, x); + } + } + let argb = u32::from_str_radix( + text["color_argb"] + .as_str() + .ok_or("Missing text color")? + .trim_start_matches('#'), + 16, + ) + .map_err(|e| e.to_string())?; + let color = [ + ((argb >> 16) & 255) as f32 / 255., + ((argb >> 8) & 255) as f32 / 255., + (argb & 255) as f32 / 255., + (argb >> 24) as f32 / 255., + ]; + if !vertices.is_empty() { + out.push(Draw { + texture: font.texture.clone(), + size: font.size, + vertices, + multiply: std::array::from_fn(|i| multiply[i] * color[i]), + add, + }); + } + } + for child in instance.children.values() { + visit(movie, vm, shapes, *child, matrix, multiply, add, out)?; + } + Ok(()) +} diff --git a/crates/skate-game/src/apt_text.rs b/crates/skate-game/src/apt_text.rs new file mode 100644 index 00000000..752ac5eb --- /dev/null +++ b/crates/skate-game/src/apt_text.rs @@ -0,0 +1,119 @@ +//! SkateAptString82CA14D0 and cFont828076D0/82808708 layout. +use serde::Deserialize; +use std::collections::BTreeMap; + +#[derive(Clone, Deserialize)] +pub struct Glyph { + pub glyph_index: usize, + pub width: f32, + pub height: f32, + pub x_offset: f32, + pub y_offset: f32, + pub x_advance: f32, + pub atlas_bounds: [f32; 4], +} +#[derive(Clone)] +pub struct Font { + pub texture: String, + pub size: [u32; 2], + pub scale: [f32; 2], + pub offset: [f32; 2], + pub ascent: f32, + pub glyphs: BTreeMap, +} +#[derive(Clone, Default)] +pub struct TextAssets { + pub fonts: BTreeMap, + pub language: BTreeMap, +} +impl TextAssets { + pub fn load(json: &serde_json::Value) -> Result { + let mut out = Self::default(); + out.language = + serde_json::from_value(json["language"].clone()).map_err(|e| e.to_string())?; + for c in json["characters"] + .as_array() + .ok_or("HUD characters missing")? + { + if c["type_name"] != "font" { + continue; + } + let name = c["font"]["name"].as_str().ok_or("HUD font name missing")?; + let asset = &json["fonts"][name]; + let d = &asset["definition"]; + let glyphs: Vec = + serde_json::from_value(d["glyphs"].clone()).map_err(|e| e.to_string())?; + let layout = &json["font_mappings"][name]["native_layout"]; + let f = |key: &str| { + layout[key] + .as_f64() + .map(|v| v as f32) + .ok_or_else(|| format!("Missing native font {name}.{key}")) + }; + let mut font = Font { + texture: asset["texture"] + .as_str() + .ok_or("Font texture missing")? + .into(), + size: [ + d["textures"][0]["width"] + .as_u64() + .ok_or("Font width missing")? as u32, + d["textures"][0]["height"] + .as_u64() + .ok_or("Font height missing")? as u32, + ], + scale: [f("ScaleX")?, f("ScaleY")?], + offset: [f("OffsetX")?, f("OffsetY")?], + ascent: d["metrics"]["Ascent"] + .as_f64() + .ok_or("Font ascent missing")? as f32, + glyphs: BTreeMap::new(), + }; + for mapping in d["characters"] + .as_array() + .ok_or("Font character map missing")? + { + let index = mapping["glyph_index"] + .as_u64() + .ok_or("Invalid font glyph index")? as usize; + let glyph = glyphs + .iter() + .find(|g| g.glyph_index == index) + .ok_or("Absent mapped glyph")?; + font.glyphs.insert( + mapping["codepoint"] + .as_u64() + .ok_or("Invalid font codepoint")? as u32, + glyph.clone(), + ); + } + out.fonts.insert( + c["id"].as_i64().ok_or("Invalid font character id")? as i32, + font, + ); + } + Ok(out) + } + pub fn localize(&self, text: &str) -> String { + if let Some(literal) = text.strip_prefix('#') { + return literal.into(); + } + self.language + .get(text) + .cloned() + .unwrap_or_else(|| text.into()) + } +} +impl Font { + pub fn glyph(&self, c: char) -> Option<&Glyph> { + self.glyphs + .get(&(c as u32)) + .or_else(|| self.glyphs.get(&65535)) + } + pub fn width(&self, text: &str, height: f32) -> f32 { + text.chars() + .filter_map(|c| self.glyph(c)) + .fold(0.0, |x, g| g.x_advance.mul_add(self.scale[0] * height, x)) + } +} diff --git a/crates/skate-game/src/apt_vm.rs b/crates/skate-game/src/apt_vm.rs index 2ec53029..da12fdef 100644 --- a/crates/skate-game/src/apt_vm.rs +++ b/crates/skate-game/src/apt_vm.rs @@ -114,10 +114,14 @@ struct Function { struct Scope { this: usize, + local_definitions: bool, locals: BTreeMap, } pub trait Host { + fn property_changed(&mut self, _vm: &mut Vm, _object: usize, _key: &str) -> Result<(), String> { + Ok(()) + } fn call( &mut self, vm: &mut Vm, @@ -129,6 +133,7 @@ pub trait Host { #[derive(Default)] pub struct Vm { pub objects: Vec, + free_objects: Vec, functions: Vec, pub global: usize, remaining: usize, @@ -151,6 +156,14 @@ impl Vm { vm } pub fn object(&mut self, kind: ObjectKind) -> usize { + if let Some(id) = self.free_objects.pop() { + self.objects[id] = Object { + kind, + fields: BTreeMap::new(), + prototype: None, + }; + return id; + } let id = self.objects.len(); self.objects.push(Object { kind, @@ -159,6 +172,45 @@ impl Vm { }); id } + /// Collect only between updates. Host-owned movie handles are explicit + /// roots; script references and prototypes preserve retired clips when + /// they remain reachable. Stable handles never move during collection. + pub fn collect(&mut self, host_roots: impl IntoIterator) -> Result<(), String> { + if self.depth != 0 { + return Err("Cannot collect during an APT call".into()); + } + let mut marked = vec![false; self.objects.len()]; + let mut pending: Vec<_> = host_roots.into_iter().collect(); + pending.push(self.global); + while let Some(id) = pending.pop() { + let seen = marked.get_mut(id).ok_or("Invalid APT collection root")?; + if *seen { + continue; + } + *seen = true; + let object = &self.objects[id]; + if let Some(id) = object.prototype { + pending.push(id); + } + for value in object.fields.values() { + if let Value::Object(id) = value { + pending.push(*id); + } + } + } + self.free_objects.clear(); + for (id, live) in marked.into_iter().enumerate() { + if !live { + self.objects[id] = Object { + kind: ObjectKind::Plain, + fields: BTreeMap::new(), + prototype: None, + }; + self.free_objects.push(id); + } + } + Ok(()) + } pub fn set( &mut self, object: usize, @@ -219,6 +271,7 @@ impl Vm { &mut Vec::new(), &mut Scope { this: object, + local_definitions: false, locals: BTreeMap::new(), }, host, @@ -261,6 +314,7 @@ impl Vm { let mut reg = 1; let mut scope = Scope { this, + local_definitions: true, locals: BTreeMap::new(), }; if f.code.flags & 2 == 0 { @@ -351,6 +405,17 @@ impl Vm { }; match op { 0 => break, + 0x70 => stack.push(Value::Object(scope.this)), + 0xa1 => stack.push(Value::Text( + i.operand + .as_str() + .ok_or("APT string operand missing")? + .into(), + )), + 0xa4 => stack.push(self.variable( + scope, + i.operand.as_str().ok_or("APT variable operand missing")?, + )), 0x06 | 0x07 => { host.call( self, @@ -426,7 +491,8 @@ impl Vm { let k = pop(&mut stack)?.text(); let o = pop(&mut stack)?; if let Value::Object(id) = o { - self.set(id, k, v)?; + self.set(id, &k, v)?; + host.property_changed(self, id, &k)?; } } 0x1c => { @@ -436,10 +502,11 @@ impl Vm { 0x1d | 0x3c => { let v = pop(&mut stack)?; let k = pop(&mut stack)?.text(); - if op == 0x3c || scope.locals.contains_key(&k) { + if (op == 0x3c && scope.local_definitions) || scope.locals.contains_key(&k) { scope.locals.insert(k, v); } else { - self.set(scope.this, k, v)?; + self.set(scope.this, &k, v)?; + host.property_changed(self, scope.this, &k)?; } } 0x3a => { diff --git a/crates/skate-game/src/hud_render.wgsl b/crates/skate-game/src/hud_render.wgsl new file mode 100644 index 00000000..0077654a --- /dev/null +++ b/crates/skate-game/src/hud_render.wgsl @@ -0,0 +1,9 @@ +#import bevy_sprite::mesh2d_vertex_output::VertexOutput +struct ColorTransform { multiply: vec4, add: vec4 } +@group(#{MATERIAL_BIND_GROUP}) @binding(0) var color: ColorTransform; +@group(#{MATERIAL_BIND_GROUP}) @binding(1) var atlas: texture_2d; +@group(#{MATERIAL_BIND_GROUP}) @binding(2) var atlas_sampler: sampler; +@fragment +fn fragment(in: VertexOutput) -> @location(0) vec4 { + return clamp(textureSample(atlas, atlas_sampler, in.uv) * color.multiply + color.add, vec4(0.0), vec4(1.0)); +} diff --git a/crates/skate-game/src/hud_runtime.rs b/crates/skate-game/src/hud_runtime.rs new file mode 100644 index 00000000..7a6b50a1 --- /dev/null +++ b/crates/skate-game/src/hud_runtime.rs @@ -0,0 +1,249 @@ +//! Native HUD bindings and the original ActionScript-driven movie lifecycle. +use crate::{ + apt_movie::Movie, + apt_vm::{Host, ObjectKind, Value, Vm}, +}; + +#[derive(Clone, Debug, PartialEq)] +pub struct Input { + pub sequence_score: i32, + pub line_score: i32, + pub sequence_timer: i32, + pub line_time: f32, + pub line_capacity: f32, + pub multiplier: f32, + pub clean: bool, + pub sketchy: bool, + pub stance: [bool; 4], + pub trick_name: String, + pub trick_metrics: [Value; 5], + pub context_tricks: Vec, +} +pub struct Bindings { + pub movie: Movie, + pub input: Input, + random: u32, +} +pub struct Runtime { + pub vm: Vm, + pub bindings: Bindings, + pub controller: usize, +} + +fn array(vm: &mut Vm, values: impl IntoIterator) -> Result { + let object = vm.object(ObjectKind::Plain); + let mut n = 0; + for value in values { + vm.set(object, n.to_string(), value)?; + n += 1; + } + vm.set(object, "length", Value::Number(n as f64))?; + Ok(Value::Object(object)) +} +fn score(value: i32) -> String { + let digits = value.unsigned_abs().to_string(); + let mut out = String::new(); + if value < 0 { + out.push('-'); + } + for (i, c) in digits.chars().enumerate() { + if i > 0 && (digits.len() - i) % 3 == 0 { + out.push(','); + } + out.push(c); + } + out +} +impl Host for Bindings { + fn property_changed(&mut self, vm: &mut Vm, object: usize, key: &str) -> Result<(), String> { + if key == "text" || key == "autoSize" { + self.movie.text_changed(vm, object)?; + } + Ok(()) + } + fn call( + &mut self, + vm: &mut Vm, + object: usize, + method: &str, + args: Vec, + ) -> Result { + if self.movie.method(vm, object, method, &args)? { + return Ok(Value::Undefined); + } + let native = match vm.objects.get(object).map(|o| &o.kind) { + Some(ObjectKind::Native(name)) => name.as_str(), + _ => "global", + }; + match (native, method) { + ("Math", "floor") => Ok(Value::Number( + args.first().ok_or("Math.floor argument")?.number().floor(), + )), + ("Math", "random") => { + // Presentation-only random source; never influences scoring. + self.random ^= self.random << 13; + self.random ^= self.random >> 17; + self.random ^= self.random << 5; + Ok(Value::Number(self.random as f64 / 4294967296.0)) + } + // The HUD never enumerates prototype properties. Marking their + // enumeration flags therefore leaves its observable fields intact. + ("global", "ASSetPropFlags") => Ok(Value::Undefined), + ("FELanguage", "GetLanguage") => Ok(Value::Text("english".into())), + ("Tricks", "GetLineTimerMaxPoints") => { + Ok(Value::Number(self.input.line_capacity as f64)) + } + //825C2910 truncates manager+32 to integer;825C28F8 returns+192. + ("Tricks", "GetLineTimeRemaining") => { + Ok(Value::Number(self.input.line_time.trunc() as f64)) + } + ("Tricks", "GetLineScore") => Ok(Value::Number(self.input.line_score as f64)), + ("HUDComponents", "TrickDisplay_GetSequenceMultiplier") => { + Ok(Value::Number(self.input.multiplier as f64)) + } + ("Tricks", "GetGeneralInfo") => array( + vm, + [ + Value::Number(self.input.sequence_score as f64), + Value::Text(score(self.input.sequence_score)), + Value::Bool(self.input.clean), + Value::Bool(self.input.sketchy), + Value::Number(self.input.sequence_timer as f64), + Value::Number(self.input.line_score as f64), + Value::Text(score(self.input.line_score)), + ], + ), + ("Tricks", "GetCurrentTrickStance") => array(vm, self.input.stance.map(Value::Bool)), + ("Tricks", "GetCurrentTrickName") => Ok(Value::Text(self.input.trick_name.clone())), + ("Tricks", "GetCurrentTrickMetrics") => array(vm, self.input.trick_metrics.clone()), + ("Tricks", "TrickDisplay_GetAllTrickData") => { + array(vm, self.input.context_tricks.clone()) + } + _ => Err(format!( + "Unimplemented original HUD binding {native}.{method}" + )), + } + } +} +impl Runtime { + pub fn load(json: &serde_json::Value, input: Input) -> Result { + let mut vm = Vm::new(); + for name in ["MovieClip", "Tricks", "FELanguage", "HUDComponents", "Math"] { + let object = vm.object(ObjectKind::Native(name.into())); + let prototype = vm.object(ObjectKind::Plain); + vm.set(object, "prototype", Value::Object(prototype))?; + vm.set(vm.global, name, Value::Object(object))?; + } + vm.set(vm.global, "Screen_EdgeOffset", Value::Number(0.0))?; + let mut bindings = Bindings { + movie: Movie::load(json)?, + input, + random: 0x9e3779b9, + }; + let initial: Vec<_> = bindings + .movie + .characters + .values() + .flat_map(|c| &c.frames) + .flat_map(|f| &f.controls) + .filter(|c| c.type_name == "do_init_action") + .map(|c| c.actions_offset) + .collect(); + for offset in initial { + let code = bindings.movie.actions[&offset.to_string()].clone(); + vm.run(&code, &mut bindings)?; + } + vm.begin_update(); + bindings.movie.initialize(&mut vm)?; + let mut runtime = Self { + vm, + bindings, + controller: usize::MAX, + }; + runtime.drain()?; + let Value::Object(controller) = runtime.vm.get(runtime.bindings.movie.root, "screen") + else { + return Err("Original HUD controller was not constructed".into()); + }; + runtime.controller = controller; + runtime.vm.call_method( + controller, + "ScreenShow", + vec![Value::Bool(true)], + &mut runtime.bindings, + )?; + runtime.drain()?; + Ok(runtime) + } + fn drain(&mut self) -> Result<(), String> { + let mut calls = 0; + while let Some((object, offset)) = self.bindings.movie.pending.pop_front() { + calls += 1; + if calls > 4096 { + return Err("HUD frame action limit".into()); + } + if !self.bindings.movie.instances.contains_key(&object) { + continue; + } + let code = self + .bindings + .movie + .actions + .get(&offset.to_string()) + .ok_or("Missing HUD action block")? + .clone(); + self.vm.run_on(object, &code, &mut self.bindings)?; + } + Ok(()) + } + pub fn update( + &mut self, + input: Input, + new_trick: bool, + modified_trick: bool, + close_tricks: bool, + ) -> Result<(), String> { + self.vm.begin_update(); + let previous = self.bindings.input.clone(); + self.bindings.input = input; + let mut methods = Vec::new(); + if previous.multiplier != self.bindings.input.multiplier { + methods.push("UpdateMultiplier"); + } + if previous.sequence_score != self.bindings.input.sequence_score + || previous.clean != self.bindings.input.clean + || previous.sketchy != self.bindings.input.sketchy + || previous.line_score != self.bindings.input.line_score + { + methods.push("UpdateTrickScoring"); + } + if previous.stance != self.bindings.input.stance { + methods.push("UpdateStance"); + } + if new_trick { + methods.push("RefreshTrickName"); + } else if modified_trick { + methods.push("CurrentTrickModified"); + } + if close_tricks { + methods.push("CloseTrickDisplay"); + } + for method in methods { + self.vm + .call_method(self.controller, method, vec![], &mut self.bindings)?; + self.drain()?; + } + self.vm.call_method( + self.controller, + "UpdateLineDisplay", + vec![], + &mut self.bindings, + )?; + self.drain()?; + self.bindings.movie.advance(&mut self.vm)?; + self.drain()?; + self.vm + .collect(self.bindings.movie.instances.keys().copied())?; + Ok(()) + } +} diff --git a/crates/skate-game/src/main.rs b/crates/skate-game/src/main.rs index 1202e40e..6a1136dd 100644 --- a/crates/skate-game/src/main.rs +++ b/crates/skate-game/src/main.rs @@ -1,6 +1,12 @@ mod animation; mod apt_vm; mod apt_display; +mod apt_movie; +mod apt_text; +mod apt_scene; +mod hud_runtime; +mod scoring_runtime; +mod scoring_hud; mod animation_pose; mod app; mod assets; diff --git a/crates/skate-game/src/physics/animation_phase.rs b/crates/skate-game/src/physics/animation_phase.rs index 5e794057..49f562eb 100644 --- a/crates/skate-game/src/physics/animation_phase.rs +++ b/crates/skate-game/src/physics/animation_phase.rs @@ -27,6 +27,7 @@ pub(crate) fn advance( graphs: &StockGraphs, profile: &AnimationProfile, ) -> Result { + skater.animation.motion.score_packet = Default::default(); let p = &skater.player_input.processed; let physical = &skater.player_input.physical; let deck = physics.board.part_transforms()[BodyId::Deck.index()]; diff --git a/crates/skate-game/src/physics/frame.rs b/crates/skate-game/src/physics/frame.rs index 2c9b97a1..78c04ccd 100644 --- a/crates/skate-game/src/physics/frame.rs +++ b/crates/skate-game/src/physics/frame.rs @@ -222,6 +222,23 @@ pub(super) fn advance( events, }); camera_output::advance(physics, skater, &feedback, camera)?; + let score = &skater.animation.motion.score_packet; + let deck_frame = physics.board.part_transforms()[BodyId::Deck.index()]; + let position = deck.rates.position; + let velocity = deck.rates.linear_velocity; + let filtered = skater.player_state.filtered_output; + skater.scoring.advance(crate::scoring_runtime::Frame { + tick: tick as u32, dt: simulation.time_step, + category: filtered.map_or(Default::default(), |f|f.category), + state: skater.player_state.current() as u32, + descriptor: score.trick.or_else(||score.grab.map(|g|g.0)), + grind_id: filtered.map_or(-1, |f|f.grind.scorable_id), flags:score.flags, + position:[position.x,position.y,position.z], velocity:[velocity.x,velocity.y,velocity.z], + forward:deck_frame.basis.columns[2], + switch:skater.animation.packet.riding_switch,fakie:skater.animation.packet.riding_fakie, + nollie:skater.animation.packet.weight_forwards,body_flip:skater.player_input.physical.air.flag_441!=0, + landing:skater.landing_quality,teleported,reverting:skater.player_state.state_flags[70-52], + })?; super::climbing::approach::advance(physics, skater, controls); skater.animation_input.finish_output_publication(); skater.player_input.player.update_count_1316 = diff --git a/crates/skate-game/src/physics/skater.rs b/crates/skate-game/src/physics/skater.rs index 38e31bfe..35995455 100644 --- a/crates/skate-game/src/physics/skater.rs +++ b/crates/skate-game/src/physics/skater.rs @@ -27,6 +27,7 @@ use std::path::Path; #[derive(Resource)] pub(crate) struct SkaterRuntime { + pub scoring: crate::scoring_runtime::Runtime, pub climbing: super::climbing::Runtime, /// Completed physical pose in native animation space, read by rendering. pub render_pose: Vec, @@ -191,6 +192,7 @@ impl SkaterRuntime { &data, asset_root, &animation.evaluator.frames.source_sha256, )?; Ok(Self { + scoring: crate::scoring_runtime::Runtime::load(&data)?, climbing: super::climbing::Runtime::load(asset_root, &animation.evaluator.frames.bone_names)?, offboard, render_pose: initial_hierarchy, diff --git a/crates/skate-game/src/scoring_hud.rs b/crates/skate-game/src/scoring_hud.rs new file mode 100644 index 00000000..5d009094 --- /dev/null +++ b/crates/skate-game/src/scoring_hud.rs @@ -0,0 +1,293 @@ +//! Original APT HUD rendered independently of the world's resolution scale. +use crate::{apt_scene, config::Config, hud_runtime, physics::SkaterRuntime}; +use bevy::{ + asset::{RenderAssetUsages, embedded_asset}, + camera::{RenderTarget, visibility::RenderLayers}, + prelude::*, + render::render_resource::{ + AsBindGroup, Extent3d, PrimitiveTopology, ShaderType, TextureDimension, TextureFormat, + }, + shader::ShaderRef, + sprite_render::{AlphaMode2d, Material2d, Material2dPlugin, MeshMaterial2d}, +}; +use std::{collections::BTreeMap, path::PathBuf}; + +#[derive(Clone, Copy, Debug, ShaderType)] +struct ColorTransform { + multiply: Vec4, + add: Vec4, +} +#[derive(Asset, TypePath, AsBindGroup, Debug, Clone)] +struct HudMaterial { + #[uniform(0)] + color: ColorTransform, + #[texture(1)] + #[sampler(2)] + atlas: Handle, +} +impl Material2d for HudMaterial { + fn fragment_shader() -> ShaderRef { + "embedded://skate3rust/hud_render.wgsl".into() + } + fn alpha_mode(&self) -> AlphaMode2d { + AlphaMode2d::Blend + } +} +struct Slot { + entity: Entity, + mesh: Handle, + material: Handle, +} +#[derive(Resource)] +struct Hud { + runtime: hud_runtime::Runtime, + source: serde_json::Value, + shapes: apt_scene::Shapes, + textures: BTreeMap>, + slots: Vec, + generation: u64, + failed: bool, +} +pub(crate) struct ScoringHudPlugin; +impl Plugin for ScoringHudPlugin { + fn build(&self, app: &mut App) { + embedded_asset!(app, "hud_render.wgsl"); + app.add_plugins(Material2dPlugin::::default()) + .add_systems(PostStartup, setup) + .add_systems( + FixedUpdate, + advance + .after(crate::app::SimulationSet::Physics) + .run_if(crate::graphics_menu::gameplay_active), + ) + .add_systems( + Update, + (reset, render).chain().after(crate::app::FrameSet::Physics), + ); + } +} +fn setup( + mut commands: Commands, + config: Res, + skater: Res, + cameras: Query>, + mut images: ResMut>, +) { + let root = std::env::var_os("SKATE_SCORING_HUD_ROOT") + .map(PathBuf::from) + .unwrap_or_else(|| config.asset_root.join("private/hud")); + let result = (|| -> Result { + let source: serde_json::Value = serde_json::from_slice( + &std::fs::read(root.join("runtime/trickdisplay.json")) + .map_err(|e| format!("{}: {e}", root.display()))?, + ) + .map_err(|e| e.to_string())?; + let runtime = hud_runtime::Runtime::load(&source, skater.scoring.hud_input())?; + let shapes: apt_scene::Shapes = + serde_json::from_value(source["shapes"].clone()).map_err(|e| e.to_string())?; + let mut files = BTreeMap::new(); + for shape in shapes.values().flatten() { + files.insert( + shape.texture.rgba.clone(), + [shape.texture.width, shape.texture.height], + ); + } + for font in runtime.bindings.movie.text_assets.fonts.values() { + files.insert(font.texture.clone(), font.size); + } + let mut textures = BTreeMap::new(); + for (path, size) in files { + let bytes = std::fs::read(root.join(&path)).map_err(|e| format!("HUD {path}: {e}"))?; + if bytes.len() != size[0] as usize * size[1] as usize * 4 { + return Err(format!("Invalid HUD texture size {path}")); + } + let image = Image::new( + Extent3d { + width: size[0], + height: size[1], + depth_or_array_layers: 1, + }, + TextureDimension::D2, + bytes, + TextureFormat::Rgba8UnormSrgb, + RenderAssetUsages::RENDER_WORLD, + ); + textures.insert(path, images.add(image)); + } + Ok(Hud { + runtime, + source, + shapes, + textures, + slots: Vec::new(), + generation: 0, + failed: false, + }) + })(); + match result { + Ok(hud) => { + let target = images.add(Image::new_target_texture( + 1280, + 720, + TextureFormat::Rgba8UnormSrgb, + None, + )); + commands.spawn(( + Camera2d, + Camera { + order: -1, + clear_color: ClearColorConfig::Custom(Color::NONE), + ..default() + }, + RenderTarget::Image(target.clone().into()), + RenderLayers::layer(31), + Msaa::Off, + )); + let Ok(output) = cameras.single() else { + error!("Original HUD requires the presentation camera"); + return; + }; + commands.spawn(( + ImageNode::new(target), + UiTargetCamera(output), + GlobalZIndex(1), + Pickable::IGNORE, + Node { + position_type: PositionType::Absolute, + width: percent(100), + height: percent(100), + ..default() + }, + )); + commands.insert_resource(hud); + info!("Original scoring HUD loaded"); + } + Err(error) => error!("Original scoring HUD could not load: {error}"), + } +} +fn reset( + hud: Option>, + skater: Res, + map: Res, +) { + let Some(mut hud) = hud else { + return; + }; + if hud.generation == map.generation { + return; + } + match hud_runtime::Runtime::load(&hud.source, skater.scoring.hud_input()) { + Ok(runtime) => { + hud.runtime = runtime; + hud.generation = map.generation; + hud.failed = false; + } + Err(error) => { + error!("Original scoring HUD reset: {error}"); + hud.failed = true; + } + } +} +fn advance( + hud: Option>, + skater: Res, + map: Res, +) { + let Some(mut hud) = hud else { + return; + }; + if hud.failed || hud.generation != map.generation { + return; + } + if let Err(error) = hud.runtime.update( + skater.scoring.hud_input(), + skater.scoring.new_trick, + skater.scoring.modified_trick, + skater.scoring.close_tricks, + ) { + error!("Original scoring HUD stopped: {error}"); + hud.failed = true; + } +} +fn render( + mut commands: Commands, + hud: Option>, + mut meshes: ResMut>, + mut materials: ResMut>, +) { + let Some(mut hud) = hud else { + return; + }; + let draws = if hud.failed { + Vec::new() + } else { + match apt_scene::draw(&hud.runtime.bindings.movie, &hud.runtime.vm, &hud.shapes) { + Ok(draws) => draws, + Err(error) => { + error!("Original HUD geometry: {error}"); + hud.failed = true; + Vec::new() + } + } + }; + for (index, draw) in draws.iter().enumerate() { + let Some(texture) = hud.textures.get(&draw.texture).cloned() else { + continue; + }; + let mut mesh = Mesh::new( + PrimitiveTopology::TriangleList, + RenderAssetUsages::MAIN_WORLD | RenderAssetUsages::RENDER_WORLD, + ); + mesh.insert_attribute( + Mesh::ATTRIBUTE_POSITION, + draw.vertices + .iter() + .map(|v| [v.position[0] - 640., 360. - v.position[1], 0.]) + .collect::>(), + ); + mesh.insert_attribute( + Mesh::ATTRIBUTE_UV_0, + draw.vertices.iter().map(|v| v.uv).collect::>(), + ); + mesh.insert_attribute( + Mesh::ATTRIBUTE_NORMAL, + vec![[0., 0., 1.]; draw.vertices.len()], + ); + let material = HudMaterial { + color: ColorTransform { + multiply: draw.multiply.into(), + add: draw.add.into(), + }, + atlas: texture, + }; + if index == hud.slots.len() { + let mesh = meshes.add(mesh); + let material = materials.add(material); + let entity = commands + .spawn(( + Mesh2d(mesh.clone()), + MeshMaterial2d(material.clone()), + Transform::from_xyz(0., 0., index as f32 * 0.01), + RenderLayers::layer(31), + )) + .id(); + hud.slots.push(Slot { + entity, + mesh, + material, + }); + } else { + let slot = &hud.slots[index]; + if let Some(old) = meshes.get_mut(&slot.mesh) { + *old = mesh; + } + if let Some(old) = materials.get_mut(&slot.material) { + *old = material; + } + commands.entity(slot.entity).insert(Visibility::Visible); + } + } + for slot in &hud.slots[draws.len()..] { + commands.entity(slot.entity).insert(Visibility::Hidden); + } +} diff --git a/crates/skate-game/src/scoring_runtime.rs b/crates/skate-game/src/scoring_runtime.rs new file mode 100644 index 00000000..ed102ca1 --- /dev/null +++ b/crates/skate-game/src/scoring_runtime.rs @@ -0,0 +1,597 @@ +//! ScoreModule integration. The simulation owns recognition and accounting; +//! the APT movie only consumes the resulting publication. +use crate::{apt_vm::Value, hud_runtime}; +use skate_core::{ + animation::output::attributes::AttributeName, + physics::filtered_state::FilteredCategory, + scoring::{ + carrier::{Carrier, delay_ticks}, + conversions::LINKS, + session::Session, + }, +}; +use skate_data::{collections::Collections, scoring::ScoringData}; + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Collector { + None, + Ground, + Air, + Grind, + Offboard, + Special, +} +pub(crate) struct Frame { + pub tick: u32, + pub dt: f32, + pub category: FilteredCategory, + pub state: u32, + pub descriptor: Option, + pub grind_id: i32, + pub flags: u32, + pub position: [f32; 3], + pub velocity: [f32; 3], + pub forward: [f32; 3], + pub switch: bool, + pub fakie: bool, + pub nollie: bool, + pub body_flip: bool, + pub landing: skate_core::animation::landing_quality::Output, + pub teleported: bool, + pub reverting: bool, +} +pub(crate) struct Runtime { + pub data: ScoringData, + pub session: Session, + collector: Collector, + carriers: [Option; 4], + held: [f32; 4], + distance: [f32; 4], + metric_rewards: [f32; 4], + start: [f32; 3], + previous: [f32; 3], + previous_heading: f32, + spin: f32, + peak: f32, + air_factor: f32, + air_repetition: f32, + air_repetition_set: bool, + grab_chain: u32, + air_metrics: [f32; 5], + landing_countdown: u32, + idle_ticks: u32, + collector_ticks: u32, + manual_revert_ticks: u32, + revert_id: Option, + sequence_active: bool, + sequence_score: f32, + trick_name: String, + stance: [bool; 4], + clean: bool, + sketchy: bool, + pub new_trick: bool, + pub modified_trick: bool, + pub close_tricks: bool, +} +impl Runtime { + pub fn load(data: &Collections) -> Result { + Ok(Self { + data: ScoringData::load(data)?, + session: Session::default(), + collector: Collector::None, + carriers: std::array::from_fn(|_| None), + held: [0.; 4], + distance: [0.; 4], + metric_rewards: [0.; 4], + start: [0.; 3], + previous: [0.; 3], + previous_heading: 0., + spin: 0., + peak: 0., + air_factor: 1., + air_repetition: 1., + air_repetition_set: false, + grab_chain: 0, + air_metrics: [0.; 5], + landing_countdown: 0, + idle_ticks: 0, + collector_ticks: 0, + manual_revert_ticks: 0, + revert_id: None, + sequence_active: false, + sequence_score: 0., + trick_name: String::new(), + stance: [false; 4], + clean: false, + sketchy: false, + new_trick: false, + modified_trick: false, + close_tricks: false, + }) + } + pub fn hud_input(&self) -> hud_runtime::Input { + hud_runtime::Input { + sequence_score: self.sequence_score as i32, + line_score: self.session.holder.snapshot.line as i32, + sequence_timer: self.session.line.points as i32, + line_time: self.session.line.points, + line_capacity: self.data.line_capacity, + multiplier: self.session.combo.multiplier, + clean: self.clean, + sketchy: self.sketchy, + stance: self.stance, + trick_name: self.trick_name.clone(), + trick_metrics: [ + Value::Text(self.trick_name.clone()), + Value::Bool(false), + Value::Bool(false), + Value::Bool(false), + Value::Bool(false), + ], + context_tricks: Vec::new(), + } + } + fn penalty(&self, id: usize) -> f32 { + let Some(d) = self.data.by_id(id) else { + return 1.; + }; + if d.metadata.repetition_applies() { + self.data.repetition.evaluate( + self.session + .holder + .repetition_count(d.metadata) + .unwrap_or(0) as f32, + ) + } else { + 1. + } + } + fn finish(&mut self, slot: usize, complete: bool, keep_metric: bool) { + if let Some(mut carrier) = self.carriers[slot].take() { + if complete { + carrier.complete(self.data.unannounced_factor); + if self.collector == Collector::Air { + self.session + .holder + .end_trick(carrier.scorable, carrier.reward); + } else { + let metric = self.collector == Collector::Grind + || self.collector == Collector::Ground && slot < 3; + if !(keep_metric && self.collector == Collector::Ground) { + let reward = if keep_metric { + 0. + } else if metric { + if carrier.scorable.score_type == 9 { + carrier.reward.max(self.metric_rewards[slot]) + } else { + self.metric_rewards[slot].max(0.) + } + } else { + carrier.reward + }; + self.session.holder.credit_trick(carrier.scorable, reward); + } + } + } + } + if !keep_metric { + self.held[slot] = 0.; + self.distance[slot] = 0.; + self.metric_rewards[slot] = 0.; + } + } + fn carrier(&mut self, slot: usize, id: Option, f: &Frame) -> Result<(), String> { + if self.carriers[slot].as_ref().map(|c| c.scorable.id) != id { + let conversion = id.filter(|new| { + self.collector == Collector::Air + && self.carriers[slot].is_some() + && LINKS[*new].0 >= 0 + }); + let chained_grab = self.collector == Collector::Air + && id + .and_then(|id| self.data.by_id(id)) + .is_some_and(|d| d.metadata.class == 2) + && self.carriers[slot] + .as_ref() + .is_some_and(|c| c.scorable.class == 2 && !c.announced); + let previous_tick = self.carriers[slot].as_ref().map(|c| c.start_tick); + let keep_metric = id.is_some() + && (self.collector == Collector::Grind + || self.collector == Collector::Ground && slot == 0); + if conversion.is_none() { + if chained_grab { + self.carriers[slot] = None; + self.grab_chain += 1; + } else { + self.finish(slot, true, keep_metric); + self.grab_chain = 0; + } + } + if let Some(id) = id { + let d = self + .data + .by_id(id) + .ok_or_else(|| format!("Missing native scorable {id}"))?; + let factor = self.penalty(id) + * if self.collector == Collector::Air { + self.air_factor + } else { + 1. + }; + let mut carrier = Carrier::new( + d.metadata, + d.points, + factor, + self.data.announcement.evaluate(d.points as f32), + if chained_grab { + previous_tick.unwrap_or(f.tick) + } else { + f.tick + }, + delay_ticks( + d.completion_delay, + if self.grab_chain > 1 { + self.data.collector.scalar(0x698) + } else { + 0. + }, + ), + f.switch, + f.fakie, + ); + if let Some(old) = self.carriers[slot].as_mut() { + old.convert_to(&mut carrier, self.data.unannounced_factor); + self.modified_trick = true; + } + if conversion.is_some() { + self.trick_name = d.label.clone(); + } + self.carriers[slot] = Some(carrier); + self.sequence_active = true; + self.idle_ticks = 0; + } + } + let penalty = self.carriers[slot] + .as_ref() + .map(|c| self.penalty(c.scorable.id)) + .unwrap_or(1.); + if let Some(c) = self.carriers[slot].as_mut() { + if c.announce(f.tick, self.data.unannounced_factor) { + let d = self + .data + .by_id(c.scorable.id) + .ok_or("Missing announced scorable")?; + self.trick_name = d.label.clone(); + self.stance = [f.switch, f.fakie, f.nollie, false]; + self.new_trick = true; + if self.collector == Collector::Air && !self.air_repetition_set { + self.air_repetition = penalty; + self.air_repetition_set = true; + } + } + if c.announced { + self.held[slot] += f.dt; + let previous_distance = self.distance[slot]; + let displacement = + std::array::from_fn::<_, 3, _>(|i| f.position[i] - self.previous[i]); + let delta = if self.collector == Collector::Ground && slot == 1 { + displacement + .iter() + .zip(f.forward) + .map(|(x, y)| x * y) + .sum::() + } else { + (displacement.iter().map(|x| x * x).sum::()).sqrt() + }; + if delta > f32::from_bits(0x3ba3d70a) { + self.distance[slot] += delta; + } + let curve = match self.collector { + Collector::Air if c.scorable.class == 2 => Some(0x460), + Collector::Offboard => Some(0x140), + _ => None, + }; + if let Some(curve) = curve { + c.reward += (self.data.collector.curve(curve, self.held[slot]) * f.dt) + * c.announcement_threshold; + } + let curve = match (self.collector, c.scorable.score_type) { + (Collector::Ground, 9) => Some((0x000, 0x050)), + (Collector::Ground, 8) => Some((0x0f0, 0x0a0)), + (Collector::Ground, _) if slot == 2 => Some((0x230, 0x1e0)), + (Collector::Grind, _) => Some((0x2d0, 0x280)), + _ => None, + }; + if let Some((distance, time)) = curve { + let delta = (self.data.collector.curve(distance, self.distance[slot]) + - self.data.collector.curve(distance, previous_distance)) + .max(0.); + self.metric_rewards[slot] = (self + .data + .collector + .curve(time, self.held[slot]) + .mul_add(f.dt, delta)) + .mul_add(c.announcement_threshold, self.metric_rewards[slot]); + } + } + } + Ok(()) + } + pub fn advance(&mut self, f: Frame) -> Result<(), String> { + self.new_trick = false; + self.modified_trick = false; + self.close_tricks = false; + let descriptor = f + .descriptor + .and_then(|name| self.data.by_name(name)) + .filter(|d| !(f.flags & 0x02000000 != 0 && [67, 68, 69].contains(&d.metadata.id))) + .map(|d| (d.metadata.id, d.metadata.class, d.metadata.score_type)); + let mut next = match f.category { + FilteredCategory::Ground => Collector::Ground, + FilteredCategory::Air => Collector::Air, + FilteredCategory::Grind => Collector::Grind, + FilteredCategory::Offboard | FilteredCategory::OffboardAir => Collector::Offboard, + _ => Collector::None, + }; + if f.state == 600 && matches!(next, Collector::Ground | Collector::Air) { + next = Collector::Special; + } + if next == Collector::Air + && self.collector == Collector::Ground + && descriptor.is_some_and(|d| d.1 == 0) + { + next = Collector::Ground; + } + if next == Collector::Ground + && self.collector == Collector::Air + && descriptor.is_some_and(|d| d.1 == 3) + { + next = Collector::Air; + } + if f.teleported { + next = Collector::None; + } + if next != self.collector { + let complete = next != Collector::None; + let previous_type = self + .carriers + .iter() + .flatten() + .last() + .map(|c| c.scorable.score_type); + for slot in 0..4 { + self.finish(slot, complete, false); + } + if self.collector == Collector::Air { + if complete { + for (i, reward) in self.air_metrics.into_iter().enumerate() { + if let Some(d) = self.data.by_id(129 + i) { + self.session.holder.end_trick(d.metadata, reward); + } + } + } + self.session.holder.finish_collector(); + self.landing_countdown = 2; + } + self.collector = next; + self.start = f.position; + self.peak = f.position[1]; + self.spin = 0.; + self.previous_heading = f.forward[0].atan2(f.forward[2]); + self.air_metrics = [0.; 5]; + self.air_repetition = 1.; + self.air_repetition_set = false; + self.air_factor = 1.; + self.grab_chain = 0; + self.collector_ticks = 0; + self.manual_revert_ticks = 0; + self.revert_id = None; + if next == Collector::Air { + self.session.holder.reward_sequence(1.); + self.landing_countdown = 0; + if previous_type == Some(8) { + self.air_factor *= self.data.collector.scalar(0x68c); + } + if previous_type == Some(5) { + self.air_factor *= self.data.collector.scalar(0x690); + } + if f.velocity[0] * f.velocity[0] + f.velocity[2] * f.velocity[2] + < self.data.collector.scalar(0x66c).powi(2) + { + self.air_factor *= self.data.collector.scalar(0x688); + } + if f.switch && !f.fakie { + self.air_factor *= self.data.collector.scalar(0x684); + } + if f.fakie && !f.switch { + self.air_factor *= self.data.collector.scalar(0x694); + } + } + } + let mut ids = [None; 4]; + self.collector_ticks = self.collector_ticks.saturating_add(1); + match self.collector { + Collector::Ground => { + ids[0] = if f.flags & 0x80000000 != 0 { + Some(0) + } else if f.flags & 0x40000000 != 0 { + Some(1) + } else { + None + }; + ids[1] = if f.flags & 0x08000000 != 0 { + Some(2) + } else if f.flags & 0x04000000 != 0 { + Some(3) + } else { + None + }; + if ids[1].is_some() && f.reverting { + self.manual_revert_ticks += 1; + ids[1] = None; + } else { + self.manual_revert_ticks = 0; + } + if self.manual_revert_ticks > 6 { + ids[1] = None; + } + if ids[1].is_some() { + if let Some(c) = &self.carriers[1] { + ids[1] = Some(c.scorable.id); + } + } + ids[2] = descriptor + .filter(|d| d.0 == 60 && f.flags & 0x02000000 != 0) + .map(|d| d.0); + if f.flags & 0x20000000 != 0 { + self.revert_id = Some(4); + } else if f.flags & 0x10000000 != 0 { + self.revert_id = Some(5); + } + ids[3] = if f.reverting { self.revert_id } else { None }; + } + Collector::Air => ids[0] = descriptor.filter(|d| d.2 != 5).map(|d| d.0), + Collector::Grind => ids[0] = (f.grind_id >= 0).then_some(f.grind_id as usize), + Collector::Offboard => ids[0] = descriptor.filter(|d| d.2 == 6).map(|d| d.0), + Collector::Special => ids[0] = descriptor.filter(|d| d.0 == 234).map(|d| d.0), + Collector::None => {} + } + for (slot, id) in ids.into_iter().enumerate() { + self.carrier(slot, id, &f)?; + } + if self.collector == Collector::Air { + if self.collector_ticks > 5 || f.flags & 0x01000000 != 0 { + self.sequence_active = true; + } + let heading = f.forward[0].atan2(f.forward[2]); + let delta = (heading - self.previous_heading + std::f32::consts::PI) + .rem_euclid(std::f32::consts::TAU) + - std::f32::consts::PI; + self.spin += delta; + self.previous_heading = heading; + self.peak = self.peak.max(f.position[1]); + let dx = f.position[0] - self.start[0]; + let dz = f.position[2] - self.start[2]; + let scale = self.air_factor * self.air_repetition; + self.air_metrics[0] = + self.data.collector.curve(0x3c0, (dx * dx + dz * dz).sqrt()) * scale; + self.air_metrics[1] = + self.data.collector.curve(0x410, self.peak - self.start[1]) * scale; + self.air_metrics[2] = self + .data + .collector + .curve(0x320, f.position[1] - self.start[1]) + * scale; + let turns = + ((self.spin.to_degrees().abs() + self.data.collector.scalar(0x63c)) / 180.) as i32; + self.air_metrics[3] = self.data.collector.curve(0x370, (turns * 180) as f32) * scale; + if f.body_flip + && self.carriers[0] + .as_ref() + .is_some_and(|c| c.announced && c.scorable.class == 2) + { + self.air_metrics[4] = self.data.collector.scalar(0x640) * scale; + } + } + let active = self.carriers.iter().any(Option::is_some) || self.collector == Collector::Air; + self.idle_ticks = if active { + 0 + } else { + self.idle_ticks.saturating_add(1) + }; + if self.session.holder.has_pending_sequence() { + if f.landing.landing_data_167 { + self.clean = f.landing.landing_type_96 == 0; + self.sketchy = f.landing.landing_type_96 == 1 + && f.landing.sideways_speed_84 > self.data.sketchy_side_speed + && f.landing.spin_92.abs() > 0.5; + } + if self.landing_countdown == 0 { + let mut factor = 1.; + if f.switch && !f.fakie { + factor *= self.data.collector.scalar(0x61c); + } + if f.fakie && !f.switch { + factor *= self.data.collector.scalar(0x62c); + } + if self.collector == Collector::Grind { + factor *= self.data.collector.scalar(0x628); + } + if self.clean { + factor *= self.data.collector.scalar(0x630); + } + if self.sketchy { + factor *= self.data.collector.scalar(0x620); + } + self.session.holder.reward_sequence(factor); + } else { + self.landing_countdown -= 1; + } + } + let scales = match self.collector { + Collector::Air => (0x668, 0x664), + Collector::Grind => (0x618, 0x614), + Collector::Offboard => (0x608, 0x604), + _ => (0x610, 0x60c), + }; + let line_scale = if active { + self.data.collector.scalar(scales.0) + } else { + 1. + }; + let combo_scale = if active { + self.data.collector.scalar(scales.1) + } else { + 1. + }; + self.session + .line + .advance(f.dt, self.data.line_drain, line_scale, active); + self.session + .combo + .timer + .advance(f.dt, self.data.combo_drain, combo_scale, active); + let bailout = self.collector == Collector::None && self.sequence_active; + if self.sequence_active && (bailout || self.idle_ticks >= 3 && self.landing_countdown == 0) + { + if bailout { + self.session.holder.cancel_pending(); + } + self.sequence_score = + self.session + .publish_sequence(&self.data.session_rules(), 1., bailout, true); + self.sequence_active = false; + self.close_tricks = true; + } else if self.sequence_active { + let s = &self.session.holder.snapshot; + self.sequence_score = (s.accumulated + + s.general_pending + + s.fingerflip_pending + + self + .carriers + .iter() + .enumerate() + .filter_map(|(i, c)| { + c.as_ref().map(|c| { + if self.collector == Collector::Grind + || self.collector == Collector::Ground && i < 3 + { + if c.scorable.score_type == 9 { + c.reward.max(self.metric_rewards[i]) + } else { + self.metric_rewards[i] + } + } else { + c.reward + } + }) + }) + .sum::() + + self.air_metrics.iter().sum::()) + * self.session.combo.multiplier; + } + self.session.settle_line(f.teleported || bailout, active); + self.previous = f.position; + Ok(()) + } +} diff --git a/docs/native-scoring-hud.md b/docs/native-scoring-hud.md index ad2f0865..33e76d70 100644 --- a/docs/native-scoring-hud.md +++ b/docs/native-scoring-hud.md @@ -59,40 +59,65 @@ The original movie is authored at 1280 by 720. Its score, line, stance, multiplier and trick-name timelines must control presentation; replacing them with a new overlay would not meet this port's requirements. -## Remaining implementation +## Production integration and validation -Collectors are not wired into the production tick yet. Recognition still -requires conditioner gates, descriptor conversion, air/ground/grind collector -transitions, spin/flip bonuses, gaps, landing and sequence publication ordering. -Announcement delay conversion uses the verified binary32 60 at `8303745C`. -The carrier's start/current tick source still needs to be connected at the -matching production phase. +The production fixed tick now supplies animation descriptors, conditioned category, +grind IDs, landing output and physical motion to `scoring_runtime`. The runtime +loads native VLT points, repetition, announcement and collector curves, maintains +carriers and continuous distance rewards, and publishes sequence/line accounting. +It is a usable integration for testing, **not a finished native-parity port**. -The HUD still needs a complete movie object hierarchy, native bindings, -timeline execution and rendering with multiplicative and additive colors. -Superclass preloads and supported action coverage also remain incomplete. -The audit's dummy host intentionally does not establish native binding parity. +The HUD executes the owned trickdisplay ActionScript and timelines, including +score, multiplier, line meter, stance and trick-name updates. It renders original +shape atlases and font glyphs through a separate 1280x720 camera with inherited +multiplicative/additive colors. Movie objects are collected and mesh/material +slots reused. Fixed updates pause with gameplay; map-generation changes recreate +the movie even while paused. Missing assets or unsupported actions are logged. -Font resolution is recovered from FontManager initialization `82808AE8`, -APT-name lookup `82809208` and loading `82809308`. VLT class -`FECFBCAF356518C4` maps the APT name (field `340832CCFD9FDEB4`, layout +8) to -`FileName` (layout +4). In particular, `FuturaOuterGlow` maps to `futurashadow`, -not the prior extractor's inferred `Futura Glow`. The prepared movie has no -unresolved font references. The manifest retains VLT record provenance and -native scale/offset fields. The community symbol calling `82809168` -GetFontByAptName is misleading: that function searches the filename column; -`82809208` searches the APT-name column. +Font resolution follows FontManager initialization `82808AE8`, APT-name lookup +`82809208` and loading `82809308`. VLT class `FECFBCAF356518C4` maps AptName to +FileName; `FuturaOuterGlow` resolves to `futurashadow`. Glyph advance and placement +use native scale/ascent metrics. English text resolves through the owned language +asset. No replacement interface or bundled game assets are introduced. -Pause, map-change generation reset and resource reuse remain to be integrated -and checked. A release compilation succeeds; its executable is copied into -the ignored task-local `logs/scoring/build` directory, with an untracked -`Launch scoring worktree.bat`. This is a compilation checkpoint, not a working -scoring/HUD feature build. No game/recomp, -controller harness, gameplay automation, `--check-assets`, or screenshot -capture has been run for this work. +Validation on 2026-09-08: -Validation so far: nine scoring unit tests pass (including unsigned clock -rollover, early completion, conversion, timer thresholds, one-time banking and -cancelled rewards); the owned-data loader audit resolves 300 definitions; the -APT data-only audit passes placement validation and constructor checks. +- All nine scoring unit tests pass, covering clock wrap, early completion, + conversion, cancellation, repetition, timer thresholds and one-time banking. +- The data-only scoring-flow example credits the authored 100-point stationary + kickflip once and verifies idle stability and teleport cancellation. +- The original HUD data-only audit passes 1,800 frames, with 172 VM slots, + 116 display instances, at most 31 draw batches and finite geometry. +- The release target builds successfully with static MSVC CRT, without default + features. Compiler warnings remain in the existing game and audit-only code. +- No game, recomp, controller harness, gameplay automation, `--check-assets` + or screenshot capture was run. GPU rendering and interactive behavior remain + unverified until the user launches the build. +The copied build and launcher are in ignored `logs/scoring/build`. The launcher +uses this worktree's HUD cache and the existing owned asset installation and +starts paused. It checks required paths before running and retains failures on +screen. The executable SHA256 is +`775264c02f456665052355d466b99cb2e3ead1e359af3123c9a53afd68e1e182`. + +## Native parity gaps + +These are implementation gaps, not merely missing gameplay validation: + +- Collector activation gates and exact publication timing need further porting; + the current runtime uses conditioned categories and an idle countdown. +- Air spin uses accumulated board heading rather than the complete native + transform accumulator. Body-flip direction and some landing modifiers remain + incomplete. +- Gap/context collectors and their native ground-query inputs, contextual + bonuses and off-board height rewards are not wired. +- Revert recognition depends on an unpublished physical state flag in the + current host. Full native revert scoring is not yet available. +- Trick-name spin/direction modifiers and the HUD manager's stance transitions + are incomplete. The bindings expose five metric slots but currently only + supply the base trick label and default modifier flags. +- The compact VM supports the exercised movie paths, not arbitrary APT programs. + Superclass/native constructor behavior is limited, and Math.random uses a + local presentation RNG rather than the original engine RNG stream. + +Do not describe this build as fully finished or an exact native recreation. diff --git a/tools/prepare_hud.py b/tools/prepare_hud.py index 55c76722..f81b6a08 100644 --- a/tools/prepare_hud.py +++ b/tools/prepare_hud.py @@ -86,6 +86,8 @@ def prepare(game: Path, output: Path, collections: Path): 'characters': list(bundle['characters'].values()), 'shapes': shapes, 'fonts': fonts, 'actions': blocks, 'font_mappings':mappings, + 'language': {row['label'].strip(): row['value'] for row in + json.loads((output / 'metadata/languages/english_global.json').read_text(encoding='utf-8'))['entries']}, 'unresolved_fonts': [family for family, asset in fonts.items() if asset is None], } target = output / 'runtime' / 'trickdisplay.json'