diff --git a/crates/skate-audio/src/grain/bed.rs b/crates/skate-audio/src/grain/bed.rs index 6e586832..6b1cf6bd 100644 --- a/crates/skate-audio/src/grain/bed.rs +++ b/crates/skate-audio/src/grain/bed.rs @@ -129,8 +129,9 @@ impl Chain { } /// The full chain (graphs 1, 3, 2); the env send adds into `env` (mono, × `user`). Returns - /// whether the env send contributed. - fn process_full(&mut self, out: &mut [[f32; BLOCK]; 6], env: &mut [f32; BLOCK], tuning: &ChainTuning, user: f32) -> bool { + /// whether the env send contributed. `graph3`: graph 3 exists (`sub_824C8878` builds it for the + /// local player only); without it graph 1's send to it and its return are absent. + fn process_full(&mut self, out: &mut [[f32; BLOCK]; 6], env: &mut [f32; BLOCK], tuning: &ChainTuning, user: f32, graph3: bool) -> bool { let rate = crate::MIX_RATE as f32; let v = self.values; let mut mono = self.bus[0]; @@ -142,20 +143,24 @@ impl Chain { self.fss.shift_hz = v.fss_hz; self.fss.process(&mut mono, rate); let mut g3 = [0.0f32; BLOCK]; - self.to_graph3.target = v.graph3_send; - self.to_graph3.add(&mono, &mut g3); + if graph3 { + self.to_graph3.target = v.graph3_send; + self.to_graph3.add(&mono, &mut g3); + } self.gain.target = v.level; self.gain.process(&mut [&mut mono[..]]); let mut g2 = [0.0f32; BLOCK]; self.from_graph1.add(&mono, &mut g2); // Graph 3. - clip(&mut [&mut g3[..]], tuning.clip); - self.gain3.target = v.graph3_gain; - self.gain3.process(&mut [&mut g3[..]]); - self.shelf.freq = tuning.shelf_hz; - self.shelf.gain = tuning.shelf_gain; - self.shelf.process(&mut [&mut g3[..]], rate); - self.from_graph3.add(&g3, &mut g2); + if graph3 { + clip(&mut [&mut g3[..]], tuning.clip); + self.gain3.target = v.graph3_gain; + self.gain3.process(&mut [&mut g3[..]]); + self.shelf.freq = tuning.shelf_hz; + self.shelf.gain = tuning.shelf_gain; + self.shelf.process(&mut [&mut g3[..]], rate); + self.from_graph3.add(&g3, &mut g2); + } // Graph 2: the FlangeSub send has no bus here; the env send is mono, before the panner. self.env.target = v.env_send * user; let sends = !self.env.silent(); @@ -212,6 +217,10 @@ pub struct GrainBed { pub chain_extras: bool, /// Graph 3's clip and shelf (the owner vault; the speed-driven values come from the host). pub chain_tuning: ChainTuning, + /// The local player's bed (instance 0 of the Player slot): its chains have graph 3 + /// (`sub_824C8878` builds it only for the local player). An NPC skater's bed (instance 1, + /// [`crate::runtime::Runtime::npc_grains`]) has graphs 1 and 2 only. + pub local: bool, /// This block's dry 6-channel mix (before [`GrainBed::gain`]) and the chains' env sends. mix: Box<[[f32; BLOCK]; 6]>, env: Box<[f32; BLOCK]>, @@ -239,6 +248,7 @@ impl GrainBed { fold: [0.0; 6], chain_extras: true, chain_tuning: ChainTuning::default(), + local: true, mix: Box::new([[0.0; BLOCK]; 6]), env: Box::new([0.0; BLOCK]), env_active: false, @@ -264,6 +274,16 @@ impl GrainBed { } } + /// Run `f` with `rng` as this bed's generator (swapped in and back out): retail's grain picks + /// all draw from the one title-wide generator (`0x82FD7D74`), so a second owner's bed (an NPC + /// skater's, [`crate::runtime::Runtime::npc_grains`]) draws from the local bed's. + pub fn share_rng(&mut self, rng: &mut Rng, f: impl FnOnce(&mut Self) -> R) -> R { + std::mem::swap(&mut self.rng, rng); + let out = f(self); + std::mem::swap(&mut self.rng, rng); + out + } + /// Stop both players of a truck at once (surface change, no contact). pub fn stop_truck(&mut self, truck: usize) { for p in &mut self.trucks[truck].players { @@ -328,7 +348,7 @@ impl GrainBed { if self.chain_extras && !t.built { c.bus = [[0.0; BLOCK]; 6]; } else if self.chain_extras { - self.env_active |= c.process_full(mix, &mut self.env, &self.chain_tuning, self.gain); + self.env_active |= c.process_full(mix, &mut self.env, &self.chain_tuning, self.gain, self.local); } else { c.process(mix); } @@ -465,6 +485,50 @@ mod tests { } } + /// An NPC skater's bed (`local` false) has no graph 3: its send level changes nothing. Its + /// picks draw from the generator it is handed (`share_rng`), leaving its own untouched. + #[test] + fn a_non_local_bed_has_no_graph3_and_shares_the_generator() { + let a = GrainParams { attack: 0.1, sustain: 0.2, release: 0.1, window: 1.6, drift: 0.05 }; + let rec = [Record { gain: 0.5, pitch: 1.0, position: 0.3 }, Record { gain: 0.25, pitch: 1.0, position: 0.2 }]; + let run = |local: bool, send: f32| { + let mut bed = GrainBed::new(); + bed.local = local; + bed.bind_truck(0, source(), [a, a], rec); + bed.set_chains(0, [ChainValues { graph3_send: send, ..ChainValues::default() }; 2]); + let mut all = Vec::new(); + for _ in 0..100 { + let mut out = [[0.0f32; BLOCK]; 6]; + bed.render(&mut out); + all.extend(out.iter().flatten().copied()); + } + all + }; + assert_eq!(run(false, 0.0), run(false, 3.0), "no graph 3: the send is inert"); + assert_ne!(run(true, 0.0), run(true, 3.0), "the local bed's graph 3 adds the copy"); + // Send 0: a built graph 3 still adds its filters' anti-denormal bias (~1e-18), nothing else. + let gap = run(true, 0.0).iter().zip(run(false, 0.0)).fold(0.0f32, |m, (x, y)| m.max((x - y).abs())); + assert!(gap < 1e-12, "send 0: graph 3 adds only its bias ({gap})"); + // Shared generator: the same output as a bed that owns that generator's state, and the + // borrowing bed's own generator never moves. + let seed = [1, 2, 3, 4, 5, 6]; + let mut owner = GrainBed::new(); + owner.rng = Rng::new(seed); + let mut borrower = GrainBed::new(); + let own = format!("{:?}", borrower.rng); + let mut shared = Rng::new(seed); + owner.bind_truck(0, source(), [a, a], rec); + borrower.share_rng(&mut shared, |b| b.bind_truck(0, source(), [a, a], rec)); + for _ in 0..100 { + let (mut x, mut y) = ([[0.0f32; BLOCK]; 6], [[0.0f32; BLOCK]; 6]); + owner.render(&mut x); + borrower.share_rng(&mut shared, |b| b.render(&mut y)); + assert_eq!(x, y); + } + assert_eq!(format!("{:?}", borrower.rng), own, "its own generator is untouched"); + assert_eq!(format!("{shared:?}"), format!("{:?}", owner.rng), "the shared one drew as the owner's did"); + } + #[test] fn extras_off_is_the_older_chain() { // The older chain ignores the new values (no FSS, graph 3 or env send). diff --git a/crates/skate-audio/src/player/contacts.rs b/crates/skate-audio/src/player/contacts.rs index 72c9dd5f..f3cae930 100644 --- a/crates/skate-audio/src/player/contacts.rs +++ b/crates/skate-audio/src/player/contacts.rs @@ -132,6 +132,11 @@ pub struct ContactsTuning { /// (`3695327CFB5E1AC3` / `35FEE8A95523D812`), 112 (the face) at tier 0 (`076E9081CA1759E9` / /// `DF539915EB7E883E`) and tier 1 (`8E3025BAA686F721` / `504D3B73505972D4`). pub body_cooldown: f32, + /// The audio-state bridge's speed graph (`sub_824B0DA8`, the holder `*(0x830CFDA4)+36` = class + /// `6EBA5BCD3E38A98A` `default` field `8B164823E008749C`, a `Sk8::PointNegGraphData8`): the + /// eight region impacts are multiplied by it at the previous frame's |COM v| + /// ([`super::AudioState::com_speed_216`]) before the body poster reads them. + pub body_speed_curve: SpeedGraph8, pub body_110: [[f32; 2]; 2], pub body_111: [f32; 2], pub body_112: [[f32; 2]; 2], @@ -186,6 +191,7 @@ impl Default for ContactsTuning { lift_ids: [85, 89, 93, 81, 77], plant_eq: 1, body_cooldown: 15.0, + body_speed_curve: SpeedGraph8::BODY_SPEED, body_110: [[0.75, 1.25], [0.7, 1.25]], body_111: [0.65, 1.25], body_112: [[0.05, 0.3], [0.3, 0.55]], @@ -193,6 +199,57 @@ impl Default for ContactsTuning { } } +/// An 8-point vault graph (`Sk8::PointNegGraphData8`: x at record `+16`, y at `+48`) evaluated by +/// `sub_82481E10(8, x, y, v)`: below x0 → y0, from x7 on → y7, else the linear piece of the first +/// i with v < x[i] (y[i] where two x coincide), slope first, `fmadds` order. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct SpeedGraph8 { + pub x: [f32; 8], + pub y: [f32; 8], +} + +impl SpeedGraph8 { + /// The stock vault's bridge speed graph (class `6EBA5BCD3E38A98A` `default`, field + /// `8B164823E008749C`; the same words read from the running game at `[[[0x830CFDA4]+36]+4]`): + /// x 0 … 0.946 m/s, y 1.0 … 5.0. + pub const BODY_SPEED: SpeedGraph8 = SpeedGraph8::from_bits( + [0x0000_0000, 0x3EBB_9F41, 0x3EE5_50DE, 0x3F05_6B91, 0x3F17_C3F8, 0x3F3D_B4F8, 0x3F5C_FA27, 0x3F72_3DB4], + [0x3F80_0000, 0x3F99_999A, 0x3FBE_2BE0, 0x3FF5_0753, 0x401B_6DB5, 0x4066_6666, 0x4092_4921, 0x40A0_0000], + ); + + pub const fn from_bits(x: [u32; 8], y: [u32; 8]) -> Self { + let mut g = SpeedGraph8 { x: [0.0; 8], y: [0.0; 8] }; + let mut i = 0; + while i < 8 { + g.x[i] = f32::from_bits(x[i]); + g.y[i] = f32::from_bits(y[i]); + i += 1; + } + g + } + + /// `sub_82481E10` with eight points. + pub fn eval(&self, v: f32) -> f32 { + let (x, y) = (&self.x, &self.y); + if v < x[0] { + return y[0]; + } + if !(v < x[7]) { + return y[7]; + } + for i in 1..8 { + if v < x[i] { + let dx = x[i] - x[i - 1]; + if dx > 0.0 { + return ((y[i] - y[i - 1]) / dx).mul_add(v - x[i - 1], y[i - 1]); + } + return y[i]; + } + } + y[0] + } +} + /// The body poster's material of a body region (`sub_824BCBA0`: head 97, torso 98, arms 100, legs /// 99), its cloth contact (`+112`: none / denim 109 / skin 107 / denim 108) and its pad contact /// (`+120`: 110, the face 112 with the face point in contact, 111 on the limbs). The Hall of Meat @@ -272,12 +329,33 @@ pub struct Contacts { lift: Option, /// `+260..+280` the body regions' cooldowns (frames, `sub_824BC188`). body_cooldown: [f32; 6], - /// Body-poster messages posted (diagnostics). + /// Body-poster messages posted (diagnostics) and a running FNV-1a digest of them (every field, + /// in posting order): two runs posted the same messages iff counts and digests agree. pub body_posts: u64, + pub body_digest: u64, + /// The body poster's console cadence: `Some(n)` = n console frames end in this process (the + /// host's `mixmap::cadence`; 0 on the 60 Hz steps between them), so the poster runs n times + /// here, once per console frame as retail's per-rendered-frame `sub_824BC188` does on the 30 fps + /// console (15-frame cooldown = 0.5 s at any real frame rate). `None`: once per process, the + /// cadence before 2026-10-03 (the tests, `SKATE_AEMS_BODY_CONSOLE=0`). + pub body_calls: Option, + /// The deck-impact poster (`sub_824BD000`) the same way: `Some(n)` runs it n times in this + /// process (its 6-frame cooldown in console frames = 0.2 s), `None` once per process (before + /// 2026-10-03; `SKATE_AEMS_DECK_CONSOLE=0`). Its messages so far and their digest (diagnostics). + pub deck_calls: Option, + pub deck_posts: u64, + pub deck_digest: u64, /// The push foot's plant / lift (`sub_824BBB28`; `SKATE_AEMS_PLANT_LIFT=0` off) and the body /// poster (`sub_824BC188`; `SKATE_AEMS_BODY_IMPACTS=0` off). The host turns them on. pub plant_lift_on: bool, pub body_on: bool, + /// The bridge's speed graph on the region impacts ([`ContactsTuning::body_speed_curve`] at + /// [`AudioState::com_speed_216`]; `SKATE_AEMS_BODY_CURVE=0` / `E2E_BODY_CURVE=0` off: the + /// impacts as the conditioner wrote them, before 2026-10-03). The host turns it on. + pub body_speed_on: bool, + /// Diagnostics (the e2e harness): when `Some`, every body-poster message with its region and + /// the impact the poster read. + pub body_log: Option>, } /// The route of a poster's sounds: eEQChain bus `bus` (re-rolled on first use by the local @@ -291,6 +369,20 @@ fn route(bus: u8, s: &AudioState, env: Option) -> crate::bus::Route { } } +/// FNV-1a over a collision message's fields (diagnostics: [`Contacts::body_digest`]). +fn body_digest(mut h: u64, m: &Message) -> u64 { + if h == 0 { + h = 0xCBF2_9CE4_8422_2325; + } + let words = [m.material[0] as u32, m.material[1] as u32, m.tier[0] as u32, m.tier[1] as u32, m.position[0].to_bits(), m.position[1].to_bits(), m.position[2].to_bits(), m.level[0] as u32, m.level[1] as u32, u32::from(m.local)]; + for w in words { + for b in w.to_le_bytes() { + h = (h ^ u64::from(b)).wrapping_mul(0x0000_0100_0000_01B3); + } + } + h +} + /// `sub_824B95A0`'s memory: time out of the deck box per foot (`+360` foot 0, `+352` foot 1) and /// of both (`+368`), the step counter `+364`, last frame's feet (`+356` foot 0, `+348` foot 1), the /// hippy-jump latch `+476` with its bucket `+480`, and the tap sounds `+100..+108` by mode. @@ -375,9 +467,13 @@ impl Contacts { self.plant_lift(s, t, c, host); } if self.body_on { - self.body(s, t, c); + for _ in 0..self.body_calls.unwrap_or(1) { + self.body(s, t, c); + } + } + for _ in 0..self.deck_calls.unwrap_or(1) { + self.deck(s, t, c); } - self.deck(s, t, c); self.step_on.process(s, &c.step_on, host); } @@ -428,8 +524,11 @@ impl Contacts { } let ct = &t.collision; let none = NO_MATERIAL as i32; + // `sub_824B0DA8`: `+496 + 4i` ×= graph(`+216`) (`fmuls`, no clamp after: the poster sees up + // to 5.0, which is what reaches the concrete record's tier 1 / 2 above 1.0 / 1.85). + let scale = if self.body_speed_on { c.body_speed_curve.eval(s.com_speed_216) } else { 1.0 }; for i in 0..6 { - let impact = s.body_impact[i]; + let impact = if self.body_speed_on { s.body_impact[i] * scale } else { s.body_impact[i] }; if impact > 0.0 && !(self.body_cooldown[i] > 0.0) { let (a, cloth, pad) = body_materials(i, s.body_slide_flag); let tag = s.body_tag[i]; @@ -464,6 +563,13 @@ impl Contacts { } } self.body_posts += posts; + let fresh = self.outbox.len() - posts as usize; + for m in &self.outbox[fresh..] { + self.body_digest = body_digest(self.body_digest, m); + } + if let Some(log) = self.body_log.as_mut() { + log.extend(self.outbox[fresh..].iter().map(|m| (i, impact, *m))); + } } } if self.body_cooldown[i] > 0.0 { @@ -574,6 +680,12 @@ impl Contacts { let lb = (ct.level_scale(b) * lb as f32) as i32; self.outbox.push(Message { tier: [sa, sb], level: [la, lb], ..msg }); } + let posts = 1 + usize::from(!(sa == 3 && sb == 3)); + self.deck_posts += posts as u64; + let fresh = self.outbox.len() - posts; + for m in &self.outbox[fresh..] { + self.deck_digest = body_digest(self.deck_digest, m); + } } } if self.deck_cooldown > 0.0 { @@ -1214,8 +1326,8 @@ mod tests { assert_eq!(h.log.started.len(), 3, "plant_lift_on off: silent"); } - #[test] - fn a_body_region_impact_posts_the_pair_cloth_and_pad_contacts() { + /// Concrete (2), the arm (100), skin (107) and the limb pad (111) with retail-shaped bands. + fn body_tuning() -> PlayerTuning { use crate::player::collision::{CollisionTuning, Material}; let windows = [16000, 32767, 25000, 32767, 16000, 32767, 25000, 32767, 24000, 32767, 16000, 32767, 25000, 32767]; let mut materials = vec![Material::default(); 143]; @@ -1223,7 +1335,138 @@ mod tests { materials[100] = Material { kind: 0, gain: 32767, windows, bands: [0.65, 1.25, 0.2, 0.005], scale: 1.0, ..Material::default() }; materials[107] = Material { kind: 0, gain: 18000, windows, bands: [1.0, 2.0, 0.5, 0.005], ..Material::default() }; materials[111] = Material { kind: 0, gain: 15000, windows, bands: [1.0, 2.0, 0.5, 0.005], ..Material::default() }; - let t = PlayerTuning { collision: CollisionTuning { materials, surface_class: vec![0; 95], surface_eq: Vec::new() }, ..PlayerTuning::default() }; + PlayerTuning { collision: CollisionTuning { materials, surface_class: vec![0; 95], surface_eq: Vec::new() }, ..PlayerTuning::default() } + } + + /// The body poster on the console cadence (2026-10-03): a host at any real frame rate runs + /// the components on the frames that complete 60 Hz steps (newest step's state) and the poster + /// once per console frame (`mixmap::cadence`, every second step), so the same messages are + /// posted on the same steps at 30 / 60 / 144 / 240 / 365 fps, and the 15-frame cooldown lasts + /// 30 steps = 0.5 s. (Below 30 fps a pass spans more than one console frame and the host has only + /// the newest step's state.) The old per-process cadence depends on the frame rate. + #[test] + fn the_body_poster_keeps_the_console_cadence_at_any_frame_rate() { + use crate::mixmap::cadence::Cadence; + let (t, c) = (body_tuning(), ContactsTuning::default()); + // 20 s of 60 Hz steps: both arm regions on concrete, impacts rising and falling (some + // under every floor, some tier 2), as the conditioner's 4-step max hands them over. + let steps: Vec = (0..1200u32) + .map(|i| { + let mut s = AudioState { bail: true, ..AudioState::default() }; + for (r, k) in [(2usize, 7u32), (3, 11)] { + let x = (i.wrapping_mul(k).wrapping_add(r as u32 * 13) % 41) as f32 / 50.0; + s.body_impact[r] = if x < 0.1 { 0.0 } else { x }; + s.body_tag[r] = 3; + } + s.board_position = [i as f32 * 0.1, 0.0, 0.0]; + s + }) + .collect(); + let run = |fps: f64, console: bool| -> Vec<(usize, u64, u64)> { + let mut k = Contacts { body_on: true, ..Default::default() }; + let mut h = Log::default(); + let (mut cadence, mut owed, mut step, mut out) = (Cadence::default(), 0.0f64, 0usize, Vec::new()); + while step < steps.len() { + owed += 60.0 / fps; + let n = (owed.floor() as usize).min(steps.len() - step); + owed -= n as f64; + if n == 0 { + continue; + } + step += n; + let calls = cadence.advance(n); + k.body_calls = console.then_some(calls); + let before = k.body_posts; + k.process(&steps[step - 1], [0; 4], &t, &c, &mut h); + k.outbox.clear(); + if k.body_posts != before { + out.push((step, k.body_posts, k.body_digest)); + } + } + out + }; + let reference = run(60.0, true); + assert!(reference.len() > 50, "the stream posts ({} times)", reference.len()); + for fps in [30.0, 144.0, 240.0, 365.0] { + assert_eq!(run(fps, true), reference, "{fps} fps"); + } + assert_ne!(run(30.0, false), run(60.0, false), "the per-process cadence depends on the frame rate"); + // A held impact: one post group per 15 console frames = 30 steps (0.5 s). + let mut k = Contacts { body_on: true, ..Default::default() }; + let mut h = Log::default(); + let mut s = AudioState { bail: true, ..AudioState::default() }; + s.body_impact[2] = 0.7; + s.body_tag[2] = 3; + let (mut cadence, mut at) = (Cadence::default(), Vec::new()); + for step in 1..=120 { + k.body_calls = Some(cadence.advance(1)); + let before = k.body_posts; + k.process(&s, [0; 4], &t, &c, &mut h); + if k.body_posts != before { + at.push(step); + } + } + assert_eq!(at, [2, 32, 62, 92]); + } + + /// The deck poster (`sub_824BD000`) on the console cadence (2026-10-03): the same messages on the + /// same steps at 30 / 60 / 144 / 240 / 365 fps, and the 6-frame cooldown lasts 12 steps (0.2 s). + #[test] + fn the_deck_poster_keeps_the_console_cadence_at_any_frame_rate() { + use crate::mixmap::cadence::Cadence; + let (t, c) = (body_tuning(), ContactsTuning::default()); + let steps: Vec = (0..1200u32) + .map(|i| { + let x = (i.wrapping_mul(13) % 37) as f32 / 20.0; + AudioState { deck_impact: if x < 0.3 { 0.0 } else { x.min(1.0) }, deck_material: 2, board_position: [i as f32 * 0.1, 0.0, 0.0], ..AudioState::default() } + }) + .collect(); + let run = |fps: f64, console: bool| -> Vec<(usize, u64, u64)> { + let mut k = Contacts::default(); + let mut h = Log::default(); + let (mut cadence, mut owed, mut step, mut out) = (Cadence::default(), 0.0f64, 0usize, Vec::new()); + while step < steps.len() { + owed += 60.0 / fps; + let n = (owed.floor() as usize).min(steps.len() - step); + owed -= n as f64; + if n == 0 { + continue; + } + step += n; + k.deck_calls = console.then_some(cadence.advance(n)); + let before = k.deck_posts; + k.process(&steps[step - 1], [0; 4], &t, &c, &mut h); + k.outbox.clear(); + if k.deck_posts != before { + out.push((step, k.deck_posts, k.deck_digest)); + } + } + out + }; + let reference = run(60.0, true); + assert!(reference.len() > 50, "the stream posts ({} times)", reference.len()); + for fps in [30.0, 144.0, 240.0, 365.0] { + assert_eq!(run(fps, true), reference, "{fps} fps"); + } + assert_ne!(run(30.0, false), run(60.0, false), "the per-process cadence depends on the frame rate"); + let mut k = Contacts::default(); + let mut h = Log::default(); + let s = AudioState { deck_impact: 0.7, deck_material: 2, ..AudioState::default() }; + let (mut cadence, mut at) = (Cadence::default(), Vec::new()); + for step in 1..=50 { + k.deck_calls = Some(cadence.advance(1)); + let before = k.deck_posts; + k.process(&s, [0; 4], &t, &c, &mut h); + if k.deck_posts != before { + at.push(step); + } + } + assert_eq!(at, [2, 14, 26, 38, 50], "every 6 console frames = 12 steps"); + } + + #[test] + fn a_body_region_impact_posts_the_pair_cloth_and_pad_contacts() { + let t = body_tuning(); let c = ContactsTuning::default(); let mut k = Contacts { body_on: true, ..Default::default() }; let mut h = Log::default(); @@ -1255,6 +1498,64 @@ mod tests { assert!(k.outbox.is_empty()); } + /// `sub_82481E10` over the bridge's 8 points: clamped to y0 below x0 and to y7 from x7 on + /// (an unordered input too), linear in between, a knot gives its own y. + #[test] + fn the_bridge_speed_graph_is_the_vault_curve_piecewise_linear_and_clamped() { + let g = SpeedGraph8::BODY_SPEED; + let x = [0.0, 0.366_449_4, 0.447_882_6, 0.521_172_6, 0.592_834, 0.741_042_6, 0.863_192, 0.946_254]; + let y = [1.0, 1.2, 1.485_714, 1.914_286, 2.428_571, 3.6, 4.571_427, 5.0]; + for i in 0..8 { + assert!((g.x[i] - x[i]).abs() < 1e-6 && (g.y[i] - y[i]).abs() < 1e-6, "point {i}"); + assert_eq!(g.eval(g.x[i]), g.y[i], "a knot reads its own y"); + } + assert_eq!(g.eval(-1.0), 1.0, "below x0: y0"); + assert_eq!(g.eval(0.0), 1.0, "at rest: x1"); + assert_eq!(g.eval(0.946_254), 5.0); + assert_eq!(g.eval(23.0), 5.0, "past x7: y7"); + assert_eq!(g.eval(f32::NAN), 5.0, "unordered: the last value"); + // Halfway between the knots 5 and 6: (3.6 + 4.571) / 2. + let mid = (g.x[5] + g.x[6]) * 0.5; + assert!((g.eval(mid) - (g.y[5] + g.y[6]) * 0.5).abs() < 1e-5); + // Slope first, fmadds: bit-exact against the formula. + let v = 0.2f32; + assert_eq!(g.eval(v), ((g.y[1] - g.y[0]) / (g.x[1] - g.x[0])).mul_add(v - g.x[0], g.y[0])); + } + + /// The bridge's graph scales the region impacts before the poster: at rest (×1) a 0.3 arm hit on + /// concrete is tier 0 for both; moving (×5) the same hit reads 1.5 (concrete tier 1, the arm + /// tier 2), 0.4 → 2.0 (concrete tier 2): the concrete record's 1047 / 991 tiers become + /// reachable as in the recomp. Off, or at rest, the impacts post unchanged. The deck poster + /// does not see the graph. + #[test] + fn the_speed_graph_scales_the_body_impacts_not_the_deck() { + let t = body_tuning(); + let c = ContactsTuning::default(); + let post = |impact: f32, speed: f32, on: bool| { + let mut k = Contacts { body_on: true, body_speed_on: on, ..Default::default() }; + let mut s = AudioState { bail: true, com_speed_216: speed, ..AudioState::default() }; + s.body_impact[2] = impact; + s.body_tag[2] = 3; + k.process(&s, [0; 4], &t, &c, &mut Log::default()); + k.outbox.first().map(|m| (m.material, m.tier)) + }; + assert_eq!(post(0.3, 0.0, true), Some(([100, 2], [1, 0])), "×1: arm tier 1 (> 0.2), concrete tier 0"); + assert_eq!(post(0.3, 2.0, true), Some(([100, 2], [2, 1])), "×5: 1.5 → arm tier 2, concrete tier 1"); + assert_eq!(post(0.4, 2.0, true), Some(([100, 2], [2, 2])), "×5: 2.0 → concrete tier 2"); + assert_eq!(post(0.3, 2.0, false), post(0.3, 0.0, true), "off = the conditioner's value"); + // Under every floor at rest (0.001 < 0.005 arm, < 0.12 concrete), above the arm's when moving. + assert_eq!(post(0.001, 0.0, true), None); + assert_eq!(post(0.0011, 2.0, true).map(|m| m.1), Some([0, 3]), "0.0055 > the arm floor 0.005"); + // The deck poster reads `+668`, which the bridge does not scale. + let deck = |on: bool| { + let mut k = Contacts { body_speed_on: on, ..Default::default() }; + let s = AudioState { deck_impact: 0.3, deck_material: 2, com_speed_216: 3.0, ..AudioState::default() }; + k.process(&s, [0; 4], &t, &c, &mut Log::default()); + (k.deck_posts, k.deck_digest) + }; + assert_eq!(deck(true), deck(false)); + } + #[test] fn a_foot_sliding_on_the_deck_scuffs() { let (t, c) = (PlayerTuning::default(), ContactsTuning::default()); diff --git a/crates/skate-audio/src/player/state.rs b/crates/skate-audio/src/player/state.rs index bd298872..ede6d1cc 100644 --- a/crates/skate-audio/src/player/state.rs +++ b/crates/skate-audio/src/player/state.rs @@ -158,8 +158,14 @@ pub struct AudioState { /// `+496..+516` the six body regions' impacts (Collision `+80..`: `sub_82BD60C8` writes /// clamp01(max(0.001, Δv(part) · region normal × part mass × physics_collision `+164` (10))) /// per region with a contact part, else 0, and the conditioner `sub_82773298` keeps the max of - /// the last 4 frames), read by the body poster `sub_824BC188` ([`super::contacts`]). + /// the last 4 frames), read by the body poster `sub_824BC188` ([`super::contacts`]). These are + /// the values before the bridge's speed graph: the poster applies it at [`Self::com_speed_216`] + /// (retail stores the product back into `+496`). pub body_impact: [f32; 6], + /// `+216`: the previous bridge frame's `+212` (|COM v|, m/s; the bridge `sub_824B0DA8` copies + /// `+212` there after scaling the region impacts by the speed graph at it). The game sets it + /// per physics step; 0 (graph value 1.0) when nobody does. + pub com_speed_216: f32, /// `+688` / `+689`: hand limb 2 (part 3) / 3 (part 7) on the deck: Skeleton `+602` / `+603` /// ([`super::step_on::hands_on_deck`]). pub hands_on_deck: [bool; 2], @@ -247,6 +253,7 @@ impl Default for AudioState { body_tag: [0; 6], body_slide_flag: false, body_impact: [0.0; 6], + com_speed_216: 0.0, hands_on_deck: [false; 2], in_water: false, under_water: false, diff --git a/crates/skate-audio/src/runtime.rs b/crates/skate-audio/src/runtime.rs index 01324119..8babbe8c 100644 --- a/crates/skate-audio/src/runtime.rs +++ b/crates/skate-audio/src/runtime.rs @@ -23,6 +23,14 @@ pub struct Runtime { pub eval: Evaluator, pub mixer: Mixer, pub grains: GrainBed, + /// The Player slot's second owner's rolling bed: the NPC skater holding instance 1 + /// (`world::skaters`; retail's second `SFXObj_SkateBoard` has its own grain players). The host + /// creates it at the NPC bed's first step; `None` until then (nothing renders, the local bed's + /// output is unchanged). It draws its picks from [`Self::grains`]' generator (retail's one title-wide + /// generator), plays at the local bed's user gain and has no graph 3 (`GrainBed::local` false). + pub npc_grains: Option>, + /// The two beds' summed env sends (when both send). + env_sum: Box<[f32; BLOCK]>, /// The Splice one-shot player (`SPLC` banks); its voices live in [`Runtime::mixer`]. pub splice: SplicePlayer, /// Our own user-volume scales (1 = retail level): AEMS voices of world banks and of the @@ -50,6 +58,8 @@ impl Runtime { eval: Evaluator::new(), mixer: Mixer::new(), grains: GrainBed::new(), + npc_grains: None, + env_sum: Box::new([0.0; BLOCK]), splice: SplicePlayer::new(), aems_gain: 1.0, player_gain: 1.0, @@ -137,10 +147,35 @@ impl Runtime { // The bed renders first so that its chains' env sends reach this block's environment // network; its dry mix is added after the mixer's buses, as before. let bed = self.grains.render_block(); - self.mixer.render_with_env(&mut self.bus, if bed { self.grains.env_send() } else { None }); + // The NPC skater's bed after the local one, on the same generator. + let npc = match self.npc_grains.as_deref_mut() { + Some(n) => { + n.gain = self.grains.gain; + n.chain_extras = self.grains.chain_extras; + n.share_rng(&mut self.grains.rng, GrainBed::render_block) + } + None => false, + }; + let local_env = if bed { self.grains.env_send() } else { None }; + let npc_env = if npc { self.npc_grains.as_deref().and_then(GrainBed::env_send) } else { None }; + let env = match (local_env, npc_env) { + (Some(a), Some(b)) => { + for ((s, x), y) in self.env_sum.iter_mut().zip(a).zip(b) { + *s = x + y; + } + Some(&*self.env_sum) + } + (a, b) => a.or(b), + }; + self.mixer.render_with_env(&mut self.bus, env); if bed { self.grains.add_to(&mut self.bus); } + if npc { + if let Some(n) = self.npc_grains.as_deref() { + n.add_to(&mut self.bus); + } + } if !self.scheduled.is_empty() { let now = self.blocks; let mut i = 0; diff --git a/crates/skate-audio/src/world/skaters.rs b/crates/skate-audio/src/world/skaters.rs index 98c7284f..ab6cbe87 100644 --- a/crates/skate-audio/src/world/skaters.rs +++ b/crates/skate-audio/src/world/skaters.rs @@ -286,6 +286,17 @@ impl NpcSkater { c } + /// The body poster's console cadence for the next [`Self::process`] (`Contacts::body_calls`: + /// `Some(n)` = n console frames end there; `None` = once per call). + pub fn set_body_calls(&mut self, calls: Option) { + self.board.body_calls = calls; + } + + /// The deck poster's console cadence for the next [`Self::process`] (`Contacts::deck_calls`). + pub fn set_deck_calls(&mut self, calls: Option) { + self.board.deck_calls = calls; + } + /// The collision messages this skater's contacts posted (hand them to the collision manager /// before its process, like the local player's). pub fn take_collisions(&mut self) -> Vec { diff --git a/crates/skate-game/src/game_audio/e2e.rs b/crates/skate-game/src/game_audio/e2e.rs index fb45b44e..864c5677 100644 --- a/crates/skate-game/src/game_audio/e2e.rs +++ b/crates/skate-game/src/game_audio/e2e.rs @@ -49,11 +49,39 @@ struct Script { jump_velocity: f32, /// Last row's push plant (the edge `+335`). planted: bool, + /// The bridge's `+212` (|COM v|) of the last row and the last row's COM position (the + /// fallback below), for this row's `+216`. + com_212: f32, + com_at: Option<[f32; 3]>, } impl Script { + /// The audio state's `+216` for this row (last row's `+212`), then this row's `+212`: the + /// logged |COM v| (`com_speed`, logs since 2026-10-03 afternoon); for older logs |Δ COM + /// position| × 60 from the logged COM positions (`com_x/y/z`, the reckoning's followed point at + /// 4 decimals, so ±0.006 m/s; the graph saturates from 0.95 m/s); without either 0 (graph 1.0: + /// the impacts as logged). + fn com_216(&mut self, r: &Row) -> f32 { + let previous = self.com_212; + self.com_212 = if r.0.contains_key("com_speed") { + r.f("com_speed") + } else if r.0.contains_key("com_x") { + let at = [r.f("com_x"), r.f("com_y"), r.f("com_z")]; + let v = self.com_at.map_or(0.0, |p| { + let d = [at[0] - p[0], at[1] - p[1], at[2] - p[2]]; + (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt() * 60.0 + }); + self.com_at = Some(at); + v + } else { + 0.0 + }; + previous + } + fn riding(&mut self, r: &Row) -> Riding { let speed = r.f("speed"); + let com_speed_216 = self.com_216(r); self.x += speed / 60.0; let state = r.i("state") as u32; // As `skate_events::observe`: an unridden board reports no contacts (logs recorded before @@ -194,6 +222,7 @@ impl Script { step_code: if r.0.contains_key("step") { r.i("step") as i32 } else { 1 }, body_speed: r.f("body"), limb_speed: r.f("limb"), + com_speed_216, ..h }; Riding { @@ -348,15 +377,37 @@ fn e2e_render() { p.footsteps_on = p.contacts_on && !off("E2E_FOOTSTEPS"); // The session-review ports (E2E_PLANT_LIFT / E2E_BODY_IMPACTS / E2E_GRIND_ONOFF=0: off). p.set_review_ports(!off("E2E_PLANT_LIFT"), !off("E2E_BODY_IMPACTS"), !off("E2E_GRIND_ONOFF")); + // The body poster on the console cadence (with the MixMap's; E2E_BODY_CONSOLE=0: per call). + p.body_console = !off("E2E_BODY_CONSOLE"); + // The deck poster too (E2E_DECK_CONSOLE=0: per call). + p.deck_console = !off("E2E_DECK_CONSOLE"); + // The bridge's speed graph on the body impacts (E2E_BODY_CURVE=0: the logged impacts). + p.set_body_curve(!off("E2E_BODY_CURVE")); + // E2E_BODY_LOG=1: every body-poster message (`.ours.bodymsg.tsv`). + let body_log = std::env::var("E2E_BODY_LOG").is_ok_and(|v| v == "1"); + p.set_body_log(body_log); p.set_footstep_materials(library.footstep_materials()); p.wheels_on = wheels && std::env::var("E2E_WHEELS").map_or(true, |v| v != "0"); (p.rolling_on, p.rattle_on, p.slide_on, p.tricks_on, p.treatment_on) = (optional[0], optional[1], optional[2], optional[3], optional[4]); let mut bed = super::grain_bed::Bed::new(&library).expect("grain bed data"); let seams = skate_audio::player::tuning::PlayerTuning { seam_wobbles: p.tuning.seam_wobbles.clone(), ..Default::default() }; - let mut script = Script { x: 0.0, air_time: 0.0, pushes: 0, family: None, material: None, jump_velocity: 0.0, planted: false }; + let mut script = Script { x: 0.0, air_time: 0.0, pushes: 0, family: None, material: None, jump_velocity: 0.0, planted: false, com_212: 0.0, com_at: None }; let mut out = std::io::BufWriter::new(std::fs::File::create(dir.join(format!("{name}.ours.f32"))).unwrap()); let mut voices = std::io::BufWriter::new(std::fs::File::create(dir.join(format!("{name}.ours.voices.tsv"))).unwrap()); writeln!(voices, "frame\tbank\tslot\tgain\tpitch").unwrap(); + // The body poster's messages: the row where its count changed, the count and the digest. + let mut body = std::io::BufWriter::new(std::fs::File::create(dir.join(format!("{name}.ours.body.tsv"))).unwrap()); + writeln!(body, "frame\tposts\tdigest").unwrap(); + let mut body_seen = p.body_trace(); + let mut bodymsg = body_log.then(|| { + let mut w = std::io::BufWriter::new(std::fs::File::create(dir.join(format!("{name}.ours.bodymsg.tsv"))).unwrap()); + writeln!(w, "frame\trow\tregion\timpact\tcom216\tmat_a\tmat_b\ttier_a\ttier_b\tlevel_a\tlevel_b").unwrap(); + w + }); + // The deck poster's messages, the same way. + let mut deck = std::io::BufWriter::new(std::fs::File::create(dir.join(format!("{name}.ours.deck.tsv"))).unwrap()); + writeln!(deck, "frame\tposts\tdigest").unwrap(); + let mut deck_seen = p.deck_trace(); let (mut owed, blocks_per_frame) = (0.0f64, 48_000.0 / 256.0 / 60.0); let order: Vec = std::iter::repeat(0).take(60).chain(0..rows.len()).collect(); // E2E_TIMING=1: per-frame cost of the game-thread side and per-block cost of the render. @@ -477,6 +528,19 @@ fn e2e_render() { let t1 = std::time::Instant::now(); p.process(&mut m, &s, &mut shared.lock().unwrap(), speed_scale, loose); let t2 = std::time::Instant::now(); + if p.body_trace() != body_seen { + body_seen = p.body_trace(); + writeln!(body, "{frame}\t{}\t{:016x}", body_seen.0, body_seen.1).unwrap(); + } + if let Some(w) = bodymsg.as_mut() { + for (region, impact, m) in p.take_body_log() { + writeln!(w, "{frame}\t{i}\t{region}\t{impact}\t{}\t{}\t{}\t{}\t{}\t{}\t{}", s.com_speed_216, m.material[0], m.material[1], m.tier[0], m.tier[1], m.level[0], m.level[1]).unwrap(); + } + } + if p.deck_trace() != deck_seen { + deck_seen = p.deck_trace(); + writeln!(deck, "{frame}\t{}\t{:016x}", deck_seen.0, deck_seen.1).unwrap(); + } if mix_console { for _ in 0..mix_calls { m.tick(skate_audio::mixmap::cadence::CONSOLE_DT); diff --git a/crates/skate-game/src/game_audio/grain_bed.rs b/crates/skate-game/src/game_audio/grain_bed.rs index b7cb7624..5b47cf6b 100644 --- a/crates/skate-game/src/game_audio/grain_bed.rs +++ b/crates/skate-game/src/game_audio/grain_bed.rs @@ -26,6 +26,13 @@ //! - **Routing:** with the native rolling layers (`PlayerAudio::rolling_on`) the owner's two-truck //! surface routing (`player::rolling`, `sub_824C5CA8`) decides the binds and stops ([`GrainEvent`]s) //! and writes SkateBoard inputs 0 / 6; otherwise the bed routes one truck itself (as before). +//! - **The NPC skater's bed** ([`Bed::for_instance`], `npc_skaters.rs`, 2026-10-03): retail's +//! SkateBoard update `sub_824C6BD8` and most of its process run for every Player-slot instance, so +//! the NPC holding instance 1 gets its own bed (`Runtime::npc_grains`) on its own SkateBoard +//! outputs, fed by its routing. The parts with a local test (`[owner+16]+72`) stay the local +//! player's: the seam-pattern gain envelope (`sub_824CA448`), the graph-1 → graph-3 send and level +//! ramp (`sub_824CAEC0`), the gain wobbles (`sub_824CB180`), graph 3 itself (`sub_824C8878`), the +//! graph-2 FlangeSub sends (level(21) / (22), `sub_824C9058`) and the rocket (SenseOfSpeed). use std::collections::{HashMap, HashSet}; use std::sync::Arc; @@ -128,6 +135,9 @@ pub(crate) struct Bed { rng: Rng, /// Downhill level D (owner `+1508`), computed with the owner inputs. downhill: f32, + /// The Player-slot instance this bed belongs to: 0 = the local player (`Runtime::grains`), + /// 1 = the NPC skater holding instance 1 (`Runtime::npc_grains`, [`Bed::for_instance`]). + instance: u32, } impl Bed { @@ -169,6 +179,7 @@ impl Bed { seam: SeamEnvelope::default(), rng: Rng::new(skate_audio::grain::GrainBed::SEED), downhill: 0.0, + instance: 0, }; // Decode every recording now: a grain's first trigger must sound like the later ones // (no decode on the game thread at first use). @@ -179,6 +190,36 @@ impl Bed { Some(bed) } + /// A fresh bed for Player-slot instance `instance` ≥ 1 (an NPC skater's board): the same + /// tunings and decoded recordings (shared `Arc`s, nothing decoded again), no rocket (local + /// only), every modulator at its start. + pub(crate) fn for_instance(&self, instance: u32) -> Self { + Self { + tunings: self.tunings.clone(), + rocket: None, + chain_tuning: self.chain_tuning, + tuned: false, + sources: self.sources.clone(), + failed: self.failed.clone(), + bound: [None; 2], + primary: 0, + rocket_on: false, + pulse_surface: false, + pulse_push: false, + pushes_seen: None, + push_scale: PushEnvelope::default(), + shift: PushShift::default(), + brake: 0.0, + chain: ChainState::default(), + turn: TurnIntensity::default(), + latches: Latches::default(), + seam: SeamEnvelope::default(), + rng: Rng::new(skate_audio::grain::GrainBed::SEED), + downhill: 0.0, + instance, + } + } + /// The member for a wheel surface tag: the soft member while the wheels are soft and the /// install has it (`sub_824C8370`). fn member(&self, tag: u32, soft: bool) -> &'static str { @@ -209,7 +250,7 @@ impl Bed { /// member's divisors). Heading rate (5) and skid (1) reach no MixMap output and are not written. /// `own_routing`: the bed routes itself and writes 0 / 6 (else `player::rolling` does). pub(crate) fn write_inputs(&mut self, m: &mut MixMap, s: &skate_audio::player::AudioState, own_routing: bool) { - let owner = keys::skateboard(0); + let owner = keys::skateboard(self.instance); let pulse = std::mem::take(&mut self.pulse_surface); if own_routing { m.set_input(owner, 0, if pulse { 32767 } else { 0 }); @@ -292,6 +333,29 @@ pub(super) fn step( seams: &skate_audio::player::tuning::PlayerTuning, routed: Option<(Vec, usize)>, ) { + step_with(bed, library, m, r, dt, seams, routed, |apply| { + if let Ok(mut runtime) = super::timing::lock(shared, &super::timing::GAME_LOCK) { + apply(&mut runtime); + } + }); +} + +/// [`step`] with the runtime handed in by `with_runtime` (which calls the given closure with it, +/// or not at all when it can't get it): everything up to the runtime writes runs first, as in +/// [`step`]. The NPC skaters' host calls it with the runtime it already holds. +#[allow(clippy::too_many_arguments)] +pub(super) fn step_with( + bed: &mut Bed, + library: &Library, + m: &MixMap, + r: &super::skate_events::Riding, + dt: f32, + seams: &skate_audio::player::tuning::PlayerTuning, + routed: Option<(Vec, usize)>, + with_runtime: impl FnOnce(&mut dyn FnMut(&mut skate_audio::runtime::Runtime)), +) { + // The local player's bed (instance 0) or an NPC skater's (module docs: what is local-only). + let local = bed.instance == 0; let r = *r; let s = r.audio; let frames = (dt * 60.0).clamp(0.0, 6.0); @@ -346,13 +410,15 @@ pub(super) fn step( bed.brake = board::slew(bed.brake, if r.braking { 1.0 } else { 0.0 }, BRAKE_STEP * frames); let special = bed.latches.update(s.balance, s.wheel_count, s.hippy_jump, s.feet_in_deck_box); let turn = tuning.as_ref().map_or(0.0, |t| bed.turn.step(t, s.com_speed(), s.turn, special, frames)); - let rng = &mut bed.rng; - bed.seam.update(s.seam_pattern[0], dt, |p| seams.seam_wobble(p), || rng.draw()); - // The chain modulators share the title generator, after the seam draws (retail's frame order). - let rng = &mut bed.rng; - bed.chain.frame(&bed.chain_tuning, speed, dt, [wanted[0].is_some(), wanted[1].is_some()], || rng.draw()); + if local { + let rng = &mut bed.rng; + bed.seam.update(s.seam_pattern[0], dt, |p| seams.seam_wobble(p), || rng.draw()); + // The chain modulators share the title generator, after the seam draws (retail's frame order). + let rng = &mut bed.rng; + bed.chain.frame(&bed.chain_tuning, speed, dt, [wanted[0].is_some(), wanted[1].is_some()], || rng.draw()); + } - let owner = keys::skateboard(0); + let owner = keys::skateboard(bed.instance); let inputs = BoardInputs { speed, speed_scale: bed.push_scale.value(), @@ -363,7 +429,7 @@ pub(super) fn step( brake: bed.brake, special, downhill: bed.downhill, - seam: bed.seam.value(), + seam: if local { bed.seam.value() } else { None }, }; let truck_tuning = |b: Option| b.and_then(|b| bed.tunings.get(b.tuning)).cloned(); let tunings = [truck_tuning(wanted[0]), truck_tuning(wanted[1])]; @@ -374,7 +440,7 @@ pub(super) fn step( lowpass_hz: m.filter_hz(owner, 11) as f32, pan_degrees: m.raw(owner, 0) as f32 * chain::DEGREES_PER_RAW, env_send: m.level(owner, 13) as f32 * chain::PER_LEVEL, - flange_send: [m.level(owner, 21) as f32 * chain::PER_LEVEL, m.level(owner, 22) as f32 * chain::PER_LEVEL], + flange_send: if local { [m.level(owner, 21) as f32 * chain::PER_LEVEL, m.level(owner, 22) as f32 * chain::PER_LEVEL] } else { [0.0; 2] }, fss_hz: chain::fss_shifts(t, tuning.as_ref().unwrap_or(t), special, push_shift, downhill), }; let sos = keys::sense_of_speed(0); @@ -387,42 +453,71 @@ pub(super) fn step( _ => None, }; - let Ok(mut runtime) = super::timing::lock(shared, &super::timing::GAME_LOCK) else { return }; - let grains = &mut runtime.grains; - if !bed.tuned { - grains.chain_tuning = bed.chain_tuning; - bed.tuned = true; - } - for t in 0..2 { - if wanted[t] != bed.bound[t] { - grains.stop_truck(t); - if routed.is_none() { - bed.pulse_surface = bed.bound[t].is_some(); + let routed_none = routed.is_none(); + let mut apply = |runtime: &mut skate_audio::runtime::Runtime| { + // The local bed, or the NPC skater's (created at its first use, without graph 3), whose picks + // draw from the local bed's generator (retail's one title-wide generator). + let (grains, mut shared_rng) = if local { + (&mut runtime.grains, None) + } else { + let npc = runtime.npc_grains.get_or_insert_with(|| { + let mut g = Box::new(skate_audio::grain::GrainBed::new()); + g.local = false; + g + }); + (&mut **npc, Some(&mut runtime.grains.rng)) + }; + if !bed.tuned { + grains.chain_tuning = bed.chain_tuning; + bed.tuned = true; + } + for t in 0..2 { + if wanted[t] != bed.bound[t] { + grains.stop_truck(t); + if routed_none { + bed.pulse_surface = bed.bound[t].is_some(); + } + bed.bound[t] = None; + if let (Some(b), Some(source), Some(tu), Some(rec)) = (wanted[t], sources[t].clone(), &tunings[t], records[t]) { + if local { + info!("AUDIO_EVENT grain bind truck {t} {} ({} tuning, surface tag {})", b.stem, b.tuning, r.surface); + } else { + info!("AUDIO_NPC grain bind instance {} truck {t} {} ({} tuning)", bed.instance, b.stem, b.tuning); + } + bed.chain.rebuilt(t); + match shared_rng.as_deref_mut() { + Some(rng) => grains.share_rng(rng, |g| g.bind_truck(t, source, tu.params, rec)), + None => grains.bind_truck(t, source, tu.params, rec), + } + bed.bound[t] = Some(b); + } + } else if let Some(rec) = records[t] { + grains.set_records(t, rec); } - bed.bound[t] = None; - if let (Some(b), Some(source), Some(tu), Some(rec)) = (wanted[t], sources[t].clone(), &tunings[t], records[t]) { - info!("AUDIO_EVENT grain bind truck {t} {} ({} tuning, surface tag {})", b.stem, b.tuning, r.surface); - bed.chain.rebuilt(t); - grains.bind_truck(t, source, tu.params, rec); - bed.bound[t] = Some(b); + if let Some(tu) = &tunings[t] { + grains.set_chains(t, bed.chain.values(t, &frame(tu))); } - } else if let Some(rec) = records[t] { - grains.set_records(t, rec); } - if let Some(tu) = &tunings[t] { - grains.set_chains(t, bed.chain.values(t, &frame(tu))); - } - } - if let (Some(t), Some(rec)) = (bed.rocket, rocket_record) { - if bed.rocket_on && speed * 3.6 <= t.start_kmh { - grains.stop_rocket(); - bed.rocket_on = false; - } else if let Some(source) = rocket_source { - grains.start_rocket(source, t.params, rec); - bed.rocket_on = true; - } else if bed.rocket_on { - grains.rocket.record = rec; + if let (Some(t), Some(rec)) = (bed.rocket, rocket_record) { + if bed.rocket_on && speed * 3.6 <= t.start_kmh { + grains.stop_rocket(); + bed.rocket_on = false; + } else if let Some(source) = rocket_source.clone() { + grains.start_rocket(source, t.params, rec); + bed.rocket_on = true; + } else if bed.rocket_on { + grains.rocket.record = rec; + } } + }; + with_runtime(&mut apply); +} + +/// Stop an NPC skater's bed (its instance was released; `Runtime::npc_grains`). +pub(super) fn stop_npc(rt: &mut skate_audio::runtime::Runtime) { + if let Some(g) = rt.npc_grains.as_deref_mut() { + g.stop_truck(0); + g.stop_truck(1); } } diff --git a/crates/skate-game/src/game_audio/library.rs b/crates/skate-game/src/game_audio/library.rs index 366b3c63..698c8180 100644 --- a/crates/skate-game/src/game_audio/library.rs +++ b/crates/skate-game/src/game_audio/library.rs @@ -559,6 +559,15 @@ impl CollisionJson { c.lift_ids = five(ids("lift_ids"), c.lift_ids); c.plant_eq = f("plant_eq").map_or(c.plant_eq, |v| v as u8); c.body_cooldown = f("body_cooldown").unwrap_or(c.body_cooldown); + // The bridge's speed graph (exported since 2026-10-03; the stock vault's words otherwise). + let eight = |k: &str| -> Option<[f32; 8]> { + let a = self.posters.get(k)?.as_array()?; + let v: Vec = a.iter().filter_map(|v| v.as_f64().map(|v| v as f32)).collect(); + (v.len() == 8).then(|| std::array::from_fn(|i| v[i])) + }; + if let (Some(x), Some(y)) = (eight("body_speed_x"), eight("body_speed_y")) { + c.body_speed_curve = skate_audio::player::contacts::SpeedGraph8 { x, y }; + } let pair = |a: &str, b: &str, d: [f32; 2]| [f(a).unwrap_or(d[0]), f(b).unwrap_or(d[1])]; c.body_110 = [pair("body_110_head_low", "body_110_head_high", c.body_110[0]), pair("body_110_torso_low", "body_110_torso_high", c.body_110[1])]; c.body_111 = pair("body_111_low", "body_111_high", c.body_111); @@ -1042,6 +1051,32 @@ impl Library { mod tests { use super::*; + /// The bridge's speed graph: the exported posters override the default, and the default is + /// the stock vault's record word for word (data-gated: the converted `skater-collections.json` + /// of the user's own disc, class `6EBA5BCD3E38A98A` `default` field `8B164823E008749C`, a + /// `Sk8::PointNegGraphData8`: 16-byte header, x at +16, y at +48). + #[test] + fn the_body_speed_graph_is_the_stock_vault_record() { + use skate_audio::player::contacts::SpeedGraph8; + let json: CollisionJson = serde_json::from_str(r#"{"posters": {"body_speed_x": [0, 1, 2, 3, 4, 5, 6, 7], "body_speed_y": [1, 1, 1, 1, 2, 2, 2, 2]}}"#).unwrap(); + let g = json.contacts().body_speed_curve; + assert_eq!((g.x[7], g.y[4]), (7.0, 2.0), "exported values win"); + assert_eq!(CollisionJson::default().contacts().body_speed_curve, SpeedGraph8::BODY_SPEED, "else the vault's words"); + let path = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/private/stock/skater-collections.json")); + let Ok(text) = std::fs::read_to_string(path) else { + return eprintln!("skipped: no converted skater collections"); + }; + let all: serde_json::Value = serde_json::from_str(&text).unwrap(); + let rec = all["collections"].as_array().unwrap().iter() + .find(|c| c["class"] == "Hash_6EBA5BCD3E38A98A" && c["key"] == "default").expect("the Contacts body record"); + let field = &rec["fields"]["Hash_8B164823E008749C"]; + assert_eq!(field["type"], "Sk8::PointNegGraphData8"); + let hex: String = field["data"].as_str().unwrap().split_whitespace().collect(); + let word = |i: usize| f32::from_bits(u32::from_str_radix(&hex[8 * i..8 * i + 8], 16).unwrap()); + let vault = SpeedGraph8 { x: std::array::from_fn(|i| word(4 + i)), y: std::array::from_fn(|i| word(12 + i)) }; + assert_eq!(vault, SpeedGraph8::BODY_SPEED); + } + #[test] fn wav_peak_reads_the_data_chunk() { let mut wav = b"RIFF\x00\x00\x00\x00WAVEfmt \x10\x00\x00\x00".to_vec(); diff --git a/crates/skate-game/src/game_audio/npc_skaters.rs b/crates/skate-game/src/game_audio/npc_skaters.rs index 3be93b8f..60ab5bbb 100644 --- a/crates/skate-game/src/game_audio/npc_skaters.rs +++ b/crates/skate-game/src/game_audio/npc_skaters.rs @@ -18,8 +18,12 @@ //! - its collision messages go to the local player's collision manager (retail's one //! `CSTATEMGR_Collision`). //! -//! Not yet: the NPC's granular rolling bed (the routing's grain binds are dropped: the runtime has -//! one bed), wheels, tricks, footsteps, clothing (module docs of `skaters`). +//! - its granular rolling bed (2026-10-03): retail's SkateBoard update runs per instance, so the +//! held skater's routing binds its own grain players (`grain_bed::Bed::for_instance`, the +//! runtime's `npc_grains`) on its SkateBoard instance's MixMap outputs; the local-only parts stay +//! off (`grain_bed.rs` module docs). Its picks draw from the local bed's generator. +//! +//! Not yet: wheels, tricks, footsteps, clothing (module docs of `skaters`). use std::collections::HashMap; use bevy::prelude::*; @@ -57,6 +61,9 @@ pub(crate) struct NpcHost { last_tick: u64, last_camera: Option<[f32; 3]>, announced: bool, + /// The held skater's grain bed (instance 1) and its push-plant count (`+335` rises; the bed's + /// push envelopes and SkateBoard input 4 follow it), with the native rolling layers on. + beds: HashMap, } pub(crate) fn register(app: &mut App) { @@ -107,7 +114,9 @@ fn distance(a: [f32; 3], b: [f32; 3]) -> f32 { ((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt() } +#[allow(clippy::too_many_arguments)] fn frame( + library: Option>, native: Option>, published: Res, mut host: ResMut, @@ -124,7 +133,7 @@ fn frame( let Some(mut native) = native else { return }; let Ok(camera) = listener.single() else { return }; let host = &mut *host; - let Native { mixmap, player, shared, .. } = &mut *native; + let Native { mixmap, player, shared, bed: local_bed, .. } = &mut *native; let (Some(m), Some(player)) = (mixmap.as_mut(), player.as_mut()) else { return }; if !player.components { return; @@ -161,12 +170,18 @@ fn frame( npc.deactivate(m, &l); } host.release_all(rt, id); + if host.beds.remove(&id).is_some() { + super::grain_bed::stop_npc(rt); + } info!("AUDIO_NPC release skater {id} (instance {g})"); } let parts = Parts { rolling: player.rolling_on, rattle: player.rattle_on, contacts: player.contacts_on }; for (id, g) in assignment.claimed { let npc = NpcSkater::new(g as u32, parts, on("SKATE_AEMS_GRIND_ONOFF"), on("SKATE_AEMS_PLANT_LIFT"), on("SKATE_AEMS_BODY_IMPACTS")); host.objects.insert(id, npc); + if let (true, Some(bed)) = (parts.rolling, local_bed.as_ref()) { + host.beds.insert(id, (bed.for_instance(g as u32), 0)); + } info!("AUDIO_NPC claim skater {id} (instance {g})"); } @@ -180,11 +195,35 @@ fn frame( s.dt = dt; let cmds = npc.update(m, &s, tuning, &mut rt.splice_host()); host.apply(rt, id, cmds); + // The instance's grain bed after this evaluation (SkateBoard update `sub_824C6BD8`: records + // from its outputs), with the binds / stops of the routing's last process; then its owner + // inputs 2 / 3 / 4 for the next evaluation. + if let (Some((bed, pushes)), Some(library)) = (host.beds.get_mut(&id), library.as_deref()) { + *pushes = pushes.wrapping_add(u32::from(s.push_trigger)); + let r = super::skate_events::Riding { + board: Vec3::from_array(s.board_position), + speed: s.ground_speed, + grinding: s.grinding, + braking: s.brake, + wheels: s.wheel_count, + pushes: *pushes, + audio: s, + ..Default::default() + }; + let routed = Some((std::mem::take(&mut npc.routed.grains), npc.routed.primary)); + super::grain_bed::step_with(bed, library, m, &r, dt, tuning.player, routed, |apply| apply(&mut *rt)); + bed.write_inputs(m, &s, false); + } npc.write_inputs(m, &s, &l, local.com_velocity, tuning.player); + // The body poster once per console evaluation, as the local player's (`body_console`). + npc.set_body_calls((player.body_console && player.jitter_steps.is_some()).then_some(evaluations as usize)); + npc.set_deck_calls((player.deck_console && player.jitter_steps.is_some()).then_some(evaluations as usize)); let cmds = npc.process(m, &s, tuning, &mut rt.splice_host()); host.apply(rt, id, cmds); - // No per-owner grain bed yet: the routing's binds are dropped. - npc.routed.grains.clear(); + // The routing's binds wait for the bed's next step (dropped without a bed). + if !host.beds.contains_key(&id) { + npc.routed.grains.clear(); + } collisions.extend(npc.take_collisions()); host.objects.insert(id, npc); } diff --git a/crates/skate-game/src/game_audio/npc_skaters/tests.rs b/crates/skate-game/src/game_audio/npc_skaters/tests.rs index 0614552e..8c96b897 100644 --- a/crates/skate-game/src/game_audio/npc_skaters/tests.rs +++ b/crates/skate-game/src/game_audio/npc_skaters/tests.rs @@ -300,3 +300,110 @@ fn npc_skater_rolls_and_grinds_past_the_listener() { let q = pass(&library, &mxb, true); assert!(!q.seam_soft.is_empty() && q.seam_soft.iter().all(|&w| w == 0), "soft local wheels → 0"); } + +/// The NPC skater's grain bed (2026-10-03): the same pass, with the host's order per console +/// evaluation (`npc_skaters::frame`): update → the instance's bed step on this evaluation's +/// SkateBoard(1) outputs with the routing's last binds → its owner inputs → write inputs → process +/// → tick. Checks: the second bed binds the NPC's soft concrete member (the local player's wheels +/// hard), sounds only while the instance is held, louder near the camera than at 22–30 m, has no +/// graph 3 and never touches the local bed's players; release stops it. `--nocapture` prints a +/// per-0.5 s table (distance, held, member, voices, record A gain, bed RMS). +#[test] +fn npc_skater_rolls_on_its_own_grain_bed() { + let Some((library, mxb)) = install() else { return eprintln!("skipped: no install with a MixMap") }; + let Some(local_bed) = super::super::grain_bed::Bed::new(&library) else { return eprintln!("skipped: no grain recordings / tuning") }; + let (mut rt, _, parts) = runtime(&library); + if !parts.rolling { + return eprintln!("skipped: no PatchBank_Rolling_Surfaces (the routing drives the bed)"); + } + assert!(rt.npc_grains.is_none(), "inert until an NPC bed binds"); + let tuning = library.player_tuning(); + let contact_tuning = library.contacts_tuning(); + let t = Tuning { player: &tuning, contacts: &contact_tuning }; + let mut m = MixMap::from_bytes(&mxb).unwrap(); + let mut slots = Slots::default(); + let mut npc: Option = None; + let mut bed: Option<(super::super::grain_bed::Bed, u32)> = None; + let camera = [0.0, 1.8, 0.0]; + let l = Listener { camera, view: [0.0, 0.0, -1.0], camera_velocity: [0.0; 3], followed: [0.0, 1.0, -3.0], facing: [0.0, 0.0, -1.0], followed_velocity: [0.0; 3] }; + let dt = 1.0 / 30.0; + let frames = ((-START_X * 2.0 / V) / dt) as usize; + let mut out = vec![0.0f32; 2 * 1600]; + let (mut rows, mut members) = (Vec::new(), std::collections::BTreeSet::new()); + let (mut near, mut far, mut held_voices, mut free_voices) = (0.0f32, 0.0f32, 0usize, 0usize); + let (mut sum, mut n, mut gain_max) = (0.0f64, 0usize, 0.0f32); + let mut released = None; + for f in 0..frames { + let time = f as f32 * dt; + let published = npc_state(time); + let d = super::distance(published.com_position, camera); + let a = slots.assign(&[(7, d)]); + for _ in a.released { + if let Some(mut x) = npc.take() { + x.deactivate(&mut m, &l); + } + if bed.take().is_some() { + super::super::grain_bed::stop_npc(&mut rt); + } + released = Some(f); + } + for (_, g) in a.claimed { + npc = Some(NpcSkater::new(g as u32, parts, true, true, true)); + bed = Some((local_bed.for_instance(g as u32), 0)); + } + globals(&mut m); + let s = component_state(&published, false); + if let (Some(x), Some((b, pushes))) = (npc.as_mut(), bed.as_mut()) { + let _ = x.update(&m, &s, t, &mut rt.splice_host()); + *pushes = pushes.wrapping_add(u32::from(s.push_trigger)); + let r = super::super::skate_events::Riding { speed: s.ground_speed, grinding: s.grinding, braking: s.brake, wheels: s.wheel_count, pushes: *pushes, audio: s, ..Default::default() }; + let routed = Some((std::mem::take(&mut x.routed.grains), x.routed.primary)); + super::super::grain_bed::step_with(b, &library, &m, &r, dt, &tuning, routed, |apply| apply(&mut rt)); + b.write_inputs(&mut m, &s, false); + x.write_inputs(&mut m, &s, &l, [0.0; 3], &tuning); + let _ = x.process(&mut m, &s, t, &mut rt.splice_host()); + let _ = x.take_collisions(); + } + m.tick(dt); + rt.fill_stereo(&mut out); + assert!(rt.grains.trucks.iter().all(|tr| tr.bound().is_none()) && rt.grains.voices() == 0, "the local bed is never touched"); + let Some(g) = rt.npc_grains.as_deref() else { continue }; + assert!(!g.local, "the NPC bed has no graph 3"); + let voices = g.voices(); + if let Some(name) = g.trucks[0].bound() { + members.insert(name.to_owned()); + } + // (An unbound player keeps its constructor record, gain 1: count bound ones only.) + let gain = if g.trucks[0].bound().is_some() { g.trucks[0].players[0].record.gain } else { 0.0 }; + if npc.is_some() { + held_voices += voices; + if d < 12.0 { + near = near.max(gain); + } else if d > 22.0 { + far = far.max(gain); + } + } else if released.is_some_and(|r| f > r + 30) { + free_voices += voices; + } + gain_max = gain_max.max(gain); + sum += out.iter().map(|&x| f64::from(x) * f64::from(x)).sum::(); + n += out.len(); + if f % 15 == 14 { + let rms = (10.0 * (sum / n.max(1) as f64).max(1e-12).log10()) as f32; + rows.push((time, d, npc.is_some(), g.trucks[0].bound().map(str::to_owned), voices, gain_max, rms)); + (sum, n, gain_max) = (0.0, 0, 0.0); + } + } + println!(" t dist held truck-0 member voices max A gain RMS dBFS"); + for (t, d, held, member, voices, gain, rms) in &rows { + println!("{t:6.2} {d:7.1} {:4} {:26} {voices:6} {gain:10.4} {rms:8.1}", if *held { "yes" } else { "-" }, member.as_deref().unwrap_or("-")); + } + println!("members bound {members:?}; record A gain max near (< 12 m) {near:.4}, 22–30 m {far:.4}"); + assert!(members.contains("concrete_smooth_soft") || members.contains("concrete_smooth_hard"), "concrete bound: {members:?}"); + if library.grain_whole("concrete_smooth_soft").is_some() { + assert!(members.contains("concrete_smooth_soft"), "hard local wheels → the NPC's soft member"); + } + assert!(held_voices > 0, "the NPC bed sounds while held"); + assert!(near > far && far >= 0.0, "louder near the camera ({near} vs {far})"); + assert_eq!(free_voices, 0, "stopped a second after the release"); +} diff --git a/crates/skate-game/src/game_audio/player_audio.rs b/crates/skate-game/src/game_audio/player_audio.rs index fd4e25f4..384db2eb 100644 --- a/crates/skate-game/src/game_audio/player_audio.rs +++ b/crates/skate-game/src/game_audio/player_audio.rs @@ -37,6 +37,15 @@ pub(crate) struct PlayerAudio { /// console cadence (n console evaluations in this pass, `native::mixmap_frame`), `None`: one /// step per call (the old 60 Hz host and the tests). pub(crate) jitter_steps: Option, + /// The body poster (`player::contacts`, `sub_824BC188`) on the console cadence: it runs once per + /// console evaluation of this pass ([`Self::jitter_steps`]), so its 15-frame cooldown is 0.5 s at + /// any real frame rate (2026-10-03). `SKATE_AEMS_BODY_CONSOLE=0` / `E2E_BODY_CONSOLE=0`: once per + /// process, as before. Follows the MixMap cadence: without it (`jitter_steps` None) the old one. + pub(crate) body_console: bool, + /// The deck-impact poster (`sub_824BD000`) on the console cadence too (its 6-frame cooldown = + /// 0.2 s at any frame rate; 2026-10-03). `SKATE_AEMS_DECK_CONSOLE=0` / `E2E_DECK_CONSOLE=0`: once + /// per process, as before. + pub(crate) deck_console: bool, positions: [ObjPos; 2], was_grinding: bool, last_camera: Option<[f32; 3]>, @@ -124,6 +133,9 @@ impl PlayerAudio { // grind on / off sounds (#5); `SKATE_AEMS_{PLANT_LIFT,BODY_IMPACTS,GRIND_ONOFF}=0` off. board.plant_lift_on = on("SKATE_AEMS_PLANT_LIFT"); board.body_on = on("SKATE_AEMS_BODY_IMPACTS"); + // The bridge's speed graph on the body impacts (`sub_824B0DA8`, 2026-10-03); + // `SKATE_AEMS_BODY_CURVE=0`: the conditioner's impacts as before. + board.body_speed_on = on("SKATE_AEMS_BODY_CURVE"); let mut grind = Grind::default(); grind.onoff = on("SKATE_AEMS_GRIND_ONOFF"); Self { @@ -132,6 +144,8 @@ impl PlayerAudio { contacts: Contacts::default(), jitter, jitter_steps: None, + body_console: on("SKATE_AEMS_BODY_CONSOLE"), + deck_console: on("SKATE_AEMS_DECK_CONSOLE"), positions: [ObjPos::default(); 2], was_grinding: false, last_camera: None, @@ -178,6 +192,18 @@ impl PlayerAudio { } } + /// The body poster's messages so far and their digest (`Contacts::body_posts` / `body_digest`). + #[cfg(test)] + pub(crate) fn body_trace(&self) -> (u64, u64) { + (self.board.body_posts, self.board.body_digest) + } + + /// The deck poster's messages so far and their digest (`Contacts::deck_posts` / `deck_digest`). + #[cfg(test)] + pub(crate) fn deck_trace(&self) -> (u64, u64) { + (self.board.deck_posts, self.board.deck_digest) + } + /// The session-review ports (the e2e harness's `E2E_*` switches): the plant / lift, the body /// poster, the grind on / off sounds. #[cfg(test)] @@ -187,6 +213,24 @@ impl PlayerAudio { self.grind.onoff = grind_onoff; } + /// The bridge's speed graph on the body impacts (the e2e harness's `E2E_BODY_CURVE`). + #[cfg(test)] + pub(crate) fn set_body_curve(&mut self, on: bool) { + self.board.body_speed_on = on; + } + + /// Diagnostics (the e2e harness's `E2E_BODY_LOG`): keep every body-poster message. + #[cfg(test)] + pub(crate) fn set_body_log(&mut self, on: bool) { + self.board.body_log = on.then(Vec::new); + } + + /// The body-poster messages since the last call (region, impact read, message). + #[cfg(test)] + pub(crate) fn take_body_log(&mut self) -> Vec<(usize, f32, skate_audio::player::collision::Message)> { + self.board.body_log.as_mut().map(std::mem::take).unwrap_or_default() + } + /// Collision messages of another Player-slot owner (an NPC skater, `npc_skaters.rs`): retail /// has one `CSTATEMGR_Collision`, so they join the local player's (processed at its next /// `process`). No messages, no change. @@ -384,6 +428,8 @@ impl PlayerAudio { // The grind on / off contact sounds of this frame's Rail posts (Splice). self.grind.sounds(s, &self.tuning, &mut rt.splice_host()); let before = self.board.starts; + self.board.body_calls = if self.body_console { self.jitter_steps } else { None }; + self.board.deck_calls = if self.deck_console { self.jitter_steps } else { None }; self.board.process(s, self.contacts.buckets(), &self.tuning, &self.contact_tuning, &mut rt.splice_host()); self.posts += self.board.starts - before; // The collision manager runs after the player's components (its own state manager). diff --git a/crates/skate-game/src/game_audio/skate_events.rs b/crates/skate-game/src/game_audio/skate_events.rs index 22ff50b8..a501a84e 100644 --- a/crates/skate-game/src/game_audio/skate_events.rs +++ b/crates/skate-game/src/game_audio/skate_events.rs @@ -73,6 +73,9 @@ pub(super) struct Seen { /// The conditioner's 4-frame ring of the body regions' impacts (`sub_82773298`, `+640`). region_ring: [[f32; 6]; 4], region_at: usize, + /// The bridge's `+212` (|COM v|) of the last tick: this tick's `+216`, the speed graph's input + /// (`sub_824B0DA8`). + com_speed_212: f32, /// The conditioner's step code (`+740`, `sub_827729B8`). step_code: skate_audio::player::footsteps::StepCode, /// Rows written to the audio state log. @@ -161,7 +164,7 @@ pub(super) fn observe( let (planted, plant_edge) = push_plant(push_plant_on(), state55, audio.push, seen.planted, seen.push); let plant_edge = seen.started && plant_edge; let memory: &mut Seen = &mut seen; - let audio_state = audio_state(&physics, &skater, AudioFrame { + let mut audio_state = audio_state(&physics, &skater, AudioFrame { dt: time.delta_secs(), state, unridden, @@ -196,6 +199,9 @@ pub(super) fn observe( [w.dot(Vec3::from_array(basis[0])), w.dot(Vec3::from_array(basis[1]))] }, }); + // `+216` = last tick's `+212`; the body poster applies the graph at it (`player::contacts`). + audio_state.com_speed_216 = seen.com_speed_212; + let com_speed_212 = audio_state.com_speed(); if super::state_log::enabled() { let p = &skater.player_input.physical; let s = &audio_state; @@ -269,6 +275,7 @@ pub(super) fn observe( region_impact: s.body_impact, region_slide: s.body_slide, region_tag: s.body_tag, + com_speed: com_speed_212, }); } // The bed's push envelopes (`grain_bed.rs` `pushes_seen`) count the same edge as `+335`. @@ -299,6 +306,7 @@ pub(super) fn observe( deck_at: seen.deck_at, region_ring: seen.region_ring, region_at: seen.region_at, + com_speed_212, log_frame: seen.log_frame + u64::from(super::state_log::enabled()), }; } @@ -403,7 +411,9 @@ pub(super) fn region_impacts( let Some(part) = r.part else { return 0.0 }; let dv = velocity_changes.get(part).copied().unwrap_or([0.0; 4]); let n = r.normal; - let along = dv[0] * n[0] + dv[1] * n[1] + dv[2] * n[2]; + // |Δv·n|: retail masks the dot product's sign bit (`vandc128` with `v63 << 31` after the + // `vmsum3fp128`), so a part pulled away from the surface counts as much as one stopped by it. + let along = (dv[0] * n[0] + dv[1] * n[1] + dv[2] * n[2]).abs(); let x = along * masses.get(part).copied().unwrap_or(0.0) * REGION_IMPACT_SCALE; let x = if REGION_IMPACT_FLOOR - x >= 0.0 { REGION_IMPACT_FLOOR } else { x }; skate_audio::player::clamp01(x) @@ -540,7 +550,7 @@ fn audio_state(physics: &GamePhysics, skater: &SkaterRuntime, f: AudioFrame) -> wheel_contact, // `+620..+632` / `+636..+648` come from the wheel lines (82C079E0: each wheel's 0.2 m ray), not // from contact: retail keeps wheel 0's material on 100 % of 3-wheel and 96 % of 2-wheel frames - // and changes it 0.9 times a second while rolling (GREC, `aems-port/tools/grec_material.py`). + // and changes it 0.45 times a second while rolling (GREC, local rider only; `aems-port/tools/grec_material.py`). // Gating them by contact made every contact flicker a material change, i.e. a Class_Seams // transition hit (5–9 changes/s on wheel 0 in the 20:08 / 20:13 sessions). wheel_material: std::array::from_fn(|i| if f.unridden { 143 } else { material_of_tag(lines.audio_surfaces[i]) }), @@ -621,6 +631,8 @@ fn audio_state(physics: &GamePhysics, skater: &SkaterRuntime, f: AudioFrame) -> body_tag: std::array::from_fn(|i| if fb.regions[i].part.is_some() { fb.regions[i].material_flags } else { 0 }), body_slide_flag: fb.specific[1].current, body_impact, + // `+216`: set by `observe` (last tick's `+212`). + com_speed_216: 0.0, hands_on_deck: skate_audio::player::step_on::hands_on_deck(hands, f.state == 500, p.off_board.flag_311 != 0), in_water, under_water, @@ -688,7 +700,51 @@ mod tests { assert_eq!(r[0], 0.0, "no contact part"); assert!((r[1] - 0.6).abs() < 1e-6, "3 m/s × 0.02 × 10"); assert_eq!(r[2], 1.0, "clamped"); - assert_eq!(r[3], f32::from_bits(0x3A83_126F), "away from the surface: the floor 0.001"); + assert!((r[3] - 0.2).abs() < 1e-6, "away from the surface counts too: |−2| × 0.01 × 10 = {}", r[3]); + // Under the floor either way. + dv[7] = [0.0, -0.001, 0.0, 0.0]; + assert_eq!(region_impacts(®ions, &dv, &masses)[3], f32::from_bits(0x3A83_126F), "the floor 0.001"); + } + + /// Against the recomp (data-gated): every BAILREG line of the fixed-hook bail runs (`audiox`; + /// `X region part impact |dv.n| v_old.n v_new.n |dv| normal mass slide tag`) gives the impact the + /// game wrote from Δv·n = v_new·n − v_old·n along its normal and its mass, within the lines' 4-decimal + /// rounding. The signed form misses the lines with Δv·n < 0. + #[test] + fn region_impacts_match_the_recomps_bail_lines() { + use skate_core::physics::skeleton_body::ContactRegion; + let root = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../.local/recomp/sessions")); + let mut lines = Vec::new(); + for run in ["bailrun_t2_20261003_100040", "bailrun_ok_20261003_100226", "bailrun_ok2_20261003_100514", "bailrun_ok3_20261003_101354"] { + let Ok(text) = std::fs::read_to_string(root.join(run).join("trace.tsv")) else { + return eprintln!("skipped: no recomp bail trace {run}"); + }; + for l in text.lines().filter(|l| l.starts_with("BAILREG\t")) { + let f: Vec<&str> = l.split('\t').collect(); + assert_eq!(f.len(), 14, "malformed BAILREG line: {l}"); + lines.push(f.iter().map(|x| x.to_string()).collect::>()); + } + } + let (mut ok, mut negative) = (0usize, 0usize); + for f in &lines { + let num = |i: usize| f[i].parse::().unwrap(); + let (impact, v_old, v_new, mass) = (num(5), num(7), num(8), num(11)); + let n: Vec = f[10].split(' ').map(|x| x.parse().unwrap()).collect(); + let d = v_new - v_old; + negative += usize::from(d < 0.0); + let mut regions = [ContactRegion::default(); 8]; + regions[0] = ContactRegion { part: Some(1), normal: [n[0], n[1], n[2], 0.0], ..ContactRegion::default() }; + let len2 = n[0] * n[0] + n[1] * n[1] + n[2] * n[2]; + let mut dv = [[0.0f32; 4]; 26]; + dv[1] = [n[0] * d / len2, n[1] * d / len2, n[2] * d / len2, 0.0]; + let mut masses = [0.0f32; 24]; + masses[1] = mass; + let ours = region_impacts(®ions, &dv, &masses)[0]; + ok += usize::from((ours - impact).abs() <= 0.002); + } + println!("BAILREG lines {}, Δv·n < 0 on {negative}, ours within 0.002 on {ok}", lines.len()); + assert!(lines.len() > 10_000 && negative > 1_000); + assert_eq!(ok, lines.len(), "every line"); } #[test] diff --git a/crates/skate-game/src/game_audio/state_log.rs b/crates/skate-game/src/game_audio/state_log.rs index f324c141..44224e85 100644 --- a/crates/skate-game/src/game_audio/state_log.rs +++ b/crates/skate-game/src/game_audio/state_log.rs @@ -9,7 +9,7 @@ use std::sync::OnceLock; use std::sync::atomic::{AtomicU64, Ordering}; /// The header (the e2e scenario columns between `ms` and the positions). -pub(crate) const HEADER: &str = "ms\tframe\tspeed\tturn\twheels\ttag\tair\tair_time\tto_land\tjump_height\tjv\tgrinding\tfamily\tgrind_tag\tbrake\tmanual\tbalance\tscorable\tpush\tslope\ttilt\tslip\tfeet\tstate\tboard_x\tboard_y\tboard_z\tcom_x\tcom_y\tcom_z\tgrind_impact\tdeck_impact\tdeck_tag\tfoot_y0\tfoot_y1\tfoot_xz0\tfoot_xz1\tseam0\tseam3\twheel_x\twheel_z\theading\tlines\tfoot_down\tfoot_tag_a\tfoot_tag_b\thands\tstrength\tfoot_vy_a\tfoot_vy_b\tstep\tbody\tlimb\tslide\tdeck_up\tdeck_contact\tplant\tstroke\tdeck_spin\tspin_x\tspin_y\tbail\tbail_end\theld\toffboard_air\tfootplant\trevert\tsoft\tface\trimp0\trimp1\trimp2\trimp3\trimp4\trimp5\trslide0\trslide1\trslide2\trslide3\trslide4\trslide5\trtag0\trtag1\trtag2\trtag3\trtag4\trtag5"; +pub(crate) const HEADER: &str = "ms\tframe\tspeed\tturn\twheels\ttag\tair\tair_time\tto_land\tjump_height\tjv\tgrinding\tfamily\tgrind_tag\tbrake\tmanual\tbalance\tscorable\tpush\tslope\ttilt\tslip\tfeet\tstate\tboard_x\tboard_y\tboard_z\tcom_x\tcom_y\tcom_z\tgrind_impact\tdeck_impact\tdeck_tag\tfoot_y0\tfoot_y1\tfoot_xz0\tfoot_xz1\tseam0\tseam3\twheel_x\twheel_z\theading\tlines\tfoot_down\tfoot_tag_a\tfoot_tag_b\thands\tstrength\tfoot_vy_a\tfoot_vy_b\tstep\tbody\tlimb\tslide\tdeck_up\tdeck_contact\tplant\tstroke\tdeck_spin\tspin_x\tspin_y\tbail\tbail_end\theld\toffboard_air\tfootplant\trevert\tsoft\tface\trimp0\trimp1\trimp2\trimp3\trimp4\trimp5\trslide0\trslide1\trslide2\trslide3\trslide4\trslide5\trtag0\trtag1\trtag2\trtag3\trtag4\trtag5\tcom_speed"; /// One frame of the log. #[derive(Clone, Copy, Debug, Default, PartialEq)] @@ -96,13 +96,16 @@ pub(crate) struct Row { pub region_impact: [f32; 6], pub region_slide: [f32; 6], pub region_tag: [u32; 6], + /// Appended 2026-10-03: the bridge's `+212` |COM v| (m/s), the speed graph's input one row later + /// (`+216`), so replays apply the graph to the logged (pre-graph) region impacts. + pub com_speed: f32, } impl Row { pub(crate) fn line(&self) -> String { let b = |v: bool| u8::from(v); format!( - "{:.1}\t{}\t{:.6}\t{:.6}\t{}\t{}\t{}\t{:.6}\t{:.6}\t{:.6}\t{:.6}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{:.6}\t{:.6}\t{:.6}\t{}\t{}\t{:.4}\t{:.4}\t{:.4}\t{:.4}\t{:.4}\t{:.4}\t{:.6}\t{:.6}\t{}\t{:.6}\t{:.6}\t{:.6}\t{:.6}\t{}\t{}\t{:.4}\t{:.4}\t{:.5}\t{}\t{}\t{}\t{}\t{}\t{:.4}\t{:.4}\t{:.4}\t{}\t{:.4}\t{:.4}\t{:.4}\t{:.4}\t{}\t{}\t{}\t{:.4}\t{:.4}\t{:.4}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{:.4}\t{:.4}\t{:.4}\t{:.4}\t{:.4}\t{:.4}\t{:.4}\t{:.4}\t{:.4}\t{:.4}\t{:.4}\t{:.4}\t{}\t{}\t{}\t{}\t{}\t{}", + "{:.1}\t{}\t{:.6}\t{:.6}\t{}\t{}\t{}\t{:.6}\t{:.6}\t{:.6}\t{:.6}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{:.6}\t{:.6}\t{:.6}\t{}\t{}\t{:.4}\t{:.4}\t{:.4}\t{:.4}\t{:.4}\t{:.4}\t{:.6}\t{:.6}\t{}\t{:.6}\t{:.6}\t{:.6}\t{:.6}\t{}\t{}\t{:.4}\t{:.4}\t{:.5}\t{}\t{}\t{}\t{}\t{}\t{:.4}\t{:.4}\t{:.4}\t{}\t{:.4}\t{:.4}\t{:.4}\t{:.4}\t{}\t{}\t{}\t{:.4}\t{:.4}\t{:.4}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{:.4}\t{:.4}\t{:.4}\t{:.4}\t{:.4}\t{:.4}\t{:.4}\t{:.4}\t{:.4}\t{:.4}\t{:.4}\t{:.4}\t{}\t{}\t{}\t{}\t{}\t{}\t{:.5}", self.ms, self.frame, self.speed, self.turn, self.wheels, self.tag, b(self.air), self.air_time, self.to_land, self.jump_height, self.jv, b(self.grinding), self.family, self.grind_tag, b(self.brake), b(self.manual), b(self.balance), self.scorable, b(self.push), self.slope, self.tilt, self.slip, self.feet, self.state, @@ -116,7 +119,7 @@ impl Row { self.region_impact[0], self.region_impact[1], self.region_impact[2], self.region_impact[3], self.region_impact[4], self.region_impact[5], self.region_slide[0], self.region_slide[1], self.region_slide[2], self.region_slide[3], self.region_slide[4], self.region_slide[5], self.region_tag[0], self.region_tag[1], self.region_tag[2], - self.region_tag[3], self.region_tag[4], self.region_tag[5] + self.region_tag[3], self.region_tag[4], self.region_tag[5], self.com_speed ) } } diff --git a/docs/hails-additions/11-audio.md b/docs/hails-additions/11-audio.md index 9c1406f2..c1ad9686 100644 --- a/docs/hails-additions/11-audio.md +++ b/docs/hails-additions/11-audio.md @@ -3225,8 +3225,9 @@ Caveats: - **Two GREC owners pass the local test** in this session (board objects `40C33020` from the start, `40C34020` from 22.9 s), each once per frame. FIRSTHIT kept one set of last values, so the +676 / +677 bytes "alternated" 1 / 0 every call: one owner was bailing and the other was not. `40C34020` bailed once (154.7 s) with B+16 still 0, so it - is not the local skater. `40C33020` is. FIRSTHIT now keeps its last values per state. Earlier sessions had one - owner. GREC / GRECX / TREAT / SEAMPAT readers should filter by owner (GREC's published local state alternates + is not the local skater. `40C33020` is. FIRSTHIT now keeps its last values per state. ~~Earlier sessions had one + owner.~~ (Wrong: 15 of 18 sessions had the second owner, an NPC skater; the hooks now gate on local72 — see "The + per-player recomp hooks logged an NPC skater too" below.) GREC / GRECX / TREAT / SEAMPAT readers should filter by owner (GREC's published local state alternates too, so the TREAT / SEAMPAT local-mask bit 2 is unreliable when a second owner exists). - **10 local bails**, 3.6–7.9 s from +676 rising to clearing. +677 (end) rises 1.4–4.0 s in and clears ≈ 1.0 s before +676 clears. @@ -3579,6 +3580,11 @@ The user, after the listening check of build 04:20: "go ahead and remove the int still in use: "oh.. if they are still being used then we aren't done." Decisions: splash "port it natively first"; the riding bed: remove it, then check it against the recomp; installs without the data: require it (no fallback). +**Listening (user, 2026-10-03, bin\ 10:06, session `state_20261003_101640`, 0 malformed):** "The sounds are SO GOOD. +I think that is basically retail, if there is a difference i couldn't tell. Splashes sounded correct. Riding sounded +right, jumping sounded right, falling sounded right. Cracks on bricks sounded correct at both higher speed and lower +speed. Manual sounded amazing." + **1. The water splash, from retail's mechanism (not the carried-over sound).** `SFXObj_OffBoard`'s process ends with `sub_824EBB58`; its update with `sub_824EBE78`. Read from the TU3 recompilation (reference only): - Inputs. The bridge `sub_824B0DA8` copies record `+172` bits 30 / 29 / 28 into audio state `+811` / `+812` / `+813`. @@ -3714,3 +3720,494 @@ The zone beds, location sets and crossfades still play their measured layers thr Rerun: `py -3.13 .local/audio-re/retire/bed_vs_recomp.py `, `py -3.13 .local/audio-re/retire/riding_posters.py ` (PYTHONIOENCODING=utf-8). + +### The ragdoll part masses against the recomp; the missing |Δv·n| (2026-10-03, analysis only; no code change) + +Open item 1 of "Bail density" listed the recomp's SkeletonState `+4560` masses rounded to two digits and asked for a +check against ours. Ours = `SkeletonAnimationMasses::part_weights` = the raw bone-box product x·y·z of each +`PhysicsParamBoneData` size (`assets/private/stock/physics-skeletons.json`, f32 as the code multiplies). The recomp = +the `mass` field of every BAILREG line of the fixed-hook runs t2 / ok / ok2 / ok3 (12,054 lines, 0 malformed), printed +to six decimals: + +| part | ours | the recomp | | part | ours | the recomp | +|---|---|---|---|---|---|---| +| 1 neck1 (head) | 0.005248 | 0.005248 | | 13 spine1 | 0.005832 | 0.005832 | +| 2 neck | 0.000784 | 0.000784 | | 15 / 19 toes | 0.000307 / 0.000307 | 0.000307 / 0.000307 | +| 3 / 7 hands | 0.000336 / 0.000336 | 0.000336 / 0.000336 | | 16 / 20 feet | 0.001015 / 0.001016 | 0.001015 / 0.001016 | +| 4 / 8 forearms | 0.001666 / 0.001667 | 0.001666 / 0.001667 | | 17 / 21 legs | 0.005109 / 0.005110 | 0.005109 / 0.005110 | +| 5 / 9 arms | 0.003134 / 0.003050 | 0.003134 / 0.003050 | | 18 / 22 uplegs | 0.006708 / 0.006708 | 0.006708 / 0.006708 | +| 6 / 10 shoulders | 0.001483 / 0.001483 | 0.001483 / 0.001483 | | 23 hips | 0.009753 | 0.009753 | + +**The masses are identical** for all 20 parts the recomp's bails touched (the left / right arm asymmetry 0.003134 / +0.003050 included). The 2-digit list in "Bail density" was rounding, and the earlier "≈ 0.003 arm … 0.015 torso" +estimate was a guess. So the masses are not part of the bail-density gap. (Parts 11 / 12 / 14, the upper spine, never +touched in those runs: ours 0.007751 / 0.007001 / 0.005935.) + +**Found on the way: retail takes the absolute value of Δv·n; ours doesn't.** `sub_82BD60C8`'s region loop computes +`vmsum3fp128 v44 = Δv · n`, then `vandc128 v42 = v44 & ~(v63 << 31)` (the sign mask): |Δv·n|. The recomp lines agree: +with Δv·n = v_new·n − v_old·n from each line, clamp01(max(0.001, |Δv·n| × mass × 10)) matches the written impact in +12,054 / 12,054 lines (within 0.002), the signed form in 11,776. `skate_events::region_impacts` uses the signed +`along`, so a step with Δv·n < 0 is floored to 0.001 there. +- How often: 5,355 of the recomp's 12,054 region steps have Δv·n < 0. Of the 1,729 above the floor, 447 (26 %) are + negative ones; above 0.1 it is 8 of 166, above 0.25 4 of 46. +- What it does to ours: about a quarter of the above-floor region contacts read 0.001, under every body floor (0.002 + head / torso / legs, 0.005 arms) and the concrete floor (0.12). Those contacts post nothing. It barely touches tier 1 + / 2 (5 % of the impacts above 0.1). So it costs mostly tier-0 posts: a small part of the 4–5× gap, which stays the + ragdoll's small per-step Δv (open items 1–2). +- Not changed (this task was report-only). The fix is audio-side, not physics: `along.abs()` in `region_impacts` + (the doc's "matches term by term" missed the `vandc`). It changes the bail renders, so it needs the user's go. + *Applied 2026-10-03 with the user's approval: see "Region impacts take |Δv·n|" below.* + +### The per-player recomp hooks logged an NPC skater too; measurements re-checked (2026-10-03, recomp hooks + analysis; no engine change) + +**Problem.** GREC / GRECX / FIRSTHIT / SKID / TREAT / SEAMPAT / SEAMHIT were meant to log the local rider only. Once an +NPC skater spawned (20–80 s into a session), they also logged its board, treatment and seams objects, interleaved per +frame. The earlier note "earlier sessions had one owner" was wrong: 15 of the 18 sessions with these hooks had the +second owner (`.local/research/localtest/owner_scan.py`); only `all_20261002_204336`, `all_20261002_232153` and +`bailrun_verify_20261003_095507` are clean. In `all_20261002_180430` (the bed-level session) the NPC owner is 227,295 of +460,476 GREC lines. + +**Root cause.** The hooks tested the 32-bit WORD at `[[object+16]+72]`. The game's local flag is the BYTE at +72 (the +skid updater `sub_824C7A20` reads `lbz 72` of `[owner+16]`; the eqchain / grain / tricks code reads `[[object+28]+72]`). +The NPC's component has byte 72 = 0 but a non-zero byte after it, so the word test passed. The TREAT / SEAMPAT mask bit 2 +(state = GREC's last accepted state) alternated with the two owners, so it selected neither reliably. + +**Change (recomp research hooks only, `src/research/hooks_audio.cpp`, uncommitted).** All seven kinds now gate on the +byte `[[object+28]+72]` (local72, as COLLPOST). SKID keeps its last holder per owner; FIRSTHIT keeps its per-state slots. +TREAT / SEAMPAT / SEAMHIT's last field: bit 4 = local72 (the gate), bit 1 = byte `[[object+16]+72]`, bit 2 = state equals +GREC's local state. New kind `LOCALTEST` (category `audio`, ≤ 256 lines, 6 fields; `trace.py` FIELD_COUNTS): per hook and +object, both bytes, the old word test and the decision, whenever the pair (old, new) changes. + +**Proof (our own runs, `ride_spawn.txt` at the Mega-Park spawn, background, `SKATE3_TRACE=audio,audiox`, 0 malformed).** +`localtest2_20261003_102933`: at 21.57 s the NPC's board `40C34020`, treatment `40C58200` and seams `40C60560` start +passing the old test (bytes 0 / 0, word ≠ 0) and are rejected (`LOCALTEST … 0 0 1 0`); the local `40C33020` / +`40C581A0` / `40C60320` pass with bytes 1 / 1. GREC / GRECX / SKID / TREAT / SEAMPAT / SEAMHIT each logged one object +(33,048 / 33,048 / 28,533 / 33,048 / 33,047 / 929 lines) while the NPC was posting deck and body impacts (COLLPOST +local72 0, `40C710A0`) and visible in the screenshots. Run `localtest_20261003_102703` agrees (NPC seams object +rejected at 20.4 s). + +**Measurements re-checked** (copies with only the local rows: `.local/research/localtest/filter_local.py [--mask2]`; +scripts in the same folder; the local object is the first one each trace logs, confirmed by LOCALTEST): + +| measurement (session) | as published | local rider only | conclusion | +|---|---|---|---| +| Retail turn input: share of riding frames at full / in between (180430) | 16 % / 25 % | **17 % (0.95–1.0, median 0.992) / 8 %**; release ≥ 0.9 → 0 in a median 34 ms | **Changes.** The 25 % was the NPC's steering (81 % of its frames in between). Retail's local input is as binary as ours (29 % / 8 %, release 50 ms in `state_20261002_181631`). | +| I vs \|turn\| medians 0.1 / 0.3 / 0.5 / 1.0 (180430) | 0.066 / 0.185 / 0.30 / 0.60 | 0.062 / 0.180 / 0.301 / 0.600 | Holds. Retail's I is 0 by 50 ms after the input reads 0 (p90 0.000, 366 releases). | +| Wheel-0 material changes while rolling (180430, `grec_material.py`) | 0.9 /s | **0.45 /s** (keep rates 100 % / 96 % unchanged) | Value changes; the fix it supported (materials from the wheel lines; ours was 5–9 /s) holds and is stronger. The comment in `skate_events.rs` (≈ line 543) still says 0.9. | +| Trucks: neither running / (1,0)↔(0,1) swaps (180430, `grec_running.py`) | 621 of 310 k / 39 k swaps | **9 of 233 k / 86 direct swaps** (+ ~310 through both-running) | "Never stops the last sounding truck" holds. The 39 k swaps were the two owners alternating; the hand-over is rare. | +| Clean straight-roll level(1) per material (180430) | 0.219 (mat 2, 5–10), 0.21 (40, 10–15), 0.229–0.244, 0.245–0.25 | 0.219, 0.215, 0.228–0.246, 0.243–0.250 | Holds (ours within ~1 dB). Material 65's low 5–15 km/h values (0.079 / 0.024) were the NPC; local 0.208. | +| Manual rows gain A, 10–50 km/h (180430) | 0.141–0.157 | 0.139–0.158 | Holds. | +| Class_Seams cadence (223306, `seampat.py`) | calls every 2.9 ms, frame field 2.5 ms; speed changes 18 %, positions 37 % | 3.0 ms, 3.0 ms; 19 %, 41 % | Holds (~3 ms pulses). The old `mask & 2` filter kept only 14,861 of 33,785 local calls. | +| Pattern 11 hits (223306) | 15–21 /s at 20–40 km/h | 15–29 /s | Holds (sparse sidewalk = short pulse). | +| Grid hits on a cell change of that wheel (223306, `seamhit_match.py`) | 400 of 520 | 2,677 of 3,156 (85 %); pattern 11: 470 of 619 | Holds. | +| TREAT replay, air-time +236 changes (223306), sense_of_speed replay (223613) | 860 of 5,094 | same | Already filtered by object (`40C581A0`, `40C33020`). The "57,323 lines" count includes the NPC (local 33,786, ~305 /s). | +| Clean carves A/(1−I), B/I; bed per material (`review/bed.py`) | — | — | Already filtered by owner (`rlib` / local owner). | + +**Not separable:** voice-based figures (PLAY, SPLC: the riding Skate_Collisions / sk8_foley rates, seam voice rates +and levels) include an NPC's sounds whenever one was near, and these sessions have no COLLPOST. That was already noted +for the 1.7 /s body posts; a user session with `PLAY_TRACE_AUDIOX.bat` (COLLPOST) settles it. + +**For the main session (no engine change made):** +- "(1) Carving": the turn input is not the difference. If ours really keeps I up for ~13 frames after the stick + returns (retail: 0 within 50 ms), the cause is on our I side, not the animation's `Turn` scalar; worth a look. +- Hook consumers written before this fix need an object filter (or `filter_local.py`); new traces need none. + +### The body poster on the console cadence (2026-10-03, `player::contacts`, open item 3 of "Bail density") + +**Problem.** `Contacts::body` (retail `sub_824BC188`, called from the Contacts frame `sub_824B8218`) ran once per +`PlayerAudio::process`, and its 15-frame region cooldown counted those calls. The host runs the components once per +pass with at least one 60 Hz step (`native::mixmap_frame`), so the cooldown was 0.25 s at 60 fps and above but 0.5 s +at 30 fps. Retail runs the Contacts frame once per rendered frame: on the 30 fps console the cooldown is 0.5 s. The +standing rule applies: per-frame retail processes target the 360's ~30 fps cadence, at any engine fps. + +**The 4-frame region max is not part of it.** The ring is the conditioner `sub_82773298`, called from +`sub_82772748` next to the landed latch `sub_82772FD8` (same caller, same pass). Those PhysOut conditioners run per +physics step (gotchas "MixMap console cadence": the recomp's `+236` air time steps by 1/60 on 17 % of its ~345 fps +calls), and the bail hook measured the ragdoll pass at 60 Hz in the recomp too. So "4 frames" = 4 physics steps = +67 ms on the console as well, which is what `skate_events::observe` (per physics tick) already does. Unchanged. + +**Change.** +- `Contacts::body_calls: Option`: `Some(n)` runs the poster n times in this process (n console frames end + here), `None` once (the old cadence; tests). +- `PlayerAudio::body_console` (`SKATE_AEMS_BODY_CONSOLE=0` off; e2e `E2E_BODY_CONSOLE=0`) sets `body_calls` to the + MixMap cadence's evaluations of the pass (`jitter_steps`: `mixmap::cadence`, every second 60 Hz step on a fixed + grid). Without the MixMap console cadence (`SKATE_AEMS_MIX_CONSOLE=0`) the old cadence stays. +- The NPC skaters' host does the same for its instance (`NpcSkater::set_body_calls`, the evaluations since its last + call). +- Diagnostics: `Contacts::body_digest` (FNV-1a over every posted message) and, in the e2e harness, + `.ours.body.tsv` (the row where the count changed, the count, the digest). +- Below 30 fps a pass spans more than one console frame and the host has only the newest step's state, so the poster + then runs twice on it (as the MixMap evaluations do). + +**Proof of frame-rate independence.** +- Unit test `the_body_poster_keeps_the_console_cadence_at_any_frame_rate`: a 20 s 60 Hz stream of rising and falling + impacts on both arm regions, run through a host at 30 / 60 / 144 / 240 / 365 fps (process on passes that complete + steps, newest state, `Cadence`): the same messages on the same steps (counts and digests). The old cadence differs + between 30 and 60 fps. A held impact posts on steps 2, 32, 62, 92: every 30 steps = 0.5 s. +- e2e, the two bail sessions (`state_20261003_084712`, `state_20261003_101640`; 0 malformed; cut with + `scenarios.py --cut r0-999999`, 3,324 / 14,922 rows), `E2E_FPS` = 30 / 60 / 144 / 365: the body traces are + byte-identical at all four rates. With the old cadence 30 fps differs from 60 (144 / 365 equal 60). +- Identity of everything else: `E2E_BODY_CONSOLE=0` renders are byte-identical (f32 and voices) to the pre-change + build at all four rates. The optimisation bench (13 scenarios + 4 sessions, row and `E2E_FPS=300`, + `opt/runs/cad_t1` against `cad_base`): IDENTICAL. Those logs carry no region impacts (row mode also keeps the old + cadence). + +**The e2e effect on bails (`E2E_FPS=60`, `bails_cmp.py`, window −0.5…+2.5 s; tools `.local/audio-re/bodycad/`).** + +| | 084712 before | 084712 after | 101640 before | 101640 after | +|---|---|---|---|---| +| body-poster messages (whole session) | 35 | 30 (−14 %) | 102 | 91 (−11 %) | +| bails | 2 | 2 | 10 | 10 | +| Skate_Collisions voices / bail | 45.5 (46, 45) | 42.0 (43, 41) | 26.9 | 26.6 | +| three banks (Collisions + Metal + Bodyslide) voices / bail | 52.0 | 48.5 | 29.0 | 28.8 | +| three banks Σg² / bail | 1.895 (1.95, 1.84) | 2.114 (1.74, 2.49) | 0.954 | 0.997 | + +The longer cooldown removes 11–14 % of the body messages, far less than the "up to 2×" bound: in our bails few +regions are hit again within 0.25–0.5 s. The voice counts drop slightly. Σg² moves both ways, because the posts that +remain fall on different steps of the impact curve (bail 2 of 084712 now posts on a stronger step: peak 0.715 → +0.976). The bail-density gap against the recomp (≈ 208 Skate_Collisions voices, Σg² 4.7 per bail) stays. Its cause is +the ragdoll's per-step Δv (open items 1–2) and, for a quarter of the floor crossings, the missing |Δv·n| (section +above). The recomp's post counts are inflated by its ~345 fps cadence (cooldown ≈ 43 ms there), so it is not the +reference for this rate either; the console's 0.5 s is. + +**Files.** `crates/skate-audio/src/player/contacts.rs`, `crates/skate-audio/src/world/skaters.rs`, +`crates/skate-game/src/game_audio/{player_audio,e2e,npc_skaters}.rs`. + +**Open.** The deck-impact poster `sub_824BD000` has the same shape (a 6-frame cooldown counted per process), and the +other Contacts / component processes still run per 60 Hz step. Not changed here (not asked; extending the rule is the +user's call). + +### NPC skaters' second grain bed (2026-10-03, `grain::GrainBed`, `Runtime::npc_grains`, `game_audio/{grain_bed,npc_skaters}.rs`) + +**Problem.** The NPC skater holding the Player slot's instance 1 (`world::skaters`, inert until an AI-skater system +publishes) ran its routing, but its grain binds were dropped: the runtime had one bed. Most surfaces (asphalt, +concrete, wood, aggregate, metal) are grain surfaces, so the NPC's rolling was silent there. + +**Retail's mechanism (TU3 recompilation, reference only).** Every `SFXObj_SkateBoard` owns 2 trucks × 2 GrainPlayers +(owner `+1176 + 8t` / `+1180 + 8t`) and their chains. Its process `sub_824C6A78` (vtable `0x822FC780` +20) runs for an +active record (`[+28]+52`), and its update `sub_824C6BD8` (+24) has no local test, so instance 1's board runs its own +bed. The local tests (`[owner+16]+72`) in the bed's helpers, read one by one: + +| part | function | for the NPC instance | +|---|---|---| +| slope inputs 2 / 3 (D, U) | `sub_824CA738` | runs (no local test) | +| routing, soft member | `sub_824C5CA8`, `sub_824C8370` | one pass, no hand-over (already ported in `player::rolling`); soft via `sub_824B23C8` = the local player's inverse | +| push envelopes, input 4, turn intensity | `sub_824C6198` → `sub_824C8588` | run | +| chain values (HPF / LPF / pan / env send / FSS) | `sub_824C9058` | run; the graph-2 FlangeSub sends level(21) / (22) are local only | +| seam-pattern gain envelope | `sub_824CA448` | returns at once (local only) | +| graph-1 → graph-3 send, level ramp | `sub_824CAEC0` | returns at once | +| gain wobbles | `sub_824CB180` (→ `sub_824CB078`) | returns at once: the graph-1 / graph-3 Gains stay at the module default 1 | +| graph 3 | `sub_824C8878` (chain build) | built only for the local player (`lbz 72` before the graph-3 build); the eEQChain create flag is the local byte too | +| records (gain / pitch / position), brake slew, latches | `sub_824C6BD8` | run, from the instance's SkateBoard outputs level(1) / (2), pitch(3) | +| rocket `x_jet_rolling` | SenseOfSpeed `sub_824E7980` | returns for non-local (already noted) | +| pick generator | `sub_82A8AF10`, `0x82FD7D74` | one title-wide generator for every GrainPlayer | + +**Change.** +- `GrainBed::local` (true by default): false skips graph 3 (`process_full(…, graph3)`). `GrainBed::share_rng` runs a + closure with another generator swapped in. +- `Runtime::npc_grains: Option>`: rendered after the local bed on the local bed's generator (retail's + one generator), at the local bed's user gain and `chain_extras`. The env sends of both beds are summed when both + send; its dry mix is added after the local one. `None` = the previous render path exactly. +- `grain_bed::Bed` gains its instance. `Bed::for_instance(g)`: the same tunings and decoded recordings, no rocket. + `write_inputs` writes `SkateBoard(g)` inputs 2 / 3 / 4. `step_with` (the old `step` body, with the runtime handed + in) gates the local-only parts above and targets `npc_grains` (created at the NPC bed's first step; binds draw + through the shared generator). `step` keeps its signature and order for the local player. +- `npc_skaters::frame`: at the claim (native rolling layers on) the held skater gets `Bed::for_instance(1)`. Per + console evaluation: update → the bed step on this evaluation's outputs with the routing's binds of the last + process → `write_inputs` (2 / 3 / 4) → the instance's inputs → process. Push plants = `+335` rises + (`push_trigger`); brake = `+336` (`AudioState::brake`). At the release the bed is dropped and the trucks stop. + Logged as `AUDIO_NPC grain bind instance 1 truck …`. + +**Verification.** +- `grain::bed::tests::a_non_local_bed_has_no_graph3_and_shares_the_generator`: without graph 3 the send level is + inert; with it, it adds the copy. At send 0 a built graph 3 adds only its filters' anti-denormal bias (< 1e-12; + retail builds none for the NPC). A bed on a shared generator renders exactly like one owning that state, and its own + generator never moves. +- `npc_skaters::tests::npc_skater_rolls_on_its_own_grain_bed` (real install; the synthetic pass of the existing NPC + test): the second bed binds `concrete_smooth_soft` (local wheels hard → soft member), 2–4 voices while held, record + A gain 0.004 at 29 m → 0.096 at 10 m → 0.009 at 29 m (the instance's MixMap distance roll-off), nothing after the + release, the local bed never bound. In the air before the rail, the fixture's "no material" wheels rebind + `asphalt_smooth_soft` (surface 3): the routing's known UNCERTAIN air behaviour (grain spec §2.10). +- The local player's renders: see "Verification of the 2026-10-03 cadence / NPC-bed stage" below. + +**Open.** +- What the released instance's players do in retail (here they stop, as the packets are released): not read. +- Retail's order of the two owners' picks on the shared generator (ours: the local bed first per block). The picks + are random either way; it only matters while an NPC is held. +- The brake slew's direction test (`+336` against the direction of travel) is unported for both beds. + +### Verification of the 2026-10-03 cadence / NPC-bed stage (the two sections above) + +- `cargo test -p skate-audio --locked`: all pass (lib 220 + the integration tests; new: + `the_body_poster_keeps_the_console_cadence_at_any_frame_rate`, `a_non_local_bed_has_no_graph3_and_shares_the_generator`). +- `cargo test -p skate-game --release --bin skate3rust --locked -- game_audio::`: 55 pass, 4 ignored (new: + `npc_skater_rolls_on_its_own_grain_bed`). +- `cargo build --locked` (dev): OK, only the 3 known warnings (`library.rs` ×2, `prefetch.rs`). +- e2e identity (target `.local/fma-target`; tools `.claude/skills/optimisation/tools/e2e_bench.sh`, + `.local/audio-re/bodycad/render.sh`): + - the optimisation bench, 13 scenarios + 4 sessions, row and `E2E_FPS=300`: `cad_base` (before) = `cad_t1` (body + cadence) = `cad_t3` (+ NPC bed), byte-identical (f32 and voices), and equal to `retire1`; + - the bail sessions 084712 / 101640: `E2E_BODY_CONSOLE=0` = before at 30 / 60 / 144 / 365 fps; after Task 3 = after + Task 1 at 60 and 30 fps (f32, voices and body traces). So the NPC bed leaves the local player's renders untouched. +- Headless only: no game launch, no smoke test, bin\ / data\ untouched. For the user in game: bails (the body poster + now has the console's 0.5 s cooldown at 60+ fps). The NPC bed stays silent until an AI-skater system publishes. + +### Region impacts take |Δv·n|; the deck poster on the console cadence (2026-10-03, user-approved follow-up) + +The user approved both follow-ups of the sections above: retail's |Δv·n| and the console cadence for the deck poster. + +**1. |Δv·n| in `skate_events::region_impacts`.** +- Change: `along = (Δv · n).abs()`, matching the `vandc128` sign mask in `sub_82BD60C8`. A part pulled away from the + surface now counts like one stopped by it, no longer flooring to 0.001. +- Tests: + - `region_impacts_scale_the_velocity_change_by_the_part_mass` pins it: −2 m/s gives 0.2; a tiny step still reads the + 0.001 floor. + - `region_impacts_match_the_recomps_bail_lines` (data-gated: the fixed-hook runs t2 / ok / ok2 / ok3) rebuilds every + BAILREG line's Δv along its normal with its mass. Ours equals the written impact within 0.002 on **12,054 / 12,054 + lines**, 5,355 of them with Δv·n < 0. +- **The e2e can't show it.** The harness replays the impacts the game logged (`rimp0..5`), and the two bail sessions + were logged with the signed formula. Re-rendered 084712 / 101640 at `E2E_FPS=60` with the new build (and the deck + cadence off): byte-identical to the current state. So the per-bail change (Skate_Collisions voices, Σg², tiers) needs + a new user session with this build. + - What it acts on: in the logs, contact region-frames at the 0.001 floor are 1,035 of 1,237 (084712) and 649 of + 1,042 (101640), after the 4-step max. Some of these are negative steps that will now read their |Δv·n|. + - In the recomp, 26 % of the above-floor steps are negative, mostly small: 8 of 166 above 0.1. + - So expect more tier-0 body / cloth posts and few new tier-1 / 2 ones. + +**2. The deck-impact poster `sub_824BD000` on the console cadence.** +- It had the body poster's shape: a 6-frame cooldown (vault `27D3C5DC3282B59D`) counted per `process`, so 0.1 s at + 60+ fps and 0.2 s at 30 fps. The 4-step deck-impact max (`+668`) is the per-physics-step conditioner: unchanged. +- Change: + - `Contacts::deck_calls` (as `body_calls`); `PlayerAudio::deck_console` from the MixMap cadence's evaluations. + - The NPC instance follows: `NpcSkater::set_deck_calls`. + - Off switches: `SKATE_AEMS_DECK_CONSOLE=0` / `E2E_DECK_CONSOLE=0`. + - Diagnostics: `deck_posts` / `deck_digest`, and e2e writes `.ours.deck.tsv`. +- Proof: + - Unit test `the_deck_poster_keeps_the_console_cadence_at_any_frame_rate`: the same messages on the same steps at + 30 / 60 / 144 / 240 / 365 fps; the old cadence differs at 30. A held impact posts on steps 2, 14, 26, 38, 50, i.e. + every 12 steps = 0.2 s. + - The bail sessions at `E2E_FPS` 30 / 60 / 144 / 365: deck traces byte-identical across the four rates; the old + cadence's 30 fps differed. + - Off = before: `E2E_DECK_CONSOLE=0` renders are byte-identical to the current state (at all four rates for the bail + sessions; the bench `cad_dkoff` against `cad_t3`, row and fps300). +- Effect on the bench (`cad_dk` against `cad_t3`): + - Row mode: identical (no console cadence there). + - `E2E_FPS=300`: 5 outputs change, the scenario `ollies_log` and the sessions 213757, 214224, 215843, 224747. The + other 12 scenarios are identical. + - In each changed output the body trace is unchanged and the first differing voice row comes exactly one row after the + first differing deck post (`.local/audio-re/bodycad/deck_attr.py ON OFF`). + - Later rows keep differing: from that post on, the shared Splice picks shift. + +| deck messages (`E2E_FPS`=300 bench / 60 bails) | old | console | +|---|---|---| +| ollies_log | 3 | 3 (timing moved) | +| 213757 / 214224 / 215843 / 224747 | 11 / 2 / 18 / 19 | 9 / 2 / 15 / 19 | +| 084712 / 101640 | 6 / 55 | 6 / 44 | + +- Bails (`E2E_FPS=60`, against the current state, `bails_cmp.py`): + +| | 084712 now | 084712 deck console | 101640 now | 101640 deck console | +|---|---|---|---|---| +| Skate_Collisions voices / bail | 42.0 | 41.0 | 26.6 | 25.7 | +| Skate_Collisions Σg² / bail | 1.926 | 1.600 | 0.982 | 0.970 | +| three banks voices / bail; Σg² / bail | 48.5; 2.114 | 47.5; 1.788 | 28.8; 0.997 | 27.6; 0.982 | +| body / cloth voices, tier 0 / 1 / 2 | 47 / 5 / 0 | 47 / 5 / 0 | 110 / 26 / 2 | 107 / 27 / 4 | + + - 084712's bail 2 Σg² drop (2.485 → 1.834) has the same count of deck posts; the post timings moved and so did the + Splice picks after them. + - The tier counts come from `bails_cmp.py`'s top-24 ids per bail; the body traces themselves are unchanged. + +**Verification.** +- `cargo test -p skate-audio --locked`: all pass (lib 221). +- `game_audio::`: 56 pass, 4 ignored. +- `cargo build --locked`: OK, with the 3 known warnings. + +**Files.** `game_audio/{skate_events,player_audio,e2e,npc_skaters}.rs`, `skate-audio/src/player/contacts.rs`, +`skate-audio/src/world/skaters.rs`. + +### Bail density: the missing speed curve, not the ragdoll (2026-10-03, research only; no engine change) + +Spec: `.claude/notes/ragdoll-contact-spec.md` (retail mechanism, ours, decision points). Summary: + +- **The retail Δv figures above (p99 1.8–13 m/s, max 7.6–20) are not comparable with our bails.** The scripted runs at + the Mega-Park spawn bailed into the gap or slid down the MegaRamp roll-in, so the body hit at 11–23 m/s (screenshots; + e.g. foot v·n −23 → −12 → −1 m/s). In the ordinary-height tumbles of run `bailx3_20261003_110208` (first 2 s of each + bail) retail's |Δv·n| is p90 2.3, max 3.7 m/s, approach speeds ≥ −4.3 m/s: the size of ours. The ragdoll solve + (Horus solver, 50 iterations, restitution 0, ragdoll friction 0.9, angular drag 0.5 × 60, ragdoll masses) is ported in + ours from the same code and data; no difference was found in the contact response. +- **What is missing: the audio bridge's speed curve.** `sub_824B0DA8`, after copying the conditioner's 8 region impacts + into the audio state (`+496..`), multiplies each by an 8-point graph (`sub_82481E10`) of the previous frame's |COM v| + (`+216` ← `+212`): x 0 / 0.366 / 0.448 / 0.521 / 0.593 / 0.741 / 0.863 / 0.946, y 1.0 / 1.2 / 1.486 / 1.914 / 2.429 / + 3.6 / 4.571 / 5.0 (read from the running game with a watch on `[[[0x830CFDA4]+36]+4]`). New recomp hook `BANDQ` on + `sub_82497088` (impact, tier, band edges per query) shows the body poster's impact = 5.0 × the clamped region impact + (ratio p50 5.0 over 218 posts) and band edges equal to our exported table. Ours skips the curve, so our body impacts + are 1/5 of retail's at the poster: no tier 1 / 2, and concrete 1047 / 991 (> 1.0 / 1.85) unreachable — this also + answers the open question "which impact posts concrete tier 1 / 2" above. +- **Not changed** (needs the user's go): port the curve in `skate_events` (audio only; skate-core untouched), together + with the |Δv·n| abs and the body poster's 30 fps cadence; then re-measure bail density against the recomp. + *Ported 2026-10-03 (user: "Port it"): see "The bridge's speed curve on the body impacts" below.* +- Script: `bail_ledge.txt` now uses `lt rt l3 r3` (6 / 6 bails; user tip), with an X jump + mid-air `l3 r3 lt rt` fallback. + +### The bridge's speed curve on the body impacts (2026-10-03, user decision "Port it"; `player::contacts`, `skate_events`) + +The user decided (2026-10-03): port retail's speed curve on the body impacts ("Port it"); the ragdoll physics item is +closed with no physics change. + +**Listening before the curve** (user, session `state_20261003_110725`, build with |Δv·n| and the body / deck posters on +the console cadence, no curve): "bails sounded good, a bit soft like you said. Deck hits sound really good and seem to +be either equal to retail or almost perfect to retail." The deck hits are approved, so the curve must leave the deck +posts alone (shown below). + +**Retail's mechanism (TU3 recompilation, reference only).** +- The audio-state bridge `sub_824B0DA8` copies the skater entry's 116-byte block `+320` into the audio state `+496`. + This holds the conditioner's 8 region impacts, already clamped to [0, 1] by `sub_82BD60C8`. +- Then, for i = 0..7: `+496 + 4i` = `fmuls`(graph(`+216`), `+496 + 4i`). The graph is `sub_82481E10(8, rec + 16, rec + 48, v)` + with rec = `[[0x830CFDA4] + 36] + 4`. +- **No clamp after the multiply.** The poster sees up to 5.0. The recomp's BANDQ lines agree: the body poster's queries + (callers `824BC4C8` / `824BC530`) reach 4.11 (bandq run) and 4.88 (bailx3 run), concrete (material 2) up to 2.72 at + tier 2. The deck poster's queries (`824BD090` / `824BD0C4`) stay ≤ 1.0. +- The deck impact `+668` lies outside the multiplied range (`+496..+524`). +- **Which frame.** `+212` is written earlier in the same call from the skater entry's `+108`. `+216` is read by the + multiply, then set from `+212` after the loop. So the graph sees the *previous* bridge call's `+212`. +- **Units.** The entry's `+108` is written by `sub_827A1B78`: the length (`vmsum3fp128` + `vrsqrtefp128` with two + Newton steps) of the vector at `[r30 + 36] + 16`. That is SystemReckoning `+16`, the COM velocity, in m/s. It is the + same quantity as our `AudioState::com_speed()` (the footsteps' walk curve reads it too). +- The BANDQ floor queries agree with m/s. A lying body posts 0.00101–0.00116, i.e. ×1.0–1.16, and the graph gives that + below 0.37 m/s. +- **Interpolation** (`sub_82481E10`, already ported for the footsteps' 16-point curves): + - below x0 → y0; from x7 on, or for an unordered input → y7; + - in between, the first i with v < x[i]: slope first, then `fmadds`; + - y[i] where two x coincide. +- **Data: the install's own vault.** The words read from the running game equal the stock vault's class + `6EBA5BCD3E38A98A` `default` field `8B164823E008749C` (a `Sk8::PointNegGraphData8`, the same collection as the body + cooldown and the deck cooldown). Header 0 / 1 / 1 / 5, x at +16, y at +48: + - x 0, 0.3664494, 0.4478826, 0.5211726, 0.592834, 0.7410426, 0.863192, 0.946254; + - y 1.0, 1.2, 1.485714, 1.914286, 2.428571, 3.6, 4.571427, 5.0. + +**Change.** +- `skate_audio::player::contacts::SpeedGraph8`: the 8-point graph and its evaluator. `ContactsTuning::body_speed_curve` + defaults to the vault words (`SpeedGraph8::BODY_SPEED`, bit patterns). +- Setup exports the graph: `audio_export.collision_tuning` posters `body_speed_x` / `body_speed_y`. `library.rs` + prefers them when present. Installs set up before today use the identical default. +- `AudioState::com_speed_216`: retail's `+216`. `skate_events::observe` sets it per physics tick to last tick's + `com_speed()` (`Seen::com_speed_212`). +- `AudioState::body_impact` stays the conditioner's (pre-graph) value. `Contacts::body` multiplies each region's + impact by `body_speed_curve.eval(com_speed_216)` (f32, no clamp). The cloth and pad contacts read the same product, + as retail's poster reads `+496`. +- Switch `Contacts::body_speed_on`: `SKATE_AEMS_BODY_CURVE=0` / `E2E_BODY_CURVE=0` turn it off, giving the impacts as + logged. The NPC instance's Contacts leaves it off. Its state has no `+216` (0 → ×1.0) and no ragdoll impacts yet. +- The deck poster, the 4-step region max and `region_impacts` are unchanged. +- **Replays.** The state log gains a `com_speed` column (`+212`, appended), and `scenarios.py` (both copies) carries + `com_speed` and `com_x/y/z` into cut scenarios. The e2e harness computes each row's `+216` from the previous row: + - from `com_speed` when the log has it; + - else (logs before today) |Δ COM position| × 60 from `com_x/y/z`, the reckoning's followed point at 4 decimals + (±0.006 m/s). The graph saturates from 0.95 m/s, so this only matters for slow bodies. A respawn jump reads as fast + (×5, as any speed > 0.95); + - else 0 (×1). + + So the old bail sessions render with the curve. +- Diagnostics: `E2E_BODY_LOG=1` writes `.ours.bodymsg.tsv` (every body message with its row, region, the impact + the poster read, `+216`, materials, tiers, levels). `Contacts::body_log` has no effect on the output. + +**Tests.** +- `the_bridge_speed_graph_is_the_vault_curve_piecewise_linear_and_clamped` checks the knots, both clamps, NaN, the + midpoint, and is bit-exact against the slope-first `mul_add`. +- `the_speed_graph_scales_the_body_impacts_not_the_deck`: at rest (×1) a 0.3 arm hit on concrete is arm tier 1 / + concrete tier 0. Moving (×5) it is 1.5, i.e. arm tier 2 / concrete tier 1 (1047). 0.4 → 2.0 gives concrete tier 2 + (991). Off equals at rest. 0.0011 × 5 crosses the arm floor. The deck messages are identical with the graph on and off. +- `the_body_speed_graph_is_the_stock_vault_record` (`library.rs`, data-gated on the converted `skater-collections.json`): + exported posters win; the default equals the vault record word for word. + +**Frame-rate independence and identity** (`.local/audio-re/bodycurve/`: `render.sh`, `bails.py`, `body_attr.py`; +inputs re-cut with `scenarios.py --cut r0-999999`, 0 malformed). +- Body traces with the curve are byte-identical at `E2E_FPS` 30 / 60 / 144 / 365 for all three sessions. +- `E2E_BODY_CURVE=0` renders (f32 and voices) are byte-identical to the previous state's (`bodycad/dk`) at all four rates + for 084712 / 101640. The new COM columns are inert when the curve is off. +- **Deck posts unchanged:** the deck traces (count + FNV digest of every message) are identical with the curve on and + off in all three sessions, at every rate. +- In each session the first differing voice row is the row of the first differing body post (084712 row 1101, 101640 + row 2427, 110725 row 1963, all in a bail, state 300). Before it, the voices are identical. +- After that the voices keep differing, outside the bails too: the shared Splice picks shift, as with the deck cadence + change. The deck hits' messages (timing, materials, tiers, levels) stay the same. Only the random variant a hit draws + may differ after the first bail. +- **Bench** (13 scenarios + 4 sessions, row and `E2E_FPS=300`, `opt/runs/curve` against `cad_dk`): IDENTICAL. Those + inputs carry no region impacts and no COM columns, so they cannot change. The graph acts only on body posts, and it + scales them only while the body moves (> 0 m/s): ×1.2 at 0.37 m/s, ×5 from 0.95 m/s. + +**Per bail** (`E2E_FPS=60`; posts = message sides with a tier, each plays one collision id; window = bail rise − 0.5 s … +bail clear, as the recomp's 094336 summary; voices / Σg² = `bails_cmp.py`, −0.5…+2.5 s): + +| per bail | the recomp (094336, 10 bails) | 084712 off → on (2) | 101640 off → on (10) | 110725 off → on (7) | +|---|---|---|---|---| +| body posts | 71 | 15.0 → 24.5 | 7.8 → 10.9 | 15.1 → 20.0 | +| tier 0 / 1 / 2 | ≈ 49 / 13 / 9 | 14.0 / 1.0 / 0 → 19.5 / 3.5 / 1.5 | 6.5 / 1.1 / 0.2 → 6.7 / 2.9 / 1.3 | 12.7 / 1.7 / 0.7 → 12.3 / 5.4 / 2.3 | +| tier-2 1030 / 1031 / 1032 | 1.8 / 3.3 / 2.1 | 0 / 0 / 0 → 0 / 0.5 / 0.5 | 0 / 0 / 0 → 0.1 / 0.6 / 0.3 | 0 / 0.1 / 0 → 0.3 / 0.9 / 0.3 | +| torso 951 | 1.0 | 0 → 0.5 | 0.2 → 0.2 | 0.3 → 0.4 | +| concrete 958 / 1047 / 991 | 3.4 / 2.3 / 0.9 | 1.0 / 0 / 0 → 2.0 / 0.5 / 0 | 0.1 / 0 / 0 → 0.5 / 0 / 0 | 0.6 / 0 / 0 → 1.9 / 0.4 / 0 | +| Skate_Collisions voices (recomp: 38 bails, nine sessions) | 207.7 | 41.0 → 59.5 | 25.7 → 30.6 | 35.6 → 43.7 | +| three banks Σg² | ≈ 5.1 | 1.79 → 2.52 | 0.98 → 1.03 | 1.58 → 1.78 | +| peak impact the poster read (max over the bails) | 4.9 (bailx3) | 0.21 → 1.03 | 0.32 → 1.59 | 1.00 → 5.00 | + +- What changed: the tier-1 / 2 hits and concrete 1047 now occur. The posts per bail rise 30–60 %: floor contacts + 0.001 × 5 = 0.005 pass the arm floor, and more regions clear their bands. +- Concrete 991 (> 1.85, i.e. a region impact > 0.37 while moving) still doesn't occur in our bails. +- The gap to the recomp's 71 posts stays, but the recomp is not the reference for counts. Its ~345 fps cadence makes + the 15-frame cooldown ≈ 43 ms (0.5 s on the console). With 6 regions and a 0.5 s cooldown, our poster can't post + more than ~12 groups a second. +- Σg² rises less than the counts. A tier-2 hit's level comes from its own window, and many of the new posts are + quiet tier-0 floor hits. +- 110725's last bail (155.9 s, 4.5 s long) has no region impacts at all (`rimp*` 0 throughout). Not investigated. + +**|Δv·n| against the older sessions** (110725 was recorded with |Δv·n|, 084712 / 101640 without; from the logs): + +| | 084712 | 101640 | 110725 | +|---|---|---|---| +| contact region-frames at the 0.001 floor | 84 % (1,035 / 1,237) | 62 % (649 / 1,042) | 76 % (1,950 / 2,554) | +| above-floor region-frames / bail | 101 | 39 | 86 | +| region-frames > 0.1 / > 0.25 | 12 / 0 | 68 / 16 | 71 / 29 | +| body posts / bail, no curve | 15.0 | 7.8 | 15.1 | + +- No session-level signature of the abs. The bails differ more between sessions than the expected effect: 26 % of + above-floor steps in the recomp, mostly small, 8 of 166 above 0.1. +- The abs can't be isolated without the same bail logged both ways (the replay reads the logged impacts). 110725's + many hits above 0.25 are its larger falls (peak 1.0). + +**Files.** `crates/skate-audio/src/player/{contacts,state}.rs`, +`crates/skate-game/src/game_audio/{skate_events,state_log,e2e,player_audio,library}.rs`, +`tools/asset_pipeline/audio_export.py`, `tools/audio-e2e/scenarios.py`. + +**Open.** +- The NPC instance gets no `+216` (its state comes from `world::skaters`, and no ragdoll impacts are published). +- Old logs use the COM-position fallback. Sessions from this build log `com_speed`. +- For the user in game: bails should now have more tier-1 / 2 body hits and concrete tier 1 (louder, punchier). Deck + hits are unchanged. + +### The turn intensity after release; trace / comment leftovers (2026-10-03) + +**Turn intensity "linger": no change. The recomp's 50 ms is its frame rate.** +- Retail's `sub_824C8588` moves the signed I toward the raw target by the grain collection's step per call: `+1160` ± + the primary truck's rise / fall step (the vault field lookup `sub_82B72420`; 0.06, 0.1 on one surface). There is no dt. +- The local GREC rows of `all_20261002_180430` (filtered by `filter_local.py`; 1,874 calls where the input reads 0 and + I > 0.07) step by exactly 0.06 per call (1,832; 0.1 on 36), at 2.8 ms per call (the recomp's ~345 fps). +- So the recomp's I is back to 0 in ~10 calls ≈ 30 ms. That is the "0 by 50 ms" figure, a recomp artefact. On the + 30 fps console the same 10 steps take ~333 ms. +- Ours: `TurnIntensity::step` with `frames = dt × 60` takes 0.06 per 1/60 s. + - In 110725 / 101640 our input (the animation's Turn) falls from ≥ 0.9 to 0 in 3 frames (p50; p90 4–5), like + retail's 34–50 ms. + - Our I then reaches 0 in 8 frames (p50; p90 9–10, simulated from the log at cap 0.6): the "~13 frames" from the + release start. + - That is already half the console's time, not a linger. +- The retail mechanism on the console-cadence rule would be 0.06 per console frame (`frames = dt × 30`; the brake slew + `BRAKE_STEP` has the same shape). That doubles our fall time to ≈ 330 ms and changes the rolling bed in every riding + scenario. Not changed: it is the opposite of the reported symptom and needs the user's call. + +**Leftovers.** +- `skate_events.rs`: the wheel-material comment now says 0.45 changes / s (local rider only). +- The published `tools/recomp-trace/trace.py` FIELD_COUNTS gains BAILLOCAL, BAILSTEP, BAILREG, BAILCAND, COLLPOST, + BANDQ and LOCALTEST (scrubbed comments). + +**Verification (both sections).** +- `cargo test -p skate-audio --locked`: all pass (lib 223). +- `cargo test -p skate-game --release --bin skate3rust --locked -- game_audio::`: 57 pass, 4 ignored. +- `cargo build --locked`: OK, with the 3 known warnings. +- Headless only (target `.local/fma-target`); bin\ and data\ untouched. diff --git a/docs/hails-additions/13-recomp-research-hooks.md b/docs/hails-additions/13-recomp-research-hooks.md index a43b9fb6..108b6402 100644 --- a/docs/hails-additions/13-recomp-research-hooks.md +++ b/docs/hails-additions/13-recomp-research-hooks.md @@ -46,6 +46,10 @@ The recompilation is **not** a perfect copy of the console game, and its traces is a step-by-step guide: a first trace, choosing categories, reading and checking a trace, watch lists, input scripts and the audio capture. 4. Check every trace for malformed lines before using it; there should be none. +5. Traces made before 2026-10-03 ~10:30: the per-player kinds (GREC, GRECX, FIRSTHIT, SKID, TREAT, SEAMPAT, SEAMHIT) + also logged a nearby NPC skater's objects, interleaved per frame. Filter them by object (the first one logged is + the local rider). Newer builds gate on the game's local byte `[[object+28]+72]` and log `LOCALTEST` lines; see + doc 11, "The per-player recomp hooks logged an NPC skater too". The analysis scripts we used are published in `tools/recomp-trace/` (trace readers, per-bank level and voice tools, rolling-bed and send analysis) and `tools/recomp-code-search/` (searching the recompiled sources and the diff --git a/docs/hails-additions/PULL-REQUESTS.md b/docs/hails-additions/PULL-REQUESTS.md index ea3319a3..95d386e7 100644 --- a/docs/hails-additions/PULL-REQUESTS.md +++ b/docs/hails-additions/PULL-REQUESTS.md @@ -16,7 +16,7 @@ verification, open questions). | F | Tooling | Streaming map validator (`--validate-maps`) wired into setup | [06](06-map-validator.md) | `18f41a3` | E (speed only) | Draft upstream [#28](https://github.com/SK8-ENGINE/skate-3-rust-engine/pull/28) | | H | Gameplay + rendering | Water: detection and bail, shallow water solid / deep water floats, board floats, water camera + vignette, entry splash, water look (family 33 cube blur + tint, anti-tiling, body-size calming, slower waves), animated water (frame-state buffer fix, ocean PCA from default.xex, XEX2 unpacker), ripple scale | [09](09-water.md) | `gameplay/water` (`af1b350`…`55776dc`), upstream branch `water/retail-water` (`8a6e328`, one commit on `60efdef`) | — (carries #28's `install.py` `spawn()` split verbatim) | Draft upstream [#30](https://github.com/SK8-ENGINE/skate-3-rust-engine/pull/30) | | I | Test fix | `sky_shader_validates`: add the `VERTEX_POSITIONS` shader def Bevy sets for real meshes | [10](10-sky-shader-test.md) | `b7865d0` | — | Not opened; tiny, can go upstream alone | -| J | Feature (audio) | Game audio: setup group `audio` extracts/decodes the disc's audio (vgmstream r2117), per-map ambience, water emitters, rolling by surface and speed band, trick/landing/grind/bail/splash cues, footsteps from foot strikes, volume settings and `--mute` | [11](11-audio.md) | `gameplay/audio` (`98c52b6`, `d9a1818`); upstream PR branch `audio/retail-audio`, which mirrors `gameplay/audio` exactly (source of truth; batch commit `f438017` "New audio who dis", merged as `ca7b4f3`, 2026-10-02) | H (branched from `gameplay/water`; uses its water detection — `collision_feedback.flags.material_12`, `is_water_tag`). Overlaps upstream #4/#1 (retail-exact player audio via transcribed game code + memory image; not adopted — see doc 11): both edit `tools/asset_pipeline/asset_exports.py` and `install.py`, different asset layouts | Draft upstream [#32](https://github.com/SK8-ENGINE/skate-3-rust-engine/pull/32), **Work in Progress**. Branch has the first version; the world-audio work (.ems emitters, location-set one-shots/sirens, zone ambience) is done locally and goes up with the next batch commit; native AEMS runtime (`crates/skate-audio`, new workspace crate) implemented 2026-10-02 behind `SKATE_AEMS=1` (world emitters only), then the MixMap mixer (emitter words) and the granular rolling bed (`grain/`, `game_audio/grain_bed.rs`; setup keeps the whole grains, copies `MixMapSK8.mxb`, exports the grain tuning), then the player inputs and the first native player components (`skate_audio::player`, `game_audio/player_audio.rs`: Class_grind, SenseOfSpeed, Class_foot_drag; setup exports `player_tuning`; `skate-core` `WheelLineState.seam_patterns`), then the Splice board contacts / skid / squeaks and the collision manager, wheel spin and foot taps / scuffs (`player/{contacts,collision,wheels}.rs`; `skate-core` `BoardGroundState.part_audio_surfaces`; setup `player_tuning.collision`), then the buses (reverb network, eEQChain, flanger returns, collision submix) and Class_Seams, the board layers (`player/rolling.rs`), the grain bus chain (`grain/chain.rs`, `dsp/{fss,shelf}.rs`), tricks / treatment, and footsteps / clothing / hands on the deck (`player/{tricks,treatment,footsteps,clothing,step_on}.rs`; setup adds four Class_rolling banks, `bus_tuning.flange`, `player_tuning.audio_tricks_2`, per-material footstep fields and 16 grain-chain vault keys). All uncommitted, all on #32's checklist (updated 2026-10-02 21:30). The seam sample shuffle is in (Listening test 8: retail's boot utility `Start_up_Play_ctl`; setup now copies `Common.abk`). Listening: landings, pops, footsteps and the hand pick-up / put-down confirmed; rolling "is much better, it just has issues with certain surfaces"; after the shuffle, concrete and brick "both sound better but still need work". Plan, in order: (1) surface parity from the paired sessions (ours / recomp at the Aletown brick and the University PCU Library); (2) footstep submix, remaining engine inputs, reverb-zone emitters (1,040 records), Reverb inputs / global env-send scale, utility boot order (done headless 2026-10-02, doc 11 "FootStep SubMix, the skeleton inputs, …"; needs the listening check; setup exports the zones' `reverb` preset); (3) native as the default, interim cue tables removed; (4) the optimisation pass; (5) world / NPC audio stages as follow-ups. #32's description carries the same status and plan (updated 2026-10-02 evening). Latest listening (build 21:57): "rolling sounds better, griding sounds better, walking sounds better"; a University fountain's "doubling/restarting" is retail's relay of short pieces, with the repeat rate being analysed from the user's WATERRELAY retail session. Seams: retail runs them per rendered frame; per the user's decision ours targets the 360's ~30 fps cadence, frame-rate independent (done, default; user: "seams sound much better. overall much better <3"). Plan step (1) done; (2) Treatments 16/17 from the TREAT trace settled 2026-10-02: the recomp plays them too (their samples equal sense_of_speed 3/4, so the trace tool misread them); `+236` now counts through the landing tick, and the e2e scenarios pass the air words (doc 11 "Treatments slots 16 / 17"). Since the push: native is the default (2026-10-02 late; `interim`/`SKATE_AEMS=0` opt-out, tables kept), uncommitted. Overnight 2026-10-03: the MixMap, the Jitter walk and the eEQChain clear on the console's 30 Hz cadence (`mixmap::cadence`, flag inputs held; `SKATE_AEMS_MIX_CONSOLE=0` = old), frame-rate independent 60–365 fps, uncommitted (doc 11 "The MixMap on the console cadence"); the seam-prominence gap re-measured (the recomp capture lags ~110 ms), no change. Overnight plan: remaining small items, then the optimisation pass, then ped speech / traffic / NPC skaters' boards. World / NPC foundation built overnight 2026-10-03, uncommitted (doc 11 "World sound sources"): `skate_audio::world` (traffic engine / horn / alarm / skid, ped footsteps, speech index and reaction cues), `game_audio/world_sources.rs` (inert until a ped / traffic system publishes owners), setup `tools/asset_pipeline/world_audio.py` (13 world banks, `world_tuning`, speech index; speech PCM opt-in `SKATE_SETUP_SPEECH=1`). NPC skaters' boards foundation 2026-10-03, uncommitted (doc 11 "NPC skaters' board sounds"): `world::skaters` (retail gives the Player slot's 2nd instance to one NPC within 30 m of the camera; the local player's components run for it), `game_audio/npc_skaters.rs` (inert until an AI-skater system publishes; `SKATE_AEMS_NPC_SKATERS=0`); e2e byte-identical. Optimisation pass (first round) done 2026-10-03, uncommitted: render per block -39 % (p50 210 -> 130 us), byte-identical e2e outputs, no audio-thread allocations; hardware FMA and emitter-bank prefetch left for the user (doc 11 "Optimisation pass"). Session-review fixes 2026-10-03, uncommitted (doc 11 "Session-review fixes"): push plant from State55, the Contacts' push-foot plant / lift, the body poster (+ setup export fix for name-keyed AudioSurface records), grind on / off sounds, new state-log columns; each behind `SKATE_AEMS_*=0`, e2e byte-identical with them off. FMA (user decision 2026-10-03): runtime dispatch in one exe, done 2026-10-03, uncommitted (doc 11 "Hardware FMA dispatch"): new workspace crate `crates/skate-audio-fma` (the per-sample `mul_add` loops compiled plain and with `#[target_feature(enable = "fma")]`, one guarded `unsafe` call; no external deps), skate-audio stays forbid(unsafe_code); `SKATE_AUDIO_FMA=0` forces plain; e2e byte-identical on both paths; render p50 129 → ~55 µs. Keep #32's description current (standing rule) When #32 is ready for merge, all of it goes up as ONE commit titled `New audio who dis`, only with the user's explicit permission at that time. | +| J | Feature (audio) | Game audio: setup group `audio` extracts the disc's audio (vgmstream r2117) and the native data (AEMS banks, MixMap, grains, Splice banks, vault tuning); a native port of the retail audio runtime (`crates/skate-audio`, `crates/skate-audio-fma`) plays all world and player audio; volume settings and `--mute` | [11](11-audio.md) | `gameplay/audio` (latest pushed `870940e`); upstream PR branch `audio/retail-audio` mirrors it exactly (`843cca9`). History: batch commit `f438017` "New audio who dis" (merged as `ca7b4f3`, 2026-10-02), then follow-up commits | H (branched from `gameplay/water`; uses its water detection — `collision_feedback.flags.material_12`, `is_water_tag`). Overlaps upstream #4/#1 (retail-exact player audio via transcribed game code + memory image; not adopted — see doc 11): both edit `tools/asset_pipeline/asset_exports.py` and `install.py`, different asset layouts | Draft upstream [#32](https://github.com/SK8-ENGINE/skate-3-rust-engine/pull/32), **Work in Progress**. State 2026-10-03: native runtime is the only path (interim cue tables removed); every retail player component ported; world emitters, location sets and zone ambience from retail data; water splash from retail's mechanism; console-cadence posters, |Δv·n|, the NPC grain bed and the bail speed curve (bridge graph on the body impacts; uncommitted). User: "basically retail, if there is a difference i couldn't tell". Open: the turn / brake slews' console cadence (user's call; the recomp's fast release is its frame rate), world / NPC stages waiting on ped / vehicle systems. Doc 11 has the full history; #32's description is kept current (standing rule). Commits only when the user asks. | | G | Performance (setup) | Skip decoding duplicate stream copies; run the customiser beside the maps; parallel clothing library and pro roster, plus a native RefPack DLL in `Build.ps1` | [08](08-setup-performance.md) | `8327e94` (+ overlap hunks of `install.py` in `18f41a3`) | — | Draft upstream [#29](https://github.com/SK8-ENGINE/skate-3-rust-engine/pull/29) | | K | Gameplay fix (physics) | Board/skater solver: 50 constraint iterations (retail live value) instead of `RWMaxIterations` = 25 | [12](12-solver-iterations.md) | uncommitted (on `gameplay/audio` working tree) | — (one-file change in `physics/settings.rs`) | Ready apart from the asset-gated `customiser_equipment_reaches_ground_force_and_torque` question (doc 12) | | L | Tooling | Development and research tools in `tools//` (regression checks, setup equivalence, collision / world-stream / audio-file / vault readers, audio e2e and bench; recomp trace and code search, reference only) | [14](14-published-tools.md) | uncommitted (on `gameplay/audio` working tree) | `audio-e2e` / `audio-bench` / `audio-file-inspect` need J's native audio and setup modules; `setup-equivalence/compare_refpack.py` needs G's RefPack DLL; `regression-checks/check_maps.py` uses F's `--validate-maps` (falls back to `--check-assets` without it) | Not opened; the generic folders could go first, the audio ones with or after J | diff --git a/docs/hails-additions/README.md b/docs/hails-additions/README.md index 4f2e8ee0..63c59eb8 100644 --- a/docs/hails-additions/README.md +++ b/docs/hails-additions/README.md @@ -16,7 +16,7 @@ Each document stands alone. File paths are relative to the repository root. | 5 | [Invisible collision volumes](05-collision-volumes.md) | Game data loader (Rust) | Done, all four affected maps confirmed in play | | 9 | [Water](09-water.md) | Game physics, camera and rendering (Rust), setup, diagnostics | Matched to retail footage (RPCS3): shallow water solid, deep water floats, board floats, water camera + vignette, entry splash, canal water look matched, small bodies calmer | | 10 | [Sky shader validation test](10-sky-shader-test.md) | Game tests (Rust) | Done; all six shader validation tests pass | -| 11 | [Game audio](11-audio.md) | Setup (Python, vgmstream), game audio (Rust) | Work in progress (draft #32). Player cues tuned against retail traces; retail world audio done: .ems emitters, location-set one-shots (sirens; 1,859/1,859 retail match over 15 locations), zone ambience beds. Native AEMS runtime (`crates/skate-audio`: evaluator + voice graph) implemented behind `SKATE_AEMS=1` for the world emitters; 234/235 PoC-oracle scripts identical, DSP kernels bit-exact; MixMap + granular rolling bed; player MixMap inputs and the first native player components (grind, wind/rattle, foot drag; wind level within 0.5 dB of retail); needs a listening check. | +| 11 | [Game audio](11-audio.md) | Setup (Python, vgmstream), game audio (Rust) | Work in progress (draft #32). Native port of Skate 3's retail audio runtime (`crates/skate-audio`): AEMS evaluator, voice graph, MixMap, granular rolling bed, Splice, buses, every player component; the only audio path since 2026-10-03 (interim cue tables removed). Retail world audio: .ems emitters, location-set one-shots, zone ambience. User: "basically retail". | | 12 | [Board solver: 50 constraint iterations](12-solver-iterations.md) | Game physics (Rust) | Done; maps identical, water traces as expected; one asset-gated customiser test fails (open question) | | 13 | [Research hooks for the recompilation](13-recomp-research-hooks.md) | Research tooling (external: skate3recomp `research-hooks` branch) | Published; reference and information gathering only (the recompilation is not a perfect copy of the console game) | | 14 | [Published development and research tools](14-published-tools.md) | Tooling (`tools//`, Python and shell) | Done (uncommitted); scripts compile and print their usage; the two `recomp-*` folders are for the recomp's research hooks, reference only | diff --git a/tools/asset_pipeline/audio_export.py b/tools/asset_pipeline/audio_export.py index 1839e850..26b0d2bf 100644 --- a/tools/asset_pipeline/audio_export.py +++ b/tools/asset_pipeline/audio_export.py @@ -699,6 +699,8 @@ BODY_POSTERS = (('Hash_6DD85F43C1B6E6AA', 'body_cooldown'), ('Hash_3695327CFB5E1AC3', 'body_111_low'), ('Hash_35FEE8A95523D812', 'body_111_high'), ('Hash_076E9081CA1759E9', 'body_112_low0'), ('Hash_DF539915EB7E883E', 'body_112_high0'), ('Hash_8E3025BAA686F721', 'body_112_low1'), ('Hash_504D3B73505972D4', 'body_112_high1')) +# The bridge's speed graph on the body impacts (sub_824B0DA8; class 6EBA5BCD3E38A98A `default`). +BODY_SPEED_GRAPH = 'Hash_8B164823E008749C' # The grind on / off contact sounds per grind surface (class GRIND_CLASS): the metal flag (Skate_Metal, else # Skate_Collisions), the ids per layer 0..3 by bank (sub_824C35D0 / sub_824C37D8), the per-layer level factor # (sub_824C2FA0 / sub_824C3190) and the level / pitch endpoints A, B (sub_824C2FA0 / 3190 / 3380 / 34A8). @@ -824,6 +826,13 @@ def collision_tuning(resolve, by_class) -> dict: v = resolve('Hash_6EBA5BCD3E38A98A', 'default', field) if v is not None: posters[name] = _scalar_or_list(v) + # The audio-state bridge's speed graph (sub_824B0DA8 scales the region impacts by it at the + # previous frame's |COM v|): a Sk8::PointNegGraphData8, x at +16, y at +48. + v = resolve('Hash_6EBA5BCD3E38A98A', 'default', BODY_SPEED_GRAPH) + if v is not None: + floats = _floats(v) + if len(floats) >= 20: + posters['body_speed_x'], posters['body_speed_y'] = floats[4:12], floats[12:20] return {'materials': rows, 'posters': posters} diff --git a/tools/audio-e2e/scenarios.py b/tools/audio-e2e/scenarios.py index 259fc369..bb473fe0 100644 --- a/tools/audio-e2e/scenarios.py +++ b/tools/audio-e2e/scenarios.py @@ -88,7 +88,9 @@ EXTRA = ['grind_impact', 'deck_impact', 'deck_tag', 'foot_y0', 'foot_y1', 'foot_ 'strength', 'foot_vy_a', 'foot_vy_b', 'step', 'body', 'limb', 'slide', 'deck_up', 'deck_contact', # the push plant, flags, deck spin, body regions. 'plant', 'stroke', 'deck_spin', 'spin_x', 'spin_y', 'bail', 'bail_end', 'held', 'offboard_air', 'footplant', - 'revert', 'soft', 'face'] + [f'{k}{i}' for k in ('rimp', 'rslide', 'rtag') for i in range(6)] + 'revert', 'soft', 'face'] + [f'{k}{i}' for k in ('rimp', 'rslide', 'rtag') for i in range(6)] + [ + # the bridge's speed graph input: |COM v| (logs since 2026-10-03), else the COM positions. + 'com_speed', 'com_x', 'com_y', 'com_z'] def from_log(log, cut, name, out): diff --git a/tools/recomp-trace/trace.py b/tools/recomp-trace/trace.py index 68f14871..bb45fb4a 100644 --- a/tools/recomp-trace/trace.py +++ b/tools/recomp-trace/trace.py @@ -31,6 +31,14 @@ FIELD_COUNTS = { 'FIRSTHIT': 6, # B b16(u8) f24 bail676 end677 why(1 byte 2 float 4 bail/end 8 heartbeat) (GREC hook) 'SKID': 7, # owner holder handle w0..w17(18 ints, or "-" on release) counter1516 slip232 rev690 (skid hook) 'EMITSLOT': 11, # object state info0(patch) info4(positional) info8(level) info12 info16 mixkey handle w0..w8 out0..out9(hex) (emitter slot hook) + # Bail body impacts (category audiox; local player, only while bailing). + 'BAILLOCAL': 5, # character object1808 X how(1 vtable checked, 2 not) entry_index (skater-entry update hook) + 'BAILSTEP': 10, # X step_ms dt5220 bail end contacts sc4036 sc4040 cfg164 how (ragdoll region pass, after its BAILREGs) + 'BAILREG': 12, # X region part impact |dv.n| v_old.n v_new.n |dv| normal("x y z") mass slide tag(hex) (ragdoll region pass) + 'BAILCAND': 7, # src(entry|pass) lr a b flag c d: lookup diagnostics (entry: character entry bit31 object X; pass: SC X 0 0 X) + 'COLLPOST': 16, # caller object state grec_owner local72 matA matB tierA tierB soundA soundB b40 b41 b42 pos("x y z") chain (collision message post) + 'BANDQ': 7, # caller material impact flag tier low high (impact-band queries, audiox) + 'LOCALTEST': 6, # hook object byte16 byte28 old_word accepted (local-test decisions per object, audio) }