Part of #41: the comments of `benilla-formats` (the library, its extract tools, examples and tests) are rewritten to the rule in `docs/METHOD.md`, which is what the code does and the reference fact behind it, in a line. 19,654 comment lines become 7,264. Fixes #105: the WMO collision comments now name walking's box-query leaf `0x6bca50` and the camera and line-of-sight segment-query leaf `0x6bc700`, in `models/collision.rs`, `examples/wmo_bsp.rs` and `tests/chain.rs`. - **The mechanical strip.** The citations that were only a private record number go first, and each edit is shown to be a pure removal. - **Wrong facts corrected.** The rewrite corrects the comments that stated wrong facts. Among them: - A placed doodad's material alpha reads file slot 0 on its spawn clock, while the reference reads bone 0's armed sequence, re-rolled every play window. The old text said doodads are armed once at load. - The fog end is stored ×36 and scaled once at load (`0x6d6090`). It is not raw. - The MCSH bit picks a map doodad's sun scale, 1.0 lit or 0.5 shadowed. 2.5 belongs to an entity's light node. - `SpellRec` is the row as stored, column × 4, not a translated copy. - A global sequence's clock is anchored at the instance's attach time (`0x714352`). - Particle twinkle is a gated size multiplier (`0x7b2adc`). - The blob shadow sizes from the Stand box. - Data claims are checked against the install: the weapon-trail kits' parameters and alphas, the beam rows' noise, and the maps' `field_16`. - Dozens more turned up on the way: renamed or deleted items, docs sitting on the wrong item, stale counts and links. - **Deviation labels.** "Deviation:" now marks only a choice with a reason. An unbuilt behaviour says so plainly. - **Verification.** Every file strips to identical code, and `cargo fmt --check` is clean. Four second readers compared every file old against new, restored the facts the rewrite had dropped, and checked each new citation against the reference, the code and the shipped data. String literals are untouched; they are #92. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
307 lines
11 KiB
Rust
307 lines
11 KiB
Rust
//! The keyed M2Track sampler shared by the particle and ribbon parsers. Sequence-timeline keys
|
|
//! (`gseq == 0xffff`) sit in each sequence's absolute `[start, end]` band, as bone keys do: an
|
|
//! effect whose sequence 0 spans `[1000, 2600]` bursts at 1000 ms, its first instant. Parsing
|
|
//! rebases them onto sequence 0's band, the one an effect or doodad plays, so seconds since spawn
|
|
//! sample them directly; a model keying an emitter per sequence is sampled on sequence 0 alone.
|
|
|
|
fn le_u16(b: &[u8], o: usize) -> u16 {
|
|
u16::from_le_bytes([b[o], b[o + 1]])
|
|
}
|
|
fn le_u32(b: &[u8], o: usize) -> u32 {
|
|
u32::from_le_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]])
|
|
}
|
|
|
|
/// A value a track can hold and lerp: `f32` scalars and the ribbon colour track's `[f32; 3]`.
|
|
pub trait TrackValue: Copy {
|
|
/// The empty-track value; the parsers fall back to a constant key instead.
|
|
const ZERO: Self;
|
|
fn lerp(a: Self, b: Self, t: f32) -> Self;
|
|
}
|
|
|
|
impl TrackValue for f32 {
|
|
const ZERO: Self = 0.0;
|
|
fn lerp(a: Self, b: Self, t: f32) -> Self {
|
|
a + (b - a) * t
|
|
}
|
|
}
|
|
|
|
impl TrackValue for [f32; 3] {
|
|
const ZERO: Self = [0.0; 3];
|
|
fn lerp(a: Self, b: Self, t: f32) -> Self {
|
|
std::array::from_fn(|i| a[i] + (b[i] - a[i]) * t)
|
|
}
|
|
}
|
|
|
|
/// A keyed M2Track as `(timestamp ms, value)` pairs. Burst emitters key their rate
|
|
/// (`BloodSpurt.m2` goes `0 → 200 → 0` over 133 ms), so the first key alone reads 0. No loop
|
|
/// wrap: the last key holds, and every looping ambient prop in the shipped corpus keys a constant.
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct ValueTrack<V = f32> {
|
|
/// `(timestamp ms, value)` keys, in file order (ascending timestamps).
|
|
pub keys: Vec<(u32, V)>,
|
|
/// Interpolation word (`+0x00`): 0 steps (`values[k0]`), as burst emitters author, else lerps.
|
|
pub interp: u16,
|
|
}
|
|
|
|
impl<V: TrackValue> ValueTrack<V> {
|
|
/// A single-key constant track (the parse fallback shape).
|
|
pub(crate) fn constant(v: V) -> Self {
|
|
Self {
|
|
keys: vec![(0, v)],
|
|
interp: 0,
|
|
}
|
|
}
|
|
|
|
/// The first key's value, for constant-track parameters and diagnostics.
|
|
pub fn first(&self) -> V {
|
|
self.keys.first().map_or(V::ZERO, |&(_, v)| v)
|
|
}
|
|
|
|
/// Step-sample at `ms` since clip start, the reference sampler's interp-0 leg: the
|
|
/// nearest-previous key, the first before the first, the last held past the end.
|
|
pub fn step_ms(&self, ms: f32) -> V {
|
|
let mut v = self.keys.first().map_or(V::ZERO, |&(_, v)| v);
|
|
for &(t, val) in &self.keys {
|
|
if (t as f32) <= ms {
|
|
v = val;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
v
|
|
}
|
|
|
|
/// Sample as the reference's per-frame sampler does (`0x71af20` → `0x713d50`, lerp at
|
|
/// `0x71af76`): step at interp 0, else lerp, held past the last key and extrapolated backward
|
|
/// below the first. Raw values may go negative; the consumer floors at 0 (`SetEmissionRate`).
|
|
pub fn sampled_ms(&self, ms: f32) -> V {
|
|
if self.interp == 0 {
|
|
return self.step_ms(ms);
|
|
}
|
|
let n = self.keys.len();
|
|
if n <= 1 {
|
|
return self.first();
|
|
}
|
|
// The segment starting at the last key at or before `ms`; below the first key, the first.
|
|
let k = self
|
|
.keys
|
|
.iter()
|
|
.rposition(|&(t, _)| (t as f32) <= ms)
|
|
.unwrap_or(0);
|
|
if k + 1 == n {
|
|
return self.keys[n - 1].1;
|
|
}
|
|
let (t0, v0) = self.keys[k];
|
|
let (t1, v1) = self.keys[k + 1];
|
|
let span = (t1.saturating_sub(t0)).max(1) as f32;
|
|
V::lerp(v0, v1, (ms - t0 as f32) / span)
|
|
}
|
|
|
|
/// Sample at `ms` since clip start: linear between keys, clamped outside them; values are raw.
|
|
pub fn sample_ms(&self, ms: f32) -> V {
|
|
let Some(&(t0, v0)) = self.keys.first() else {
|
|
return V::ZERO;
|
|
};
|
|
if ms <= t0 as f32 {
|
|
return v0;
|
|
}
|
|
for w in self.keys.windows(2) {
|
|
let ((ta, va), (tb, vb)) = (w[0], w[1]);
|
|
if ms < tb as f32 {
|
|
let span = (tb - ta).max(1) as f32;
|
|
return V::lerp(va, vb, (ms - ta as f32) / span);
|
|
}
|
|
}
|
|
self.keys.last().map_or(V::ZERO, |&(_, v)| v)
|
|
}
|
|
}
|
|
|
|
impl ValueTrack<f32> {
|
|
/// The track's peak, whether it can ever contribute (a burst's first key is 0, its peak not).
|
|
pub fn peak(&self) -> f32 {
|
|
self.keys.iter().fold(f32::MIN, |m, &(_, v)| m.max(v))
|
|
}
|
|
}
|
|
|
|
/// Rebase a track from the global timeline onto a sequence's `[start, end]` band: in-band keys
|
|
/// shift by `-start`, the last key at or before `start` becomes `t = 0`, and the first at or
|
|
/// after `end` closes the band at `end - start`.
|
|
pub(crate) fn rebase_keys_to_band<V: Copy>(keys: &mut Vec<(u32, V)>, start: u32, end: u32) {
|
|
if keys.is_empty() || (start == 0 && keys.last().is_some_and(|&(t, _)| t <= end)) {
|
|
return; // already 0-based and in band, the common ambient-prop shape
|
|
}
|
|
let mut out: Vec<(u32, V)> = Vec::with_capacity(keys.len());
|
|
for &(t, v) in keys.iter() {
|
|
if t <= start {
|
|
// Collapse everything at or before the band start to the t=0 key (last one wins).
|
|
match out.first_mut() {
|
|
Some(first) if first.0 == 0 => *first = (0, v),
|
|
_ => out.insert(0, (0, v)),
|
|
}
|
|
} else if t < end {
|
|
out.push((t - start, v));
|
|
} else {
|
|
out.push((end - start, v));
|
|
break; // first key at/past the end closes the band
|
|
}
|
|
}
|
|
*keys = out;
|
|
}
|
|
|
|
/// The first sequence's absolute time band; a sequence-less model gets the whole timeline.
|
|
pub(crate) fn seq0_band(bytes: &[u8]) -> (u32, u32) {
|
|
let (n_seq, o_seq) = (le_u32(bytes, 0x1c) as usize, le_u32(bytes, 0x20) as usize);
|
|
if n_seq > 0 && o_seq + 0x44 <= bytes.len() {
|
|
(le_u32(bytes, o_seq + 4), le_u32(bytes, o_seq + 8))
|
|
} else {
|
|
(0, u32::MAX)
|
|
}
|
|
}
|
|
|
|
/// A vanilla M2Track's `(gseq, n, timestamps offset, values offset)`.
|
|
fn track_arrays(b: &[u8], track: usize) -> Option<(u16, usize, usize, usize)> {
|
|
if track + 0x1c > b.len() {
|
|
return None;
|
|
}
|
|
let gseq = le_u16(b, track + 0x02);
|
|
let tn = le_u32(b, track + 0x0c) as usize;
|
|
let tofs = le_u32(b, track + 0x10) as usize;
|
|
let vn = le_u32(b, track + 0x14) as usize;
|
|
let vofs = le_u32(b, track + 0x18) as usize;
|
|
let n = tn.min(vn);
|
|
(n > 0 && tofs + n * 4 <= b.len()).then_some((gseq, n, tofs, vofs))
|
|
}
|
|
|
|
/// Read an M2Track's keys, `elem`-byte values decoded by `read`; sequence keys are rebased to
|
|
/// `band`, global-sequence ones keep their clock, and an empty or truncated track is `default`.
|
|
pub(crate) fn track_keys_with<V: TrackValue>(
|
|
b: &[u8],
|
|
track: usize,
|
|
default: V,
|
|
band: (u32, u32),
|
|
elem: usize,
|
|
read: impl Fn(&[u8], usize) -> V,
|
|
) -> ValueTrack<V> {
|
|
let Some((gseq, n, tofs, vofs)) = track_arrays(b, track) else {
|
|
return ValueTrack::constant(default);
|
|
};
|
|
if vofs + n * elem > b.len() {
|
|
return ValueTrack::constant(default);
|
|
}
|
|
let mut keys: Vec<(u32, V)> = (0..n)
|
|
.map(|i| (le_u32(b, tofs + i * 4), read(b, vofs + i * elem)))
|
|
.collect();
|
|
if gseq == 0xffff {
|
|
rebase_keys_to_band(&mut keys, band.0, band.1);
|
|
}
|
|
ValueTrack {
|
|
keys,
|
|
interp: le_u16(b, track),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn value_track_samples_linear_and_clamps() {
|
|
let t = ValueTrack {
|
|
keys: vec![(0, 0.0), (33, 0.0), (67, 100.0), (100, 200.0), (133, 0.0)],
|
|
interp: 1,
|
|
};
|
|
assert_eq!(t.sample_ms(-5.0), 0.0); // clamp before first key
|
|
assert_eq!(t.sample_ms(0.0), 0.0);
|
|
assert!((t.sample_ms(50.0) - 50.0).abs() < 1.0); // rising edge, linear
|
|
assert_eq!(t.sample_ms(100.0), 200.0); // the burst peak
|
|
assert!((t.sample_ms(116.5) - 100.0).abs() < 1.0); // falling edge
|
|
assert_eq!(t.sample_ms(500.0), 0.0); // clamp past last key
|
|
assert_eq!(t.peak(), 200.0);
|
|
assert_eq!(t.first(), 0.0);
|
|
let c = ValueTrack::constant(7.5);
|
|
assert_eq!(c.sample_ms(0.0), 7.5);
|
|
assert_eq!(c.sample_ms(9999.0), 7.5);
|
|
assert_eq!(ValueTrack::<f32>::default().sample_ms(10.0), 0.0);
|
|
}
|
|
|
|
#[test]
|
|
fn step_sampling_holds_nearest_previous() {
|
|
let t = ValueTrack {
|
|
keys: vec![(0, 0.0), (67, 30.0)],
|
|
interp: 0,
|
|
};
|
|
assert_eq!(t.step_ms(0.0), 0.0);
|
|
assert_eq!(
|
|
t.step_ms(66.0),
|
|
0.0,
|
|
"step, not lerp — silent until the key"
|
|
);
|
|
assert_eq!(t.step_ms(67.0), 30.0);
|
|
assert_eq!(t.step_ms(1500.0), 30.0, "held past the last key");
|
|
assert_eq!(ValueTrack::<f32>::default().step_ms(10.0), 0.0);
|
|
}
|
|
|
|
#[test]
|
|
fn sampled_ms_dispatches_on_the_interp_word() {
|
|
let step = ValueTrack {
|
|
keys: vec![(0, 0.0), (67, 30.0)],
|
|
interp: 0,
|
|
};
|
|
assert_eq!(step.sampled_ms(66.0), 0.0, "interp 0: silent until the key");
|
|
assert_eq!(step.sampled_ms(67.0), 30.0);
|
|
let ramp = ValueTrack {
|
|
keys: vec![(100, 10.0), (200, 110.0), (300, 0.0)],
|
|
interp: 1,
|
|
};
|
|
assert!(
|
|
(ramp.sampled_ms(150.0) - 60.0).abs() < 1e-4,
|
|
"mid-ramp lerp"
|
|
);
|
|
assert_eq!(ramp.sampled_ms(200.0), 110.0);
|
|
assert_eq!(ramp.sampled_ms(999.0), 0.0, "held past the last key");
|
|
assert!(
|
|
(ramp.sampled_ms(0.0) - (-90.0)).abs() < 1e-4,
|
|
"below the first key: backward extrapolation along the first segment, not a clamp"
|
|
);
|
|
let single = ValueTrack {
|
|
keys: vec![(0, 40.0)],
|
|
interp: 1,
|
|
};
|
|
assert_eq!(single.sampled_ms(500.0), 40.0, "single key holds");
|
|
}
|
|
|
|
#[test]
|
|
fn vec3_track_lerps_per_channel() {
|
|
let t = ValueTrack {
|
|
keys: vec![(0, [1.0, 0.0, 0.0]), (100, [0.0, 1.0, 0.5])],
|
|
interp: 1,
|
|
};
|
|
assert_eq!(t.sample_ms(0.0), [1.0, 0.0, 0.0]);
|
|
let mid = t.sample_ms(50.0);
|
|
assert!((mid[0] - 0.5).abs() < 1e-6);
|
|
assert!((mid[1] - 0.5).abs() < 1e-6);
|
|
assert!((mid[2] - 0.25).abs() < 1e-6);
|
|
assert_eq!(t.sample_ms(500.0), [0.0, 1.0, 0.5]);
|
|
}
|
|
|
|
#[test]
|
|
fn band_rebase_collapses_and_shifts() {
|
|
// MoltenBlast_Impact_Chest em0's shape: seq band [1000, 2600], burst keyed at its start.
|
|
let mut keys = vec![(1000u32, 0.0f32), (1133, 60.0)];
|
|
rebase_keys_to_band(&mut keys, 1000, 2600);
|
|
assert_eq!(keys, vec![(0, 0.0), (133, 60.0)]);
|
|
// A constant single key at t=0 with a late band (Fire_Cast_Hand's rate): hold survives.
|
|
let mut keys = vec![(0u32, 21.4f32)];
|
|
rebase_keys_to_band(&mut keys, 3333, 4333);
|
|
assert_eq!(keys, vec![(0, 21.4)]);
|
|
// Multiple pre-band keys: only the last one (the band-start value) survives, at t=0.
|
|
let mut keys = vec![(0u32, 1.0f32), (500, 2.0), (1200, 3.0), (3000, 4.0)];
|
|
rebase_keys_to_band(&mut keys, 1000, 2000);
|
|
assert_eq!(keys, vec![(0, 2.0), (200, 3.0), (1000, 4.0)]);
|
|
// 0-based in-band track (BloodSpurt): identity.
|
|
let mut keys = vec![(0u32, 100.0f32), (500, -100.0), (667, -100.0)];
|
|
rebase_keys_to_band(&mut keys, 0, 667);
|
|
assert_eq!(keys, vec![(0, 100.0), (500, -100.0), (667, -100.0)]);
|
|
}
|
|
}
|