- a player build ships beside the dev one — dev chords and probe surfaces go dark, and a gate enforces what a player can still reach, by medium not by module - the binary carries its own shaders and its shipped UI, finds the WoW folder by itself, and keeps its local state in one benilla-config folder - the glue screens get a capture shutter, and the engine stops depending on the protocol crate Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
137 lines
6.4 KiB
Rust
137 lines
6.4 KiB
Rust
//! Every book in the world opened and closed constantly (`#bugs` B36) — because the loader-idle
|
|
//! seed armed the model's **file-order-first** sequence, and on a book that sequence is the *Close
|
|
//! motion*, not the closed pose.
|
|
//!
|
|
//! `G_BookOpenMediumBrown.m2` is the model that makes this unmissable. Its four sequences are the
|
|
//! whole door family — Close(146) / Closed(147) / Open(148) / Opened(149) — authored in **that**
|
|
//! order, so file-order-0 is a 0.333 s motion that sweeps bone 0 from the open angle to the closed
|
|
//! one. Every one of the four carries `flags` bit 0 clear, i.e. the kernel loops it forever (wow-re
|
|
//! `gameobject-anim-arm.md` §3, `0x714585`). Looping the Close motion is therefore a book that snaps
|
|
//! open and swings shut three times a second, for ever — exactly what was reported.
|
|
//!
|
|
//! The reference arms **animation id 0 (Stand)** resolved through the model's own
|
|
//! `playableAnimationLookup` (§1, byte-verified `0x71019b`), and this book's table sends id 0 to
|
|
//! **147 Closed** — a two-key band whose keys are both identity, i.e. a still, shut book.
|
|
//!
|
|
//! The DuelingFlag test next door pins the same seed law on a model whose first sequence is a
|
|
//! *Spawn*; this one pins it where the wrong pick is a **motion**, which is the visually loudest
|
|
//! failure and the one a whole class of GameObjects (every `World\Goober\` door-family prop) shares.
|
|
//! Skips (passes) when the client isn't present at `<repo>/WoW/Data`.
|
|
|
|
use benilla_formats::{
|
|
open_chain, parse_m2_animation_lookup, parse_m2_animations, parse_m2_playable_animation_lookup,
|
|
};
|
|
|
|
const BOOK: &str = "World\\Goober\\G_BookOpenMediumBrown.m2";
|
|
/// `AnimationData.dbc`: 146 Close (motion), 147 Closed (rest), 148 Open (motion), 149 Opened (rest).
|
|
const CLOSE: u16 = 146;
|
|
const CLOSED: u16 = 147;
|
|
const OPEN: u16 = 148;
|
|
|
|
#[test]
|
|
fn the_book_idle_resolves_to_closed_not_the_close_motion() {
|
|
let data = benilla_formats::wow_data_or_skip!();
|
|
let mut chain = open_chain(&data).expect("open vanilla patch chain");
|
|
let bytes = chain.read_file(BOOK).expect("the book M2 is in the chain");
|
|
let anims = parse_m2_animations(&bytes);
|
|
|
|
// 1. File order really does lead with the Close MOTION — without this the test proves nothing.
|
|
assert_eq!(
|
|
anims.first().map(|a| a.anim_id),
|
|
Some(CLOSE),
|
|
"the model's FIRST sequence is the Close motion, not a rest pose — the whole point"
|
|
);
|
|
|
|
// 2. That first sequence loops (bit 0 clear), so arming it is a permanent flap, not a one-shot.
|
|
assert!(
|
|
anims[0].looping,
|
|
"Close is a looping band — arming it is what made the book cycle for ever"
|
|
);
|
|
|
|
// 3. The loader's seed resolves to Closed through the model's own table.
|
|
let playable = parse_m2_playable_animation_lookup(&bytes).expect("playable lookup");
|
|
let idle_id = playable.first().map_or(0, |p| p.resolved_id);
|
|
assert_eq!(
|
|
idle_id, CLOSED,
|
|
"playableAnimationLookup[0] resolves to Closed — the seed the reference arms"
|
|
);
|
|
|
|
// 4. The pose each band actually holds, as the viewer would see it. This is the assertion that
|
|
// catches the bug coming back even if the id plumbing is rewritten. Bone 0's rotation is a
|
|
// pure x-axis quaternion: x ≈ 0 is the shut book, x ≈ 0.69 (≈ 87°) the open one.
|
|
//
|
|
// Closed's band (533..633 ms) has NO keys of its own — the authored keys sit at 500 and 667,
|
|
// both identity — so the window rule (decision 0643) resolves it to a single constant key.
|
|
// One key here is the *correct* answer, not a missing one: the band genuinely doesn't move.
|
|
let root_rot = |id: u16| -> Vec<[f32; 4]> {
|
|
anims
|
|
.iter()
|
|
.find(|a| a.anim_id == id)
|
|
.and_then(|a| a.bones.iter().find(|b| b.bone == 0))
|
|
.map(|b| b.rotation.iter().map(|(_, q)| *q).collect())
|
|
.unwrap_or_default()
|
|
};
|
|
let closed = root_rot(CLOSED);
|
|
assert!(!closed.is_empty(), "Closed must resolve to a pose");
|
|
for q in &closed {
|
|
assert!(
|
|
q[0].abs() < 1e-3,
|
|
"Closed must hold the SHUT pose (bone 0 x ≈ 0), got {}",
|
|
q[0]
|
|
);
|
|
}
|
|
|
|
// Close, by contrast, sweeps: it starts at the open angle and lands shut. Looping *that* is
|
|
// the flapping book — so both halves of the claim are pinned, not just the one we fixed.
|
|
let close_motion = root_rot(CLOSE);
|
|
let (lo, hi) = close_motion
|
|
.iter()
|
|
.fold((f32::MAX, f32::MIN), |(lo, hi), q| {
|
|
(lo.min(q[0]), hi.max(q[0]))
|
|
});
|
|
assert!(
|
|
hi - lo > 0.5 && lo.abs() < 1e-3 && hi > 0.5,
|
|
"Close must swing from open ({hi}) to shut ({lo}) — that sweep, looped, IS the reported bug"
|
|
);
|
|
|
|
// 5. Closed is a non-BIND pose, which is what keeps the book on the skinned path at all: its
|
|
// two cover bones sit at ±2.5° while bind has them square. If the idle ever stopped counting
|
|
// as "differs from bind" the book would drop to the static mesh — silently, because the
|
|
// difference is small enough to pass an eyeball and large enough to be the wrong book.
|
|
let closed_seq = anims
|
|
.iter()
|
|
.find(|a| a.anim_id == CLOSED)
|
|
.expect("the book authors Closed");
|
|
let tilted = closed_seq
|
|
.bones
|
|
.iter()
|
|
.filter(|b| {
|
|
b.rotation
|
|
.iter()
|
|
.any(|(_, q)| (q[3].abs() - 1.0).abs() > 1e-4)
|
|
})
|
|
.count();
|
|
assert!(
|
|
tilted >= 2,
|
|
"Closed must pose bones off bind (the covers), got {tilted}"
|
|
);
|
|
|
|
// 6. The ownership table the GameObject arm's missing-sequence remap branches on (the
|
|
// reference's `0x711960`): this model owns the whole door family and nothing beyond it, so
|
|
// no remap leg is reachable here and the arm plays the LUT id directly.
|
|
let lookup = parse_m2_animation_lookup(&bytes).expect("animation lookup");
|
|
let owns = |id: u16| lookup.get(id as usize).is_some_and(|&s| s != 0xffff);
|
|
for id in [CLOSE, CLOSED, OPEN, 149] {
|
|
assert!(owns(id), "the book authors animation id {id}");
|
|
}
|
|
assert!(
|
|
!owns(0),
|
|
"the book authors NO Stand — the seed reaches Closed only through the playable table, \
|
|
which is exactly why a naive `find(Stand)` would arm nothing"
|
|
);
|
|
assert!(
|
|
!owns(150),
|
|
"ids past the table's end read as the out-of-bounds sentinel, i.e. not owned"
|
|
);
|
|
}
|