diff --git a/crates/skate-core/src/lib.rs b/crates/skate-core/src/lib.rs index d87cc88d..256507c8 100644 --- a/crates/skate-core/src/lib.rs +++ b/crates/skate-core/src/lib.rs @@ -14,4 +14,5 @@ pub mod physics; pub mod player; pub mod point_graph; pub mod riding; +pub mod scoring; pub mod trigonometry; diff --git a/crates/skate-core/src/scoring.rs b/crates/skate-core/src/scoring.rs new file mode 100644 index 00000000..2851ccce --- /dev/null +++ b/crates/skate-core/src/scoring.rs @@ -0,0 +1,235 @@ +//! Native ScoreHolder accounting, TU3 82DA6198/6260/6408/6468/6538. +//! +//! Collectors own recognition and reward calculation. This ledger deliberately +//! accepts their rewards rather than substituting a table of guessed points. +//! Rendering and networking consume snapshots; neither advances accounting. + +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; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Scorable { + pub id: usize, + /// Fixed metadata table +12. Distinct from the display type at +16. + pub class: u32, + pub score_type: usize, +} + +impl Scorable { + pub fn valid(self) -> bool { + self.id < SCORABLE_COUNT && self.score_type < SCORE_TYPE_COUNT + } + /// CalcPointPenalty82DA5D18 excludes metric class5 and class6. + pub fn repetition_applies(self) -> bool { + self.valid() && self.class != 5 && self.class != 6 + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct Snapshot { + pub completed_lines: f32, //24 + pub line: f32, //28 + pub accumulated: f32, //32 + pub last_reward: f32, //36 + pub general_pending: f32, //40 + pub fingerflip_pending: f32, //44 + pub grind_reward: f32, //4776 +} + +#[derive(Clone, Debug)] +pub struct ScoreHolder { + pub snapshot: Snapshot, + repetitions: [i8; SCORABLE_COUNT], //4096 + sequence_history: [i8; SCORABLE_COUNT], //4428 + type_history: [i8; SCORE_TYPE_COUNT], //4760 + pending_sequence: bool, //7260 + suppressed: bool, //7261 (physical output, not difficulty) +} + +impl Default for ScoreHolder { + fn default() -> Self { + Self { + snapshot: Snapshot::default(), + repetitions: [0; SCORABLE_COUNT], + sequence_history: [0; SCORABLE_COUNT], + type_history: [0; SCORE_TYPE_COUNT], + pending_sequence: false, + suppressed: false, + } + } +} + +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; + } + pub fn repetition_count(&self, scorable: Scorable) -> Option { + scorable.valid().then(|| self.repetitions[scorable.id]) + } + /// EndAirTrick82DA6260. Invalid IDs do not touch either history or rewards. + pub fn end_trick(&mut self, scorable: Scorable, reward: f32) { + if !scorable.valid() || self.suppressed { + return; + } + self.count_trick(scorable); + let s = &mut self.snapshot; + if scorable.class != 5 { + s.general_pending += s.fingerflip_pending; + s.fingerflip_pending = 0.0; + } + if scorable.class == 3 { + s.fingerflip_pending += reward; + } else { + s.general_pending += reward; + } + } + /// Collector Exit publishes a pending sequence even for a cancelled exit. + pub fn finish_collector(&mut self) { + self.pending_sequence = true; + } + pub fn cancel_pending(&mut self) { + if self.pending_sequence { + self.snapshot.general_pending = 0.0; + self.snapshot.fingerflip_pending = 0.0; + } + } + /// RewaredAirSequence82DA6468. Repeated calls cannot bank twice. + pub fn reward_sequence(&mut self, multiplier: f32) { + if !self.pending_sequence { + return; + } + let s = &mut self.snapshot; + s.accumulated += (s.general_pending + s.fingerflip_pending) * multiplier; + s.general_pending = 0.0; + s.fingerflip_pending = 0.0; + self.pending_sequence = false; + } + /// PublishAndResetAirSequence82DA6538 receives the final reward from the + /// module; it does not itself calculate the multiplier or bail penalty. + pub fn publish(&mut self, reward: f32, add_to_line: bool) { + let s = &mut self.snapshot; + if add_to_line { + s.line += reward; + } + s.last_reward = reward; + s.accumulated = 0.0; + s.general_pending = 0.0; + s.fingerflip_pending = 0.0; + self.clear_sequence_history(); + } + pub fn clear_sequence_history(&mut self) { + self.sequence_history.fill(0); + self.type_history.fill(0); + self.snapshot.grind_reward = 0.0; + } + /// Module82DA3B38 banks the line when its timer expires or reset is requested. + pub fn finish_line(&mut self) { + self.bank_line(true); + } + /// A zero line timer banks the current line even while a collector is + /// active, but 82DA3B38 retains repetition in that case. + pub fn bank_line(&mut self, clear_repetition: bool) { + self.snapshot.completed_lines += self.snapshot.line; + self.snapshot.line = 0.0; + if clear_repetition { + self.repetitions.fill(0); + } + } + /// Native Reset82DA5C78 retains lifetime completed-lines (+24). + pub fn reset(&mut self) { + let completed_lines = self.snapshot.completed_lines; + *self = Self::default(); + self.snapshot.completed_lines = completed_lines; + } +} + +#[cfg(test)] +mod tests { + use super::*; + const FLIP: Scorable = Scorable { + id: 96, + class: 3, + score_type: 2, + }; + #[test] + fn collector_bank_is_single_use_and_publication_retains_repetition() { + let mut h = ScoreHolder::default(); + h.end_trick(FLIP, 20.0); + h.finish_collector(); + h.reward_sequence(1.1); + h.reward_sequence(1.1); + assert_eq!(h.snapshot.accumulated, 22.0); + h.publish(22.0, true); + assert_eq!(h.snapshot.line, 22.0); + assert_eq!(h.repetition_count(FLIP), Some(1)); + h.finish_line(); + assert_eq!(h.snapshot.completed_lines, 22.0); + assert_eq!(h.repetition_count(FLIP), Some(0)); + } + #[test] + fn metric_preserves_flip_bucket_other_classes_flush_it() { + let mut h = ScoreHolder::default(); + h.end_trick(FLIP, 20.0); + h.end_trick( + Scorable { + id: 129, + class: 5, + score_type: 0, + }, + 4.0, + ); + assert_eq!( + (h.snapshot.general_pending, h.snapshot.fingerflip_pending), + (4.0, 20.0) + ); + h.end_trick( + Scorable { + id: 128, + class: 4, + score_type: 1, + }, + 2.0, + ); + assert_eq!( + (h.snapshot.general_pending, h.snapshot.fingerflip_pending), + (26.0, 0.0) + ); + } + #[test] + fn cancellation_after_collector_exit_prevents_pending_reward() { + let mut h = ScoreHolder::default(); + h.end_trick(FLIP, 20.0); + h.finish_collector(); + h.cancel_pending(); + h.reward_sequence(1.1); + assert_eq!(h.snapshot.accumulated, 0.0); + h.set_suppressed(true); + h.end_trick(FLIP, 20.0); + assert_eq!(h.repetition_count(FLIP), Some(1)); + } +} diff --git a/crates/skate-core/src/scoring/carrier.rs b/crates/skate-core/src/scoring/carrier.rs new file mode 100644 index 00000000..afe320b6 --- /dev/null +++ b/crates/skate-core/src/scoring/carrier.rs @@ -0,0 +1,149 @@ +//! Scorable carrier lifecycle, TU3 82DA45C8/46D0/5DE0/5F98. +//! Collectors supply the native clock and already-conditioned point factor. +use super::Scorable; + +/// 82DA45C8 multiplies the sum of authored and collector delay by the +/// binary32 constant at 8303745C (0x42700000), then truncates to integer. +pub fn delay_ticks(authored_seconds: f32, extra_seconds: f32) -> u32 { + (((authored_seconds + extra_seconds) * f32::from_bits(0x42700000)) as i64) as u32 +} + +#[derive(Clone, Debug)] +pub struct Carrier { + pub scorable: Scorable, + pub points: i32, + pub factor: f32, + pub reward: f32, + pub announcement_threshold: f32, + pub start_tick: u32, + pub delay_ticks: u32, + pub announced: bool, + pub completed: bool, + pub unannounced: bool, + pub switch: bool, + pub fakie: bool, +} + +impl Carrier { + /// Delay is supplied in native ticks: simulation frame count must not be + /// substituted for the source clock's frequency. + pub fn new( + scorable: Scorable, + points: i32, + factor: f32, + announcement_threshold: f32, + start_tick: u32, + delay_ticks: u32, + switch: bool, + fakie: bool, + ) -> Self { + Self { + scorable, + points, + factor, + reward: 0.0, + announcement_threshold: announcement_threshold * factor, + start_tick, + delay_ticks, + announced: false, + completed: false, + unannounced: false, + switch, + fakie, + } + } + + fn credit(&mut self, unannounced_factor: f32) { + let mut reward = self.points as f32 * self.factor; + if self.unannounced { + reward *= unannounced_factor; + } + self.reward += reward; + } + + /// Returns true only on the announcement edge. Unsigned subtraction is + /// intentional and preserves the executable's behavior at clock rollover. + pub fn announce(&mut self, tick: u32, unannounced_factor: f32) -> bool { + if self.announced + || !self.scorable.valid() + || tick.wrapping_sub(self.start_tick) < self.delay_ticks + { + return false; + } + self.announced = true; + self.credit(unannounced_factor); + true + } + + /// Called once by the collector when removing its current carrier. The + /// executable does not mark an early completion as an announcement. + pub fn complete(&mut self, unannounced_factor: f32) -> bool { + if !self.scorable.valid() { + return false; + } + if !self.announced { + self.unannounced = true; + self.credit(unannounced_factor); + } + self.completed = true; + true + } + + /// Replace a carrier through its conversion link (82DA6320). Conversion + /// reports the previous carrier as completed and credits the replacement + /// immediately, even before its normal announcement delay. + pub fn convert_to(&mut self, replacement: &mut Self, unannounced_factor: f32) { + self.completed = true; + replacement.announced = true; + replacement.credit(unannounced_factor); + } +} + +#[cfg(test)] +mod tests { + use super::*; + fn carrier(start: u32) -> Carrier { + Carrier::new( + Scorable { + id: 96, + class: 3, + score_type: 2, + }, + 100, + 0.2, + 2.25, + start, + 4, + false, + false, + ) + } + #[test] + fn announcement_clock_wraps_and_does_not_credit_twice() { + let mut c = carrier(u32::MAX - 2); + assert!(!c.announce(0, 0.3)); + assert!(c.announce(1, 0.3)); + assert!(!c.announce(2, 0.3)); + assert!(c.complete(0.3)); + assert_eq!(c.reward, 20.0); + assert!(!c.unannounced); + } + #[test] + fn early_completion_uses_authored_discount_without_announcement() { + let mut c = carrier(0); + assert!(c.complete(0.3)); + assert_eq!(c.reward, 6.0); + assert!(c.completed && c.unannounced && !c.announced); + } + #[test] + fn conversion_announces_replacement_without_completing_it() { + let mut previous = carrier(0); + let mut next = carrier(1); + previous.convert_to(&mut next, 0.3); + assert!(previous.completed); + assert_eq!(previous.reward, 0.0); + assert!(next.announced && !next.completed); + assert_eq!(next.reward, 20.0); + assert!(!next.announce(5, 0.3)); + } +} diff --git a/crates/skate-core/src/scoring/catalog.rs b/crates/skate-core/src/scoring/catalog.rs new file mode 100644 index 00000000..623abeac --- /dev/null +++ b/crates/skate-core/src/scoring/catalog.rs @@ -0,0 +1,337 @@ +//! TU3 EScorableID enum metadata, table820862A8 (332 x24 bytes). +//! Numeric class/type relationships and native identifier spellings only. +//! Points, localization, timers and UI assets are loaded from owned data. +pub const IDENTIFIERS: [(&str, u32, usize); 332] = [ + ("fspowerslide", 0, 9), // 0 + ("bspowerslide", 0, 9), // 1 + ("nosemanual", 0, 8), // 2 + ("tailmanual", 0, 8), // 3 + ("fsrevert", 0, 11), // 4 + ("bsrevert", 0, 11), // 5 + ("bs_5_o", 1, 5), // 6 + ("bs_50_50", 1, 5), // 7 + ("bs_blunt", 1, 5), // 8 + ("bs_board", 1, 5), // 9 + ("bs_crook", 1, 5), // 10 + ("bs_feeble", 1, 5), // 11 + ("bs_lip", 1, 5), // 12 + ("bs_noseblunt", 1, 5), // 13 + ("bs_nosegrind", 1, 5), // 14 + ("bs_noseslide", 1, 5), // 15 + ("bs_overcrook", 1, 5), // 16 + ("bs_overwilly", 1, 5), // 17 + ("bs_salad", 1, 5), // 18 + ("bs_smith", 1, 5), // 19 + ("bs_tailslide", 1, 5), // 20 + ("bs_willy", 1, 5), // 21 + ("fs_5_o", 1, 5), // 22 + ("fs_50_50", 1, 5), // 23 + ("fs_blunt", 1, 5), // 24 + ("fs_board", 1, 5), // 25 + ("fs_crook", 1, 5), // 26 + ("fs_feeble", 1, 5), // 27 + ("fs_lip", 1, 5), // 28 + ("fs_noseblunt", 1, 5), // 29 + ("fs_nosegrind", 1, 5), // 30 + ("fs_noseslide", 1, 5), // 31 + ("fs_overcrook", 1, 5), // 32 + ("fs_overwilly", 1, 5), // 33 + ("fs_salad", 1, 5), // 34 + ("fs_smith", 1, 5), // 35 + ("fs_tailslide", 1, 5), // 36 + ("fs_willy", 1, 5), // 37 + ("grind5_o", 1, 5), // 38 + ("grind5050", 1, 5), // 39 + ("grindcrooks", 1, 5), // 40 + ("grindfeeble", 1, 5), // 41 + ("grindnose", 1, 5), // 42 + ("grindovercrooks", 1, 5), // 43 + ("grindoverwilly", 1, 5), // 44 + ("grindsalad", 1, 5), // 45 + ("grindsmith", 1, 5), // 46 + ("grindwilly", 1, 5), // 47 + ("slideblunt", 1, 5), // 48 + ("slideboard", 1, 5), // 49 + ("slidelip", 1, 5), // 50 + ("slidenblunt", 1, 5), // 51 + ("slidenose", 1, 5), // 52 + ("slidetail", 1, 5), // 53 + ("bsgrab", 2, 4), // 54 + ("bsgrab_down", 2, 4), // 55 + ("bsgrab_left", 2, 4), // 56 + ("bsgrab_right", 2, 4), // 57 + ("bsgrab_up", 2, 4), // 58 + ("christ_air", 2, 4), // 59 + ("coffin", 2, 4), // 60 + ("dblgrab", 2, 4), // 61 + ("fsgrab", 2, 4), // 62 + ("fsgrab_down", 2, 4), // 63 + ("fsgrab_left", 2, 4), // 64 + ("fsgrab_right", 2, 4), // 65 + ("fsgrab_up", 2, 4), // 66 + ("gnd_bsgrab", 2, 4), // 67 + ("gnd_dblgrab", 2, 4), // 68 + ("gnd_fsgrab", 2, 4), // 69 + ("mutegrab", 2, 4), // 70 + ("mutegrab_down", 2, 4), // 71 + ("mutegrab_left", 2, 4), // 72 + ("mutegrab_up", 2, 4), // 73 + ("nosegrab", 2, 4), // 74 + ("nosegrab_left", 2, 4), // 75 + ("nosegrab_right", 2, 4), // 76 + ("rocketair", 2, 4), // 77 + ("stalegrab", 2, 4), // 78 + ("stalegrab_down", 2, 4), // 79 + ("stalegrab_left", 2, 4), // 80 + ("stalegrab_up", 2, 4), // 81 + ("tailgrab", 2, 4), // 82 + ("tailgrab_left", 2, 4), // 83 + ("tailgrab_right", 2, 4), // 84 + ("360flip", 3, 2), // 85 + ("360hardflip", 3, 2), // 86 + ("360inwardheelflip", 3, 2), // 87 + ("360popshuvit", 3, 2), // 88 + ("fs360popshuvit", 3, 2), // 89 + ("fspopshuvit", 3, 2), // 90 + ("hardflip", 3, 2), // 91 + ("heelflip", 3, 2), // 92 + ("heelflip2", 3, 2), // 93 + ("heelflip3", 3, 2), // 94 + ("inwardheelflip", 3, 2), // 95 + ("kickflip", 3, 2), // 96 + ("kickflip2", 3, 2), // 97 + ("kickflip3", 3, 2), // 98 + ("laserflip", 3, 2), // 99 + ("latebackfootheelflip", 3, 2), // 100 + ("latebackfootkickflip", 3, 2), // 101 + ("latebsshuv", 3, 2), // 102 + ("latefsshuv", 3, 2), // 103 + ("lateheelflip", 3, 2), // 104 + ("latekickflip", 3, 2), // 105 + ("n_360flip", 3, 2), // 106 + ("n_360hardflip", 3, 2), // 107 + ("n_360inwardheelflip", 3, 2), // 108 + ("n_360popshuvit", 3, 2), // 109 + ("n_fs360popshuvit", 3, 2), // 110 + ("n_fspopshuvit", 3, 2), // 111 + ("n_hardflip", 3, 2), // 112 + ("n_heelflip", 3, 2), // 113 + ("n_heelflip2", 3, 2), // 114 + ("n_heelflip3", 3, 2), // 115 + ("n_inwardheelflip", 3, 2), // 116 + ("n_kickflip", 3, 2), // 117 + ("n_kickflip2", 3, 2), // 118 + ("n_kickflip3", 3, 2), // 119 + ("n_laserflip", 3, 2), // 120 + ("n_popshuvit", 3, 2), // 121 + ("n_varialheelflip", 3, 2), // 122 + ("n_varialkickflip", 3, 2), // 123 + ("popshuvit", 3, 2), // 124 + ("varialheelflip", 3, 2), // 125 + ("varialkickflip", 3, 2), // 126 + ("nollie", 4, 1), // 127 + ("ollie", 4, 1), // 128 + ("air_horizontal_distance", 5, 0), // 129 + ("air_height_to_peak", 5, 0), // 130 + ("air_total_height_gain", 5, 0), // 131 + ("air_player_spin", 5, 0), // 132 + ("air_player_flip", 5, 0), // 133 + ("heelflip4", 3, 2), // 134 + ("kickflip4", 3, 2), // 135 + ("n_heelflip4", 3, 2), // 136 + ("n_kickflip4", 3, 2), // 137 + ("nofoot_air", 2, 4), // 138 + ("fs_handplant", 6, 6), // 139 + ("fs_handplant_up", 6, 6), // 140 + ("fs_handplant_right", 6, 6), // 141 + ("fs_handplant_left", 6, 6), // 142 + ("fs_handplant_down", 6, 6), // 143 + ("fs_handplant_onefoot_front", 6, 6), // 144 + ("fs_handplant_onefoot_back", 6, 6), // 145 + ("fs_handplant_nofoot", 6, 6), // 146 + ("fs_handplant_to_fakie", 6, 6), // 147 + ("fs_handplant_to_revert", 6, 6), // 148 + ("bs_handplant", 6, 6), // 149 + ("bs_handplant_up", 6, 6), // 150 + ("bs_handplant_right", 6, 6), // 151 + ("bs_handplant_left", 6, 6), // 152 + ("bs_handplant_down", 6, 6), // 153 + ("bs_handplant_onefoot_front", 6, 6), // 154 + ("bs_handplant_onefoot_back", 6, 6), // 155 + ("bs_handplant_nofoot", 6, 6), // 156 + ("bs_handplant_to_fakie", 6, 6), // 157 + ("bs_handplant_to_revert", 6, 6), // 158 + ("bs_handplant_mount", 6, 6), // 159 + ("bs_handplant_dismount", 6, 6), // 160 + ("bs_handplant_to_flat", 6, 6), // 161 + ("fs_onefoot_right", 2, 4), // 162 + ("fs_backfoot", 2, 4), // 163 + ("bs_onefoot_front", 2, 4), // 164 + ("bs_onefoot_behind", 2, 4), // 165 + ("nosegrab_onefoot_front", 2, 4), // 166 + ("nosegrab_onefoot_behind", 2, 4), // 167 + ("tailgrab_onefoot_front", 2, 4), // 168 + ("tailgrab_onefoot_behind", 2, 4), // 169 + ("ollie_onefoot_front", 4, 1), // 170 + ("ollie_onefoot_back", 4, 1), // 171 + ("tailgrab_airwalk", 2, 4), // 172 + ("fsgrab_fingerflip", 4, 3), // 173 + ("bsgrab_fingerflip", 4, 3), // 174 + ("nosegrab_fingerflip", 4, 3), // 175 + ("tailgrab_fingerflip", 4, 3), // 176 + ("mutegrab_fingerflip", 4, 3), // 177 + ("stalegrab_fingerflip", 4, 3), // 178 + ("fsgrabvarial_fingerflip", 4, 3), // 179 + ("bsgrabvarial_fingerflip", 4, 3), // 180 + ("fs_rock_n_roll", 1, 5), // 181 + ("bs_rock_n_roll", 1, 5), // 182 + ("fakie_rock", 1, 5), // 183 + ("dropin", 1, 5), // 184 + ("dropin_fs_revert", 1, 5), // 185 + ("dropin_bs_revert", 1, 5), // 186 + ("crailgrab", 2, 4), // 187 + ("crailgrab_left", 2, 4), // 188 + ("crailgrab_right", 2, 4), // 189 + ("seatbeltgrab", 2, 4), // 190 + ("seatbeltgrab_left", 2, 4), // 191 + ("seatbeltgrab_right", 2, 4), // 192 + ("fs_50_50_fsgrab", 1, 5), // 193 + ("fs_50_50_bsgrab", 1, 5), // 194 + ("fs_nosegrind_tailgrab", 1, 5), // 195 + ("fs_nosegrind_bsgrab", 1, 5), // 196 + ("fs_crook_tailgrab", 1, 5), // 197 + ("fs_crook_bsgrab", 1, 5), // 198 + ("bs_crook_tailgrab", 1, 5), // 199 + ("bs_crook_bsgrab", 1, 5), // 200 + ("fs_noseslide_tailgrab", 1, 5), // 201 + ("bs_noseslide_tailgrab", 1, 5), // 202 + ("fs_noseblunt_tailgrab", 1, 5), // 203 + ("bs_noseblunt_tailgrab", 1, 5), // 204 + ("fs_5_o_fsgrab", 1, 5), // 205 + ("fs_5_o_nosegrab", 1, 5), // 206 + ("fs_salad_fsgrab", 1, 5), // 207 + ("fs_salad_nosegrab", 1, 5), // 208 + ("bs_salad_fsgrab", 1, 5), // 209 + ("bs_salad_nosegrab", 1, 5), // 210 + ("fs_tailslide_nosegrab", 1, 5), // 211 + ("bs_tailslide_nosegrab", 1, 5), // 212 + ("fs_blunt_tailgrab", 1, 5), // 213 + ("bs_blunt_tailgrab", 1, 5), // 214 + ("fsboneless", 4, 12), // 215 + ("bsboneless", 4, 12), // 216 + ("fsfastplant", 4, 12), // 217 + ("bsfastplant", 4, 12), // 218 + ("bsgrab_fs_footplant", 4, 13), // 219 + ("bsgrab_bs_footplant", 4, 13), // 220 + ("fsgrab_fs_footplant", 4, 13), // 221 + ("fsgrab_bs_footplant", 4, 13), // 222 + ("nosegrab_fs_footplant", 4, 13), // 223 + ("nosegrab_bs_footplant", 4, 13), // 224 + ("tailgrab_fs_footplant", 4, 13), // 225 + ("tailgrab_bs_footplant", 4, 13), // 226 + ("ollie_fs_footplant", 4, 13), // 227 + ("ollie_bs_footplant", 4, 13), // 228 + ("nocomply", 4, 7), // 229 + ("no_comply_fs180", 4, 7), // 230 + ("no_comply_bs180", 4, 7), // 231 + ("no_comply_fs360", 4, 7), // 232 + ("no_comply_bs360", 4, 7), // 233 + ("hippyjump", 4, 10), // 234 + ("hippyjump_fs180", 4, 10), // 235 + ("hippyjump_bs180", 4, 10), // 236 + ("air_metric", 5, 0), // 237 + ("fsgrabtostalegrab_fingerflip", 4, 3), // 238 + ("bsgrabtomutegrab_fingerflip", 4, 3), // 239 + ("stalegrabtofsgrab_fingerflip", 4, 3), // 240 + ("mutegrabtobsgrab_fingerflip", 4, 3), // 241 + ("bs_50_50_fsgrab", 1, 5), // 242 + ("bs_50_50_bsgrab", 1, 5), // 243 + ("bs_nosegrind_tailgrab", 1, 5), // 244 + ("bs_nosegrind_bsgrab", 1, 5), // 245 + ("bs_5_o_fsgrab", 1, 5), // 246 + ("bs_5_o_nosegrab", 1, 5), // 247 + ("fs_overcrook_tailgrab", 1, 5), // 248 + ("fs_overcrook_bsgrab", 1, 5), // 249 + ("bs_overcrook_tailgrab", 1, 5), // 250 + ("bs_overcrook_bsgrab", 1, 5), // 251 + ("superman", 2, 4), // 252 + ("generic_metric", 5, 0), // 253 + ("nosegrab_airwalk", 2, 4), // 254 + ("seatbelttonose_fingerflip", 4, 3), // 255 + ("crailtotail_fingerflip", 4, 3), // 256 + ("fs_board_double", 1, 5), // 257 + ("fs_board_tail", 1, 5), // 258 + ("fs_board_nose", 1, 5), // 259 + ("bs_board_double", 1, 5), // 260 + ("bs_board_tail", 1, 5), // 261 + ("bs_board_nose", 1, 5), // 262 + ("fs_lip_double", 1, 5), // 263 + ("fs_lip_tail", 1, 5), // 264 + ("fs_lip_nose", 1, 5), // 265 + ("bs_lip_double", 1, 5), // 266 + ("bs_lip_tail", 1, 5), // 267 + ("bs_lip_nose", 1, 5), // 268 + ("bs_darkslide", 1, 5), // 269 + ("fs_darkslide", 1, 5), // 270 + ("bs_darkslide_double", 1, 5), // 271 + ("bs_darkslide_tail", 1, 5), // 272 + ("bs_darkslide_nose", 1, 5), // 273 + ("fs_darkslide_double", 1, 5), // 274 + ("fs_darkslide_tail", 1, 5), // 275 + ("fs_darkslide_nose", 1, 5), // 276 + ("heelflip_underflip", 3, 2), // 277 + ("heelflip2_underflip", 3, 2), // 278 + ("heelflip3_underflip", 3, 2), // 279 + ("heelflip4_underflip", 3, 2), // 280 + ("kickflip_underflip", 3, 2), // 281 + ("kickflip2_underflip", 3, 2), // 282 + ("kickflip3_underflip", 3, 2), // 283 + ("kickflip4_underflip", 3, 2), // 284 + ("n_heelflip_underflip", 3, 2), // 285 + ("n_heelflip2_underflip", 3, 2), // 286 + ("n_heelflip3_underflip", 3, 2), // 287 + ("n_heelflip4_underflip", 3, 2), // 288 + ("n_kickflip_underflip", 3, 2), // 289 + ("n_kickflip2_underflip", 3, 2), // 290 + ("n_kickflip3_underflip", 3, 2), // 291 + ("n_kickflip4_underflip", 3, 2), // 292 + ("popshuvit_underflip", 3, 2), // 293 + ("fspopshuvit_underflip", 3, 2), // 294 + ("n_popshuvit_underflip", 3, 2), // 295 + ("n_fspopshuvit_underflip", 3, 2), // 296 + ("heelflip_darkcatch", 3, 2), // 297 + ("heelflip2_darkcatch", 3, 2), // 298 + ("heelflip3_darkcatch", 3, 2), // 299 + ("heelflip4_darkcatch", 3, 2), // 300 + ("kickflip_darkcatch", 3, 2), // 301 + ("kickflip2_darkcatch", 3, 2), // 302 + ("kickflip3_darkcatch", 3, 2), // 303 + ("kickflip4_darkcatch", 3, 2), // 304 + ("n_heelflip_darkcatch", 3, 2), // 305 + ("n_heelflip2_darkcatch", 3, 2), // 306 + ("n_heelflip3_darkcatch", 3, 2), // 307 + ("n_heelflip4_darkcatch", 3, 2), // 308 + ("n_kickflip_darkcatch", 3, 2), // 309 + ("n_kickflip2_darkcatch", 3, 2), // 310 + ("n_kickflip3_darkcatch", 3, 2), // 311 + ("n_kickflip4_darkcatch", 3, 2), // 312 + ("360flip_darkcatch", 3, 2), // 313 + ("360hardflip_darkcatch", 3, 2), // 314 + ("360inwardheelflip_darkcatch", 3, 2), // 315 + ("laserflip_darkcatch", 3, 2), // 316 + ("n_360flip_darkcatch", 3, 2), // 317 + ("n_360hardflip_darkcatch", 3, 2), // 318 + ("n_360inwardheelflip_darkcatch", 3, 2), // 319 + ("n_laserflip_darkcatch", 3, 2), // 320 + ("darktolightbsshuv", 3, 2), // 321 + ("darktolightfsshuv", 3, 2), // 322 + ("darktolightheelflip", 3, 2), // 323 + ("darktolightkickflip", 3, 2), // 324 + ("darkslideout_bsstraight", 3, 2), // 325 + ("darkslideout_bsleft", 3, 2), // 326 + ("darkslideout_bsright", 3, 2), // 327 + ("darkslideout_fsstraight", 3, 2), // 328 + ("darkslideout_fsleft", 3, 2), // 329 + ("darkslideout_fsright", 3, 2), // 330 + ("lateflip_darkcatch", 3, 2), // 331 +]; 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-core/src/scoring/session.rs b/crates/skate-core/src/scoring/session.rs new file mode 100644 index 00000000..d7e979a9 --- /dev/null +++ b/crates/skate-core/src/scoring/session.rs @@ -0,0 +1,109 @@ +//! Module publication and line settlement, TU3 82DA37B0/82DA3B38. +use super::{ + ScoreHolder, + timer::{ComboTimer, PointTimer}, +}; + +#[derive(Clone, Copy, Debug)] +pub struct Rules { + pub combo_capacity: f32, + pub combo_levels: [(f32, f32); 3], + pub combo_refresh_threshold: f32, + pub line_capacity: f32, + pub bail_factor: f32, +} + +#[derive(Clone, Debug, Default)] +pub struct Session { + pub holder: ScoreHolder, + pub combo: ComboTimer, + pub line: PointTimer, +} + +impl Session { + /// The collector owns the decision to publish. Capture the multiplier + /// before crediting timers: a threshold reached by this reward affects + /// the next publication, not this publication's score (f29 at 82DA37B0). + pub fn publish_sequence( + &mut self, + rules: &Rules, + landing_factor: f32, + penalized: bool, + multiplier_enabled: bool, + ) -> f32 { + self.holder.reward_sequence(landing_factor); + let snapshot = &self.holder.snapshot; + let mut raw = + (snapshot.fingerflip_pending + snapshot.general_pending) + snapshot.accumulated; + if penalized && rules.bail_factor < 1.0 { + raw *= rules.bail_factor; + } + let multiplier = if multiplier_enabled { + self.combo.multiplier + } else { + 1.0 + }; + if multiplier_enabled { + self.combo.credit( + raw, + rules.combo_capacity, + rules.combo_levels, + rules.combo_refresh_threshold, + ); + if self.combo.multiplier > f32::from_bits(0x3f8147ae) { + self.line.credit(raw, rules.line_capacity); + } + } + let reward = multiplier * raw; + self.holder + .publish(reward, multiplier_enabled && self.line.points > 0.0); + reward + } + + /// Reset/bail/output requests or the expiry edge reset line time and the + /// multiplier. A merely empty line timer does not reset the multiplier. + pub fn settle_line(&mut self, reset_requested: bool, collector_active: bool) { + if reset_requested || self.line.expired { + self.line = PointTimer::default(); + self.combo.multiplier = 1.0; + self.holder.finish_line(); + } else if self.line.points <= 0.0 { + self.holder.bank_line(!collector_active); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::scoring::Scorable; + #[test] + fn newly_crossed_threshold_applies_to_the_following_publication() { + let rules = Rules { + combo_capacity: 801.0, + combo_levels: [(50.0, 1.5), (450.0, 2.0), (800.0, 3.0)], + combo_refresh_threshold: 799.0, + line_capacity: 400.0, + bail_factor: 0.0, + }; + let mut session = Session::default(); + let trick = Scorable { + id: 96, + class: 3, + score_type: 2, + }; + session.holder.end_trick(trick, 50.0); + session.holder.finish_collector(); + assert_eq!(session.publish_sequence(&rules, 1.0, false, true), 50.0); + assert_eq!(session.combo.multiplier, 1.5); + session.holder.end_trick(trick, 10.0); + session.holder.finish_collector(); + assert_eq!(session.publish_sequence(&rules, 1.0, false, true), 15.0); + assert_eq!(session.holder.snapshot.line, 65.0); + session.line.points = 0.0; + session.settle_line(false, true); + assert_eq!(session.holder.repetition_count(trick), Some(2)); + session.settle_line(false, false); + assert_eq!(session.holder.repetition_count(trick), Some(0)); + } +} diff --git a/crates/skate-core/src/scoring/timer.rs b/crates/skate-core/src/scoring/timer.rs new file mode 100644 index 00000000..0b629ca0 --- /dev/null +++ b/crates/skate-core/src/scoring/timer.rs @@ -0,0 +1,93 @@ +//! Point-backed countdowns, 82DA4C28 and module82DA37B0. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct PointTimer { + pub points: f32, + pub expired: bool, +} +impl PointTimer { + /// Returns the native near-one hold result used by the ground collector. + pub fn advance(&mut self, dt: f32, drain: f32, scale: f32, hold_near_one: bool) -> bool { + self.expired = false; + if self.points > 0.0 { + let previous = self.points; + self.points = -((drain * dt) * scale - self.points); + if hold_near_one && self.points < 1.001 && previous > 1.000001 { + self.points = previous; + return true; + } + if self.points <= 0.0 { + self.points = 0.0; + self.expired = true; + } + } + false + } + pub fn credit(&mut self, points: f32, capacity: f32) { + self.points = (self.points + points).min(capacity); + } + pub fn seconds(&self, drain: f32) -> f32 { + self.points / drain + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ComboTimer { + pub timer: PointTimer, + pub multiplier: f32, +} +impl Default for ComboTimer { + fn default() -> Self { + Self { + timer: PointTimer::default(), + multiplier: 1.0, + } + } +} +impl ComboTimer { + /// The multiplier may increase, or decrease when a sufficiently large + /// new reward authorizes refreshing it. Timer decay alone does not lower it. + pub fn credit(&mut self, reward: f32, capacity: f32, levels: [(f32, f32); 3], refresh: f32) { + self.timer.credit(reward, capacity); + let mut next = 1.0; + for (threshold, multiplier) in levels { + if self.timer.points >= threshold { + next = multiplier; + } + } + if next > self.multiplier || reward > refresh { + self.multiplier = next; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn native_ground_hold_preserves_previous_points_and_expiry_is_an_edge() { + let mut timer = PointTimer { + points: 2.0, + expired: false, + }; + assert!(timer.advance(0.1, 50.0, 1.0, true)); + assert_eq!(timer.points, 2.0); + assert!(!timer.advance(0.1, 50.0, 1.0, false)); + assert!(timer.expired); + timer.advance(0.1, 50.0, 1.0, false); + assert!(!timer.expired); + } + #[test] + fn multiplier_uses_credited_points_and_survives_timer_decay() { + let levels = [(50.0, 1.5), (450.0, 2.0), (800.0, 3.0)]; + let mut combo = ComboTimer::default(); + combo.credit(50.0, 801.0, levels, 799.0); + assert_eq!(combo.multiplier, 1.5); + combo.credit(400.0, 801.0, levels, 799.0); + assert_eq!(combo.multiplier, 2.0); + combo.credit(350.0, 801.0, levels, 799.0); + assert_eq!(combo.multiplier, 3.0); + combo.timer.advance(8.0, 100.0, 1.0, false); + combo.credit(1.0, 801.0, levels, 799.0); + assert_eq!(combo.multiplier, 3.0); + } +} diff --git a/crates/skate-data/examples/apt_data.rs b/crates/skate-data/examples/apt_data.rs new file mode 100644 index 00000000..0ced2ef1 --- /dev/null +++ b/crates/skate-data/examples/apt_data.rs @@ -0,0 +1,125 @@ +//! Bytecode-only validation. No Bevy, game, input, rendering or simulation. +#[path = "../../skate-game/src/apt_display.rs"] +mod apt_display; +#[path = "../../skate-game/src/apt_vm.rs"] +mod apt_vm; +use apt_vm::*; +struct Audit; + +// Build the authored frame-zero hierarchy, so constructor property writes +// have actual targets. This is not a timeline playback or renderer test. +fn initial_tree( + vm: &mut Vm, + json: &serde_json::Value, + character: i32, + nesting: usize, +) -> Result { + if nesting > 32 { + return Err("APT hierarchy nesting limit".into()); + } + let record = json["characters"] + .as_array() + .ok_or("No characters")? + .iter() + .find(|c| c["id"].as_i64() == Some(character as i64)) + .ok_or("Unknown APT character")?; + let object = vm.object(ObjectKind::Native(format!("character:{character}"))); + vm.set(object, "_visible", Value::Bool(true))?; + vm.set(object, "_x", Value::Number(0.0))?; + vm.set(object, "_y", Value::Number(0.0))?; + let mut list = apt_display::DisplayList::default(); + if let Some(controls) = record["frames"][0]["controls"].as_array() { + for control in controls { + list.apply(&serde_json::from_value(control.clone()).map_err(|e| e.to_string())?)?; + } + } + for placement in list.depths.values() { + let child = initial_tree(vm, json, placement.character, nesting + 1)?; + vm.set(child, "_parent", Value::Object(object))?; + 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(object, &placement.name, Value::Object(child))?; + } + } + Ok(object) +} +impl Host for Audit { + fn call( + &mut self, + _vm: &mut Vm, + object: usize, + method: &str, + args: Vec, + ) -> Result { + println!("native {object}.{method}({args:?})"); + Ok(Value::Undefined) + } +} +fn main() -> Result<(), String> { + let path = std::env::args_os() + .nth(1) + .ok_or("Expected private 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 vm = Vm::new(); + let mut timeline_count = 0; + for character in json["characters"].as_array().ok_or("No characters")? { + let Some(frames) = character["frames"].as_array() else { + continue; + }; + let mut list = apt_display::DisplayList::default(); + for frame in frames { + for control in frame["controls"].as_array().ok_or("No controls")? { + list.apply(&serde_json::from_value(control.clone()).map_err(|e| e.to_string())?)?; + } + } + timeline_count += 1; + } + println!("Validated placement controls in {timeline_count} original timelines"); + let clip = vm.object(ObjectKind::Native("MovieClip".into())); + let proto = vm.object(ObjectKind::Plain); + vm.set(clip, "prototype", Value::Object(proto))?; + vm.set(vm.global, "MovieClip", Value::Object(clip))?; + for c in json["characters"].as_array().ok_or("No characters")? { + if let Some(frames) = c["frames"].as_array() { + for frame in frames { + for control in frame["controls"].as_array().ok_or("No controls")? { + if control["type_name"] == "do_init_action" { + let key = control["actions_offset"].as_u64().unwrap().to_string(); + let code: Vec = + serde_json::from_value(json["actions"][&key].clone()) + .map_err(|e| e.to_string())?; + vm.run(&code, &mut Audit)?; + } + } + } + } + } + let Value::Object(class) = vm.get(vm.global, "trickdisplay2") else { + return Err("APT class initialization failed".into()); + }; + println!("APT class initialized; {} objects", vm.objects.len()); + let screen = initial_tree(&mut vm, &json, 0, 0)?; + vm.begin_update(); + let instance = vm.construct(class, vec![Value::Object(screen)], &mut Audit)?; + for name in [ + "mcTrickAnim0", + "mcTrickAnim1", + "mcTrickAnim2", + "mcTrickAnim3", + ] { + let Value::Object(child) = vm.get(screen, name) else { + return Err(format!("Missing authored child {name}")); + }; + if vm.get(child, "_visible") != Value::Bool(false) { + return Err(format!("Constructor failed to hide {name}")); + } + } + println!( + "APT constructor completed; instance {instance}; {} objects", + vm.objects.len() + ); + Ok(()) +} 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..747046d7 --- /dev/null +++ b/crates/skate-data/examples/hud_data.rs @@ -0,0 +1,136 @@ +//! 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; + let mut saw_native_shadow_pair = false; + let mut glow_alphas = std::collections::BTreeSet::new(); + let mut flicker_alphas = std::collections::BTreeSet::new(); + let mut previous_timer_frame = None; + 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 = 8.0; + input.multiplier = match (tick / 180) % 3 { + 0 => 1.5, + 1 => 2.0, + _ => 3.0, + }; + } + input.line_time = (input.line_time - 1.0 / 60.0).max(0.0); + input.sequence_timer = input.line_time as i32; + runtime + .update(input.clone(), new_trick, false, tick % 180 == 120) + .map_err(|e| format!("Data frame {tick}: {e}"))?; + if tick == 10 { + if !runtime.vm.get(runtime.controller, "mLastClean").truth() + || runtime.vm.get(runtime.controller, "mLastSketchy").truth() + { + return Err("Clean landing selected the sketchy color timeline".into()); + } + } + let draws = apt_scene::draw(&runtime.bindings.movie, &runtime.vm, &shapes)?; + for (&id, instance) in &runtime.bindings.movie.instances { + let name = instance.placement.as_ref().map(|p| p.name.as_str()); + if name == Some("multiTimer_mc") && (20..40).contains(&tick) { + if let Some(previous) = previous_timer_frame { + if instance.frame != previous + 1 { + return Err( + "Line timer was re-seeked instead of playing consecutive frames".into(), + ); + } + } + previous_timer_frame = Some(instance.frame); + } + if input.multiplier == 3.0 { + let alpha = runtime.vm.get(id, "_alpha").number(); + if name == Some("mBlueGlow") { + if !(80.0..100.0).contains(&alpha) { + return Err("Blue glow outside authored flicker range".into()); + } + glow_alphas.insert(alpha.to_bits()); + } + if name == Some("mFlicker") { + if !(90.0..100.0).contains(&alpha) { + return Err("Multiplier flicker outside authored range".into()); + } + flicker_alphas.insert(alpha.to_bits()); + } + } + } + max_draws = max_draws.max(draws.len()); + saw_native_shadow_pair |= draws.windows(2).any(|pair| { + pair[0].texture.contains("futurashadow") + && pair[0].multiply[..3] == [0., 0., 0.] + && pair[1].texture.contains("futuraheavy") + && pair[1].multiply[0] > 0. + }); + + 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()); + } + } + if !saw_native_shadow_pair { + return Err("Missing native black shadow / heavy foreground pair".into()); + } + if glow_alphas.len() < 4 || flicker_alphas.len() < 4 { + return Err("Original x3 blue glow/flicker clips did not animate".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_data.rs b/crates/skate-data/examples/scoring_data.rs new file mode 100644 index 00000000..7d569ab0 --- /dev/null +++ b/crates/skate-data/examples/scoring_data.rs @@ -0,0 +1,24 @@ +//! Data-only audit: no Bevy, input polling, physics ticks or window. +fn main() -> Result<(), String> { + let root = std::env::args_os() + .nth(1) + .ok_or("Expected owned assets directory")?; + let collections = skate_data::collections::Collections::load(std::path::Path::new(&root))?; + let scoring = skate_data::scoring::ScoringData::load(&collections)?; + for id in [96, 128] { + let d = scoring + .by_id(id) + .ok_or(format!("Missing required scorable {id}"))?; + println!( + "{}: {} points; label {}; delay {}", + d.identifier, d.points, d.label, d.completion_delay + ); + } + println!( + "{} authored definitions; repetition {:?}; combo levels {:?}", + scoring.definitions.len(), + scoring.repetition, + scoring.combo_levels + ); + 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..2c577f1e --- /dev/null +++ b/crates/skate-data/examples/scoring_flow_data.rs @@ -0,0 +1,154 @@ +//! 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, + suspend_air: false, + landing: Default::default(), + teleported: false, + reverting: false, + } +} +fn publish_hud( + scoring: &scoring_runtime::Runtime, + hud: &mut hud_runtime::Runtime, +) -> Result<(), String> { + hud.update( + scoring.hud_input(), + scoring.new_trick, + scoring.modified_trick, + scoring.close_tricks, + ) +} +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 movie_path = std::env::args_os() + .nth(2) + .ok_or("Expected owned trickdisplay.json")?; + let json: serde_json::Value = + serde_json::from_slice(&std::fs::read(movie_path).map_err(|e| e.to_string())?) + .map_err(|e| e.to_string())?; + let mut hud = hud_runtime::Runtime::load(&json, scoring.hud_input())?; + 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)))?; + publish_hud(&scoring, &mut hud)?; + } + for tick in 61..70 { + scoring.advance(frame(tick, FilteredCategory::Ground, None))?; + publish_hud(&scoring, &mut hud)?; + if scoring.close_tricks { + return Err("Landing emitted the native bail/close event".into()); + } + } + if scoring.hud_input().sequence_score != 100 || scoring.hud_input().line_time <= 0. { + return Err("Banked landing lost its displayed score/line timer".into()); + } + if scoring.hud_input().line_time != scoring.session.line.points / scoring.data.line_drain { + return Err("HUD timer is not in native seconds".into()); + } + if !hud.vm.get(hud.bindings.movie.root, "_visible").truth() { + return Err("Original APT root disappeared on landing".into()); + } + 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))?; + publish_hud(&scoring, &mut hud)?; + } + 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)))?; + publish_hud(&scoring, &mut hud)?; + } + 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()); + } + // An independent supplied-data sequence checks natural line expiry. + let mut expired = scoring_runtime::Runtime::load(&data)?; + expired.advance(frame(0, FilteredCategory::Ground, None))?; + for tick in 1..61 { + expired.advance(frame(tick, FilteredCategory::Air, Some(kickflip)))?; + } + for tick in 61..300 { + expired.advance(frame(tick, FilteredCategory::Ground, None))?; + } + if expired.hud_input().sequence_score != 0 || expired.hud_input().line_time != 0. { + return Err("Expired line retained its HUD score".into()); + } + // Native Air452 suspends continuous air metrics without removing the carrier. + let mut suspended = scoring_runtime::Runtime::load(&data)?; + suspended.advance(frame(0, FilteredCategory::Ground, None))?; + for tick in 1..61 { + suspended.advance(frame(tick, FilteredCategory::Air, Some(kickflip)))?; + } + let held_score = suspended.hud_input().sequence_score; + let mut freeze = frame(61, FilteredCategory::Air, Some(kickflip)); + freeze.suspend_air = true; + freeze.position = [100., 100., 100.]; + freeze.body_flip = true; + suspended.advance(freeze)?; + if suspended.hud_input().sequence_score != held_score { + return Err("Air452 suspension accrued distance/height reward".into()); + } + println!( + "Scoring data audit: authored kickflip credited once; landing display persists; timer uses seconds; line expiry clears score; teleport cancels pending rewards and multiplier; Air452 freezes continuous metrics" + ); + Ok(()) +} diff --git a/crates/skate-data/src/lib.rs b/crates/skate-data/src/lib.rs index 9d1e8d04..d6674afe 100644 --- a/crates/skate-data/src/lib.rs +++ b/crates/skate-data/src/lib.rs @@ -7,6 +7,8 @@ pub mod animation_banks; 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 new file mode 100644 index 00000000..e4520b96 --- /dev/null +++ b/crates/skate-data/src/scoring.rs @@ -0,0 +1,171 @@ +//! VLT fields identified through Scorable82DA2340 and ScoreHolder82DA5D18. +//! No fallback point values: a missing/invalid authored field is an error. +use crate::collections::Collections; +use skate_core::{ + animation::{output::attributes::AttributeName, skeleton_input::name::encode}, + point_graph::PointGraph, + scoring::{Scorable, catalog::IDENTIFIERS}, +}; + +const SCORABLE: &str = "Hash_6918469984A8C596"; +const TUNING: &str = "Hash_349215E2E817703C"; + +#[derive(Clone, Debug)] +pub struct Definition { + pub metadata: Scorable, + pub identifier: &'static str, + pub encoded_name: AttributeName, + pub points: i32, + pub label: String, + pub trick_type: u32, + pub completion_delay: f32, + pub variant: i32, + pub flags: u32, +} + +#[derive(Clone, Debug)] +pub struct ScoringData { + pub collector: CollectorTuning, + pub definitions: Vec, + pub repetition: PointGraph<8>, + pub announcement: PointGraph<8>, + pub line_drain: f32, + pub line_capacity: f32, + pub combo_drain: f32, + pub combo_capacity: f32, + /// Ascending point thresholds and their authored multiplier. + pub combo_levels: [(f32, f32); 3], + pub combo_refresh_threshold: f32, + pub unannounced_factor: f32, + pub bail_factor: f32, + pub sketchy_side_speed: f32, +} + +impl ScoringData { + pub fn session_rules(&self) -> skate_core::scoring::session::Rules { + skate_core::scoring::session::Rules { + combo_capacity: self.combo_capacity, + combo_levels: self.combo_levels, + combo_refresh_threshold: self.combo_refresh_threshold, + line_capacity: self.line_capacity, + bail_factor: self.bail_factor, + } + } + pub fn load(data: &Collections) -> Result { + let mut definitions = Vec::new(); + for (id, &(identifier, class, score_type)) in IDENTIFIERS.iter().enumerate() { + // The executable enum includes unused entries without VLT records. + // Only actual records are eligible for recognition. + if !data.entries().iter().any(|r| { + r.class_name == SCORABLE + && (r.key == identifier + || r.key == crate::attrib_hash::numeric_name(identifier)) + }) { + continue; + } + let words = |name| data.words::<1>(SCORABLE, identifier, name).map(|v| v[0]); + let actual_id = words("Hash_B2383F16252DFE8E")?; + if actual_id != id as u32 { + return Err(format!( + "Scorable {identifier}: enum {id} disagrees with VLT {actual_id}" + )); + } + let label = data.field(SCORABLE, identifier, "Hash_843613E915014627")?; + if label.type_name != "EA::Reflection::Text" { + return Err(format!("Invalid scorable label {identifier}")); + } + definitions.push(Definition { + metadata: Scorable { + id, + class, + score_type, + }, + identifier, + encoded_name: encode(identifier.as_bytes()), + points: data.integer(SCORABLE, identifier, "Hash_937F62AE5C1ED284")? as i32, + label: label.data.clone(), + trick_type: words("TrickType")?, + completion_delay: data.float(SCORABLE, identifier, "Hash_DC7F402E5680BA02")?, + variant: words("Hash_2E90BC04042A0B5A")? as i32, + flags: words("Hash_D34A84B044B60CE3")?, + }); + } + if definitions.is_empty() { + return Err("No native scorable definitions in owned VLT data".into()); + } + let graph = |field| -> Result, String> { + let w = data.words::<20>(TUNING, "default", field)?; + let x = std::array::from_fn(|i| f32::from_bits(w[4 + i])); + let y = std::array::from_fn(|i| f32::from_bits(w[12 + i])); + if x.iter().chain(y.iter()).any(|v| !v.is_finite()) || x.windows(2).any(|v| v[0] > v[1]) + { + return Err(format!("Invalid native scoring curve {field}")); + } + Ok(PointGraph { x, y }) + }; + let f = |field| data.float(TUNING, "default", field); + Ok(Self { + collector: CollectorTuning::load(data)?, + definitions, + repetition: graph("Hash_59D91EAABF033A24")?, + announcement: graph("Hash_263B277F8CA17126")?, + line_drain: f("Hash_FE02A45231F7B06A")?, + line_capacity: f("Hash_57478337ACCFFF18")?, + combo_drain: f("Hash_88407506DC9780ED")?, + combo_capacity: f("Hash_28767A5F961C7129")?, + combo_levels: [ + (f("Hash_829887D8C24A5B8A")?, f("Hash_4752056364FF91FE")?), + (f("Hash_FB8408A15C17A9D4")?, f("Hash_F4993A13ED7B44C1")?), + (f("Hash_30AAE071A7868771")?, f("Hash_DC0C3853F6C5FC2E")?), + ], + 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> { + self.definitions.iter().find(|d| d.encoded_name == name) + } + pub fn by_id(&self, id: usize) -> Option<&Definition> { + 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 6afd85d4..ce89bd75 100644 --- a/crates/skate-game/src/app.rs +++ b/crates/skate-game/src/app.rs @@ -109,5 +109,6 @@ pub(crate) fn build( )); app.add_plugins((crate::session_marker::SessionMarkerPlugin, crate::customiser::CustomiserPlugin)); app.add_plugins(crate::multiplayer::MultiplayerPlugin); + app.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 new file mode 100644 index 00000000..8db5ee1e --- /dev/null +++ b/crates/skate-game/src/apt_display.rs @@ -0,0 +1,107 @@ +//! Retained APT depth list. Placement flags update individual properties; +//! omitted properties on a move retain their previous values. +use serde::Deserialize; +use std::collections::BTreeMap; + +#[derive(Clone, Debug, Deserialize)] +pub struct Control { + pub label: Option, + pub type_name: String, + #[serde(default)] + pub flags: u32, + #[serde(default)] + pub depth: i32, + pub character_id: Option, + pub matrix: Option<[f32; 6]>, + pub color_transform: Option<[u8; 8]>, + pub ratio: Option, + pub name: Option, + pub clip_depth: Option, + pub blend_mode: Option, + #[serde(default)] + pub filter_pointer: u32, + #[serde(default)] + pub actions_offset: u32, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Placement { + pub character: i32, + pub matrix: [f32; 6], + pub color: [u8; 8], + pub ratio: f32, + pub name: String, + pub clip_depth: i32, + pub blend_mode: i32, +} + +#[derive(Clone, Debug, Default)] +pub struct DisplayList { + pub depths: BTreeMap, +} +impl DisplayList { + pub fn apply(&mut self, control: &Control) -> Result<(), String> { + match control.type_name.as_str() { + "remove_object2" | "remove_object3" => { + self.depths.remove(&control.depth); + } + "place_object2" | "place_object3" => { + if control.filter_pointer != 0 { + return Err("APT placement filter is not implemented".into()); + } + if control.flags & 0x80 != 0 && control.actions_offset != 0 { + return Err("APT placement clip actions are not implemented".into()); + } + let mut placement = if control.flags & 1 != 0 { + self.depths.get(&control.depth).cloned().ok_or_else(|| { + format!("APT move references empty depth {}", control.depth) + })? + } else { + Placement { + character: control + .character_id + .filter(|_| control.flags & 2 != 0) + .ok_or("APT new placement lacks character")?, + matrix: [1.0, 0.0, 0.0, 1.0, 0.0, 0.0], + color: [255, 255, 255, 255, 0, 0, 0, 0], + ratio: 0.0, + name: String::new(), + clip_depth: -1, + blend_mode: -1, + } + }; + if control.flags & 2 != 0 { + placement.character = control + .character_id + .ok_or("APT character flag lacks value")?; + } + if control.flags & 4 != 0 { + placement.matrix = control.matrix.ok_or("APT matrix flag lacks value")?; + } + if control.flags & 8 != 0 { + placement.color = control + .color_transform + .ok_or("APT color flag lacks value")?; + } + if control.flags & 0x10 != 0 { + placement.ratio = control.ratio.ok_or("APT ratio flag lacks value")?; + } + if control.flags & 0x20 != 0 { + placement.name = control.name.clone().ok_or("APT name flag lacks value")?; + } + if control.flags & 0x40 != 0 { + placement.clip_depth = control + .clip_depth + .ok_or("APT clip-depth flag lacks value")?; + } + if control.type_name == "place_object3" { + placement.blend_mode = control.blend_mode.unwrap_or(-1); + } + self.depths.insert(control.depth, placement); + } + "do_action" | "do_init_action" | "frame_label" | "background_color" => {} + kind => return Err(format!("Unsupported APT control {kind}")), + } + Ok(()) + } +} 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..6c8e75a9 --- /dev/null +++ b/crates/skate-game/src/apt_scene.rs @@ -0,0 +1,215 @@ +//! 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)]; + // Native shadow text uses a black atlas pass followed by the sharp + // futuraheavy glyphs translated +1 in text X (825D6B68/82CA1FD8). + let passes = if let Some(foreground) = font.foreground { + vec![ + (font, true, 0.), + (&movie.text_assets.fonts[&foreground], false, 1.), + ] + } else { + vec![(font, false, 0.)] + }; + for (font, shadow, advance_x) in passes { + 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] + + advance_x + + 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| { + if shadow && i < 3 { + 0. + } else { + 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..47349d30 --- /dev/null +++ b/crates/skate-game/src/apt_text.rs @@ -0,0 +1,133 @@ +//! 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 { + /// 825D6B68 attaches futuraheavy to Futura Shadow; 82CA1FD8 draws both. + pub foreground: Option, + 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 { + foreground: None, + 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, + ); + } + for c in json["characters"].as_array().unwrap() { + if c["type_name"] == "font" && c["font"]["name"] == "Futura Shadow" { + let foreground = json["characters"].as_array().unwrap().iter().find(|f| + f["type_name"] == "font" && json["font_mappings"][f["font"]["name"].as_str().unwrap_or("")]["file_name"] == "futuraheavy") + .and_then(|f| f["id"].as_i64()).ok_or("Missing native Futura Shadow foreground font")? as i32; + out.fonts + .get_mut(&(c["id"].as_i64().unwrap() as i32)) + .unwrap() + .foreground = Some(foreground); + } + } + 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 new file mode 100644 index 00000000..da12fdef --- /dev/null +++ b/crates/skate-game/src/apt_vm.rs @@ -0,0 +1,651 @@ +//! APT's compact ActionScript instruction stream. All execution is bounded; +//! native callbacks are supplied by the HUD owner, never by extracted scripts. +use serde::Deserialize; +use std::collections::BTreeMap; + +#[derive(Clone, Debug, Default, PartialEq)] +pub enum Value { + #[default] + Undefined, + Number(f64), + Bool(bool), + Text(String), + Object(usize), +} +impl Value { + pub fn number(&self) -> f64 { + match self { + Self::Number(n) => *n, + Self::Bool(v) => { + if *v { + 1.0 + } else { + 0.0 + } + } + Self::Text(s) => s.parse().unwrap_or(f64::NAN), + _ => f64::NAN, + } + } + pub fn truth(&self) -> bool { + match self { + Self::Undefined => false, + Self::Number(n) => *n != 0.0 && !n.is_nan(), + Self::Bool(v) => *v, + Self::Text(s) => !s.is_empty(), + Self::Object(_) => true, + } + } + pub fn text(&self) -> String { + match self { + Self::Undefined => "undefined".into(), + Self::Number(n) => n.to_string(), + Self::Bool(v) => v.to_string(), + Self::Text(s) => s.clone(), + Self::Object(_) => "[object Object]".into(), + } + } +} + +#[derive(Clone, Debug, Deserialize)] +pub struct Constant { + pub kind: u32, + pub value: serde_json::Value, +} +impl Constant { + fn value(&self) -> Result { + Ok(match self.kind { + 1 => Value::Text( + self.value + .as_str() + .ok_or("Invalid APT string constant")? + .into(), + ), + 5 => Value::Bool(self.value.as_u64().ok_or("Invalid APT bool constant")? != 0), + 6 | 7 => Value::Number(self.value.as_f64().ok_or("Invalid APT number constant")?), + 3 => Value::Undefined, + _ => return Err(format!("Unresolved APT constant type {}", self.kind)), + }) + } +} +#[derive(Clone, Debug, Deserialize)] +pub struct Parameter { + pub register: usize, + pub name: String, +} +#[derive(Clone, Debug, Deserialize)] +pub struct Instruction { + pub offset: u32, + pub opcode: u8, + #[serde(default)] + pub next: u32, + #[serde(default)] + pub operand: serde_json::Value, + pub target: Option, + #[serde(default)] + pub values: Vec, + #[serde(default)] + pub body: Vec, + #[serde(default)] + pub flags: u32, + #[serde(default)] + pub parameters: Vec, + #[serde(default)] + pub name: String, +} + +#[derive(Clone, Debug)] +pub enum ObjectKind { + Plain, + Function(usize), + Native(String), +} +#[derive(Clone, Debug)] +pub struct Object { + pub kind: ObjectKind, + pub fields: BTreeMap, + pub prototype: Option, +} +#[derive(Clone, Debug)] +struct Function { + code: Instruction, + constants: Vec, +} + +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, + object: usize, + method: &str, + arguments: Vec, + ) -> Result; +} +#[derive(Default)] +pub struct Vm { + pub objects: Vec, + free_objects: Vec, + functions: Vec, + pub global: usize, + remaining: usize, + depth: usize, +} +impl Vm { + fn variable(&self, scope: &Scope, key: &str) -> Value { + if let Some(value) = scope.locals.get(key) { + return value.clone(); + } + let value = self.get(scope.this, key); + if value != Value::Undefined { + return value; + } + self.get(self.global, key) + } + pub fn new() -> Self { + let mut vm = Self::default(); + vm.global = vm.object(ObjectKind::Plain); + 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, + fields: BTreeMap::new(), + prototype: None, + }); + 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, + key: impl Into, + value: Value, + ) -> Result<(), String> { + self.objects + .get_mut(object) + .ok_or("Invalid APT object handle")? + .fields + .insert(key.into(), value); + Ok(()) + } + pub fn get(&self, object: usize, key: &str) -> Value { + let mut current = Some(object); + for _ in 0..64 { + let Some(o) = current.and_then(|i| self.objects.get(i)) else { + break; + }; + if let Some(v) = o.fields.get(key) { + return v.clone(); + } + current = o.prototype; + } + Value::Undefined + } + pub fn begin_update(&mut self) { + self.remaining = 100_000; + } + pub fn construct( + &mut self, + class: usize, + args: Vec, + host: &mut impl Host, + ) -> Result { + let id = self.object(ObjectKind::Plain); + if let Value::Object(proto) = self.get(class, "prototype") { + self.objects[id].prototype = Some(proto); + } + if let ObjectKind::Function(f) = self.objects[class].kind.clone() { + self.invoke(f, id, args, host)?; + } + Ok(id) + } + pub fn run(&mut self, code: &[Instruction], host: &mut impl Host) -> Result { + self.begin_update(); + self.run_on(self.global, code, host) + } + pub fn run_on( + &mut self, + object: usize, + code: &[Instruction], + host: &mut impl Host, + ) -> Result { + self.execute( + code, + &mut vec![Value::Undefined; 256], + &mut Vec::new(), + &mut Scope { + this: object, + local_definitions: false, + locals: BTreeMap::new(), + }, + host, + ) + } + pub fn call_method( + &mut self, + object: usize, + method: &str, + args: Vec, + host: &mut impl Host, + ) -> Result { + if let Value::Object(f) = self.get(object, method) { + if let Some(Object { + kind: ObjectKind::Function(index), + .. + }) = self.objects.get(f) + { + return self.invoke(*index, object, args, host); + } + } + host.call(self, object, method, args) + } + fn invoke( + &mut self, + index: usize, + this: usize, + args: Vec, + host: &mut impl Host, + ) -> Result { + if self.depth >= 32 { + return Err("APT call-depth limit".into()); + } + let f = self + .functions + .get(index) + .ok_or("Invalid APT function")? + .clone(); + let mut regs = vec![Value::Undefined; 256]; + let mut reg = 1; + let mut scope = Scope { + this, + local_definitions: true, + locals: BTreeMap::new(), + }; + if f.code.flags & 2 == 0 { + scope.locals.insert("this".into(), Value::Object(this)); + } + let root = match self.get(this, "_root") { + Value::Undefined => Value::Object(self.global), + value => value, + }; + let parent = self.get(this, "_parent"); + let arguments = if f.code.flags & 4 != 0 || f.code.flags & 8 == 0 { + let object = self.object(ObjectKind::Plain); + for (index, value) in args.iter().enumerate() { + self.set(object, index.to_string(), value.clone())?; + } + self.set(object, "length", Value::Number(args.len() as f64))?; + Value::Object(object) + } else { + Value::Undefined + }; + if f.code.flags & 8 == 0 { + scope.locals.insert("arguments".into(), arguments.clone()); + } + // DefineFunction2 preload flags (82E47688). Native integer registers + // are scoped to a call, unlike persistent display-object properties. + for (flag, value) in [ + (1, Value::Object(this)), + (4, arguments), + (16, Value::Undefined), + (64, root), + (128, parent), + (256, Value::Object(self.global)), + ] { + if f.code.flags & flag != 0 { + regs[reg] = value; + reg += 1; + } + } + for (i, p) in f.code.parameters.iter().enumerate() { + if p.register >= regs.len() { + return Err("APT parameter register outside bank".into()); + } + if p.register != 0 { + regs[p.register] = args.get(i).cloned().unwrap_or_default(); + } else { + scope + .locals + .insert(p.name.clone(), args.get(i).cloned().unwrap_or_default()); + } + } + self.depth += 1; + let result = self.execute( + &f.code.body, + &mut regs, + &mut f.constants.clone(), + &mut scope, + host, + ); + self.depth -= 1; + result + } + fn execute( + &mut self, + code: &[Instruction], + regs: &mut Vec, + constants: &mut Vec, + scope: &mut Scope, + host: &mut impl Host, + ) -> Result { + let mut stack = Vec::::new(); + let mut pc = 0; + fn pop(s: &mut Vec) -> Result { + s.pop().ok_or("APT stack underflow".into()) + } + while let Some(i) = code.get(pc) { + if self.remaining == 0 || self.objects.len() > 4096 { + return Err("APT execution/object budget exceeded".into()); + } + self.remaining -= 1; + pc += 1; + let op = i.opcode; + let operand = i.operand.as_u64().unwrap_or(0) as usize; + let constant = |k: usize| { + constants + .get(k) + .cloned() + .ok_or_else(|| format!("APT constant index {k} at {:x}", i.offset)) + }; + 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, + scope.this, + if op == 6 { "play" } else { "stop" }, + vec![], + )?; + } + 0x17 => { + pop(&mut stack)?; + } + 0x12 => { + let a = pop(&mut stack)?; + stack.push(Value::Bool(!a.truth())); + } + 0x4c => stack.push( + stack + .last() + .cloned() + .ok_or("APT duplicate on empty stack")?, + ), + 0x59 => stack.push(Value::Number(0.0)), + 0x5a => stack.push(Value::Number(1.0)), + 0x71 => stack.push(Value::Object(self.global)), + 0x73 => stack.push(Value::Bool(true)), + 0x74 => stack.push(Value::Bool(false)), + 0x75 | 0x76 => stack.push(Value::Undefined), + 0xb5 => stack.push(Value::Number((operand as u8 as i8) as f64)), + 0xb6 => stack.push(Value::Number((operand as u16 as i16) as f64)), + 0xb7 => stack.push(Value::Number((operand as u32 as i32) as f64)), + 0xb4 => stack.push(Value::Number(f32::from_bits(operand as u32) as f64)), + 0xb9 => stack.push( + regs.get(operand) + .cloned() + .ok_or("APT register outside bank")?, + ), + 0x87 => { + *regs.get_mut(operand).ok_or("APT register outside bank")? = stack + .last() + .cloned() + .ok_or("APT register store on empty stack")?; + } + 0x88 => { + *constants = i + .values + .iter() + .map(Constant::value) + .collect::>()?; + } + 0x96 => { + for c in &i.values { + stack.push(c.value()?); + } + } + 0xa2 | 0xa3 => stack.push(constant(operand)?), + 0xae => stack.push(self.variable(scope, &constant(operand)?.text())), + 0x4e | 0xaf => { + let name = if op == 0xaf { + constant(operand)? + } else { + pop(&mut stack)? + } + .text(); + let obj = pop(&mut stack)?; + stack.push(if let Value::Object(id) = obj { + self.get(id, &name) + } else { + Value::Undefined + }); + } + 0x4f => { + let v = pop(&mut stack)?; + let k = pop(&mut stack)?.text(); + let o = pop(&mut stack)?; + if let Value::Object(id) = o { + self.set(id, &k, v)?; + host.property_changed(self, id, &k)?; + } + } + 0x1c => { + let k = pop(&mut stack)?.text(); + stack.push(self.variable(scope, &k)); + } + 0x1d | 0x3c => { + let v = pop(&mut stack)?; + let k = pop(&mut stack)?.text(); + if (op == 0x3c && scope.local_definitions) || scope.locals.contains_key(&k) { + scope.locals.insert(k, v); + } else { + self.set(scope.this, &k, v)?; + host.property_changed(self, scope.this, &k)?; + } + } + 0x3a => { + let k = pop(&mut stack)?.text(); + let o = pop(&mut stack)?; + let existed = if let Value::Object(id) = o { + self.objects[id].fields.remove(&k).is_some() + } else { + false + }; + stack.push(Value::Bool(existed)); + } + 0x47 | 0x0b | 0x0c | 0x0d | 0x48 | 0x49 | 0x66 | 0x67 => { + let b = pop(&mut stack)?; + let a = pop(&mut stack)?; + stack.push(match op { + 0x47 if matches!(a, Value::Text(_)) || matches!(b, Value::Text(_)) => { + Value::Text(a.text() + &b.text()) + } + 0x47 => Value::Number(a.number() + b.number()), + 0x0b => Value::Number(a.number() - b.number()), + 0x0c => Value::Number(a.number() * b.number()), + 0x0d => Value::Number(a.number() / b.number()), + 0x48 => Value::Bool(a.number() < b.number()), + 0x67 => Value::Bool(a.number() > b.number()), + 0x66 => Value::Bool(a == b), + _ => Value::Bool(a == b || a.number() == b.number()), + }); + } + 0x50 | 0x51 => { + let a = pop(&mut stack)?.number(); + stack.push(Value::Number(a + if op == 0x50 { 1.0 } else { -1.0 })); + } + 0x99 | 0x9d | 0xb8 => { + let jump = op == 0x99 || { + let v = pop(&mut stack)?.truth(); + if op == 0xb8 { !v } else { v } + }; + if jump { + let target = i.target.ok_or("APT branch lacks target")?; + if target == code.last().map_or(0, |x| x.next) { + break; + } + pc = code + .iter() + .position(|x| x.offset == target) + .ok_or_else(|| format!("APT invalid jump target {target:x}"))?; + } + } + 0x8e | 0x9b => { + let index = self.functions.len(); + self.functions.push(Function { + code: i.clone(), + constants: constants.clone(), + }); + let object = self.object(ObjectKind::Function(index)); + let proto = self.object(ObjectKind::Plain); + self.set(object, "prototype", Value::Object(proto))?; + if i.name.is_empty() { + stack.push(Value::Object(object)); + } else { + self.set(self.global, &i.name, Value::Object(object))?; + } + } + 0x69 => { + let super_class = pop(&mut stack)?; + let sub_class = pop(&mut stack)?; + if let (Value::Object(a), Value::Object(b)) = (sub_class, super_class) { + if let (Value::Object(ap), Value::Object(bp)) = + (self.get(a, "prototype"), self.get(b, "prototype")) + { + self.objects[ap].prototype = Some(bp); + } + } + } + 0x40 => { + let name = pop(&mut stack)?.text(); + let n = pop(&mut stack)?.number() as usize; + if n > 256 { + return Err("APT argument limit".into()); + } + let args = (0..n) + .map(|_| pop(&mut stack)) + .collect::, _>>()?; + let id = self.object(ObjectKind::Plain); + if name == "Array" { + for (j, v) in args.into_iter().enumerate() { + self.set(id, j.to_string(), v)?; + } + self.set(id, "length", Value::Number(n as f64))?; + } else if let Value::Object(class) = self.get(self.global, &name) { + if let Value::Object(proto) = self.get(class, "prototype") { + self.objects[id].prototype = Some(proto); + } + if let ObjectKind::Function(f) = self.objects[class].kind.clone() { + self.invoke(f, id, args, host)?; + } + } else { + return Err(format!("APT constructor absent: {name}")); + } + stack.push(Value::Object(id)); + } + 0x52 | 0xb0 | 0xb1 | 0xb2 | 0xb3 | 0x3d | 0x5d => { + let method = if matches!(op, 0xb0 | 0xb1 | 0xb2 | 0xb3) { + constant(operand)? + } else { + pop(&mut stack)? + }; + let object = if matches!(op, 0x3d | 0xb0 | 0xb1) { + Value::Object(self.global) + } else { + pop(&mut stack)? + }; + let count = pop(&mut stack)?.number() as usize; + if count > 256 { + return Err("APT argument limit".into()); + } + let args = (0..count) + .map(|_| pop(&mut stack)) + .collect::, _>>()?; + let value = if let Value::Object(id) = object { + self.call_method(id, &method.text(), args, host)? + } else { + Value::Undefined + }; + if matches!(op, 0xb1 | 0xb3) { + return Ok(value); + } + if !matches!(op, 0xb0 | 0xb2 | 0x5d) { + stack.push(value); + } + } + 0x3e => return Ok(stack.pop().unwrap_or_default()), + _ => return Err(format!("Unsupported APT opcode {op:02x} at {:x}", i.offset)), + } + if stack.len() > 1024 { + return Err("APT stack limit".into()); + } + } + Ok(Value::Undefined) + } +} diff --git a/crates/skate-game/src/graphics_menu.rs b/crates/skate-game/src/graphics_menu.rs index c86578c7..7c94c446 100644 --- a/crates/skate-game/src/graphics_menu.rs +++ b/crates/skate-game/src/graphics_menu.rs @@ -112,11 +112,15 @@ struct StatusLabel; #[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)] pub(crate) struct MenuInput; +/// The presentation camera must exist before overlays select their UI target. +#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)] +pub(crate) struct PresentationSetup; + pub(crate) struct GraphicsMenuPlugin; impl Plugin for GraphicsMenuPlugin { fn build(&self, app: &mut App) { app.insert_resource(FramePacer(Instant::now())) - .add_systems(PostStartup, setup) + .add_systems(PostStartup, setup.in_set(PresentationSetup)) .add_systems(PreUpdate, interact.in_set(MenuInput).after(bevy::input::InputSystems)) .add_systems(Update, (apply, labels).chain()) .add_systems(Last, pace); diff --git a/crates/skate-game/src/hud_composite.wgsl b/crates/skate-game/src/hud_composite.wgsl new file mode 100644 index 00000000..b122dfb2 --- /dev/null +++ b/crates/skate-game/src/hud_composite.wgsl @@ -0,0 +1,7 @@ +#import bevy_ui::ui_vertex_output::UiVertexOutput +@group(1) @binding(0) var hud: texture_2d; +@group(1) @binding(1) var hud_sampler: sampler; +@fragment +fn fragment(in: UiVertexOutput) -> @location(0) vec4 { + return textureSample(hud, hud_sampler, in.uv); +} 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..50f4017f --- /dev/null +++ b/crates/skate-game/src/hud_runtime.rs @@ -0,0 +1,259 @@ +//! 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)), + // 825C2D90 exposes backend152/153; the shipped + // UpdateTrickScoring reads [2] as sketchy, [3] as clean. + Value::Bool(self.input.sketchy), + Value::Bool(self.input.clean), + 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_timer != self.bindings.input.sequence_timer + || 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()?; + } + // The movie handles this as event 11 (0196CC), not onEnterFrame. + // Re-seeking the 501-frame multiTimer_mc on every tick pins it to + // the same whole-second sample instead of letting gotoAndPlay run. + if previous.line_time.trunc() != self.bindings.input.line_time.trunc() + || previous.line_score != self.bindings.input.line_score + { + 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 5d1a95cf..bf6ddb17 100644 --- a/crates/skate-game/src/main.rs +++ b/crates/skate-game/src/main.rs @@ -1,5 +1,13 @@ mod animation; mod multiplayer; +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 1bbf415d..35cbb2da 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 c22a3d04..87c0cb32 100644 --- a/crates/skate-game/src/physics/frame.rs +++ b/crates/skate-game/src/physics/frame.rs @@ -240,6 +240,24 @@ 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, + suspend_air:skater.player_input.physical.air.use_air_reckoning_452!=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 f8573b50..5073251c 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, @@ -197,6 +198,7 @@ impl SkaterRuntime { let respawn = super::respawn::Runtime::load(&data, spawn, animation.checkpoint_stance())?; Ok(Self { respawn, + 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..1b80d30e --- /dev/null +++ b/crates/skate-game/src/scoring_hud.rs @@ -0,0 +1,479 @@ +//! 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, ScalingMode, visibility::RenderLayers}, + prelude::*, + render::render_resource::{ + AsBindGroup, BlendState, Extent3d, PrimitiveTopology, RenderPipelineDescriptor, ShaderType, + TextureDimension, TextureFormat, + }, + shader::ShaderRef, + sprite_render::{AlphaMode2d, Material2d, Material2dPlugin, MeshMaterial2d}, + ui_render::UiMaterialPlugin, + window::PrimaryWindow, +}; +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 + } +} +/// The offscreen target already contains RGB multiplied by coverage. Applying +/// ImageNode's straight-alpha blend again suppresses the original soft glow. +#[derive(Asset, TypePath, AsBindGroup, Debug, Clone)] +struct HudComposite { + #[texture(0)] + #[sampler(1)] + image: Handle, +} +impl UiMaterial for HudComposite { + fn fragment_shader() -> ShaderRef { + "embedded://skate3rust/hud_composite.wgsl".into() + } + fn specialize(descriptor: &mut RenderPipelineDescriptor, _: UiMaterialKey) { + if let Some(fragment) = &mut descriptor.fragment { + for target in fragment.targets.iter_mut().flatten() { + target.blend = Some(BlendState::PREMULTIPLIED_ALPHA_BLENDING); + } + } + } +} +struct Slot { + entity: Entity, + mesh: Handle, + material: Handle, +} +#[derive(Resource)] +struct Hud { + target: Handle, + composite: Handle, + rebind_after_resize: bool, + 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"); + embedded_asset!(app, "hud_composite.wgsl"); + app.add_plugins(( + Material2dPlugin::::default(), + UiMaterialPlugin::::default(), + )) + .add_systems( + PostStartup, + setup.after(crate::graphics_menu::PresentationSetup), + ) + .add_systems( + FixedUpdate, + advance + .after(crate::app::SimulationSet::Physics) + .run_if(crate::graphics_menu::gameplay_active), + ) + .add_systems( + Update, + (resize_target, reset, render) + .chain() + .after(crate::app::FrameSet::Physics), + ); + } +} +fn setup( + mut commands: Commands, + config: Res, + skater: Res, + cameras: Query>, + window: Single<&Window, With>, + mut images: ResMut>, + mut composites: ResMut>, +) { + // The startup dependency also applies the presentation system's deferred + // camera spawn before this query. Without it, setup silently lost the HUD. + let Ok(output) = cameras.single() else { + error!("Original HUD requires the presentation camera"); + return; + }; + 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 { + target: Handle::default(), + composite: Handle::default(), + rebind_after_resize: false, + runtime, + source, + shapes, + textures, + slots: Vec::new(), + generation: 0, + failed: false, + }) + })(); + match result { + Ok(mut hud) => { + let target = images.add(Image::new_target_texture( + window.physical_width().max(1), + window.physical_height().max(1), + TextureFormat::Rgba8UnormSrgb, + None, + )); + hud.target = target.clone(); + commands.spawn(( + Camera2d, + Projection::Orthographic(OrthographicProjection { + scaling_mode: ScalingMode::Fixed { + width: 1280., + height: 720., + }, + ..OrthographicProjection::default_2d() + }), + Camera { + order: -1, + clear_color: ClearColorConfig::Custom(Color::NONE), + ..default() + }, + RenderTarget::Image(target.clone().into()), + RenderLayers::layer(31), + Msaa::Off, + )); + hud.composite = composites.add(HudComposite { image: target }); + commands.spawn(( + MaterialNode(hud.composite.clone()), + 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}"), + } +} +// Rasterize at output pixel resolution; retain the original 1280x720 APT +// coordinate space. This avoids a second enlargement of every glyph/glow. +fn resize_target( + hud: Option>, + window: Single<&Window, With>, + mut images: ResMut>, + mut composites: ResMut>, +) { + let Some(mut hud) = hud else { + return; + }; + // Bevy 0.18's UI material preparation has no dependency on GpuImage + // preparation. Retry once on the following frame, when the replacement + // image is available regardless of preparation order during the resize. + if hud.rebind_after_resize { + if let Some(material) = composites.get_mut(&hud.composite) { + material.image = hud.target.clone(); + } + } + let size = Extent3d { + width: window.physical_width().max(1), + height: window.physical_height().max(1), + depth_or_array_layers: 1, + }; + hud.rebind_after_resize = resize_image( + &mut images, + &mut composites, + &hud.target, + &hud.composite, + size, + ); +} +fn resize_image( + images: &mut Assets, + composites: &mut Assets, + target: &Handle, + composite: &Handle, + size: Extent3d, +) -> bool { + if images + .get(target) + .is_some_and(|image| image.texture_descriptor.size != size) + { + // Assets::get_mut emits Modified even without a write. Doing that + // every frame recreates the GPU target behind the compositor's + // cached bind group. Only invalidate the image on a real resize. + if let Some(image) = images.get_mut(target) { + image.resize(size); + } + // The UI material retains its bind group. A real target resize must + // reprepare it so it samples the replacement GPU texture view. + if let Some(material) = composites.get_mut(composite) { + material.image = target.clone(); + } + return true; + } + false +} +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hud_target_changes_only_on_resize_and_refreshes_composite() { + // Asset scheduling only: no render plugin, window or gameplay systems. + let mut app = App::new(); + app.add_plugins((MinimalPlugins, bevy::asset::AssetPlugin::default())) + .init_asset::() + .init_asset::(); + let target = + app.world_mut() + .resource_mut::>() + .add(Image::new_target_texture( + 1280, + 720, + TextureFormat::Rgba8UnormSrgb, + None, + )); + let composite = app + .world_mut() + .resource_mut::>() + .add(HudComposite { + image: target.clone(), + }); + app.update(); + app.world_mut() + .resource_mut::>>() + .clear(); + app.world_mut() + .resource_mut::>>() + .clear(); + for (width, height, changed) in + [(1280, 720, false), (1920, 1080, true), (1920, 1080, false)] + { + app.world_mut() + .resource_scope(|world, mut images: Mut>| { + let mut composites = world.resource_mut::>(); + resize_image( + &mut images, + &mut composites, + &target, + &composite, + Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + ); + }); + app.update(); + let images: Vec<_> = app + .world_mut() + .resource_mut::>>() + .drain() + .collect(); + let materials: Vec<_> = app + .world_mut() + .resource_mut::>>() + .drain() + .collect(); + assert_eq!( + images + .iter() + .any(|e| matches!(e, AssetEvent::Modified { id } if *id == target.id())), + changed + ); + assert_eq!( + materials + .iter() + .any(|e| matches!(e, AssetEvent::Modified { id } if *id == composite.id())), + changed + ); + } + } +} +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..b28324c6 --- /dev/null +++ b/crates/skate-game/src/scoring_runtime.rs @@ -0,0 +1,616 @@ +//! 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 suspend_air: 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], + metric_started: [bool; 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], + metric_started: [false; 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 / self.data.line_drain) as i32, + line_time: self.session.line.points / self.data.line_drain, + 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.metric_started[slot] = false; + 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; + } + } + let distance_metric = self.collector == Collector::Grind + || self.collector == Collector::Ground && slot < 3; + let was_active = self.metric_started[slot]; + self.metric_started[slot] = true; + if c.announced && !(self.collector == Collector::Air && f.suspend_air) { + // 82DB0588 starts a distance collector at time/distance zero. + // Every active frame still updates its reference position. + if !distance_metric || was_active { + 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 was_active && 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]; + if !(self.collector == Collector::Air && f.suspend_air) { + 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 && !f.suspend_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; + // 82775328 -> 82774E88 closes only for ScoreModule reset/bail + // output 14630 (82DA4010/82DA4238), not a banked landing. + self.close_tricks = bailout; + } 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; + } + if self.session.line.expired || f.teleported || bailout { + self.sequence_score = 0.; + } + 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 new file mode 100644 index 00000000..c80c8fc1 --- /dev/null +++ b/docs/native-scoring-hud.md @@ -0,0 +1,235 @@ +# Native scoring and HUD port + +Work branch: `lol/native-scoring-hud`. This document distinguishes implemented +components from remaining integration; it is not a claim of gameplay parity. + +## Evidence + +The source executable is the locally owned TU3 image loaded at `0x82000000`, +size `0x011B0000`, SHA-256 +`f4aa113eb541bfba03dbc108cf5ab43f58c965b20fa3b82f9c40938a0ad841c4`. +Static analysis uses a disposable IDA database and the existing generated PPC +translation. Original binaries, analysis databases and extracted assets are +not part of this repository. IDA incorrectly decodes some VMX instructions; +PPC instructions and the generated translation must corroborate those paths. + +Implemented source components: + +* `skate-core::scoring`: ScoreHolder accounting at `82DA6198`, `82DA6260`, + `82DA6408`, `82DA6468`, `82DA6538`; point timers at `82DA4C28`; carrier + announcement/completion at `82DA45C8`, `82DA46D0`, `82DA5DE0`, `82DA5F98`. + Session publication at `82DA37B0` captures the multiplier before crediting + this reward to the combo timer. Settlement at `82DA3B38` preserves repetition + on an empty line timer while a collector is active. +* `skate-data::scoring`: resolves authored VLT points, labels, delay, repetition, + announcement curves, combo thresholds and timer settings. Missing data is + an error. The executable's 332-entry identifier metadata includes unused + entries; the owned installed database resolves 300 records. +* The scoring display/timer defaults belong to class `349215E2E817703C`. + They are global tuning, not user difficulty settings. Physical collector + tuning is a separate class `546C36B656038E04`. +* `tools/prepare_hud.py`: decodes the original `hud2/trickdisplay2` APT, + geometry, font metadata, textures and compact action stream into a private + cache. Source asset hashes are retained in the compiled manifest. +* `apt_vm`: partial bounded interpreter for the original compact instructions. + The data-only audit initializes the original class, builds the authored + initial child hierarchy and checks the constructor's four trick-name + visibility writes. Native method calls still use a dummy host, so this does + not validate a functioning display. +* `apt_display`: retained depth-list placement flags and transforms. The + data-only audit validates all controls across 38 original movie timelines. + Unsupported clip actions and filters fail explicitly. + +## Extractor provenance + +`tools/vendor/skate3_ui` reuses the Python extractor from the local Custom +Engine Layer preview.18 source distribution. Its accompanying project MIT +license is preserved. `actions.py` is the new compact instruction decoder, +based on TU3 `_parseStream` at `82E67868` and dispatch table `82FC9BF0`. +Extracted game assets are not covered by that source license and must stay in +the ignored private output directory. + +Example preparation (data extraction only): + +```powershell +python tools/prepare_hud.py --game --output assets/private/hud --collections /private/stock/skater-collections.json +``` + +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. + +## Production integration and validation + +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 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 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 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. + +Validation on 2026-09-08: + +- 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 33 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 +`59c7c876d289c58e9661f669274fa4faa4e50a7bf51085461bcf33f2fc1bd809`. +The launcher now selects `skate3rust-hud-animation.exe`. + +The missing-HUD startup defect is fixed: HUD setup now depends on presentation +setup, so Bevy applies the deferred camera spawn before the HUD queries it. +Previously the two PostStartup systems were unordered and a missing camera +caused HUD initialization to return permanently. The rebuilt launcher also +saves runtime output to `logs/scoring/build/scoring-test.log`. This fix is +release-compiled; interactive rendering has not been launched for verification. + +## Landing lifetime and native font passes + +The landing hide bug came from sending `CloseTrickDisplay` at every score +publication. Native `82775328` calls `82774E88` only on scoring output byte +14630; `82DA4238` copies that from module byte128, selected by `82DA4010` +for its reset/bail category. `82774E88` sets backend byte184, which +`825E4F40` turns into event3, resolved by `825D3B38` to +`_global.TrickDisplay.CloseTrickDisplay`. Normal banking no longer emits it. +Line expiry clears the score and lets the authored Clear/outro actions run. + +The timer unit is now traced end-to-end: `82DA4238` writes line points divided +by drain rate to output+40; `82775328` copies it to backend+32; +`825C2910` and `825C2D90` truncate that value to seconds for ActionScript. +The previous raw-point binding was wrong. Changes in that seconds field now +also refresh `UpdateTrickScoring`. + +Observed font code: `825D6B68` compares the APT name with `Futura Shadow` +(string at `8220BD44`) and attaches `futuraheavy` (`8220BD54`) as a secondary +font, setting X adjustment 1 and the other adjustment 0. `82CA1FD8` first +sets the primary RGB multiplier to black while retaining alpha, draws it, +then restores the color and draws the foreground with the X adjustment. +The scene renderer now follows those two passes. FuturaOuterGlow remains +its separately authored tinted atlas pass. No outline radius, new glow +color or replacement glyphs were invented. + +The offscreen adapter also needed premultiplied-alpha compositing. The +mesh target already stores covered RGB; Bevy's ordinary ImageNode uses +straight-alpha blending, which multiplied coverage again and weakened +soft atlas edges. The dedicated HUD composite now uses premultiplied +blending to preserve the original layers' coverage. + +Validation: supplied scoring data exercises landing, retained HUD visibility, +seconds conversion, natural line expiry and teleport cancellation. The +1,800-frame original movie audit checks a black shadow/foreground pair and +finite geometry (33 maximum batches). Release compilation passes. User +screenshots guided the investigation but did not supply rendering constants; +no game, reference executable or GPU capture was launched for validation. + +## Output resolution and authored blue glow + +The HUD target now uses the window's physical pixel dimensions and resizes with +the window. Its orthographic projection retains the authored 1280x720 coordinate +space. Previously the adapter rasterized at 720p and enlarged the finished image, +adding blur on larger windows. This change improves rasterization without +inventing higher-resolution source artwork. + +A data-only inventory of `fedata.big`, `fedynamic.big`, `fetexture.big` and +`miscboot.big` found byte-identical duplicate trickdisplay APT/CONST/GEO/RX2 +assets and 512x512 Futura font atlases. No higher-resolution variant was found +in those banks. The inventory and hashes are retained in ignored +`logs/scoring/hud-resolution-inventory.json`. + +`GetGeneralInfo` had clean and sketchy reversed. The original +`UpdateTrickScoring` action stream reads slot2 as sketchy (0199D2) and slot3 +as clean (019A30), matching backend bytes152/153 returned by `825C2D90`. +Correcting those slots selects the authored clean color animation. The native +FontPS/Font8PS programs contain texture/color modulation; the glow is supplied +by the authored layers, not a replacement procedural effect. + +Additional collector fixes follow `82DA8A70`/`82DA93D8`: published Air452 +suspends continuous air metrics and grab accrual while preserving the carrier. +Distance collectors also start their first active frame at zero elapsed +time/distance, following `82DB0588`. + +Validation: the 1,800-frame supplied-data HUD audit asserts that clean input +selects mLastClean rather than mLastSketchy; the scoring-flow audit verifies +Air452 suspension in addition to landing lifetime, expiry and cancellation. +Both pass, and the static-CRT release build succeeds. The game was not launched; +pixel appearance and GPU resize behavior still require interactive verification. + +The first window-resolution build introduced a render-target invalidation +regression: Bevy 0.18.1 `Assets::get_mut` queues a Modified event even for a +read-only inspection. The size check therefore recreated the GPU image each +frame while the UI material retained its earlier texture binding. The check +now uses immutable access; a real resize refreshes the compositor material, +including a following-frame refresh to account for independent image/material +preparation order. An asset-only regression test checks that an unchanged +target emits no Modified event and a resize invalidates both image and material. +The test uses no window, renderer or gameplay systems. + +## Multiplier timeline playback + +The host previously called `UpdateLineDisplay` every fixed tick. The shipped +action at 019C39 computes `floor(GetLineTimeRemaining() * 60) - 30`, clamps +it, and seeks `multiTimer_mc` with `gotoAndPlay` at 019CF4. Since the native +binding returns whole seconds, repeated calls pin the 501-frame timer to the +same frame for a second, then jump it forward. The ActionScript dispatches +this method for event11 (0196CC) and ScreenShow, rather than onEnterFrame. +The adapter now dispatches when the exposed timer sample or line score changes, +allowing the authored timeline to advance between samples. This is an adapter +event policy; the entire native HUD event producer is not yet ported. + +Original x3 effect clips are retained: character59 changes mBlueGlow alpha +with `80 + Math.random() * 20` every four frames; character70 changes mFlicker +with `90 + Math.random() * 10`. Original multiplier labels select the glow +and background timelines (characters66 and77); no replacement particles, +colors or universal x3 effect were added to lower multiplier levels. + +The supplied-data movie audit now visits x1.5, x2 and x3 over 1,800 frames, +asserts consecutive timer frames between events, checks both flicker ranges +and changing alpha values, and reaches 40 authored draw batches. The scoring +flow audit still passes landing persistence, line expiry and cancellation. +No game or GPU capture was launched. + +## Remaining 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 new file mode 100644 index 00000000..f81b6a08 --- /dev/null +++ b/tools/prepare_hud.py @@ -0,0 +1,109 @@ +"""Incrementally prepare original HUD assets from the user's extracted game. + +No executable/game launch. Output contains copyrighted assets and stays private. +The vendored preview.18 UI extractor supplies the original decoding pipeline. +""" +from pathlib import Path +import argparse +import hashlib +import json +import sys +import struct + +from vendor.skate3_ui.project import extract_project +from vendor.skate3_ui.scene_graph import AssetCache, SceneFlattener +from vendor.skate3_ui.actions import Actions +from asset_pipeline.vlt import hash64 + + +def font_mapping(collections: Path, cache: AssetCache) -> dict: + """FontManager82808AE8 table; APT-name lookup82809208, load82809308. + + Resolve the authored FileName, never infer family aliases from metrics. + """ + data = json.loads(collections.read_text(encoding='utf-8')) + result = {} + for row in data['collections']: + if row['class'] != 'Hash_FECFBCAF356518C4': + continue + def field(name): + return row['fields'].get(name, row['fields'].get(f'Hash_{hash64(name):016X}')) + apt_name = row['fields']['Hash_340832CCFD9FDEB4']['data'] + filename = field('FileName')['data'] + matches = [r for r in cache.manifest['bundles'] + if 'font' in r and Path(r['name']).name == filename] + if not matches: + continue # The debug font is not a packaged front-end bitmap bank. + if len(matches) != 1 or apt_name in result: + raise ValueError(f'Ambiguous native font record: {apt_name}') + scale = {name: struct.unpack('>f',bytes.fromhex(field(name)['data']))[0] + for name in ('ScaleX','ScaleY','OffsetX','OffsetY')} + target = matches[0]['font']['family'] + provenance = {'class': row['class'], 'key': row['key'], + 'source_sha256': row['sha256'], 'file_name':filename, + 'native_layout':scale} + cache.add_font_alias(apt_name,target,provenance) + result[apt_name] = provenance + return result + + +def prepare(game: Path, output: Path, collections: Path): + extract_project(game, output, prefixes=("data/fe/source/screens/hud2/", + "data/fe/source/controls/"), update=True) + cache = AssetCache(output) + mappings = font_mapping(collections,cache) + name = "data/fe/source/screens/hud2/trickdisplay2" + bundle = cache.load_bundle(name) + apt_path = output / "raw" / (name + ".apt") + const_path = apt_path.with_suffix('.const') + actions = Actions(apt_path.read_bytes(), const_path.read_bytes()) + blocks = {} + for c in bundle['characters'].values(): + for f in c.get('frames', []): + for control in f['controls']: + if control['type_name'] in ('do_action', 'do_init_action'): + offset = control.get('actions_offset', 0) + if offset: + blocks[str(offset)] = actions.stream(offset) + shapes = {} + fonts = {} + for c in bundle['characters'].values(): + if c['type_name'] == 'shape': + scene = SceneFlattener(cache, lambda *_: {}).flatten(name, c['id']) + if scene['unresolved']: + raise ValueError(f"Unresolved original HUD shape: {scene['unresolved']}") + shapes[str(c['id'])] = scene['primitives'] + elif c['type_name'] == 'font': + family = c['font']['name'] + asset = cache.font_asset(family) + fonts[family] = asset + result = { + 'format': 'skate3-scoring-hud', 'version': 1, + 'source': {'bundle': name, + 'apt_sha256': hashlib.sha256(apt_path.read_bytes()).hexdigest(), + 'const_sha256': hashlib.sha256(const_path.read_bytes()).hexdigest(), + 'collections_sha256': hashlib.sha256(collections.read_bytes()).hexdigest()}, + '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' + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(json.dumps(result, separators=(',', ':')) + '\n', encoding='utf-8') + print(f"Prepared {len(shapes)} original HUD shapes and {len(blocks)} action blocks: {target}") + if result['unresolved_fonts']: + print('Unresolved authored font families: ' + ', '.join(result['unresolved_fonts'])) + return result + + +if __name__ == '__main__': + p = argparse.ArgumentParser(description=__doc__) + p.add_argument('--game', type=Path, required=True) + p.add_argument('--output', type=Path, required=True) + p.add_argument('--collections', type=Path, required=True, + help='Owned private/stock/skater-collections.json') + args = p.parse_args() + prepare(args.game.resolve(), args.output.resolve(), args.collections.resolve()) diff --git a/tools/vendor/skate3_ui/LICENSE b/tools/vendor/skate3_ui/LICENSE new file mode 100644 index 00000000..d0c0192d --- /dev/null +++ b/tools/vendor/skate3_ui/LICENSE @@ -0,0 +1,26 @@ +MIT License + +Copyright (c) 2026 Skate 3 Custom Engine Layer contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of the original Custom Engine Layer software and associated documentation +files (the "Software"), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to permit +persons to whom the Software is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +This license applies only to original project-owned additions. It does not +relicense Skate3Recomp, rexglue, third-party dependencies, Electronic Arts +game data, or assets embedded by map authors. diff --git a/tools/vendor/skate3_ui/__init__.py b/tools/vendor/skate3_ui/__init__.py new file mode 100644 index 00000000..9a20d0f3 --- /dev/null +++ b/tools/vendor/skate3_ui/__init__.py @@ -0,0 +1,3 @@ +"""Read-only Skate 3 front-end asset inspection helpers.""" + +__version__ = "0.1.0" diff --git a/tools/vendor/skate3_ui/actions.py b/tools/vendor/skate3_ui/actions.py new file mode 100644 index 00000000..31302927 --- /dev/null +++ b/tools/vendor/skate3_ui/actions.py @@ -0,0 +1,96 @@ +"""Read TU3 APT bytecode without running it. + +Operand alignment/relocation: native 82E67868, switch table 82E67914. +Function body boundaries: 82E73010 (DefineFunction2), 82E72EC0. +CONSTANTPOOL/PUSHDATA use CONST indices, not SWF's inline value encoding. +""" +from .binary import Reader, FormatError, align + +BYTE = {0xA2, 0xAE, 0xAF, 0xB0, 0xB1, 0xB2, 0xB3, 0xB5, 0xB9} +SHORT = {0xA3, 0xB6} +WORD = {0x81, 0x87, 0x99, 0x9D, 0x9F, 0xB8} +STRING = {0x8B, 0x8C, 0xA1, 0xA4, 0xA5, 0xA6, 0xA7} + + +class Actions: + def __init__(self, apt: bytes, const: bytes): + self.apt = Reader(apt, 'APT actions') + c = Reader(const, 'APT constants') + count, start = c.u32be(24), c.u32be(28) + c.require(start, count * 8) + self.constants = [] + for i in range(count): + at = start + i * 8 + kind, value = c.u32be(at), c.u32be(at + 4) + if kind == 1: + c.require(value, 1) + value = c.cstring(value) + elif kind == 6: + value = c.f32be(at + 4) + elif kind == 7: + value = c.i32be(at + 4) + self.constants.append({'kind': kind, 'value': value}) + + def stream(self, offset: int, end: int | None = None, nesting: int = 0): + if nesting > 64: + raise FormatError('APT action nesting exceeds 64') + r = self.apt + end = len(r.data) if end is None else end + r.require(offset, end - offset) + result = [] + while offset < end: + at, op = offset, r.u8(offset) + offset += 1 + row = {'offset': at, 'opcode': op} + if op == 0: + result.append(row) + break + if op in BYTE: + row['operand'] = r.u8(offset); offset += 1 + elif op in SHORT: + row['operand'] = r.u16be(offset); offset += 2 + elif op in {0x77, 0xB4, 0xB7}: + row['operand'] = r.i32be(offset); offset += 4 + elif op in WORD | STRING | {0x94}: + offset = align(offset, 4) + value = r.u32be(offset) + row['operand'] = r.cstring(value) if op in STRING else value + if op in {0x99, 0x9D, 0x94, 0xB8}: + row['target'] = offset + 4 + r.i32be(offset) + offset += 4 + elif op in {0x88, 0x96}: + offset = align(offset, 4) + count, pointer = r.u32be(offset), r.u32be(offset + 4) + r.require(pointer, count * 4) + indices = [r.u32be(pointer + i * 4) for i in range(count)] + if any(i >= len(self.constants) for i in indices): + raise FormatError(f'APT constant index outside CONST at {at:#x}') + row['values'] = [self.constants[i] for i in indices] + offset += 8 + elif op in {0x8E, 0x9B}: + offset = align(offset, 4) + header_size = 28 if op == 0x8E else 24 + r.require(offset, header_size) + row['name'] = r.cstring(r.u32be(offset)) + count = r.u32be(offset + 4) + pointer = r.u32be(offset + (12 if op == 0x8E else 8)) + length = r.u32be(offset + (16 if op == 0x8E else 12)) + stride = 8 if op == 0x8E else 4 + r.require(pointer, count * stride) + row['parameters'] = [ + {'register': r.u32be(pointer + i * 8) if op == 0x8E else 0, + 'name': r.cstring(r.u32be(pointer + i * stride + (4 if op == 0x8E else 0)))} + for i in range(count)] + row['flags'] = r.u32be(offset + 8) if op == 0x8E else 0 + offset += header_size + row['body'] = self.stream(offset, offset + length, nesting + 1) + offset += length + elif op in {0x83, 0x8F}: + # Keep unsupported compound instructions explicit, never scan + # their payload as though it were a stream of no-operand ops. + raise FormatError(f'APT compound opcode {op:#x} at {at:#x} needs decoding') + row['next'] = offset + if offset > end: + raise FormatError(f'APT instruction at {at:#x} crosses its function body') + result.append(row) + return result diff --git a/tools/vendor/skate3_ui/apt.py b/tools/vendor/skate3_ui/apt.py new file mode 100644 index 00000000..e82a5320 --- /dev/null +++ b/tools/vendor/skate3_ui/apt.py @@ -0,0 +1,330 @@ +from __future__ import annotations + +from pathlib import Path + +from .binary import FormatError, Reader + +CHARACTER_TYPES = { + 1: "shape", + 2: "text", + 3: "font", + 4: "button", + 5: "sprite", + 6: "sound", + 7: "bitmap", + 8: "morph", + 9: "animation", + 10: "static_text", + 11: "none", + 12: "video", + 13: "command", + 14: "command_list", + 15: "level", + 16: "custom_control", + 17: "image", + 18: "plugin", + 19: "bitmap_slice", + 20: "avatar", +} + +CONTROL_TYPES = { + 1: "do_action", + 2: "frame_label", + 3: "place_object2", + 4: "remove_object2", + 5: "background_color", + 6: "start_sound", + 7: "start_sound_stream", + 8: "do_init_action", + 9: "place_object3", + 10: "place_object4", + 11: "remove_object3", +} + + +class AptInspector: + def __init__(self, apt_path: Path, const_path: Path): + self.apt_path = Path(apt_path) + self.const_path = Path(const_path) + self.apt = Reader(self.apt_path.read_bytes(), str(self.apt_path)) + self.const = Reader(self.const_path.read_bytes(), str(self.const_path)) + self.const.require(0, 32) + self.apt.require(0, 16) + + def character(self, offset: int, character_id: int) -> dict | None: + if not offset: + return None + self.apt.require(offset, 16) + type_id = self.apt.u32be(offset) + body = offset + 16 + result = { + "id": character_id, + "offset": offset, + "type": type_id, + "type_name": CHARACTER_TYPES.get(type_id, f"unknown_{type_id}"), + } + if type_id in (1, 2, 10): + self.apt.require(body, 16) + result["bounds"] = [self.apt.f32be(body + i * 4) for i in range(4)] + if type_id == 2: + self.apt.require(body, 52) + color = self.apt.u32be(body + 24) + result["text"] = { + "font_id": self.apt.u32be(body + 16), + "alignment": self.apt.u32be(body + 20), + "color_argb": f"#{color:08x}", + "font_height": self.apt.f32be(body + 28), + "read_only": bool(self.apt.u32be(body + 32)), + "multiline": bool(self.apt.u32be(body + 36)), + "word_wrap": bool(self.apt.u32be(body + 40)), + "initial_text": self.apt.cstring(self.apt.u32be(body + 44)), + "variable": self.apt.cstring(self.apt.u32be(body + 48)), + } + elif type_id == 3: + self.apt.require(body, 12) + glyph_count = self.apt.u32be(body + 4) + glyphs_offset = self.apt.u32be(body + 8) + if glyph_count > 100_000: + raise FormatError("unreasonable APT font glyph count") + glyphs = [] + if glyph_count: + self.apt.require(glyphs_offset, glyph_count * 4) + glyphs = [ + self.apt.u32be(glyphs_offset + index * 4) + for index in range(glyph_count) + ] + result["font"] = { + "name": self.apt.cstring(self.apt.u32be(body)), + "glyph_character_ids": glyphs, + } + elif type_id == 10: + self.apt.require(body, 48) + result["matrix"] = [ + self.apt.f32be(body + 16 + i * 4) for i in range(6) + ] + record_count = self.apt.u32be(body + 40) + records_offset = self.apt.u32be(body + 44) + if record_count > 100_000: + raise FormatError("unreasonable APT static-text record count") + self.apt.require(records_offset, record_count * 56) + records = [] + for record_index in range(record_count): + record_offset = records_offset + record_index * 56 + glyph_count = self.apt.u32be(record_offset + 48) + glyphs_offset = self.apt.u32be(record_offset + 52) + if glyph_count > 1_000_000: + raise FormatError("unreasonable APT static-text glyph count") + self.apt.require(glyphs_offset, glyph_count * 4) + glyphs = [] + for glyph_index in range(glyph_count): + glyph_offset = glyphs_offset + glyph_index * 4 + glyphs.append( + { + "index": self.apt.u16be(glyph_offset), + "advance": int.from_bytes( + self.apt.bytes(glyph_offset + 2, 2), + "big", + signed=True, + ), + } + ) + records.append( + { + "offset": record_offset, + "font_id": self.apt.u32be(record_offset), + "color_transform": [ + self.apt.f32be(record_offset + 4 + i * 4) + for i in range(8) + ], + "x_offset": self.apt.f32be(record_offset + 36), + "y_offset": self.apt.f32be(record_offset + 40), + "scale": self.apt.f32be(record_offset + 44), + "glyphs": glyphs, + } + ) + result["static_text"] = {"records": records} + elif type_id in (5, 9): + self.apt.require(body, 12) + movie = { + "frame_count": self.apt.u32be(body), + "frames_offset": self.apt.u32be(body + 4), + "labels_offset": self.apt.u32be(body + 8), + } + if movie["frame_count"] > 100_000: + raise FormatError("unreasonable APT frame count") + if type_id == 9: + self.apt.require(body, 52) + movie.update( + { + "character_count": self.apt.u32be(body + 12), + "characters_offset": self.apt.u32be(body + 16), + "width": self.apt.u32be(body + 20), + "height": self.apt.u32be(body + 24), + "milliseconds_per_frame": self.apt.u32be(body + 28), + "import_count": self.apt.u32be(body + 32), + "imports_offset": self.apt.u32be(body + 36), + "export_count": self.apt.u32be(body + 40), + "exports_offset": self.apt.u32be(body + 44), + "current_constant_index": self.apt.u32be(body + 48), + } + ) + result["movie"] = movie + elif type_id == 7: + self.apt.require(body, 4) + result["bitmap"] = {"texture_id": self.apt.u32be(body)} + return result + + def control(self, offset: int) -> dict: + self.apt.require(offset, 4) + type_id = self.apt.u32be(offset) + result = { + "offset": offset, + "type": type_id, + "type_name": CONTROL_TYPES.get(type_id, f"unknown_{type_id}"), + } + if type_id in (3, 9): + size = 72 if type_id == 9 else 64 + self.apt.require(offset, size) + name_pointer = self.apt.u32be(offset + 52) + result.update( + { + "flags": self.apt.u32be(offset + 4), + "depth": self.apt.i32be(offset + 8), + "character_id": self.apt.i32be(offset + 12), + "matrix": [ + self.apt.f32be(offset + 16 + i * 4) for i in range(6) + ], + "color_transform": list(self.apt.bytes(offset + 40, 8)), + "ratio": self.apt.f32be(offset + 48), + "name": self.apt.cstring(name_pointer), + "clip_depth": self.apt.i32be(offset + 56), + "actions_offset": self.apt.u32be(offset + 60), + } + ) + if type_id == 9: + result["blend_mode"] = self.apt.i32be(offset + 64) + result["filter_pointer"] = self.apt.u32be(offset + 68) + elif type_id == 2: + self.apt.require(offset, 8) + result["label"] = self.apt.cstring(self.apt.u32be(offset + 4)) + elif type_id == 5: + self.apt.require(offset, 8) + result["color"] = f"#{self.apt.u32be(offset + 4):08x}" + elif type_id == 1: + self.apt.require(offset, 8) + result["actions_offset"] = self.apt.u32be(offset + 4) + action_offset = result["actions_offset"] + if action_offset: + self.apt.require(action_offset, 1) + result["first_action_opcode"] = self.apt.u8(action_offset) + elif type_id == 8: + self.apt.require(offset, 12) + result["sprite_id"] = self.apt.u32be(offset + 4) + result["actions_offset"] = self.apt.u32be(offset + 8) + action_offset = result["actions_offset"] + if action_offset: + self.apt.require(action_offset, 1) + result["first_action_opcode"] = self.apt.u8(action_offset) + elif type_id in (4, 11): + self.apt.require(offset, 8) + result["depth"] = self.apt.i32be(offset + 4) + elif type_id == 6: + self.apt.require(offset, 8) + result["sound_id"] = self.apt.u32be(offset + 4) + return result + + def frames(self, movie: dict) -> list[dict]: + count = movie["frame_count"] + frames_offset = movie["frames_offset"] + self.apt.require(frames_offset, count * 8) + frames = [] + for frame_index in range(count): + frame = frames_offset + frame_index * 8 + control_count = self.apt.u32be(frame) + controls_offset = self.apt.u32be(frame + 4) + if control_count > 100_000: + raise FormatError(f"frame {frame_index}: unreasonable control count") + self.apt.require(controls_offset, control_count * 4) + controls = [] + for control_index in range(control_count): + pointer = self.apt.u32be(controls_offset + control_index * 4) + if pointer: + controls.append(self.control(pointer)) + frames.append({"index": frame_index, "controls": controls}) + return frames + + def inspect(self) -> dict: + header = { + "magic_hex": self.const.bytes(0, 20).hex(), + "main_character_offset": self.const.u32be(20), + "constant_count": self.const.u32be(24), + "constants_offset": self.const.u32be(28), + } + root = self.character(header["main_character_offset"], 0) + imports = [] + exports = [] + characters = [] + if root and root["type"] in (5, 9): + movie = root["movie"] + import_count = movie.get("import_count", 0) + imports_offset = movie.get("imports_offset", 0) + if import_count > 100_000: + raise FormatError("unreasonable APT import count") + if import_count: + self.apt.require(imports_offset, import_count * 16) + for index in range(import_count): + record = imports_offset + index * 16 + imports.append( + { + "index": index, + "offset": record, + "file": self.apt.cstring(self.apt.u32be(record)), + "name": self.apt.cstring(self.apt.u32be(record + 4)), + "character_id": self.apt.u32be(record + 8), + } + ) + export_count = movie.get("export_count", 0) + exports_offset = movie.get("exports_offset", 0) + if export_count > 100_000: + raise FormatError("unreasonable APT export count") + if export_count: + self.apt.require(exports_offset, export_count * 8) + for index in range(export_count): + record = exports_offset + index * 8 + exports.append( + { + "index": index, + "offset": record, + "name": self.apt.cstring(self.apt.u32be(record)), + "character_id": self.apt.u32be(record + 4), + } + ) + count = movie.get("character_count", 0) + table = movie.get("characters_offset", 0) + if count > 100_000: + raise FormatError("unreasonable APT character count") + if count: + self.apt.require(table, count * 4) + for character_id in range(count): + parsed = self.character( + self.apt.u32be(table + character_id * 4), character_id + ) + if parsed: + if "movie" in parsed: + parsed["frames"] = self.frames(parsed["movie"]) + characters.append(parsed) + root["frames"] = self.frames(movie) + return { + "format": "skate3-apt", + "apt_source": str(self.apt_path), + "const_source": str(self.const_path), + "const_header": header, + "root": root, + "characters": characters, + "imports": imports, + "exports": exports, + } + + +def inspect_apt(apt_path: Path, const_path: Path) -> dict: + return AptInspector(apt_path, const_path).inspect() diff --git a/tools/vendor/skate3_ui/big.py b/tools/vendor/skate3_ui/big.py new file mode 100644 index 00000000..877894e9 --- /dev/null +++ b/tools/vendor/skate3_ui/big.py @@ -0,0 +1,154 @@ +from __future__ import annotations + +from dataclasses import asdict, dataclass +from pathlib import Path, PurePosixPath + +from .binary import FormatError, Reader, align +from .refpack import decompress + + +@dataclass(frozen=True) +class BigEntry: + index: int + path: str + offset: int + stored_size: int + unpacked_size: int + compression: int + + +class BigArchive: + """Reader for the EB BIG v3 archives used by Skate 3 front-end data.""" + + def __init__(self, path: Path): + self.path = Path(path) + self.data = self.path.read_bytes() + self.reader = Reader(self.data, str(self.path)) + self.entries = self._parse() + + def _parse(self) -> list[BigEntry]: + r = self.reader + r.require(0, 48) + if r.u16be(0) != 0x4542: + raise FormatError("not an EB BIG archive") + version = r.u16be(2) + if version != 3: + raise FormatError(f"unsupported EB BIG version {version}") + count = r.u32be(4) + if count > 1_000_000: + raise FormatError(f"unreasonable BIG entry count {count}") + flags = r.u16be(8) + shift = r.u8(10) + if shift > 31: + raise FormatError(f"invalid BIG alignment shift {shift}") + index_size = r.u32be(12) + names_size = r.u32be(16) + name_record_size = r.u8(20) + directory_record_size = r.u8(21) + if name_record_size < 3 or directory_record_size < 2: + raise FormatError("invalid BIG name record sizing") + if index_size > len(self.data) or names_size > len(self.data) - index_size: + raise FormatError("BIG index/name blocks exceed file") + + entry_size = 20 if flags & 1 else 16 + entries_start = 48 + entries_bytes = entry_size * count + compression_start = entries_start + align(entries_bytes, 16) + names_start = index_size + directories_start = names_start + align(name_record_size * count, 16) + r.require(entries_start, entries_bytes) + r.require(compression_start, count) + r.require(names_start, name_record_size * count) + + directories: list[str] = [] + directory_bytes_end = index_size + names_size + if directory_bytes_end > len(self.data): + raise FormatError("BIG directory block exceeds file") + cursor = directories_start + while cursor + directory_record_size <= directory_bytes_end: + directories.append( + r.bytes(cursor, directory_record_size) + .split(b"\0", 1)[0] + .decode("utf-8", errors="replace") + ) + cursor += directory_record_size + + result: list[BigEntry] = [] + for index in range(count): + entry = entries_start + index * entry_size + file_offset = r.u32be(entry) << shift + declared_stored = r.u32be(entry + 4) + unpacked_size = r.u32be(entry + 8) or declared_stored + # EB v3 stores zero in the packed-size field for raw entries. + stored_size = declared_stored or unpacked_size + compression = r.u8(compression_start + index) + name_record = names_start + index * name_record_size + directory_index = r.u16be(name_record) + filename = ( + r.bytes(name_record + 2, name_record_size - 2) + .split(b"\0", 1)[0] + .decode("utf-8", errors="replace") + ) + directory = ( + directories[directory_index] + if directory_index < len(directories) + else "." + ) + archive_path = filename if directory in ("", ".") else f"{directory}/{filename}" + r.require(file_offset, stored_size) + result.append( + BigEntry( + index, + archive_path.replace("\\", "/"), + file_offset, + stored_size, + unpacked_size, + compression, + ) + ) + return result + + def manifest(self) -> list[dict]: + return [asdict(entry) for entry in self.entries] + + def read(self, entry: BigEntry) -> bytes: + packed = self.reader.bytes(entry.offset, entry.stored_size) + if entry.compression == 0: + if len(packed) != entry.unpacked_size: + raise FormatError( + f"{entry.path}: uncompressed size metadata does not match" + ) + return packed + if entry.compression == 1: + return decompress(packed, entry.unpacked_size) + raise FormatError( + f"{entry.path}: compression {entry.compression} is not yet supported " + "(2=chunked RefPack, 3=chunked zlib, 4=Xbox LZX)" + ) + + @staticmethod + def _safe_relative(path: str) -> Path: + pure = PurePosixPath(path.replace("\\", "/")) + if pure.is_absolute() or not pure.parts or any( + part in ("", ".", "..") for part in pure.parts + ): + raise FormatError(f"unsafe archive path {path!r}") + if ":" in pure.parts[0]: + raise FormatError(f"unsafe archive path {path!r}") + return Path(*pure.parts) + + def extract(self, output: Path, force: bool = False) -> list[Path]: + output = Path(output).resolve() + output.mkdir(parents=True, exist_ok=True) + written = [] + for entry in self.entries: + relative = self._safe_relative(entry.path) + target = (output / relative).resolve() + if output != target and output not in target.parents: + raise FormatError(f"archive path escapes output: {entry.path!r}") + target.parent.mkdir(parents=True, exist_ok=True) + if target.exists() and not force: + raise FileExistsError(f"refusing to overwrite {target}") + target.write_bytes(self.read(entry)) + written.append(target) + return written diff --git a/tools/vendor/skate3_ui/binary.py b/tools/vendor/skate3_ui/binary.py new file mode 100644 index 00000000..fd95c2ba --- /dev/null +++ b/tools/vendor/skate3_ui/binary.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +import struct + + +class FormatError(ValueError): + """Raised when an input is malformed or outside supported bounds.""" + + +class Reader: + def __init__(self, data: bytes, label: str = "input"): + self.data = data + self.label = label + + def require(self, offset: int, size: int) -> None: + if offset < 0 or size < 0 or offset > len(self.data) - size: + raise FormatError( + f"{self.label}: range {offset:#x}+{size:#x} is outside " + f"{len(self.data):#x} bytes" + ) + + def bytes(self, offset: int, size: int) -> bytes: + self.require(offset, size) + return self.data[offset : offset + size] + + def u8(self, offset: int) -> int: + self.require(offset, 1) + return self.data[offset] + + def u16be(self, offset: int) -> int: + self.require(offset, 2) + return struct.unpack_from(">H", self.data, offset)[0] + + def u32be(self, offset: int) -> int: + self.require(offset, 4) + return struct.unpack_from(">I", self.data, offset)[0] + + def i32be(self, offset: int) -> int: + self.require(offset, 4) + return struct.unpack_from(">i", self.data, offset)[0] + + def u64be(self, offset: int) -> int: + self.require(offset, 8) + return struct.unpack_from(">Q", self.data, offset)[0] + + def f32be(self, offset: int) -> float: + self.require(offset, 4) + return struct.unpack_from(">f", self.data, offset)[0] + + def cstring(self, offset: int, limit: int | None = None) -> str: + if offset <= 0 or offset >= len(self.data): + return "" + end_limit = len(self.data) if limit is None else min(len(self.data), offset + limit) + end = self.data.find(b"\0", offset, end_limit) + if end < 0: + end = end_limit + return self.data[offset:end].decode("utf-8", errors="replace") + + +def align(value: int, alignment: int) -> int: + return (value + alignment - 1) & ~(alignment - 1) diff --git a/tools/vendor/skate3_ui/bitmap_font.py b/tools/vendor/skate3_ui/bitmap_font.py new file mode 100644 index 00000000..3356e977 --- /dev/null +++ b/tools/vendor/skate3_ui/bitmap_font.py @@ -0,0 +1,196 @@ +from __future__ import annotations + +import re +from pathlib import Path + +from .binary import FormatError + + +_FIELD = re.compile(r"(?m)^([A-Za-z][A-Za-z0-9]*):[ \t]*(.*)$") + + +def _fields(text: str) -> dict[str, str]: + matches = list(_FIELD.finditer(text)) + result = {} + for index, match in enumerate(matches): + end = matches[index + 1].start() if index + 1 < len(matches) else len(text) + value = text[match.start(2) : end].replace("\r", "").replace("\n", " ") + result[match.group(1)] = value.strip() + return result + + +def _number(value: str) -> int | float: + return float(value) if any(char in value for char in ".eE") else int(value) + + +def parse_bitmap_font(path: Path) -> dict: + path = Path(path) + fields = _fields(path.read_text(encoding="cp1252")) + required = {"Version", "FullName", "Family", "GlyphMetricsMap", "CharMapSet"} + missing = sorted(required - fields.keys()) + if missing: + raise FormatError(f"{path}: missing bitmap-font fields: {', '.join(missing)}") + if fields["Version"] != "3": + raise FormatError(f"{path}: unsupported bitmap-font version") + + metric_chunks = [chunk.strip() for chunk in fields["GlyphMetricsMap"].split(",")] + first = metric_chunks[0].split() + if len(first) != 10: + raise FormatError(f"{path}: malformed first glyph metric") + declared_glyphs = int(first.pop(0)) + metric_chunks[0] = " ".join(first) + glyphs = [] + for chunk in metric_chunks: + values = [int(value) for value in chunk.split()] + if len(values) != 9: + raise FormatError(f"{path}: malformed glyph metric {chunk!r}") + glyph = dict( + zip( + ( + "glyph_index", + "texture_index", + "x", + "y", + "width", + "height", + "x_offset", + "y_offset", + "x_advance", + ), + values, + ) + ) + # EA::Text::BmpGlyphMetrics stores X/Y as the glyph's pen origin. + # EATextBmpFont.cpp derives the physical atlas rectangle by adding the + # horizontal bearing and subtracting the vertical bearing. + atlas_left = glyph["x"] + glyph["x_offset"] + atlas_top = glyph["y"] - glyph["y_offset"] + glyph["atlas_bounds"] = [ + atlas_left, + atlas_top, + atlas_left + glyph["width"], + atlas_top + glyph["height"], + ] + glyphs.append(glyph) + if len(glyphs) != declared_glyphs: + raise FormatError( + f"{path}: declared {declared_glyphs} glyphs, found {len(glyphs)}" + ) + + char_chunks = [chunk.strip() for chunk in fields["CharMapSet"].split(",")] + first = char_chunks[0].split() + if len(first) != 3: + raise FormatError(f"{path}: malformed first character mapping") + declared_chars = int(first.pop(0)) + char_chunks[0] = " ".join(first) + characters = [] + for chunk in char_chunks: + values = [int(value) for value in chunk.split()] + if len(values) != 2: + raise FormatError(f"{path}: malformed character mapping {chunk!r}") + characters.append({"codepoint": values[0], "glyph_index": values[1]}) + if len(characters) != declared_chars: + raise FormatError( + f"{path}: declared {declared_chars} characters, found {len(characters)}" + ) + + scalar_keys = ( + "Weight", + "Stretch", + "Size", + "HAdvanceXMax", + "VAdvanceYMax", + "Ascent", + "Descent", + "Leading", + "Baseline", + "LineHeight", + "XHeight", + "CapsHeight", + "UnderlinePosition", + "UnderlineThickness", + "LinethroughPosition", + "LinethroughThickness", + ) + metrics = {key: _number(fields[key]) for key in scalar_keys if key in fields} + textures = [] + for key, value in fields.items(): + if not key.startswith("Texture"): + continue + parts = value.split() + if len(parts) < 4: + raise FormatError(f"{path}: malformed texture declaration {value!r}") + textures.append( + { + "index": int(key[7:]), + "bytes": int(parts[0]), + "width": int(parts[1]), + "height": int(parts[2]), + "name": " ".join(parts[3:]), + } + ) + + return { + "format": "skate3-bitmap-font", + "source": str(path), + "full_name": fields["FullName"], + "family": fields["Family"], + "style": fields.get("Style", ""), + "smooth": fields.get("Smooth") == "Yes", + "fixed_pitch": fields.get("FixedPitch") == "Yes", + "metrics": metrics, + "glyphs": glyphs, + "characters": characters, + "textures": sorted(textures, key=lambda item: item["index"]), + } + + +def measure_bitmap_text(font: dict, text: str, height: float) -> dict: + mappings = { + item["codepoint"]: item["glyph_index"] for item in font["characters"] + } + glyphs = {item["glyph_index"]: item for item in font["glyphs"]} + source_ascent = float(font["metrics"]["Ascent"]) + if source_ascent <= 0: + raise FormatError("bitmap font has a non-positive ascent") + # APT dynamic-text fontHeight is the Flash ascent height. The bitmap + # glyph bearings and advances are expressed in the bmpFont's source-pixel + # coordinate system, whose matching vertical metric is Ascent rather than + # the nominal point Size. + scale = height / source_ascent + pen = 0.0 + minimum = 0.0 + maximum = 0.0 + used = [] + for character in text: + codepoint = ord(character) + glyph_index = mappings.get(codepoint) + if glyph_index is None or glyph_index not in glyphs: + raise FormatError( + f"bitmap font {font['family']!r} lacks codepoint {codepoint}" + ) + glyph = glyphs[glyph_index] + left = pen + glyph["x_offset"] * scale + right = left + glyph["width"] * scale + minimum = min(minimum, left) + maximum = max(maximum, right) + used.append( + { + "codepoint": codepoint, + "glyph_index": glyph_index, + "pen_x": pen, + "left": left, + "right": right, + } + ) + pen += glyph["x_advance"] * scale + # Flash TextField autoSize retains the two-pixel gutter on each side. + width = max(pen, maximum) - min(0.0, minimum) + 4.0 + return { + "width": width, + "advance": pen, + "ink_bounds_x": [minimum, maximum], + "height": height, + "scale": scale, + "glyphs": used, + } diff --git a/tools/vendor/skate3_ui/geo.py b/tools/vendor/skate3_ui/geo.py new file mode 100644 index 00000000..7f47191e --- /dev/null +++ b/tools/vendor/skate3_ui/geo.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +from pathlib import Path + +from .binary import FormatError, Reader + +RENDER_TYPES = { + 0: "line", + 1: "solid", + 2: "texture_clamped", + 3: "texture_wrapped", +} + + +def parse_geo(path: Path) -> dict: + data = Path(path).read_bytes() + r = Reader(data, str(path)) + # Retail bundles use an eight-byte header as the canonical empty GEO. + if len(data) == 8 and r.u32be(4) == 0: + return {"format": "skate3-geo", "source": str(path), "shapes": []} + r.require(0, 12) + count = r.u32be(4) + if count > 4096: + raise FormatError(f"unreasonable GEO shape count {count}") + r.require(8, count * 4) + shapes = [] + for index in range(count): + shape_offset = r.u32be(8 + index * 4) + if not shape_offset: + continue + r.require(shape_offset, 8) + shape_id = r.u32be(shape_offset) + unit_count = r.u32be(shape_offset + 4) + if unit_count > 65536: + raise FormatError(f"shape {shape_id}: unreasonable unit count") + r.require(shape_offset + 8, unit_count * 4) + units = [] + all_points: list[list[float]] = [] + for unit_index in range(unit_count): + unit_offset = r.u32be(shape_offset + 8 + unit_index * 4) + r.require(unit_offset, 0x34) + render_type = r.u32be(unit_offset) + color = [r.f32be(unit_offset + 4 + channel * 4) for channel in range(4)] + texture_id = r.u32be(unit_offset + 0x14) + uv_matrix = [ + r.f32be(unit_offset + 0x18 + component * 4) + for component in range(6) + ] + primitive_count = r.u32be(unit_offset + 0x30) + if primitive_count > 1_000_000: + raise FormatError(f"shape {shape_id}: unreasonable primitive count") + r.require(unit_offset + 0x34, primitive_count * 0x18) + triangles = [] + for primitive in range(primitive_count): + base = unit_offset + 0x34 + primitive * 0x18 + triangle = [ + [r.f32be(base + vertex * 8), r.f32be(base + vertex * 8 + 4)] + for vertex in range(3) + ] + triangles.append(triangle) + all_points.extend(triangle) + units.append( + { + "render_type": render_type, + "render_type_name": RENDER_TYPES.get( + render_type, f"unknown_{render_type}" + ), + "color": color, + "texture_id": texture_id, + "uv_matrix": uv_matrix, + "triangles": triangles, + } + ) + bounds = None + if all_points: + xs = [point[0] for point in all_points] + ys = [point[1] for point in all_points] + bounds = [min(xs), min(ys), max(xs), max(ys)] + shapes.append({"id": shape_id, "bounds": bounds, "units": units}) + return {"format": "skate3-geo", "source": str(path), "shapes": shapes} diff --git a/tools/vendor/skate3_ui/language.py b/tools/vendor/skate3_ui/language.py new file mode 100644 index 00000000..27170710 --- /dev/null +++ b/tools/vendor/skate3_ui/language.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +import struct +from dataclasses import dataclass +from pathlib import Path + +from .binary import FormatError + + +@dataclass(frozen=True) +class LanguageTable: + source: str + declared_count: int + pool_offset: int + strings: tuple[str, ...] + + +def parse_language_table(path: Path) -> LanguageTable: + path = Path(path) + data = path.read_bytes() + if len(data) < 28: + raise FormatError(f"{path}: language table is shorter than its header") + declared_count = struct.unpack_from(" len(data): + raise FormatError(f"{path}: string pool lies outside the file") + raw_strings = data[strings_offset:].split(b"\0") + # The UI uses byte-oriented font character maps, including custom glyphs + # in positions that are undefined in Windows-1252. Latin-1 gives every + # source byte a lossless, one-to-one Unicode codepoint for the manifest. + strings = tuple(value.decode("latin-1") for value in raw_strings) + expected = declared_count + 2 + if len(strings) != expected: + raise FormatError( + f"{path}: expected {expected} pool strings, found {len(strings)}" + ) + return LanguageTable(str(path), declared_count, pool_offset, strings) + + +def pair_language_tables(labels_path: Path, values_path: Path) -> dict: + labels = parse_language_table(labels_path) + values = parse_language_table(values_path) + if len(labels.strings) != len(values.strings): + raise FormatError( + "language label/value pools have different numbers of strings" + ) + + entries = [] + seen: dict[str, int] = {} + duplicate_labels = [] + for index, (label, value) in enumerate(zip(labels.strings, values.strings)): + if label: + if label in seen: + duplicate_labels.append( + {"label": label, "first_index": seen[label], "index": index} + ) + else: + seen[label] = index + entries.append({"index": index, "label": label, "value": value}) + + return { + "format": "skate3-language-pair", + "encoding": "iso-8859-1-byte-preserving", + "labels_source": labels.source, + "values_source": values.source, + "declared_count": labels.declared_count, + "pool_entries": len(entries), + "entries": entries, + "duplicate_labels": duplicate_labels, + } diff --git a/tools/vendor/skate3_ui/project.py b/tools/vendor/skate3_ui/project.py new file mode 100644 index 00000000..6c450687 --- /dev/null +++ b/tools/vendor/skate3_ui/project.py @@ -0,0 +1,375 @@ +from __future__ import annotations + +import hashlib +import json +from dataclasses import dataclass +from pathlib import Path + +from .apt import inspect_apt +from .bitmap_font import parse_bitmap_font +from .big import BigArchive, BigEntry +from .binary import FormatError +from .geo import parse_geo +from .language import pair_language_tables +from .rw4 import extract_textures + + +CORE_ARCHIVES = ("fedata.big", "fetexture.big", "miscboot.big") +DYNAMIC_ARCHIVE = "fedynamic.big" +DEFAULT_PREFIXES = ( + "data/fe/source/screens/", + "data/fe/source/controls/", +) +UI_EXTENSIONS = { + ".apt", + ".bmpfont", + ".const", + ".geo", + ".rx2", + ".rps3", + ".jpg", + ".png", +} +SUPPORT_PATHS = { + "data/fe/languages/english/language_english_global_skate3ng.bin", + "data/fe/languages/labels/language_labels_global_skate3ng.bin", +} +SUPPORT_PREFIXES = ("data/fe/fonts/",) +CACHE_VERSION = 3 + + +@dataclass(frozen=True) +class SelectedEntry: + archive_name: str + archive: BigArchive + entry: BigEntry + + +def _sha256(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def _write_json(path: Path, value: object, force: bool) -> None: + if path.exists() and not force: + raise FileExistsError(f"refusing to overwrite {path}; pass --force") + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(value, indent=2) + "\n", encoding="utf-8") + + +def find_big_directory(game_root: Path) -> Path: + root = Path(game_root).resolve() + candidates = (root / "data" / "big", root / "big", root) + for candidate in candidates: + if (candidate / "fedata.big").is_file(): + return candidate + raise FileNotFoundError( + f"could not find data/big/fedata.big beneath the supplied game root: {root}" + ) + + +def is_selected_ui_path(path: str, prefixes: tuple[str, ...]) -> bool: + normalized = path.replace("\\", "/").lower() + if normalized in SUPPORT_PATHS: + return True + if any(normalized.startswith(prefix) for prefix in SUPPORT_PREFIXES): + return Path(normalized).suffix in UI_EXTENSIONS + if Path(normalized).suffix not in UI_EXTENSIONS: + return False + return any(normalized.startswith(prefix.lower()) for prefix in prefixes) + + +def cache_is_current( + game_root: Path, + output: Path, + *, + include_dynamic: bool, + prefixes: tuple[str, ...], + decode_png: bool, +) -> bool: + manifest_path = Path(output).resolve() / "manifest.json" + try: + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + if ( + manifest.get("format") != "skate3-native-ui-asset-cache" + or manifest.get("version") != CACHE_VERSION + or manifest.get("prefixes") != list(prefixes) + ): + return False + if decode_png and manifest.get("summary", {}).get("textures_decoded", 0) == 0: + return False + big_directory = find_big_directory(game_root) + expected = list(CORE_ARCHIVES) + if include_dynamic: + expected.append(DYNAMIC_ARCHIVE) + records = {record["name"]: record for record in manifest.get("archives", [])} + if set(records) != set(expected): + return False + for name in expected: + source = big_directory / name + record = records[name] + if ( + not source.is_file() + or source.stat().st_size != record.get("bytes") + or _sha256(source.read_bytes()) != record.get("sha256") + ): + return False + return True + except (FileNotFoundError, KeyError, OSError, ValueError, json.JSONDecodeError): + return False + + +def _safe_output_path(root: Path, archive_path: str) -> Path: + relative = BigArchive._safe_relative(archive_path) + target = (root / relative).resolve() + if root != target and root not in target.parents: + raise FormatError(f"archive path escapes output: {archive_path!r}") + return target + + +def extract_project( + game_root: Path, + output: Path, + *, + include_dynamic: bool = False, + prefixes: tuple[str, ...] = DEFAULT_PREFIXES, + decode_png: bool = True, + force: bool = False, + update: bool = False, +) -> dict: + """Build a local, exact-asset UI cache from an installed Skate 3 copy.""" + game_root = Path(game_root).resolve() + output = Path(output).resolve() + if game_root == output or game_root in output.parents: + raise ValueError("output must be outside the read-only game-data tree") + if update and cache_is_current( + game_root, + output, + include_dynamic=include_dynamic, + prefixes=prefixes, + decode_png=decode_png, + ): + manifest = json.loads((output / "manifest.json").read_text(encoding="utf-8")) + manifest["cache_reused"] = True + return manifest + if update: + force = True + if output.exists() and any(output.iterdir()) and not force: + raise FileExistsError(f"{output} is not empty; pass --force to update it") + output.mkdir(parents=True, exist_ok=True) + + big_directory = find_big_directory(game_root) + archive_names = list(CORE_ARCHIVES) + if include_dynamic: + archive_names.append(DYNAMIC_ARCHIVE) + + archives: list[tuple[str, BigArchive]] = [] + archive_records = [] + for name in archive_names: + source = big_directory / name + if not source.is_file(): + raise FileNotFoundError(f"required UI archive is missing: {source}") + archive = BigArchive(source) + archives.append((name, archive)) + archive_records.append( + { + "name": name, + "source": str(source), + "bytes": source.stat().st_size, + "sha256": _sha256(source.read_bytes()), + "entries": len(archive.entries), + } + ) + + # Later archives are preferred. fedynamic.big is a superset when requested, + # while fetexture.big supplies RX2 files absent from fedata.big. + candidates: dict[str, list[SelectedEntry]] = {} + for archive_name, archive in archives: + for entry in archive.entries: + if is_selected_ui_path(entry.path, prefixes): + key = entry.path.replace("\\", "/").lower() + candidates.setdefault(key, []).append( + SelectedEntry(archive_name, archive, entry) + ) + + selected = {key: choices[-1] for key, choices in candidates.items()} + raw_root = output / "raw" + file_records = [] + extracted: dict[str, Path] = {} + for key, source in sorted(selected.items()): + data = source.archive.read(source.entry) + target = _safe_output_path(raw_root, source.entry.path) + if target.exists() and not force: + raise FileExistsError(f"refusing to overwrite {target}; pass --force") + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(data) + extracted[key] = target + file_records.append( + { + "path": source.entry.path, + "archive": source.archive_name, + "bytes": len(data), + "sha256": _sha256(data), + "alternate_sources": [ + choice.archive_name for choice in candidates[key][:-1] + ], + } + ) + + bundles: dict[str, dict[str, Path]] = {} + display_names: dict[str, str] = {} + for key, path in extracted.items(): + stem = str(Path(key).with_suffix("")).replace("\\", "/") + display_names.setdefault( + stem, str(Path(selected[key].entry.path).with_suffix("")).replace("\\", "/") + ) + bundles.setdefault(stem, {})[path.suffix.lower()] = path + + bundle_records = [] + errors = [] + for key, parts in sorted(bundles.items()): + display_name = display_names[key] + record: dict = { + "name": display_name, + "files": { + suffix.lstrip("."): path.relative_to(output).as_posix() + for suffix, path in sorted(parts.items()) + }, + } + metadata_base = _safe_output_path(output / "metadata", display_name) + asset_base = _safe_output_path(output / "assets", display_name) + + if ".apt" in parts and ".const" in parts: + try: + apt_data = inspect_apt(parts[".apt"], parts[".const"]) + apt_target = metadata_base.with_suffix(".apt.json") + _write_json(apt_target, apt_data, force) + movie = (apt_data.get("root") or {}).get("movie", {}) + record["timeline"] = { + "metadata": apt_target.relative_to(output).as_posix(), + "frames": movie.get("frame_count", 0), + "milliseconds_per_frame": movie.get("milliseconds_per_frame", 0), + "characters": len(apt_data.get("characters", [])), + } + except (FormatError, OSError, ValueError) as error: + message = f"{display_name}: APT: {error}" + errors.append(message) + record.setdefault("errors", []).append(message) + + if ".geo" in parts: + try: + geo_data = parse_geo(parts[".geo"]) + geo_target = metadata_base.with_suffix(".geo.json") + _write_json(geo_target, geo_data, force) + record["geometry"] = { + "metadata": geo_target.relative_to(output).as_posix(), + "shapes": len(geo_data.get("shapes", [])), + } + except (FormatError, OSError, ValueError) as error: + message = f"{display_name}: GEO: {error}" + errors.append(message) + record.setdefault("errors", []).append(message) + + if ".bmpfont" in parts: + try: + font_data = parse_bitmap_font(parts[".bmpfont"]) + font_target = metadata_base.with_suffix(".font.json") + _write_json(font_target, font_data, force) + record["font"] = { + "metadata": font_target.relative_to(output).as_posix(), + "family": font_data["family"], + "glyphs": len(font_data["glyphs"]), + "characters": len(font_data["characters"]), + } + except (FormatError, OSError, ValueError) as error: + message = f"{display_name}: bitmap font: {error}" + errors.append(message) + record.setdefault("errors", []).append(message) + + arena = parts.get(".rx2") or parts.get(".rps3") + if arena: + try: + texture_manifest = extract_textures( + arena, asset_base, force=force, decode_png=decode_png + ) + texture_target = asset_base / "manifest.json" + _write_json(texture_target, texture_manifest, force) + record["textures"] = { + "manifest": texture_target.relative_to(output).as_posix(), + "platform": texture_manifest["platform"], + "count": len(texture_manifest["textures"]), + "decoded": sum( + "preview_file" in texture + for texture in texture_manifest["textures"] + ), + } + for texture in texture_manifest["textures"]: + if "preview_error" in texture: + message = ( + f"{display_name}: texture {texture['index']}: " + f"{texture['preview_error']}" + ) + errors.append(message) + except (FormatError, OSError, ValueError) as error: + message = f"{display_name}: RW4: {error}" + errors.append(message) + record.setdefault("errors", []).append(message) + + bundle_records.append(record) + + labels_key = ( + "data/fe/languages/labels/language_labels_global_skate3ng.bin" + ) + english_key = ( + "data/fe/languages/english/language_english_global_skate3ng.bin" + ) + language_record = None + if labels_key in extracted and english_key in extracted: + try: + language_data = pair_language_tables( + extracted[labels_key], extracted[english_key] + ) + language_target = output / "metadata" / "languages" / "english_global.json" + _write_json(language_target, language_data, force) + language_record = { + "metadata": language_target.relative_to(output).as_posix(), + "entries": language_data["pool_entries"], + "duplicate_labels": len(language_data["duplicate_labels"]), + } + except (FormatError, OSError, ValueError) as error: + message = f"English global language pair: {error}" + errors.append(message) + + manifest = { + "format": "skate3-native-ui-asset-cache", + "version": CACHE_VERSION, + "notice": ( + "Exact retail assets extracted locally from the user's installed game; " + "do not redistribute this cache." + ), + "game_root": str(game_root), + "output": str(output), + "prefixes": list(prefixes), + "archives": archive_records, + "selected_files": file_records, + "bundles": bundle_records, + "languages": {"english_global": language_record} + if language_record + else {}, + "errors": errors, + "summary": { + "files": len(file_records), + "bundles": len(bundle_records), + "timelines": sum("timeline" in bundle for bundle in bundle_records), + "geometry": sum("geometry" in bundle for bundle in bundle_records), + "bitmap_fonts": sum("font" in bundle for bundle in bundle_records), + "languages": int(language_record is not None), + "texture_arenas": sum("textures" in bundle for bundle in bundle_records), + "textures_decoded": sum( + bundle.get("textures", {}).get("decoded", 0) + for bundle in bundle_records + ), + "errors": len(errors), + }, + } + _write_json(output / "manifest.json", manifest, force) + return manifest diff --git a/tools/vendor/skate3_ui/refpack.py b/tools/vendor/skate3_ui/refpack.py new file mode 100644 index 00000000..29e7e035 --- /dev/null +++ b/tools/vendor/skate3_ui/refpack.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +from .binary import FormatError + + +def _copy_backref(output: bytearray, distance: int, count: int) -> None: + if distance <= 0 or distance > len(output): + raise FormatError( + f"RefPack back-reference distance {distance} exceeds " + f"{len(output)} output bytes" + ) + for _ in range(count): + output.append(output[-distance]) + + +def decompress(data: bytes, expected_size: int | None = None) -> bytes: + """Decode an EA RefPack stream, including its usual 0x10FB/0x90FB header.""" + if len(data) < 2: + raise FormatError("RefPack stream is too short") + + pos = 0 + header_size = None + if data[1] == 0xFB and data[0] in (0x10, 0x90): + if data[0] & 0x80: + if len(data) < 6: + raise FormatError("truncated extended RefPack header") + header_size = int.from_bytes(data[2:6], "big") + pos = 6 + else: + if len(data) < 5: + raise FormatError("truncated RefPack header") + header_size = int.from_bytes(data[2:5], "big") + pos = 5 + if expected_size is None: + expected_size = header_size + elif header_size not in (None, expected_size): + raise FormatError( + f"RefPack header size {header_size} differs from archive size " + f"{expected_size}" + ) + + output = bytearray() + + def literal(count: int) -> None: + nonlocal pos + if pos + count > len(data): + raise FormatError("truncated RefPack literal") + output.extend(data[pos : pos + count]) + pos += count + + while pos < len(data): + control = data[pos] + pos += 1 + if control < 0x80: + if pos >= len(data): + raise FormatError("truncated two-byte RefPack command") + b1 = data[pos] + pos += 1 + literal(control & 0x03) + distance = ((control & 0x60) << 3) + b1 + 1 + count = ((control >> 2) & 0x07) + 3 + _copy_backref(output, distance, count) + elif control < 0xC0: + if pos + 2 > len(data): + raise FormatError("truncated three-byte RefPack command") + b1, b2 = data[pos], data[pos + 1] + pos += 2 + literal(b1 >> 6) + distance = ((b1 & 0x3F) << 8) + b2 + 1 + _copy_backref(output, distance, (control & 0x3F) + 4) + elif control < 0xE0: + if pos + 3 > len(data): + raise FormatError("truncated four-byte RefPack command") + b1, b2, b3 = data[pos], data[pos + 1], data[pos + 2] + pos += 3 + literal(control & 0x03) + distance = ((control & 0x10) << 12) + (b1 << 8) + b2 + 1 + count = ((control & 0x0C) << 6) + b3 + 5 + _copy_backref(output, distance, count) + elif control < 0xFC: + literal(((control & 0x1F) << 2) + 4) + else: + literal(control & 0x03) + break + + if expected_size is not None and len(output) > expected_size: + raise FormatError("RefPack output exceeds declared size") + + if expected_size is not None and len(output) != expected_size: + raise FormatError( + f"RefPack produced {len(output)} bytes; expected {expected_size}" + ) + return bytes(output) diff --git a/tools/vendor/skate3_ui/rw4.py b/tools/vendor/skate3_ui/rw4.py new file mode 100644 index 00000000..48bd5877 --- /dev/null +++ b/tools/vendor/skate3_ui/rw4.py @@ -0,0 +1,161 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path, PurePath + +from .binary import FormatError, Reader +from .xenos import decode_rgba, parse_fetch_constant, write_png + +TOC_TYPE = 0x00EB000B + + +@dataclass +class Texture: + index: int + name: str + guid: int + data: bytes + platform: str + metadata: dict + + +def _safe_name(name: str, index: int) -> str: + leaf = PurePath(name.replace("\\", "/")).name + filtered = "".join(c if c.isalnum() or c in "._-" else "_" for c in leaf) + return filtered or f"texture_{index:04d}" + + +def read_textures(path: Path) -> tuple[dict, list[Texture]]: + path = Path(path) + raw = path.read_bytes() + r = Reader(raw, str(path)) + r.require(0, 68) + if r.bytes(0, 4) != b"\x89RW4": + raise FormatError("not a RenderWare 4 arena") + platform_magic = r.bytes(4, 4) + if platform_magic == b"ps3\0": + platform = "PS3" + elif platform_magic == b"xb2\0": + platform = "XBOX360" + else: + raise FormatError(f"unknown RW4 platform magic {platform_magic!r}") + + arena_offset = 32 + entry_count = r.u32be(arena_offset) + dictionary_offset = r.u32be(arena_offset + 16) + resource_header_size = r.u32be(arena_offset + 36) + if entry_count > 1_000_000: + raise FormatError("unreasonable RW4 dictionary count") + r.require(dictionary_offset, entry_count * 24) + dictionary = [] + for index in range(entry_count): + offset = dictionary_offset + index * 24 + dictionary.append( + { + "pointer": r.u32be(offset), + "relocation": r.u32be(offset + 4), + "size": r.u32be(offset + 8), + "alignment": r.u32be(offset + 12), + "type_index": r.u32be(offset + 16), + "type_id": r.u32be(offset + 20), + } + ) + + toc = next((item for item in dictionary if item["type_id"] == TOC_TYPE), None) + if not toc: + raise FormatError("RW4 arena has no table of contents") + toc_offset = toc["pointer"] + r.require(toc_offset, 20) + item_count = r.u32be(toc_offset) + item_array = r.u32be(toc_offset + 4) + if item_count > 1_000_000: + raise FormatError("unreasonable RW4 TOC count") + r.require(toc_offset + item_array, item_count * 24) + + textures = [] + for item_index in range(item_count): + item = toc_offset + item_array + item_index * 24 + name_offset = r.u32be(item) + name = r.cstring(toc_offset + name_offset) + guid = r.u64be(item + 8) + dictionary_index = r.u32be(item + 20) - 1 + if dictionary_index < 0 or dictionary_index + 1 >= len(dictionary): + continue + payload = dictionary[dictionary_index] + info = dictionary[dictionary_index + 1] + data_offset = payload["pointer"] + resource_header_size + texture_data = r.bytes(data_offset, payload["size"]) + metadata = {"raw_size": len(texture_data)} + if platform == "PS3": + r.require(info["pointer"], 40) + metadata.update( + { + "format": r.u8(info["pointer"]), + "mipmaps": r.u8(info["pointer"] + 1), + "remap": r.u32be(info["pointer"] + 4), + "width": r.u16be(info["pointer"] + 8), + "height": r.u16be(info["pointer"] + 10), + "texture_offset": r.u32be(info["pointer"] + 20), + "store_type": r.u32be(info["pointer"] + 28), + } + ) + else: + r.require(info["pointer"] + 28, 24) + fetch_data = r.bytes(info["pointer"] + 28, 24) + metadata["fetch_constant_hex"] = fetch_data.hex() + metadata.update(parse_fetch_constant(fetch_data).manifest()) + textures.append( + Texture(item_index, name, guid, texture_data, platform, metadata) + ) + + manifest = { + "format": "renderware4-arena", + "source": str(path), + "platform": platform, + "arena_id": r.u32be(28), + "dictionary_entries": entry_count, + } + return manifest, textures + + +def extract_textures( + path: Path, output: Path, force: bool = False, decode_png: bool = False +) -> dict: + manifest, textures = read_textures(path) + output = Path(output) + output.mkdir(parents=True, exist_ok=True) + entries = [] + for texture in textures: + name = _safe_name(texture.name, texture.index) + target = output / f"{texture.index:04d}_{name}.texture.bin" + if target.exists() and not force: + raise FileExistsError(f"refusing to overwrite {target}") + target.write_bytes(texture.data) + entry = { + "index": texture.index, + "name": texture.name, + "guid": f"{texture.guid:016x}", + "raw_file": target.name, + **texture.metadata, + } + if decode_png and texture.platform == "XBOX360": + preview = output / f"{texture.index:04d}_{name}.png" + if preview.exists() and not force: + raise FileExistsError(f"refusing to overwrite {preview}") + try: + fetch = parse_fetch_constant( + bytes.fromhex(texture.metadata["fetch_constant_hex"]) + ) + rgba = decode_rgba(texture.data, fetch) + rgba_target = output / f"{texture.index:04d}_{name}.rgba" + if rgba_target.exists() and not force: + raise FileExistsError(f"refusing to overwrite {rgba_target}") + rgba_target.write_bytes(rgba) + write_png(preview, fetch.width, fetch.height, rgba) + entry["rgba_file"] = rgba_target.name + entry["preview_file"] = preview.name + except (FormatError, ValueError) as error: + entry["preview_error"] = str(error) + entries.append(entry) + manifest["textures"] = entries + return manifest diff --git a/tools/vendor/skate3_ui/scene_graph.py b/tools/vendor/skate3_ui/scene_graph.py new file mode 100644 index 00000000..0d95161a --- /dev/null +++ b/tools/vendor/skate3_ui/scene_graph.py @@ -0,0 +1,430 @@ +from __future__ import annotations + +import json +from pathlib import Path, PurePosixPath +from typing import Callable + +from .binary import FormatError +from .timeline import resolve_display_list + + +IDENTITY_MATRIX = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0] + + +def compose_matrix(parent: list[float], local: list[float]) -> list[float]: + pa, pb, pc, pd, ptx, pty = parent + la, lb, lc, ld, ltx, lty = local + return [ + pa * la + pc * lb, + pb * la + pd * lb, + pa * lc + pc * ld, + pb * lc + pd * ld, + pa * ltx + pc * lty + ptx, + pb * ltx + pd * lty + pty, + ] + + +def transform_point(matrix: list[float], point: list[float]) -> list[float]: + return [ + matrix[0] * point[0] + matrix[2] * point[1] + matrix[4], + matrix[1] * point[0] + matrix[3] * point[1] + matrix[5], + ] + + +class AssetCache: + def __init__(self, root: Path): + self.root = Path(root).resolve() + self.manifest = json.loads( + (self.root / "manifest.json").read_text(encoding="utf-8") + ) + if self.manifest.get("format") != "skate3-native-ui-asset-cache": + raise FormatError(f"{self.root}: not a native UI asset cache") + self.bundles = { + self.normalize_bundle(record["name"]): record + for record in self.manifest["bundles"] + } + self._loaded: dict[str, dict] = {} + self.font_aliases: dict[str, dict] = {} + + def add_font_alias(self, source: str, target: str, provenance: dict) -> None: + self.font_aliases[source] = { + "target": target, + "provenance": provenance, + } + + @staticmethod + def normalize_bundle(value: str) -> str: + normalized = value.replace("\\", "/").lower() + if normalized.startswith("source/"): + normalized = "data/fe/" + normalized + return str(PurePosixPath(normalized)) + + def load_bundle(self, name: str) -> dict: + key = self.normalize_bundle(name) + if key in self._loaded: + return self._loaded[key] + record = self.bundles.get(key) + if not record or "timeline" not in record: + raise FormatError(f"APT bundle is absent from cache: {name}") + apt = json.loads( + (self.root / record["timeline"]["metadata"]).read_text(encoding="utf-8") + ) + geo = {"shapes": []} + if "geometry" in record: + geo = json.loads( + (self.root / record["geometry"]["metadata"]).read_text( + encoding="utf-8" + ) + ) + textures = {"textures": []} + if "textures" in record: + textures = json.loads( + (self.root / record["textures"]["manifest"]).read_text( + encoding="utf-8" + ) + ) + value = { + "key": key, + "record": record, + "apt": apt, + "characters": {item["id"]: item for item in apt["characters"]}, + "imports": { + item["character_id"]: item for item in apt.get("imports", []) + }, + "exports": {}, + "shapes": {item["id"]: item for item in geo["shapes"]}, + "textures": textures, + } + for item in apt.get("exports", []): + value["exports"].setdefault(item["name"], item["character_id"]) + self._loaded[key] = value + return value + + def resolve_character(self, bundle_name: str, character_id: int) -> tuple[dict, dict]: + bundle = self.load_bundle(bundle_name) + character = bundle["characters"].get(character_id) + if character: + return bundle, character + imported = bundle["imports"].get(character_id) + if not imported: + raise FormatError( + f"{bundle['key']}: character {character_id} is neither local nor imported" + ) + imported_bundle = self.load_bundle(imported["file"]) + exported_id = imported_bundle["exports"].get(imported["name"]) + if exported_id is None: + raise FormatError( + f"{imported_bundle['key']}: export {imported['name']!r} is absent" + ) + character = imported_bundle["characters"].get(exported_id) + if not character: + raise FormatError( + f"{imported_bundle['key']}: exported character {exported_id} is absent" + ) + return imported_bundle, character + + def font_asset(self, family: str) -> dict | None: + requested_family = family + alias = self.font_aliases.get(family) + if alias: + family = alias["target"] + matches = [] + for record in self.manifest["bundles"]: + font = record.get("font") + if font and font["family"] == family: + matches.append(record) + if len(matches) != 1: + return None + record = matches[0] + metadata = json.loads( + (self.root / record["font"]["metadata"]).read_text(encoding="utf-8") + ) + texture = json.loads( + (self.root / record["textures"]["manifest"]).read_text(encoding="utf-8") + )["textures"][0] + result = { + "bundle": record["name"], + "metadata": record["font"]["metadata"], + "texture": ( + Path(record["textures"]["manifest"]).parent + / texture["rgba_file"] + ).as_posix(), + "preview": ( + Path(record["textures"]["manifest"]).parent + / texture["preview_file"] + ).as_posix(), + "definition": metadata, + } + if alias: + result["requested_family"] = requested_family + result["alias"] = alias + return result + + def texture_for_id(self, bundle: dict, texture_id: int) -> dict: + matches = [] + for item in bundle["textures"]["textures"]: + basename = item["name"].replace("\\", "/").rsplit("/", 1)[-1] + stem = basename.rsplit(".", 1)[0] + if stem == str(texture_id): + matches.append(item) + if len(matches) != 1: + raise FormatError( + f"{bundle['key']}: expected one texture resource {texture_id}, " + f"found {len(matches)}" + ) + item = matches[0] + manifest_dir = Path(bundle["record"]["textures"]["manifest"]).parent + return { + "resource_name": item["name"], + "width": item["width"], + "height": item["height"], + "preview": (manifest_dir / item["preview_file"]).as_posix(), + "rgba": (manifest_dir / item["rgba_file"]).as_posix(), + } + + +StateProvider = Callable[[str, dict, dict], dict] + + +class SceneFlattener: + def __init__( + self, + cache: AssetCache, + state_provider: StateProvider, + *, + include_zero_alpha: bool = False, + ): + self.cache = cache + self.state_provider = state_provider + self.include_zero_alpha = include_zero_alpha + self.primitives: list[dict] = [] + self.text: list[dict] = [] + self.unresolved: list[dict] = [] + self.draw_order = 0 + + def flatten(self, bundle_name: str, character_id: int) -> dict: + self._visit( + bundle_name, + character_id, + "/", + IDENTITY_MATRIX, + 1.0, + [], + ) + return { + "primitives": self.primitives, + "text": self.text, + "unresolved": self.unresolved, + } + + def _visit( + self, + bundle_name: str, + character_id: int, + path: str, + world_matrix: list[float], + alpha: float, + stack: list[tuple[str, int]], + ) -> None: + try: + bundle, character = self.cache.resolve_character(bundle_name, character_id) + except FormatError as error: + self.unresolved.append({"path": path, "reason": str(error)}) + return + key = (bundle["key"], character["id"]) + if key in stack: + self.unresolved.append({"path": path, "reason": "recursive character graph"}) + return + state = self.state_provider(path, bundle, character) + if state.get("visible") is False: + return + alpha *= float(state.get("alpha", 1.0)) + if alpha <= 0 and not self.include_zero_alpha: + return + + type_name = character["type_name"] + if type_name == "shape": + self._shape(bundle, character, path, world_matrix, alpha) + return + if type_name in ("text", "static_text"): + self._text(bundle, character, path, world_matrix, alpha, state) + return + if type_name not in ("sprite", "animation"): + return + + frame_count = character["movie"]["frame_count"] + frame = state.get("frame") + if frame is None: + if frame_count == 1: + frame = 0 + elif self.include_zero_alpha and alpha <= 0: + # Retain a stable, non-rendering placeholder for authored + # layers whose parent colour transform currently hides them. + # Their frame cannot affect pixels until an extracted state + # explicitly makes the layer visible. + frame = 0 + else: + self.unresolved.append( + { + "path": path, + "bundle": bundle["key"], + "character_id": character["id"], + "reason": "multi-frame character has no extracted runtime state", + } + ) + return + try: + display = resolve_display_list(character, int(frame)) + except FormatError as error: + self.unresolved.append({"path": path, "reason": str(error)}) + return + self.unresolved.extend( + {"path": path, **item} for item in display["unresolved"] + ) + + for item in display["objects"]: + properties = item["properties"] + child_id = properties.get("character_id") + matrix = properties.get("matrix") + if child_id is None or matrix is None: + continue + name = properties.get("name") or f"depth_{item['depth']}" + child_path = path.rstrip("/") + "/" + name + try: + child_bundle, child_character = self.cache.resolve_character( + bundle["key"], child_id + ) + child_state = self.state_provider( + child_path, child_bundle, child_character + ) + except FormatError as error: + self.unresolved.append({"path": child_path, "reason": str(error)}) + continue + matrix = list(child_state.get("matrix", matrix)) + child_alpha = alpha + color = properties.get("color_transform_argb") + if color: + child_alpha *= color[0] / 255.0 + child_alpha *= float(child_state.get("alpha", 1.0)) + if child_state.get("visible") is False or ( + child_alpha <= 0 and not self.include_zero_alpha + ): + continue + self._visit( + bundle["key"], + child_id, + child_path, + compose_matrix(world_matrix, matrix), + child_alpha, + stack + [key], + ) + + def _shape( + self, + bundle: dict, + character: dict, + path: str, + matrix: list[float], + alpha: float, + ) -> None: + shape = bundle["shapes"].get(character["id"]) + if not shape: + self.unresolved.append( + { + "path": path, + "reason": f"shape {character['id']} has no GEO record", + } + ) + return + for unit_index, unit in enumerate(shape["units"]): + texture = None + if unit["render_type_name"].startswith("texture"): + try: + _, bitmap = self.cache.resolve_character( + bundle["key"], unit["texture_id"] + ) + texture = self.cache.texture_for_id( + bundle, bitmap["bitmap"]["texture_id"] + ) + except (FormatError, KeyError) as error: + self.unresolved.append({"path": path, "reason": str(error)}) + continue + triangles = [] + for triangle in unit["triangles"]: + vertices = [] + for point in triangle: + vertex = {"position": transform_point(matrix, point)} + if texture: + uv_point = transform_point(unit["uv_matrix"], point) + vertex["uv"] = [ + uv_point[0] / texture["width"], + uv_point[1] / texture["height"], + ] + vertices.append(vertex) + triangles.append(vertices) + self.primitives.append( + { + "draw_order": self.draw_order, + "path": path, + "bundle": bundle["key"], + "character_id": character["id"], + "unit": unit_index, + "render_type": unit["render_type_name"], + "matrix": matrix, + "color": unit["color"][:3] + [unit["color"][3] * alpha], + "texture": texture, + "triangles": triangles, + } + ) + self.draw_order += 1 + + def _text( + self, + bundle: dict, + character: dict, + path: str, + matrix: list[float], + alpha: float, + state: dict, + ) -> None: + if character["type_name"] != "text": + self.unresolved.append( + {"path": path, "reason": "static text flattening is not implemented"} + ) + return + definition = character["text"] + font_character = bundle["characters"].get(definition["font_id"]) + if not font_character or "font" not in font_character: + self.unresolved.append( + {"path": path, "reason": "text font declaration is absent"} + ) + return + family = font_character["font"]["name"] + font_asset = self.cache.font_asset(family) + if not font_asset: + self.unresolved.append( + { + "path": path, + "reason": f"no exact packaged bitmap font for {family!r}", + } + ) + self.text.append( + { + "draw_order": self.draw_order, + "path": path, + "bundle": bundle["key"], + "character_id": character["id"], + "matrix": matrix, + "bounds": character["bounds"], + "font_family": family, + "font_asset": font_asset, + "font_height": definition["font_height"], + "alignment": definition["alignment"], + "multiline": definition["multiline"], + "word_wrap": definition["word_wrap"], + "color_argb": state.get("color_argb", definition["color_argb"]), + "alpha": alpha, + "value": state.get("text", definition["initial_text"]), + } + ) + self.draw_order += 1 diff --git a/tools/vendor/skate3_ui/timeline.py b/tools/vendor/skate3_ui/timeline.py new file mode 100644 index 00000000..946e19b2 --- /dev/null +++ b/tools/vendor/skate3_ui/timeline.py @@ -0,0 +1,169 @@ +from __future__ import annotations + +from copy import deepcopy + +from .binary import FormatError + + +PLACE_MOVE = 0x01 +PLACE_HAS_CHARACTER = 0x02 +PLACE_HAS_MATRIX = 0x04 +PLACE_HAS_COLOR_TRANSFORM = 0x08 +PLACE_HAS_RATIO = 0x10 +PLACE_HAS_NAME = 0x20 +PLACE_HAS_CLIP_DEPTH = 0x40 +PLACE_HAS_CLIP_ACTIONS = 0x80 + + +def frame_labels(character: dict) -> dict[str, int]: + labels = {} + for frame in character.get("frames", []): + for control in frame["controls"]: + if control["type_name"] == "frame_label": + label = control.get("label", "") + if not label: + continue + if label in labels: + raise FormatError( + f"character {character['id']}: duplicate frame label {label!r}" + ) + labels[label] = frame["index"] + return labels + + +def first_stop_frame(character: dict, start_frame: int) -> int: + for frame in character.get("frames", [])[start_frame:]: + if any( + control["type_name"] == "do_action" + and control.get("first_action_opcode") == 0x07 + for control in frame["controls"] + ): + return frame["index"] + raise FormatError( + f"character {character['id']}: no stop action at/after frame {start_frame}" + ) + + +def playback_frames(character: dict, label: str) -> list[int]: + labels = frame_labels(character) + if label not in labels: + raise FormatError( + f"character {character['id']}: frame label {label!r} is absent" + ) + frames = character.get("frames", []) + if not frames: + raise FormatError(f"character {character['id']}: timeline has no frames") + + start = labels[label] + result = [] + frame_index = start + for _ in range(len(frames)): + result.append(frame_index) + frame = frames[frame_index] + if any( + control["type_name"] == "do_action" + and control.get("first_action_opcode") == 0x07 + for control in frame["controls"] + ): + return result + frame_index = (frame_index + 1) % len(frames) + raise FormatError( + f"character {character['id']}: playback from {label!r} never stops" + ) + + +def playback_frame(character: dict, label: str, *, play: bool) -> int: + labels = frame_labels(character) + if label not in labels: + raise FormatError( + f"character {character['id']}: frame label {label!r} is absent" + ) + start = labels[label] + return playback_frames(character, label)[-1] if play else start + + +def resolve_display_list(character: dict, through_frame: int) -> dict: + frames = character.get("frames", []) + if through_frame < 0 or through_frame >= len(frames): + raise FormatError( + f"character {character['id']}: frame {through_frame} is out of range" + ) + + objects: dict[int, dict] = {} + unresolved = [] + for frame in frames[: through_frame + 1]: + frame_index = frame["index"] + for control in frame["controls"]: + kind = control["type_name"] + if kind in ("remove_object2", "remove_object3"): + objects.pop(control["depth"], None) + continue + if kind not in ("place_object2", "place_object3"): + continue + + flags = control["flags"] + depth = control["depth"] + moving = bool(flags & PLACE_MOVE) + if moving: + if depth not in objects: + unresolved.append( + { + "frame": frame_index, + "control_offset": control["offset"], + "reason": "move references an empty depth", + "depth": depth, + } + ) + current = {"depth": depth, "properties": {}, "provenance": {}} + objects[depth] = current + else: + current = objects[depth] + else: + current = {"depth": depth, "properties": {}, "provenance": {}} + objects[depth] = current + + source = { + "frame": frame_index, + "control_offset": control["offset"], + } + + def assign(name: str, value: object) -> None: + current["properties"][name] = deepcopy(value) + current["provenance"][name] = source + + if flags & PLACE_HAS_CHARACTER: + assign("character_id", control["character_id"]) + if flags & PLACE_HAS_MATRIX: + assign("matrix", control["matrix"]) + if flags & PLACE_HAS_COLOR_TRANSFORM: + assign("color_transform_argb", control["color_transform"]) + if flags & PLACE_HAS_RATIO: + assign("ratio", control["ratio"]) + if flags & PLACE_HAS_NAME: + assign("name", control["name"]) + if flags & PLACE_HAS_CLIP_DEPTH: + assign("clip_depth", control["clip_depth"]) + if control["type"] == 9: + assign("blend_mode", control.get("blend_mode", -1)) + assign("filter_pointer", control.get("filter_pointer", 0)) + + for depth, item in objects.items(): + missing = [ + name + for name in ("character_id", "matrix") + if name not in item["properties"] + ] + if missing: + unresolved.append( + { + "depth": depth, + "reason": f"display object lacks {', '.join(missing)}", + } + ) + + return { + "character_id": character["id"], + "through_frame": through_frame, + "objects": [objects[depth] for depth in sorted(objects)], + "unresolved": unresolved, + } diff --git a/tools/vendor/skate3_ui/xenos.py b/tools/vendor/skate3_ui/xenos.py new file mode 100644 index 00000000..dca5c14e --- /dev/null +++ b/tools/vendor/skate3_ui/xenos.py @@ -0,0 +1,286 @@ +from __future__ import annotations + +import struct +import zlib +from dataclasses import asdict, dataclass +from pathlib import Path + +from .binary import FormatError, align + + +XENOS_FORMATS = { + 6: "A8R8G8B8", + 18: "DXT1", + 19: "DXT3", + 20: "DXT5", +} + + +@dataclass(frozen=True) +class FetchConstant: + width: int + height: int + format_code: int + format_name: str + mipmaps: int + tiled: bool + endian: int + pitch: int = 0 + + def manifest(self) -> dict: + return asdict(self) + + +def reverse_bits(value: int, size: int) -> int: + result = 0 + for bit in range(size): + result |= ((value >> bit) & 1) << (size - bit - 1) + return result + + +def parse_fetch_constant(data: bytes) -> FetchConstant: + """Decode the 24-byte Xbox 360 texture fetch constant used by RX2.""" + if len(data) != 24: + raise FormatError(f"Xbox texture fetch constant is {len(data)} bytes, expected 24") + encoded = struct.unpack(">6I", data) + words = [ + reverse_bits(encoded[0], 32), + reverse_bits(encoded[1], 32), + encoded[2], + reverse_bits(encoded[3], 32), + reverse_bits(encoded[4], 32), + reverse_bits(encoded[5], 32), + ] + width = (words[2] & 0x1FFF) + 1 + height = ((words[2] >> 13) & 0x1FFF) + 1 + format_code = reverse_bits((words[1] >> 26) & 0x3F, 6) + max_mip = reverse_bits((words[4] >> 22) & 0xF, 4) + return FetchConstant( + width=width, + height=height, + format_code=format_code, + format_name=XENOS_FORMATS.get(format_code, f"XENOS_{format_code}"), + mipmaps=max_mip + 1, + tiled=bool(reverse_bits(words[0] & 1, 1)), + endian=reverse_bits((words[1] >> 24) & 0x3, 2), + pitch=((encoded[0] >> 22) & 0x1FF) << 5, + ) + + +def xbox360_tiled_offset(x: int, y: int, width: int, log_bpb: int) -> int: + aligned_width = align(width, 32) + macro = ((x >> 5) + (y >> 5) * (aligned_width >> 5)) << (log_bpb + 7) + micro = ((x & 7) + ((y & 0xE) << 2)) << log_bpb + offset = macro + ((micro & ~0xF) << 1) + (micro & 0xF) + ((y & 1) << 4) + address = ( + ((offset & ~0x1FF) << 3) + + ((y & 16) << 7) + + ((offset & 0x1C0) << 2) + + (((((y & 8) >> 2) + (x >> 3)) & 3) << 6) + + (offset & 0x3F) + ) + return address >> log_bpb + + +def _layout(fetch: FetchConstant) -> tuple[int, int]: + if fetch.format_code == 18: + return 4, 8 + if fetch.format_code in (19, 20): + return 4, 16 + if fetch.format_code == 6: + return 1, 4 + raise FormatError(f"unsupported Xbox texture format {fetch.format_name}") + + +def _endian_swap(block: bytes, compressed: bool) -> bytes: + if compressed: + swapped = bytearray(block) + for index in range(0, len(swapped) - 1, 2): + swapped[index], swapped[index + 1] = swapped[index + 1], swapped[index] + return bytes(swapped) + return block[::-1] + + +def linearize_base_level(data: bytes, fetch: FetchConstant) -> bytes: + """Untile and endian-correct the base mip of an Xbox 360 texture.""" + block_size, bytes_per_block = _layout(fetch) + block_width = max(1, (fetch.width + block_size - 1) // block_size) + block_height = max(1, (fetch.height + block_size - 1) // block_size) + linear = bytearray(block_width * block_height * bytes_per_block) + compressed = block_size == 4 + + if not fetch.tiled: + pitch_texels = fetch.pitch or fetch.width + pitch_blocks = max( + block_width, (pitch_texels + block_size - 1) // block_size + ) + row_pitch = pitch_blocks * bytes_per_block + needed = (block_height - 1) * row_pitch + block_width * bytes_per_block + if len(data) < needed: + raise FormatError(f"texture payload is {len(data)} bytes, expected at least {needed}") + for y in range(block_height): + source_row = y * row_pitch + target_row = y * block_width * bytes_per_block + for x in range(block_width): + source = source_row + x * bytes_per_block + target = target_row + x * bytes_per_block + linear[target : target + bytes_per_block] = _endian_swap( + data[source : source + bytes_per_block], compressed + ) + return bytes(linear) + + tiled_width = align(fetch.width, 128 if compressed else 32) // block_size + log_bpb = bytes_per_block.bit_length() - 1 + for y in range(block_height): + for x in range(block_width): + tiled_block = xbox360_tiled_offset(x, y, tiled_width, log_bpb) + source = tiled_block * bytes_per_block + target = (y * block_width + x) * bytes_per_block + if source + bytes_per_block > len(data): + raise FormatError( + f"tiled texture address {source:#x} exceeds {len(data):#x}-byte payload" + ) + linear[target : target + bytes_per_block] = _endian_swap( + data[source : source + bytes_per_block], compressed + ) + return bytes(linear) + + +def _expand_565(value: int) -> tuple[int, int, int]: + red = (value >> 11) & 0x1F + green = (value >> 5) & 0x3F + blue = value & 0x1F + return ( + (red << 3) | (red >> 2), + (green << 2) | (green >> 4), + (blue << 3) | (blue >> 2), + ) + + +def _color_table(c0: int, c1: int, allow_transparency: bool) -> list[tuple[int, ...]]: + color0 = _expand_565(c0) + color1 = _expand_565(c1) + if not allow_transparency or c0 > c1: + return [ + (*color0, 255), + (*color1, 255), + tuple((2 * color0[i] + color1[i]) // 3 for i in range(3)) + (255,), + tuple((color0[i] + 2 * color1[i]) // 3 for i in range(3)) + (255,), + ] + return [ + (*color0, 255), + (*color1, 255), + tuple((color0[i] + color1[i]) // 2 for i in range(3)) + (255,), + (0, 0, 0, 0), + ] + + +def _write_block( + output: bytearray, + width: int, + height: int, + block_x: int, + block_y: int, + colors: list[tuple[int, ...]], + color_bits: int, + alphas: list[int] | None = None, +) -> None: + for py in range(4): + for px in range(4): + x = block_x * 4 + px + y = block_y * 4 + py + if x >= width or y >= height: + continue + pixel = py * 4 + px + color = colors[(color_bits >> (pixel * 2)) & 3] + alpha = alphas[pixel] if alphas is not None else color[3] + offset = (y * width + x) * 4 + output[offset : offset + 4] = bytes((color[0], color[1], color[2], alpha)) + + +def _decode_dxt(width: int, height: int, data: bytes, format_code: int) -> bytes: + output = bytearray(width * height * 4) + block_bytes = 8 if format_code == 18 else 16 + blocks_wide = max(1, (width + 3) // 4) + blocks_high = max(1, (height + 3) // 4) + source = 0 + for by in range(blocks_high): + for bx in range(blocks_wide): + if source + block_bytes > len(data): + raise FormatError("compressed texture base mip is truncated") + alphas = None + color_offset = source + if format_code == 19: + alpha_bytes = data[source : source + 8] + alphas = [] + for value in alpha_bytes: + alphas.extend(((value & 0xF) * 17, (value >> 4) * 17)) + color_offset += 8 + elif format_code == 20: + alpha0, alpha1 = data[source], data[source + 1] + palette = [alpha0, alpha1] + if alpha0 > alpha1: + palette.extend( + ((7 - index) * alpha0 + index * alpha1) // 7 + for index in range(1, 7) + ) + else: + palette.extend( + ((5 - index) * alpha0 + index * alpha1) // 5 + for index in range(1, 5) + ) + palette.extend((0, 255)) + alpha_bits = int.from_bytes(data[source + 2 : source + 8], "little") + alphas = [palette[(alpha_bits >> (pixel * 3)) & 7] for pixel in range(16)] + color_offset += 8 + c0, c1, color_bits = struct.unpack_from(" bytes: + linear = linearize_base_level(data, fetch) + if fetch.format_code in (18, 19, 20): + return _decode_dxt(fetch.width, fetch.height, linear, fetch.format_code) + if fetch.format_code == 6: + output = bytearray(fetch.width * fetch.height * 4) + for offset in range(0, len(output), 4): + blue, green, red, alpha = linear[offset : offset + 4] + output[offset : offset + 4] = bytes((red, green, blue, alpha)) + return bytes(output) + raise FormatError(f"unsupported Xbox texture format {fetch.format_name}") + + +def _png_chunk(chunk_type: bytes, payload: bytes) -> bytes: + checksum = zlib.crc32(chunk_type) + checksum = zlib.crc32(payload, checksum) + return ( + struct.pack(">I", len(payload)) + + chunk_type + + payload + + struct.pack(">I", checksum & 0xFFFFFFFF) + ) + + +def encode_png(width: int, height: int, rgba: bytes) -> bytes: + expected = width * height * 4 + if len(rgba) != expected: + raise FormatError(f"RGBA payload is {len(rgba)} bytes, expected {expected}") + scanlines = bytearray() + stride = width * 4 + for y in range(height): + scanlines.append(0) + scanlines.extend(rgba[y * stride : (y + 1) * stride]) + header = struct.pack(">IIBBBBB", width, height, 8, 6, 0, 0, 0) + return ( + b"\x89PNG\r\n\x1a\n" + + _png_chunk(b"IHDR", header) + + _png_chunk(b"IDAT", zlib.compress(bytes(scanlines), 9)) + + _png_chunk(b"IEND", b"") + ) + + +def write_png(path: Path, width: int, height: int, rgba: bytes) -> None: + Path(path).write_bytes(encode_png(width, height, rgba))