From 86ba0d3f6455635d018af93a5041e18c567db91e Mon Sep 17 00:00:00 2001 From: Sam Date: Thu, 20 Aug 2026 19:10:13 +0700 Subject: [PATCH] the troll gate stops flickering, the signpost lands, the chair holds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - a fix16 animation key is signed: Zul'Farrak's troll gate was drawing its burnt twin inside the intact one (a −1.0 key read as +1.0, so the cull never fired) — 24 two-state models stop showing both states, 65 fade curves lose an over-bright tail - a GameObject is placed by its quaternion, not its facing: the Ravenwind arm of the Feralas signpost comes down from mid-air onto the post, and 1,411 visible spawns — 123 of them chairs — take their authored tilt; the inspector shows it - sitting in a chair holds: a jumpless fall puts nothing on the wire until it lands and heartbeats on the ordinary 500 ms deadline, so the stalled frames inside the chair's collider no longer send the moving flag that stood you back up; and the use cursor greys past the chair's own 3.0 yd reach - README's "Not built yet" catches up: guilds, the pet bar, fishing, the dressing room, macros, key bindings and addons are in Co-Authored-By: Claude Fable 5 --- README.md | 9 +- crates/benilla-app/src/debug_panel/inspect.rs | 17 +- crates/benilla-app/src/net/apply/objects.rs | 28 ++- crates/benilla-app/src/net/motion.rs | 56 +++++- crates/benilla-app/src/net/motion/tests.rs | 150 +++++++++++++++ crates/benilla-app/src/player.rs | 6 +- crates/benilla-app/src/player/arc.rs | 34 ++-- crates/benilla-app/src/player/movement_net.rs | 172 +++++++++++++----- crates/benilla-app/src/target/cursor_mode.rs | 35 +++- crates/benilla-formats/src/models/mat_anim.rs | 84 ++++++++- crates/benilla-formats/src/ribbons.rs | 9 +- crates/benilla-m2/src/tests.rs | 47 +++++ crates/benilla-m2/src/track.rs | 14 +- .../messages/update_object/fields/player.rs | 6 +- 14 files changed, 564 insertions(+), 103 deletions(-) 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(|| {