Files
benilla-octo/crates/benilla-formats/tests/m2_go_arm_book.rs
T
SamandClaude Fable 5 2c55790ec1 portals work, GameObjects animate, and the tent stops flickering
- Area triggers exist now, so portals work: every teleport pad, dungeon entrance
  and enter-a-place quest volume reports the reference's own way — one exit latch,
  first containing volume on the map wins, a sphere or a yawed box. Verified live:
  Rut'theran to Darnassus, the Deadmines worldport, and a level-gated entrance
  refusing with the server's own "You must be at least level 10 to enter."
- GameObjects animate. Every animated object arms its idle the moment it goes live
  (doors and chests used to flash bind pose for a frame), a missing rest pose is
  remapped the way the reference remaps it instead of playing nothing — the
  Ahn'Qiraj gate's root tangle stood frozen for want of a Closed clip — and the
  state machine covers the verified twenty of thirty-one object types instead of
  three: fourteen hundred spawns' worth of traps, goobers, books and questgivers
  now change pose off the wire.
- The flickering tent is still. Cloth you can see from both sides is authored as
  two reversed copies of the same sheet, and the real client backface-culls each
  copy from its wrong side so exactly one covers any pixel; we drew every building
  batch two-sided, which left the winner to floating-point noise, flickering while
  the camera crept. WMO materials now honor their cull flag.
- A sweep of long-standing visual bugs: effects respect the far-clip wall — a
  ferry's deck lanterns were drawing from 7,000 yards away; the night-elf eye glow
  goes out in first person instead of leaving two orbs burning in mid-air; the
  goblins' stray white hair tuft is gone (a duplicated table key resolved last-wins
  where the client scans first-wins); lava and slime finally have their own
  submerged view — the dense fiery orange and dense green of the real thing, off
  two fixed lighting rows nothing in the zone data points at; and the far-horizon
  band overlaps the detailed terrain instead of leaving a strip of sky at lowered
  view distance.
- Two caught at the keyboard: the backpack icon greys instead of vanishing while
  the escape menu is up, and the world map is a real panel now — it can no longer
  open over the menu, and ESC closes it first like everything else.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 19:20:51 +07:00

147 lines
6.6 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 std::path::PathBuf;
use benilla_formats::{
open_chain, parse_m2_animation_lookup, parse_m2_animations, parse_m2_playable_animation_lookup,
};
fn vanilla_data_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data")
}
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 = vanilla_data_dir();
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return;
}
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"
);
}