diff --git a/README.md b/README.md index fbb1a465..8a9c9bb4 100644 --- a/README.md +++ b/README.md @@ -36,8 +36,9 @@ and no bundled game assets. wire coverage from movement and chat through spells, party, quests, mail, trade, vendors, bank and loot. - **UI:** a from-scratch FrameXML + Lua engine driving the built-in interface, from the login and - character screens through the full HUD, the classic windows, chat, nameplates, floating combat - text and tooltips. + character screens through the full HUD, the classic windows (guild, macros and key bindings + included), chat, nameplates, floating combat text and tooltips; third-party addons load from + your own `Interface/AddOns`. - **Combat:** melee on the faithful swing law, ranged and Auto Shot, casting with GCD and cooldowns, combo points, crowd control that really holds you, and the spell visual pipeline. - **Audio:** music, ambience and SFX under the client's own selection and crossfade rules, with @@ -45,9 +46,7 @@ and no bundled game assets. ## Not built yet -Guilds past the name over a head, the auction house, the hunter and warlock pet bar, battlegrounds -and honor, the fishing minigame, the dressing room, macros and key bindings, and third-party -addons; the Lua engine runs only the built-in UI so far. +The auction house, and battlegrounds and honor. ## Running it diff --git a/crates/benilla-app/src/debug_panel/inspect.rs b/crates/benilla-app/src/debug_panel/inspect.rs index 24c1f22f..cf3a3d75 100644 --- a/crates/benilla-app/src/debug_panel/inspect.rs +++ b/crates/benilla-app/src/debug_panel/inspect.rs @@ -283,8 +283,23 @@ pub(super) fn inspect_ui( } else { String::new() }; + // The **placement tilt** (decision 1459) — shown only when there is one. A GameObject + // is placed by its `GAMEOBJECT_ROTATION` quaternion, and 96.7% of live spawns encode a + // plain yaw in it; the rest carry a tilt that swings the model's off-origin geometry + // yards from the spawn point. So `tilt 70°` on a prop that looks misplaced says "this + // one's pose is quaternion-only" — the question B89 cost a DB round-trip to answer — + // and its absence says the placement is a bare facing and the fault is elsewhere. + let tilt = s + .0 + .gameobject_rotation() + // The angle the quaternion leans the model's own up-axis off world up: + // `acos(m22)`, i.e. `2·asin(|x, y|)` — zero for every pure-yaw spawn. + .map(|q| (2.0 * q[0].hypot(q[1]).min(1.0).asin()).to_degrees()) + .filter(|deg| *deg >= 0.5) + .map(|deg| format!(" · tilt {deg:.0}°")) + .unwrap_or_default(); format!( - "go type {} · state {state} {word} · {solidity} · flags {flags:#x}{flag_text} · {interact}{page_text}", + "go type {} · state {state} {word} · {solidity} · flags {flags:#x}{flag_text} · {interact}{page_text}{tilt}", s.0.gameobject_type_id() ) }); diff --git a/crates/benilla-app/src/net/apply/objects.rs b/crates/benilla-app/src/net/apply/objects.rs index 63ac3a75..0ee34482 100644 --- a/crates/benilla-app/src/net/apply/objects.rs +++ b/crates/benilla-app/src/net/apply/objects.rs @@ -16,8 +16,8 @@ use benilla_world::model_fade::DespawnFade; use benilla_world::vis_chain::VisChainOnly; use super::super::motion::{ - create_spline, monster_move_spline, pose_transform, resolve_facing, trace_create_spline, - trace_move_snap, write_pose, SplineStopped, + create_spline, gameobject_rotation, monster_move_spline, pose_transform, resolve_facing, + trace_create_spline, trace_move_snap, wire_yaw, write_pose, SplineStopped, }; use super::super::{ Guid, GuidIndex, NetCommands, NetEntity, ObjectStore, RemoteMotion, SelfGuid, @@ -101,6 +101,11 @@ pub(super) fn object_create( progress_ms, at: std::time::Instant::now(), }); + // The spawn's `GAMEOBJECT_ROTATION` quaternion — read once, wanted twice: it is this object's + // placement (below) and, on a type-11 lift, the basis its keyframe offsets rotate through. + let go_quat = (kind == EntityKind::GameObject) + .then(|| fields.gameobject_rotation()) + .flatten(); // A type-11 lift's arm seed (decision 0438 phase 3's second consumer): the keyframe path is // keyed by the template **entry**, the offsets rotate through the spawn's `GAMEOBJECT_ROTATION` // quat, and the base is the stationary spot the movement block carried — all already in this @@ -113,8 +118,15 @@ pub(super) fn object_create( entry, base_pos: position, yaw: orientation, - quat: fields.gameobject_rotation(), + quat: go_quat, }); + // Where this object is *pointed*. A mover carries one yaw; a GameObject is placed by its + // `GAMEOBJECT_ROTATION` quaternion, which is the reference's own placement input and is a + // strictly wider answer than the facing (decision 1459, `motion::gameobject_rotation`). + let placement = match kind { + EntityKind::GameObject => gameobject_rotation(go_quat, orientation), + _ => wire_yaw(orientation), + }; // A unit/player created already ON a transport (deck NPCs stream in this way): its LIVING // block's rider tail is its local pose — `compose_riders` re-anchors it through the boat's // live matrix each frame (decision 0438 phase 2). The block's world `position` is the @@ -192,7 +204,7 @@ pub(super) fn object_create( commands.entity(e).remove::(); } } - write_pose(transforms, e, position, orientation); + write_pose(transforms, e, position, placement); // Overlay the fresh snapshot's descriptor fields onto the existing store. merge_fields(stores, pending, e, guid, fields); } else { @@ -207,7 +219,7 @@ pub(super) fn object_create( let mut entity = commands.spawn(( Guid(guid), net, - pose_transform(position, orientation), + pose_transform(position, placement), visibility, )); // Chain-only visibility (benilla_world::vis_chain): the net root renders nothing — @@ -254,7 +266,9 @@ pub(super) fn object_move( ) { if let Some(&e) = index.0.get(&guid) { commands.entity(e).remove::(); - write_pose(transforms, e, position, orientation); + // A movement block carries a yaw and nothing else — the only GameObjects that get one are + // transports, whose pose the transport tick owns from the next frame (decision 0438). + write_pose(transforms, e, position, wire_yaw(orientation)); } } @@ -368,7 +382,7 @@ pub(super) fn unit_move( debug_assert_eq!(fire_ms, now_ms, "a seeding packet fires at arrival"); trace_relay(guid, &mv, &rm.relay, now_ms, 0, RelayOutcome::Seed); apply_move(e, &mv, &mut rm, now_ms, commands, landings); - write_pose(transforms, e, mv.position, mv.orientation); + write_pose(transforms, e, mv.position, wire_yaw(mv.orientation)); commands.entity(e).insert(rm); } } diff --git a/crates/benilla-app/src/net/motion.rs b/crates/benilla-app/src/net/motion.rs index da8681a5..12711e96 100644 --- a/crates/benilla-app/src/net/motion.rs +++ b/crates/benilla-app/src/net/motion.rs @@ -17,9 +17,9 @@ //! move replays — the per-unit fire-time chain), [`facing`] (wire facing //! resolution + the client-local idle re-face and its [`FacingStep`] shuffle latch). This face keeps //! the shared pose glue. Poses live canonically in raw WoW space on the components; the `Transform` -//! is derived (translation + facing only — scale, baked by the renderer, is preserved). +//! is derived (translation + placement rotation only — scale, baked by the renderer, is preserved). -use benilla_assets::coords::wow_to_bevy; +use benilla_assets::coords::{wow_rotation_to_bevy, wow_to_bevy}; use bevy::prelude::*; mod facing; @@ -42,13 +42,51 @@ pub(super) use spline::{ }; pub(crate) use spline::{CreatureSwimming, GroundClamped, Spline, SplineStopped}; -/// A full [`Transform`] for a freshly-spawned entity from a raw-WoW pose. WoW orientation (yaw about -/// +Z) maps straight to a Bevy +Y yaw under `wow_to_bevy` — no 180° offset (unlike doodad/WMO -/// *placement* rotation). -pub(super) fn pose_transform(position: [f32; 3], orientation: f32) -> Transform { +/// The Bevy rotation of a wire **yaw** — a mover's orientation, in radians about WoW +Z. It maps +/// straight to a Bevy +Y yaw under `wow_to_bevy` (`coords`' `yaw_conjugates_through_the_basis_map`) +/// — no 180° offset, unlike doodad/WMO *placement* rotation. +pub(super) fn wire_yaw(orientation: f32) -> Quat { + Quat::from_rotation_y(orientation) +} + +/// The Bevy rotation of a **GameObject** placement: its `GAMEOBJECT_ROTATION` quaternion, not its +/// `GAMEOBJECT_FACING` yaw. +/// +/// The reference builds a GO's render matrix from that quaternion — `0x5f7910` (the every-frame +/// slot-13 Animate) composes `GetPosition` → gx-rotate by the 4 floats at GO-fields `+0x10` → +/// gx-scale, and the same 4×4 (`obj+0x218`) is what its collision reads (wow-re +/// `object-layer/scratch/w2c1.md` §Q2/§Q3). `GAMEOBJECT_FACING` is a *separate* accessor +/// (`0x5f9fb0`) that the placement never consults. +/// +/// A yaw is the special case here, not a simplification of it: vmangos copies `rotation0/1` from the +/// spawn row verbatim and fills `rotation2/3` from the facing when the row leaves them zero +/// (`GameObject::UpdateRotationFields`), so a row that authors no `rotation0/1` sends +/// `(0, 0, sin(o/2), cos(o/2))` — and that quaternion **is** `rot_z(o)` exactly. 54 747 of its +/// 56 632 live spawn rows are that case and place bit-identically either way. The 1 885 that do not (1 410 +/// authoring a real tilt in `rotation0/1`, 475 a yaw that disagrees with their own `orientation` +/// column; 1 411 of them on a visible display) are exactly what a yaw-only placement gets wrong — +/// and it misreads as a *position* bug, because an M2's geometry sits off its own origin: a signpost +/// pointer's plank is 3.3 yd above the origin and 0.3–2.1 yd out, so dropping a 70° tilt swings that +/// plank 4.3 yd from a spawn point that never moved (decision 1459, bug B89). +/// +/// Falls back to the facing when the wire carried no quaternion at all, and when what it carried has +/// no length to normalize — a create block folds absent fields to zero, and an all-zero quat would +/// normalize to `NaN` and blank the object. Length is otherwise *not* assumed: one shipped row +/// authors a tilt with `rotation2/3` zero, so the server fills the yaw pair on top of it and sends a +/// non-unit quat; `wow_rotation_to_bevy` normalizes. +pub(super) fn gameobject_rotation(quat: Option<[f32; 4]>, orientation: f32) -> Quat { + match quat { + Some(q) if q.iter().map(|c| c * c).sum::() > 0.5 => wow_rotation_to_bevy(q), + _ => wire_yaw(orientation), + } +} + +/// A full [`Transform`] for a freshly-spawned entity from a raw-WoW position + an already-resolved +/// placement rotation ([`wire_yaw`] for a mover, [`gameobject_rotation`] for a GameObject). +pub(super) fn pose_transform(position: [f32; 3], rotation: Quat) -> Transform { Transform { translation: wow_to_bevy(position), - rotation: Quat::from_rotation_y(orientation), + rotation, ..default() } } @@ -61,11 +99,11 @@ pub(super) fn write_pose( transforms: &mut Query<&mut Transform>, e: Entity, position: [f32; 3], - orientation: f32, + rotation: Quat, ) { if let Ok(mut t) = transforms.get_mut(e) { t.translation = wow_to_bevy(position); - t.rotation = Quat::from_rotation_y(orientation); + t.rotation = rotation; } } diff --git a/crates/benilla-app/src/net/motion/tests.rs b/crates/benilla-app/src/net/motion/tests.rs index edac09aa..ade667d0 100644 --- a/crates/benilla-app/src/net/motion/tests.rs +++ b/crates/benilla-app/src/net/motion/tests.rs @@ -5,6 +5,7 @@ use std::time::{Duration, Instant}; use benilla_protocol::{JumpInfo, MonsterMoveFacing, MoveSpeeds}; +use bevy::prelude::Quat; use crate::creature_anim::move_flags; use crate::player::{GRAVITY, TERMINAL_VELOCITY}; @@ -749,3 +750,152 @@ fn a_due_arrival_never_jumps_the_queue() { of it — a still player then sends nothing, so a stale FORWARD here runs off forever" ); } + +// ── GameObject placement: the `GAMEOBJECT_ROTATION` quaternion (decision 1459) ──────────────── + +/// The seven `nightelfsignpostpointer02` arms of the Ravenwind post (Feralas, The Forgotten Coast) +/// as vmangos' `gameobject` table spawns them: `(entry, position, orientation, rotation0..3)`. +/// Six carry the pure-yaw encoding; entry 152580 — the one bug B89 was reported on — authors a +/// 70° tilt in `rotation0/1`. +const RAVENWIND_POST: [(u32, [f32; 3], f32, [f32; 4]); 7] = [ + ( + 152574, + [-4446.32, 2055.25, 46.2946], + -1.20428, + [0.0, 0.0, -0.566406, 0.824126], + ), + ( + 152575, + [-4446.34, 2055.23, 45.5724], + -1.20428, + [0.0, 0.0, -0.566406, 0.824126], + ), + ( + 152576, + [-4446.4, 2055.31, 46.2764], + 0.401426, + [0.0, 0.0, 0.199368, 0.979925], + ), + ( + 152577, + [-4446.41, 2055.24, 46.2863], + 1.97222, + [0.0, 0.0, 0.833886, 0.551937], + ), + ( + 152578, + [-4446.41, 2055.24, 45.6197], + 1.97222, + [0.0, 0.0, 0.833886, 0.551937], + ), + ( + 152579, + [-4446.38, 2055.25, 44.954], + 1.97222, + [0.0, 0.0, 0.833886, 0.551937], + ), + ( + 152580, + [-4445.28, 2058.18, 44.9976], + -0.767946, + [0.468413, 0.332221, -0.472813, 0.668331], + ), +]; + +/// The middle of the pointer plank in the model's OWN space (WoW axes, Z up) — the mid-point of +/// `nightelfsignpostpointer02.m2`'s collision hull, `benilla-extract m2coll`: x ±0.197, +/// y 0.338‥2.075, z 3.062‥3.624. It is that offset from the origin — 3.3 yd up, ~1.2 yd out — that +/// turns a dropped rotation into a *displaced* plank. +const PLANK_MID_MODEL: [f32; 3] = [0.0, 1.206, 3.343]; + +/// Where the post itself stands (the six untilted arms all spawn on its axis). +const POST_AXIS: [f32; 2] = [-4446.4, 2055.25]; + +/// Put a model-space point (WoW axes) through a spawn transform and read the answer back in WoW +/// world coordinates — the mesh is baked into Bevy space, so the point converts on the way in. +fn plank_in_world(position: [f32; 3], rotation: Quat) -> [f32; 3] { + let t = super::pose_transform(position, rotation); + benilla_assets::coords::bevy_to_wow( + t.transform_point(benilla_assets::coords::wow_to_bevy(PLANK_MID_MODEL)), + ) +} + +/// The pure-yaw quaternion vmangos writes for a spawn with no authored tilt IS `rot_z(facing)`, so +/// placing by the quaternion must leave those spawns exactly where the facing put them. This is the +/// whole safety argument for the switch: 54 747 of the 56 632 live spawn rows are this case, and if +/// the two paths disagreed by even a hair, every prop in the world would shift. +#[test] +fn a_pure_yaw_gameobject_quaternion_places_exactly_like_the_facing() { + for orientation in [0.0_f32, 0.401426, -0.767946, -1.20428, 1.97222, 3.0, -2.7] { + let (s, c) = (orientation * 0.5).sin_cos(); + let by_quat = super::gameobject_rotation(Some([0.0, 0.0, s, c]), orientation); + let by_yaw = super::wire_yaw(orientation); + assert!( + by_quat.dot(by_yaw).abs() > 0.99999, + "orientation {orientation}: quat {by_quat:?} vs yaw {by_yaw:?}" + ); + } + // …and at the reported site, on the six real spawn rows that carry no tilt. + for (entry, position, orientation, quat) in RAVENWIND_POST.iter().take(6) { + let by_quat = plank_in_world( + *position, + super::gameobject_rotation(Some(*quat), *orientation), + ); + let by_yaw = plank_in_world(*position, super::wire_yaw(*orientation)); + let moved = (0..3) + .map(|i| (by_quat[i] - by_yaw[i]).powi(2)) + .sum::() + .sqrt(); + assert!(moved < 1e-3, "entry {entry} moved {moved} yd"); + } +} + +/// Entry 152580 — bug B89. Its authored 70° tilt is the difference between a plank ON the post and +/// a plank 4.3 yd away in mid-air, and the spawn point itself is 3 yd off the post (the tilt is what +/// carries the plank back onto it), so the facing-only placement cannot even land in the right +/// neighbourhood. +#[test] +fn the_tilted_ravenwind_pointer_lands_on_its_post() { + let (_, position, orientation, quat) = RAVENWIND_POST[6]; + let by_quat = plank_in_world( + position, + super::gameobject_rotation(Some(quat), orientation), + ); + let by_yaw = plank_in_world(position, super::wire_yaw(orientation)); + + // The golden: hand-derived from the spawn row and the model's own hull, WoW world coordinates. + for (got, want) in by_quat.iter().zip([-4444.139_f32, 2055.174, 46.512]) { + assert!((got - want).abs() < 0.01, "{by_quat:?} vs the golden"); + } + // What it means: the plank hangs off the post's axis at arm's length… + let radius = |p: [f32; 3]| (p[0] - POST_AXIS[0]).hypot(p[1] - POST_AXIS[1]); + assert!( + radius(by_quat) < 2.5, + "on the post: r = {}", + radius(by_quat) + ); + // …where the facing-only placement flings it clear of the post entirely, which is the report. + assert!(radius(by_yaw) > 4.0, "off the post: r = {}", radius(by_yaw)); + let apart = (0..3) + .map(|i| (by_quat[i] - by_yaw[i]).powi(2)) + .sum::() + .sqrt(); + assert!( + (apart - 4.29).abs() < 0.05, + "the two placements are {apart} yd apart" + ); +} + +/// No usable quaternion ⇒ the facing, not a `NaN`. A create block folds absent fields to zero, so +/// "the wire sent nothing" can reach here as an all-zero quat — which has no length to normalize and +/// would blank the object rather than mis-place it. +#[test] +fn a_gameobject_without_a_usable_quaternion_falls_back_to_its_facing() { + for quat in [None, Some([0.0; 4])] { + let r = super::gameobject_rotation(quat, 1.97222); + assert!( + r.dot(super::wire_yaw(1.97222)).abs() > 0.99999, + "{quat:?} → {r:?}" + ); + } +} diff --git a/crates/benilla-app/src/player.rs b/crates/benilla-app/src/player.rs index 57ae2c4a..3612fc6a 100644 --- a/crates/benilla-app/src/player.rs +++ b/crates/benilla-app/src/player.rs @@ -1433,10 +1433,9 @@ fn control( // clears it under us. Root rides too — moving bits are what must not accompany it, and // rooted input can't produce any (`dir` is zeroed above, jumps refused). let mut move_flags_now = player.modes.wire_flags(); - // `landed`/`started_falling` gate the wire's jump/fall lifecycle; the swim branch never sets + // `landed` gates the wire's jump/fall lifecycle; the swim branch never sets // them (leaving the water resumes the ground mover from rest, no airborne report). let landed; - let started_falling; if swimming { // Swimming: `MOVEFLAG_SWIMMING` (the swim-pitch tail rides with it) plus the travel-direction // bits the swim gait selector cascades on (TU-E: turn→41, strafe→43/44, back→45, fwd→42, @@ -1459,7 +1458,6 @@ fn control( player.airborne_since = None; player.fall_far = false; landed = false; - started_falling = false; } else { // Straight off the net axis, so a netted-to-zero press pair streams NO direction bit // (the emitter's genuine STOP) rather than a phantom FORWARD we aren't actually moving @@ -1491,7 +1489,6 @@ fn control( // also rides the wire (decision 0053), so observers replay it. let arc = player.advance_airborne_arc(airborne, jumped, now, launch_y); landed = arc.landed; - started_falling = arc.started_falling; if airborne { move_flags_now |= move_flags::FALLING; // Mid-air the direction flags stay LIVE — the real client's `CMovement+0x40` keeps @@ -1745,7 +1742,6 @@ fn control( jumped, air_nudged, landed, - started_falling, fall_time: wire_fall_time, }, now, diff --git a/crates/benilla-app/src/player/arc.rs b/crates/benilla-app/src/player/arc.rs index a9650d78..d7026fe4 100644 --- a/crates/benilla-app/src/player/arc.rs +++ b/crates/benilla-app/src/player/arc.rs @@ -9,15 +9,13 @@ use super::{Player, FALL_FAR_DROP, FALL_FAR_TIME}; /// The airborne-arc lifecycle edges [`Player::advance_airborne_arc`] reports for the frame — what the /// live flags (`new_arc` re-seeds the frozen airborne direction bits) and the wire stream -/// (`landed`/`started_falling` select the `MSG_MOVE_*` opcode) read. +/// (`landed` selects the `MSG_MOVE_*` opcode) read. pub(super) struct ArcEdges { /// This frame began a new airborne arc (a first airborne frame or a fresh jump, incl. a /// same-frame land+relaunch) — the airborne direction flags re-seed from the current keys. pub(super) new_arc: bool, /// The arc ended this frame (airborne last frame, grounded now) — emit `MSG_MOVE_FALL_LAND`. pub(super) landed: bool, - /// A bracket-less step-off began this frame — push an immediate heartbeat so observers start it. - pub(super) started_falling: bool, } impl Player { @@ -94,16 +92,13 @@ impl Player { // wound grunt and dust puff of a landing that never happened. On release the fall // restarts as a fresh arc from a zero clock, which is `ClearRoot`'s `StartFalling(0)`. landed: !airborne && was_airborne && !self.modes.rooted, - // A step-off with no jump opcode needs its first airborne report pushed promptly so - // observers start the arc (→ an immediate heartbeat); a jump has its own JUMP opcode. - started_falling: airborne && !was_airborne && !jumped, } } } #[cfg(test)] mod tests { - use super::super::JUMP_SPEED; + use super::super::{GRAVITY, JUMP_SPEED}; use super::*; use bevy::prelude::Vec3; @@ -117,6 +112,13 @@ mod tests { /// Fall(40) flash. The fix seats the launch height at the ground, defeating this. const _: () = assert!(TAKEOFF_RISE > FALL_FAR_DROP); + /// What a **real** step-off's first frame descends at 60 fps: the walk election launches at + /// vz 0, gravity gives it `g·dt`, and the step moves it `g·dt²`. Small — ~5 mm — and that is + /// the point: 1458's report edge asks only whether the body went down *at all*, so the margin + /// between a genuine walk-off and a body that went nowhere is this, not a threshold anyone + /// picked. + const FIRST_TICK_DROP: f32 = GRAVITY / (60.0 * 60.0); + /// A grounded player about to jump from ground height `ground`. `launch_y` passed to /// `advance_airborne_arc` is this pre-step feet Y; the takeoff frame's post-step `pos.y` is /// already `ground + TAKEOFF_RISE` (the mover moved up within the step). @@ -146,10 +148,6 @@ mod tests { p.pos.y = 100.0 + TAKEOFF_RISE; let edges = p.advance_airborne_arc(true, true, 0.0, 100.0); assert!(edges.new_arc); - assert!( - !edges.started_falling, - "a jump is not a bracket-less step-off" - ); assert_eq!(p.jump_zspeed, JUMP_SPEED, "the launch vz is the jump speed"); assert_eq!( p.fall_start_y, 100.0, @@ -254,16 +252,15 @@ mod tests { fn a_step_off_fall_latches_falling_far_by_time_not_distance() { // A step-off (airborne, not jumped) launches at vz 0 — the walk election's StartFalling(0) // — so it latches by TIME, not descent, even before dropping a full 1/9 yd. + // `pos.y` is the POST-step pose, so a real step-off's first frame is already one tick of + // gravity below its launch ground ([`FIRST_TICK_DROP`]). let mut p = Player { - pos: Vec3::new(0.0, 100.0, 0.0), + pos: Vec3::new(0.0, 100.0 - FIRST_TICK_DROP, 0.0), vel_y: -0.5, ..Default::default() }; let edges = p.advance_airborne_arc(true, false, 0.0, 100.0); - assert!( - edges.started_falling, - "a bracket-less step-off pushes a heartbeat" - ); + assert!(edges.new_arc, "the step-off opens an arc"); assert_eq!(p.jump_zspeed, 0.0, "a step-off launches at exactly 0"); // Barely descended, but not yet FALL_FAR_TIME airborne. p.pos.y = 99.95; @@ -317,12 +314,9 @@ mod tests { p.advance_airborne_arc(false, false, 1.2, 100.0); p.modes.rooted = false; // the aura expired; still nothing under us + p.pos.y = 70.0 - FIRST_TICK_DROP; // and the released body has already fallen one tick let edges = p.advance_airborne_arc(true, false, 11.2, 70.0); assert!(edges.new_arc, "the release begins a new arc"); - assert!( - edges.started_falling, - "a bracket-less step-off — StartFalling(0), no JUMP opcode" - ); assert_eq!(p.jump_zspeed, 0.0, "launched at exactly 0, like a walk-off"); assert_eq!( p.airborne_since, diff --git a/crates/benilla-app/src/player/movement_net.rs b/crates/benilla-app/src/player/movement_net.rs index 28358db1..123d2fe8 100644 --- a/crates/benilla-app/src/player/movement_net.rs +++ b/crates/benilla-app/src/player/movement_net.rs @@ -1,7 +1,8 @@ //! Outbound self-movement → the wire — the mirror of [`crate::net::motion`] (which integrates *remote* //! movers). [`stream_self_movement`] diffs this frame's CMovement move-flags against last frame's and //! emits a `MSG_MOVE_*` per movement-*axis* transition (start/stop forward-back, strafe, turn), the -//! jump/fall lifecycle (JUMP launch, FALL_LAND, step-off heartbeat), a periodic heartbeat while moving, +//! jump/fall lifecycle (JUMP launch, FALL_LAND — a jumpless fall opens with nothing, decision 1464), +//! a periodic heartbeat while moving, //! and a SET_FACING every frame the facing changes off the turn axis — each carrying the live //! `MovementInfo` (decisions 0052 + 0053 + 0617). Split out of the controller: the wire stream is its //! own concern. @@ -99,9 +100,6 @@ pub(super) struct ArcEdges { pub(super) air_nudged: bool, /// The arc ended this frame → `MSG_MOVE_FALL_LAND`. pub(super) landed: bool, - /// An arc began with no jump opcode (a walk-off) → an immediate heartbeat, so observers start - /// the arc promptly instead of waiting for the periodic one. - pub(super) started_falling: bool, /// ms since take-off — the caller's snapshot, because the landing frame's arc bookkeeping has /// already cleared `airborne_since` and the FALL_LAND must still report the accumulated time. pub(super) fall_time: u32, @@ -116,13 +114,15 @@ pub(super) struct ArcEdges { /// the *facing* report its own independent emitter alongside it (decision 0617: in the 1.12.1 sniff /// SET_FACING outnumbers every other client-sent movement opcode combined, moving or standing). /// vmangos relays it all to nearby players, who extrapolate from the flags — how they see us walk/turn/ -/// strafe. (We claimed the mover with CMSG_SET_ACTIVE_MOVER at login.) **Airborne is its own send law** (VERIFIED, vanilla-sniffs `dwarf_rogue_dun_morogh`): the -/// fwd/back/strafe transitions and the periodic heartbeat go silent while FALLING — the live flag state -/// rides the packets that do go out (turn transitions, SET_FACING, the FALL_LAND) — so a normal jump is -/// exactly JUMP → \[turn/facing\] → FALL_LAND, with the landing flags telling observers what the keys -/// say *now*. The one press that breaks that silence is the standstill air nudge (`air_nudged`, -/// decision 0627): it is the case the reference's own deferral excludes, and the only mid-air input -/// that actually moves us. Sends are fire-and-forget; a down thread no-ops. Mutates `player`'s last-sent flags/facing/ +/// strafe. (We claimed the mover with CMSG_SET_ACTIVE_MOVER at login.) **Airborne is its own send law** +/// (VERIFIED, vanilla-sniffs `dwarf_rogue_dun_morogh`): the fwd/back/strafe **transitions** go silent +/// while FALLING — the live flag state rides the packets that do go out — so a jump is JUMP → +/// \[heartbeats/turn/facing\] → FALL_LAND, with the landing flags telling observers what the keys say +/// *now*. The one press that breaks the transition silence is the standstill air nudge (`air_nudged`, +/// decision 0627): the case the reference's own deferral excludes, and the only mid-air input that +/// actually moves us. **The periodic heartbeat is NOT part of that silence** and a jumpless fall opens +/// with no packet at all — both corrected against the bytes and the same capture by decision 1464, +/// which retired this module's two remaining 0053-era inventions. Sends are fire-and-forget; a down thread no-ops. Mutates `player`'s last-sent flags/facing/ /// heartbeat so next frame can diff against them. // Eight, down from twelve: the arc edges are one struct now (`ArcEdges`). The rest are distinct // types the compiler can tell apart, so the remaining count is noise rather than a miscount risk. @@ -141,7 +141,6 @@ pub(super) fn stream_self_movement( jumped, air_nudged, landed, - started_falling, fall_time, } = arc; let wow_pos = bevy_to_wow(player.pos); @@ -260,17 +259,35 @@ pub(super) fn stream_self_movement( // — integrating our arc from a JUMP that said `xy_speed = 0` — can ever learn we started moving // before the FALL_LAND lands. let falling = wire_flags & move_flags::FALLING != 0; - // The airborne lifecycle: a JUMP launch (carries the ballistic tail), a FALL_LAND that closes the - // arc, or — for a step-off with no jump opcode — an immediate heartbeat so observers start the arc - // promptly instead of waiting for the periodic one. A mid-air key release updated the flag state - // silently (above), so the landing frame's diff has no direction edge left and the FALL_LAND goes - // out alone — the real client sends no trailing Stop after it (sniff-verified). + // The airborne lifecycle, and it is exactly two opcodes: a JUMP launch (carrying the ballistic + // tail) and a FALL_LAND that closes the arc. A mid-air key release updated the flag state + // silently (above), so the landing frame's diff has no direction edge left and the FALL_LAND + // goes out alone — the real client sends no trailing Stop after it (sniff-verified). + // + // **A fall that had no jump opens with NOTHING** — decision 1464, and the third of 0053's + // inventions to be retired by the bytes. We used to push an immediate heartbeat here "so + // observers start the arc promptly"; wow-re's §5 refuted it three ways: the move-state + // broadcaster `0x61a820` gates every send on the *locomotion nibble* (`61a99d test al,0xf`) + // and FALLING/`0x2000` lives in `ah`, so a flags change that is only the fall bit never + // broadcasts at all; every non-jump `StartFalling 0x7c61f0` site seeds `+0xa0 = 0.0f` while + // only `CMovement::Jump 0x7c6230` seeds the launch speed; and `MSG_MOVE_JUMP` has exactly one + // emission site image-wide, the move-command drain's jump arm. The 1.12.1 sniff agrees — three + // separate non-jump falls (a login settle, a forced unroot, and a post-near-teleport arrival) + // each show only the terminal `FALL_LAND`. The law is: **echo → heartbeats every 500 ms while + // the fall lasts → FALL_LAND**, and the parenthesis is empty for any fall shorter than the + // deadline. + // + // It was not a harmless invention. It is the only packet in the client that could put + // `MOVEFLAG_JUMPING` on the wire for a jumpless arc, and vmangos reads any reported moving flag + // as movement — `Unit::HandleInterruptsOnMovement` answers it with `SetStandState(STAND)`, + // with a source comment saying the test is there *because* sitting on a chair teleports you. + // A chair seats you inside the collider we bake for it, where the mover's down-shapecast can + // report nothing and the body reads airborne while moving `dy=+0.000` for six frames; this + // packet then told the server we were falling, and stood us up (B79, decision 1458). if jumped { send_move!(MoveKind::Jump); } else if landed { send_move!(MoveKind::FallLand); - } else if started_falling { - send_move!(MoveKind::Heartbeat); } // Swim transition: the real client announces entering/leaving the water with a dedicated // MSG_MOVE_START_SWIM (0xca) / STOP_SWIM (0xcb) the frame the `SWIMMING` bit flips (VERIFIED, wow-re @@ -344,13 +361,24 @@ pub(super) fn stream_self_movement( // Heartbeat keeps a moving/turning mover's position + facing flowing between transitions. While // riding, ON_TRANSPORT alone keeps this stream alive — the deck carries us, so our world pose // really is changing (decision 0056: the wire mirrors actual motion) and observers on reference - // clients keep a fresh compose anchor. **Not while FALLING** — the JUMP packet seeded the whole - // arc and observers integrate it locally, so the real client sends a normal-length jump with no - // mid-air packet at all (sniff-verified; each extra heartbeat is a smoothing-free snap-apply on - // a reference observer). The very long fall's sparse ~3–4 s mid-air sends the sniff shows ride - // an untraced trigger — a wow-re question on the board; until it lands, a long fall streams - // nothing between JUMP/step-off and FALL_LAND. - if !sent && wire_flags != 0 && !falling && now - player.last_heartbeat >= HEARTBEAT_INTERVAL { + // clients keep a fresh compose anchor. + // + // **And it runs while FALLING too** (decision 1464). This arm used to carry `&& !falling`, + // defended as "the real client sends a normal-length jump with no mid-air packet at all + // (sniff-verified)" — which wow-re's §5 refuted on both halves against the same 1.12.1 capture. + // `MSG_MOVE_JUMP` is the **44-byte** form (the jump quad is present, `vz = -7.955547`, matching + // the `.text` constant `0xc0fe93d8` bit for bit), and mid-air packets are routine: heartbeats + // and a mid-air SET_FACING, all 44 B. The "untraced trigger" behind the sniff's sparse mid-air + // sends is traced now, and it is *this deadline*: `[mgr+0x130]` is re-armed to `now + 500 ms` + // after **every** outbound packet (`0x600a30` → `0x615b80`), which is why one 892 ms jump shows + // no heartbeat at all — the player's own SET_FACING at `fallTime 460` pushed the deadline out + // to 960. Our `last_heartbeat` is already stamped on every send below, so dropping the gate + // reproduces the reference's cadence exactly rather than approximating it. + // + // This does not re-open B79: a chair arrival's stall is ~100 ms and the arrival's own reconcile + // packet re-arms the deadline, so nothing fires inside it. What FALLING must never do is open + // an arc with a packet of its own — that is the arm above, and it is gone. + if !sent && wire_flags != 0 && now - player.last_heartbeat >= HEARTBEAT_INTERVAL { send_move!(MoveKind::Heartbeat); } // ── The position reconcile (decision 0907) ── **the server's copy of where we are may never go @@ -446,7 +474,6 @@ mod tests { jumped: false, air_nudged: false, landed: false, - started_falling: false, fall_time: 0, }, 0.0, @@ -485,8 +512,12 @@ mod tests { 0, // grounded again: no FALLING/FALLINGFAR on the land packet itself 0.0, - ArcEdges { jumped: false, air_nudged: false, landed: true, started_falling: // landed - false, fall_time: 1700 }, + ArcEdges { + jumped: false, + air_nudged: false, + landed: true, + fall_time: 1700, + }, // the snapshot: ~1.7 s of fall (> the 1229 ms damage gate) 0.0, &[], @@ -527,7 +558,6 @@ mod tests { jumped: false, air_nudged: false, landed: false, - started_falling: false, fall_time: 300, }, 0.2, @@ -550,7 +580,6 @@ mod tests { jumped: false, air_nudged: false, landed: true, - started_falling: false, fall_time: 800, }, 0.8, @@ -569,6 +598,70 @@ mod tests { ); } + /// **A fall that had no jump opens with nothing, and then heartbeats on the ordinary deadline** + /// — decision 1464, replacing two 0053-era inventions with the law wow-re's §5 read off the + /// broadcaster and reproduced in the 1.12.1 capture: `echo → heartbeats every 500 ms while the + /// fall lasts → FALL_LAND`, the parenthesis empty for any fall shorter than the deadline. + /// + /// The opener is the load-bearing half. It is the only packet that could put `MOVEFLAG_JUMPING` + /// on the wire for a jumpless arc, and vmangos answers a reported moving flag with + /// `SetStandState(STAND)` — which is what un-seated a chair-sitter for six frames of + /// `dy=+0.000` inside the chair's own collider (B79, decision 1458). + #[test] + fn a_jumpless_fall_opens_with_nothing_and_heartbeats_on_the_deadline() { + let (tx, rx) = crossbeam_channel::unbounded(); + // Grounded last frame (flags 0), airborne now with no jump: the walk-off / arrival case. + let mut player = Player::default(); + let step_off = |now: f32, player: &mut Player| { + stream_self_movement( + &tx, + player, + move_flags::FALLING, + 0.0, + ArcEdges { + jumped: false, + air_nudged: false, + landed: false, + fall_time: (now * 1000.0) as u32, + }, + now, + &[], + None, + ); + }; + + step_off(0.1, &mut player); + assert!( + rx.try_recv().is_err(), + "the arc's first frame puts NOTHING on the wire — this packet is B79's un-seat" + ); + assert_eq!( + player.move_flags, + move_flags::FALLING, + "the flag state still updated silently, to ride the next packet that does go out" + ); + + // Still inside the 500 ms deadline: silent. A chair arrival's whole stall lives here. + step_off(0.4, &mut player); + assert!(rx.try_recv().is_err(), "inside the deadline, still silent"); + + // Past it: the ordinary heartbeat runs, carrying the live FALLING bit. This is the half the + // old `&& !falling` gate suppressed, and the sniff shows mid-air heartbeats plainly. + step_off(0.7, &mut player); + let ClientCommand::Move { kind, flags, .. } = rx + .try_recv() + .expect("past the deadline the fall heartbeats") + else { + panic!("expected a Move command"); + }; + assert_eq!(kind, MoveKind::Heartbeat); + assert_ne!( + flags & move_flags::FALLING, + 0, + "and it carries the arc, so observers learn of a long fall in progress" + ); + } + #[test] fn the_standstill_air_nudge_is_the_one_press_that_breaks_the_airborne_silence() { // Decision 0627. The airborne silence is the reference's *deferral* (`StartMove 0x7c6ae0` @@ -592,7 +685,6 @@ mod tests { jumped: false, air_nudged: true, landed: false, - started_falling: false, fall_time: 300, }, 0.3, @@ -631,7 +723,6 @@ mod tests { jumped: false, air_nudged: false, landed: false, - started_falling: false, fall_time: 300, }, 0.3, @@ -664,7 +755,6 @@ mod tests { jumped: false, air_nudged: false, landed: false, - started_falling: false, fall_time: 200, }, 0.2, @@ -679,8 +769,9 @@ mod tests { assert_eq!(kind, MoveKind::StartTurnLeft); assert_ne!(flags & move_flags::FALLING, 0, "the packet rides the arc"); assert!(rx.try_recv().is_err(), "the turn axis carries the facing"); - // Mid-air mouse-turn with the turn key released: the periodic heartbeat stays suppressed - // while FALLING, and the facing streams via SET_FACING once the turn flag is gone. + // Mid-air mouse-turn with the turn key released: the StopTurn transition is this frame's + // send, so the periodic arm does not add a second packet (it is `!sent`-gated, not + // FALLING-gated — 1464), and the facing streams via SET_FACING once the turn flag is gone. player.face_yaw = 1.0; stream_self_movement( &tx, @@ -691,7 +782,6 @@ mod tests { jumped: false, air_nudged: false, landed: false, - started_falling: false, fall_time: 900, }, 1.5, @@ -708,7 +798,7 @@ mod tests { assert_eq!( kinds, vec![MoveKind::StopTurn, MoveKind::SetFacing], - "the turn closes, then the facing reports — no heartbeat while FALLING" + "the turn closes, then the facing reports — the frame already sent, so no heartbeat" ); } @@ -734,7 +824,6 @@ mod tests { jumped: false, air_nudged: false, landed: false, - started_falling: false, fall_time: 0, }, 0.05, @@ -768,7 +857,6 @@ mod tests { jumped: false, air_nudged: false, landed: false, - started_falling: false, fall_time: 0, }, 0.06, @@ -800,7 +888,6 @@ mod tests { jumped: false, air_nudged: false, landed: false, - started_falling: false, fall_time: 0, }, 0.05 * (i as f32 + 1.0), @@ -823,7 +910,6 @@ mod tests { jumped: false, air_nudged: false, landed: false, - started_falling: false, fall_time: 0, }, 0.2, @@ -860,7 +946,6 @@ mod tests { jumped: false, air_nudged: false, landed: false, - started_falling: false, fall_time: 0, }, 0.05, @@ -889,7 +974,6 @@ mod tests { jumped: false, air_nudged: false, landed: false, - started_falling: false, fall_time: 0, }, 0.0, diff --git a/crates/benilla-app/src/target/cursor_mode.rs b/crates/benilla-app/src/target/cursor_mode.rs index 9256a84e..9cefb267 100644 --- a/crates/benilla-app/src/target/cursor_mode.rs +++ b/crates/benilla-app/src/target/cursor_mode.rs @@ -259,11 +259,31 @@ pub(crate) const GO_TYPE_FISHINGNODE: i32 = 17; /// `[0x80b0b0]`) — the bobber is effectively un-range-gated for any real cast, never the ~5.56 yd /// reach. Other types stay on the 0236 interim (the base default is 5.0 and several per-type ctors /// override it — 5.5556/10.0/…; their population is a later byte-pin). +/// `GAMEOBJECT_TYPE_CHAIR` (7) — the byte-pinned second member of the per-type table (decision +/// 1464, wow-re's chair §5). Its `+0x18` predicate is its own `0x5f5670`, which reports **3.0 yd** +/// and accepts on `dist² < 9.0` against `[0xc4d808]` — whose only writer image-wide is the static +/// initializer `0x5f98b0` squaring that same 3.0. Chair-exclusive, and it independently reproduces +/// vmangos's `MAX_SITCHAIRUSE_DISTANCE`, which is why it is safe to hold as *client* law rather +/// than a transcribed server number (the B158 trap). +pub(crate) const GO_TYPE_CHAIR: i32 = 7; fn go_interact_range_sq(type_id: i32) -> f32 { - if type_id == GO_TYPE_FISHINGNODE { - 100.0 * 100.0 - } else { - GO_INTERACT_RANGE_SQ + match type_id { + GO_TYPE_FISHINGNODE => 100.0 * 100.0, + // The chair is the one type whose reach is *shorter* than the 0236 interim, so it is the one + // where the interim was visibly wrong: from 3 to ~5.56 yd we showed a live interact cursor + // and sent a `CMSG_GAMEOBJ_USE` the server drops on the floor (its own chair arm refuses + // past 3.0 to the nearest seat), so the click did nothing and said nothing. Now the cursor + // greys where the reference greys it. + // + // **Measured to the GameObject, not to the seat.** The reference runs its compare per seat, + // over the 5 slots `0x5f5760` builds at template bind; vmangos likewise measures to + // `GetClosestChairSlotPosition`. Seats spread along `orientation + PI/2` at + // `size·i − size·(slots−1)/2`, so for a 1-slot chair — the common case, and the B79 chair — + // the seat *is* the object and the two agree exactly; a long bench differs by at most half + // its span. Closing that gap needs the seat table, which needs `data0`/`data1` off the GO + // template; it is a refinement of this number, not a correction to it. + GO_TYPE_CHAIR => 9.0, + _ => GO_INTERACT_RANGE_SQ, } } /// `GameObjectFlags` bits consulted by the highlightable gate (decision 0243, wow-re cursor-system §4a): @@ -1324,6 +1344,13 @@ mod tests { // effectively un-range-gated — while everything else stays on the interim reach. assert_eq!(go_interact_range_sq(GO_TYPE_FISHINGNODE), 100.0 * 100.0); assert_eq!(go_interact_range_sq(0), GO_INTERACT_RANGE_SQ); + // The chair's own 3.0 yd (1464) — the one type that reaches *less* far than the interim, so + // the interim was the visible bug: a live cursor and a dropped packet from 3 to 5.56 yd. + assert_eq!(go_interact_range_sq(GO_TYPE_CHAIR), 9.0); + assert!( + go_interact_range_sq(GO_TYPE_CHAIR) < GO_INTERACT_RANGE_SQ, + "the chair reaches SHORTER than the interim — a longer one would restore the bug" + ); // The channel compare itself: owned iff self's channel object is exactly this guid. assert!(!fishing_channel_owned(None, Some(7))); assert!(!fishing_channel_owned(None, None)); diff --git a/crates/benilla-formats/src/models/mat_anim.rs b/crates/benilla-formats/src/models/mat_anim.rs index df960962..739d911e 100644 --- a/crates/benilla-formats/src/models/mat_anim.rs +++ b/crates/benilla-formats/src/models/mat_anim.rs @@ -5,7 +5,9 @@ //! Byte ground (wow-re `m2-alpha-combine-cull.md`, VERIFIED): the per-batch alpha is //! `A = instanceAlpha × colors[colorIndex].alpha × transparency[transLookup[idx]].weight`, both //! tracks animation-evaluated each frame; `A ≤ 0` skips the batch before the blend mode is read, and -//! an Opaque batch with `0 < A < 1` still draws opaque (no blend promotion). Clocks (wow-re +//! an Opaque batch with `0 < A < 1` is **promoted to a blended draw** (that note's original "stays +//! opaque" was refuted at the bytes on 2026-07-31 — wow-re `m2-blend-promotion-zfill.md` §1; +//! benilla implements the promotion, decisions 0831/0842). Clocks (wow-re //! `eval.md`/`doodad-anim-host.md`): a `gseq`-tagged track wraps `global_sequences[gseq]`; an //! ordinary track keys inside the **playing** sequence's absolute time band — for a placed doodad //! that is the file-order-first sequence looping forever, but a creature changes sequence constantly @@ -219,6 +221,86 @@ mod tests { ); } + /// **B138 — Zul'Farrak's troll gate drew its own ruin on top of itself** (decision 1460). + /// `TanarisTrollGate.m2` is ONE file holding TWO gates: the intact one + /// (`TANARISTROLLGATE.BLP`) and its burnt twin (`TANARISTROLLGATEBURNT.BLP`), plus a tiki-mask + /// batch for each. Which pair draws is authored entirely in the M2Color alpha keys — `+1.0` + /// (`0x7fff`) for the copy this sequence shows, `−1.0` (`0x8001`) for the copy it hides — and a + /// negative alpha is what trips the reference's `A ≤ 0` batch cull. Decoded UNSIGNED, `0x8001` + /// reads `+1.00006`: both gates drew, interpenetrating everywhere, and the pair z-fought until + /// the door swung open and pulled them apart. So the carve is per sequence slot: **Closed (0)** + /// shows the intact gate and hides the burnt one, **Destroy (3)** does the reverse — and the two + /// tiki-mask batches, which share a texture, are never both visible. Skips without client data. + #[test] + fn troll_gate_never_draws_its_burnt_twin_at_the_same_time() { + let data = crate::wow_data_or_skip!(); + let mut chain = crate::open_chain(&data).expect("open chain"); + let bytes = chain + .read_file("World\\Kalimdor\\Tanaris\\ActiveDoodads\\TrollGate\\TanarisTrollGate.m2") + .expect("read TanarisTrollGate.m2"); + let subs = super::super::parse_m2_render_submeshes(&bytes, "", &[]).expect("parse"); + // Sequence slots are FILE order (what the track ranges index): 0 Closed, 1 Open, 2 Opened, + // 3 Destroy, 4 Close. + let (closed, destroy) = (Some(0), Some(3)); + let alpha_of = |tex: &str, seq| -> Vec { + subs.iter() + .filter(|s| { + s.texture + .as_deref() + .is_some_and(|t| t.to_ascii_uppercase().contains(tex)) + }) + .map(|s| { + s.alpha_anim + .as_ref() + .map_or(1.0, |a| a.sample(seq, 0.0, 0.0)) + }) + .collect() + }; + let intact_closed = alpha_of("TANARISTROLLGATE.BLP", closed); + let burnt_closed = alpha_of("TANARISTROLLGATEBURNT.BLP", closed); + assert!( + !intact_closed.is_empty() && !burnt_closed.is_empty(), + "both gate copies are batches of this model (intact {}, burnt {})", + intact_closed.len(), + burnt_closed.len() + ); + assert!( + intact_closed.iter().all(|&a| a > 0.0), + "a shut gate draws its INTACT self (Closed alphas {intact_closed:?})" + ); + assert!( + burnt_closed.iter().all(|&a| a <= 0.0), + "a shut gate hides its BURNT twin — ±1 keys read signed ({burnt_closed:?})" + ); + assert!( + alpha_of("TANARISTROLLGATE.BLP", destroy) + .iter() + .all(|&a| a <= 0.0), + "…and Destroy swaps them: the intact copy hides" + ); + assert!( + alpha_of("TANARISTROLLGATEBURNT.BLP", destroy) + .iter() + .all(|&a| a > 0.0), + "…and the burnt copy shows" + ); + // The mask twin is the same duplication one texture further in: two batches, one texture, + // and exactly one of them visible in any sequence. + for seq in [closed, destroy] { + let masks = alpha_of("BM_TROLL_TIKI03.BLP", seq); + assert_eq!( + masks.len(), + 2, + "the model authors the mask twice ({masks:?})" + ); + assert_eq!( + masks.iter().filter(|&&a| a > 0.0).count(), + 1, + "exactly one mask copy draws per sequence ({masks:?})" + ); + } + } + /// Battle Shout's ground model, straight off the real client data: six additive crescent /// quads whose whole look is material animation — staggered colour-alpha pulses, dimming /// weight constants (0.3/0.2/0.4), and RGB tracks cooling white→red over the 0.9 s clip. diff --git a/crates/benilla-formats/src/ribbons.rs b/crates/benilla-formats/src/ribbons.rs index 947d9d14..71d8bbb4 100644 --- a/crates/benilla-formats/src/ribbons.rs +++ b/crates/benilla-formats/src/ribbons.rs @@ -115,8 +115,8 @@ pub struct RibbonEmitterDef { pub blend_mode: Option, /// Trail tint (colorTrack, RGB 0..1, keyed on the clip clock; constant white when unkeyed). pub color: ValueTrack<[f32; 3]>, - /// Trail opacity (alphaTrack, fixed16/32767, keyed — the slash's fade-out; constant 1.0 when - /// unkeyed). + /// Trail opacity (alphaTrack, **int16** fix16/32767, keyed — the slash's fade-out; constant + /// 1.0 when unkeyed). pub alpha: ValueTrack, /// Cross-section half-widths (yards) above/below the bone path, keyed — the slash's /// flare-and-collapse. Sampled at edge-commit time (each edge keeps the width it was born @@ -321,8 +321,11 @@ pub fn parse_m2_ribbon_emitters(bytes: &[u8]) -> Result> { color: track_keys_with(bytes, e + 0x24, [1.0; 3], band, 12, |b, o| { [le_f32(b, o), le_f32(b, o + 4), le_f32(b, o + 8)] }), + // fix16 keys are SIGNED (`movsx`, wow-re `tracks.md` flavour (c) — the same decode as + // `benilla_m2::track_fix16`; the ribbon spec calls the stride-2 values int16 outright, + // `ribbon-emitter-spec.md` +0x40). The strip's own draw clamps a negative to 0 below. alpha: track_keys_with(bytes, e + 0x40, 1.0, band, 2, |b, o| { - f32::from(le_u16(b, o)) / 32767.0 + f32::from(le_u16(b, o) as i16) / 32767.0 }), height_above: track_keys_with(bytes, e + 0x5c, 0.0, band, 4, le_f32), height_below: track_keys_with(bytes, e + 0x78, 0.0, band, 4, le_f32), diff --git a/crates/benilla-m2/src/tests.rs b/crates/benilla-m2/src/tests.rs index 7dc2cc1b..289ccfb9 100644 --- a/crates/benilla-m2/src/tests.rs +++ b/crates/benilla-m2/src/tests.rs @@ -69,6 +69,53 @@ fn m2track(interp: u16, gseq: u16, ts: (u32, u32), vals: (u32, u32)) -> Vec t } +/// **A fix16 key is SIGNED.** Real art authors "hide me" as `0x8001` — `−32767`, i.e. `−1.0` — +/// which read unsigned decodes to `+1.00006` and sails through the reference's `A ≤ 0` batch cull. +/// `TanarisTrollGate.m2` switches between its intact gate and its burnt twin with exactly these +/// ±1 keys, so reading them unsigned drew both copies at once (B138, decision 1460). Both records +/// that carry a fix16 track — the M2Color **alpha** (header `0x54`, stride `0x38`, track @ `+0x1c`) +/// and the M2TextureWeight (header `0x64`, stride `0x1c`) — share the decode, so both are checked. +#[test] +fn a_negative_fix16_key_decodes_signed() { + let mut b = header(); + let ts_ofs = b.len() as u32; + b.extend(0u32.to_le_bytes()); + b.extend(333u32.to_le_bytes()); + let val_ofs = b.len() as u32; + b.extend(0x7fffu16.to_le_bytes()); // +1.0 — "draw me" + b.extend(0x8001u16.to_le_bytes()); // −1.0 — "hide me" + // One M2Color: a keyless RGB track @ +0x00, then the 2-key alpha track @ +0x1c. + let color_ofs = b.len() as u32; + b.extend(m2track(0, 0xffff, (0, 0), (0, 0))); + b.extend(m2track(1, 0xffff, (2, ts_ofs), (2, val_ofs))); + set_arr(&mut b, 0x54, 1, color_ofs); + // One M2TextureWeight: the same keys again, so the weight side is covered too. + let weight_ofs = b.len() as u32; + b.extend(m2track(1, 0xffff, (2, ts_ofs), (2, val_ofs))); + set_arr(&mut b, 0x64, 1, weight_ofs); + + let fmt = parse(&b).expect("a colour + weight fixture parses"); + let m = fmt.model(); + let keys = |t: &M2ScalarTrack| t.keys.iter().map(|&(_, v)| v).collect::>(); + let alpha = keys(&m.color_alpha_tracks[0]); + assert!( + (alpha[0] - 1.0).abs() < 1e-4, + "0x7fff is +1.0 (got {})", + alpha[0] + ); + assert!( + (alpha[1] + 1.0).abs() < 1e-4, + "0x8001 is −1.0 — read unsigned it would be +1.0 and the batch would draw (got {})", + alpha[1] + ); + let weight = keys(&m.transparency_tracks[0]); + assert!( + (weight[1] + 1.0).abs() < 1e-4, + "the transparency weight decodes signed too (got {})", + weight[1] + ); +} + #[test] fn texture_transform_translation_track_decodes() { let mut b = header(); diff --git a/crates/benilla-m2/src/track.rs b/crates/benilla-m2/src/track.rs index 06eb9492..cb5f3297 100644 --- a/crates/benilla-m2/src/track.rs +++ b/crates/benilla-m2/src/track.rs @@ -110,11 +110,21 @@ fn rd_vec3(b: &[u8], o: usize) -> Option<[f32; 3]> { Some([b.f32_at(o)?, b.f32_at(o + 4)?, b.f32_at(o + 8)?]) } -/// Read a scalar `fix16` track (`uint16/32767` values). Used for the colour-**alpha** and +/// Read a scalar `fix16` track (**`int16`**/32767 values). Used for the colour-**alpha** and /// transparency-**weight** tracks that gate batch visibility and drive the animated material combine. +/// +/// **The key is SIGNED** — `movsx`, not `movzx` (VERIFIED, wow-re `system/animation/scratch/tracks.md` +/// flavour (c): `dest = (f32)(int16 P[k0]) * (1/0x7fff)`, bytes `movsx edx,word[P+k0*2]; fild; +/// fmul [0x811610]; fstp dest` at `0x715b2f`–`0x715b46`, dispatched at the M2Color-alpha site +/// `0x715b21` (`colors[]` stride 0x38, track @ +0x1c) and the transparency-weight site `0x715ce2`). +/// Read unsigned, the authored "hide me" key `0x8001` decodes as `+1.00006` instead of `−1.0`, so a +/// batch the reference culls (`A ≤ 0`, wow-re `m2-alpha-combine-cull`) draws at full alpha instead: +/// that is how Zul'Farrak's troll gate drew its BURNT twin on top of its intact self and z-fought +/// (B138, decision 1460). Values outside `[0, 1]` are the artist's own encoding, not a data quirk — +/// the combine consumes them as signed floats and the cull tests `≤ 0`. pub(crate) fn track_fix16(b: &[u8], track_ofs: usize) -> M2ScalarTrack { track_read(b, track_ofs, 2, |b, o| { - b.u16_at(o).map(|v| v as f32 / 32767.0) + b.u16_at(o).map(|v| f32::from(v as i16) / 32767.0) }) } diff --git a/crates/benilla-protocol/src/messages/update_object/fields/player.rs b/crates/benilla-protocol/src/messages/update_object/fields/player.rs index d0a44b00..4a0eee57 100644 --- a/crates/benilla-protocol/src/messages/update_object/fields/player.rs +++ b/crates/benilla-protocol/src/messages/update_object/fields/player.rs @@ -582,8 +582,10 @@ impl ObjectFields { /// the wire carried no component at all (vmangos omits zero fields from the create mask, and /// an identity-ish quat still sends its nonzero `w`/`z`); absent components within a /// partially-sent quat read as the create-block zero default, so a pure-yaw spawn - /// (`x = y = 0`, the live-data norm) round-trips exactly. Consumed by the type-11 transport - /// evaluator (elevators/lifts rotate their keyframe offsets through this). + /// (`x = y = 0`, the live-data norm) round-trips exactly. This is the field a GameObject is + /// *placed* by — the reference builds its render matrix from this quaternion and never from + /// `GAMEOBJECT_FACING` (decision 1459) — and the basis a type-11 lift rotates its keyframe + /// offsets through. pub fn gameobject_rotation(&self) -> Option<[f32; 4]> { let any_sent = (0..4u16).any(|i| self.contains(FIELD_GAMEOBJECT_ROTATION + i)); any_sent.then(|| {