knockback is a jump the server aims, and tabards wear their guild crest

- knockback is a jump whose seed came off the wire, not a server spline: SMSG_MOVE_KNOCK_BACK's four floats are exactly the MovementInfo jump tail in a different order (speedZ down-positive — a rising launch carries a negative value, the sign vmangos's own source marvels at), flown at the same take-off site as the keyboard jump and acked with the post-launch pose, which the server matches float-for-float within 0.01 before relaying to anyone; it is the one take-off that seeds BOTH axes, absolute in world XY, so it skips the arm that would hand the momentum back to a held key and reduce the whole knockback to a hop; root refuses it silently, a seated player stands up first, no MSG_MOVE_JUMP goes out — that packet is the keyboard's, and vmangos logs a jump at knockback speed as an overspeed cheat — and MSG_MOVE_KNOCK_BACK turns out not to be dead: an observed knockback replays as the arc it is
- your guild crest reaches your tabard: the three GuildEmblems layers — background, border, emblem, on both torso tiles — belong to no equipment slot, so the body composite never wrote those cells and every guild wore the Guild Tabard's own default art; the install is gated on a display flag only seven of 29,604 rows set, of which exactly one is worn by any item — which is why every battleground and Argent Dawn tabard keeps its own art; the five wire indices are signed, because an undesigned guild carries −1 in all five and the client refuses to paint on the first −1 (unguarded, the commonest guild state on a young realm would have worn a blank tabard); the emblem keys the skin cache, so two guilds in the same tabard display cannot lend each other crests; and a guild tabard's background hides the bra where a plain tabard does not — it is a cutout, 79.5% opaque off the shipped art, and what shows at the sides is bare skin
- a fly-by is framed by the world camera, not the M2 camera record's fov: a census of every reader shows the record's fov and clip planes reach only the portrait and Model-frame paths — nothing on the cinematic path reads any of the three — so fifteen of the sixteen shipped shots were rendering at HALF their intended vertical FOV, visibly over-zoomed on every race intro but the Undead; the fix is a deletion, and it dissolves a standing puzzle: the authored 0.22 and 27.8-yard "clips", which would render the dwarf intro's mountains as empty sky, are not clips at all on this path — two floats nobody consumes look exactly like that
- 104 of Onyxia's lava traps stood on an interior floor classified as outdoors, taking the day/night sun inside a windowless lair: a GameObject's light node anchors at its model's AUTHORED bounding-box centre, and the trap's box reaches 98.42 yd past its bind pose — the anchor sits ~40 yd under the chamber floor, where a downward ray finds nothing above it to claim; the reference recovers with a 1000-yd upward retry at the claim itself, which benilla had only at the colour sample, so the verdict was decided before any colour was read; all 104 now classify interior, beside the 26 rocks and 119 eggs that always did — and the drowned-corpse pose came back from its cross-check confirmed at the bytes (a strict liquid-depth test at exactly two-thirds of a yard, Dead or Drowned), with the two reference-side notes it contradicted corrected over there

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-31 19:50:50 +07:00
co-authored by Claude Fable 5
parent d4690cc67c
commit 1d65531ef8
42 changed files with 2082 additions and 213 deletions
+17 -43
View File
@@ -48,10 +48,11 @@
//!
//! # What playback takes over
//!
//! Three things, all released on the way out. The **camera** (this module writes the
//! [`WorldCamera`] pose and FOV after `control` has seated it, the same slot
//! `apply_camera_shake` uses); the **streaming focus**, which has to follow the camera rather than
//! the body, because a Tauren's shot opens 1741 yards from where the body stands and would
//! Three things, all released on the way out. The **camera** — its *pose* only; the projection is
//! left alone, because a fly-by is framed by the world camera's own optics and the M2 record's
//! `fov` reaches nothing on this path (decision 1711). Written after `control` has seated it, the
//! same slot `apply_camera_shake` uses. The **streaming focus**, which has to follow the camera
//! rather than the body, because a Tauren's shot opens 1741 yards from where the body stands and would
//! otherwise fly over unstreamed terrain; and the **UI's cinematic flag**, which drives
//! `CinematicFrame`'s letterbox and makes `InCinematic()` answer truthfully so `StaticPopup`
//! suppresses dialogs the way the reference's does.
@@ -62,7 +63,7 @@ use benilla_assets::{AssetSet, LockRecover, WorldAssets};
use benilla_formats::{CinematicCatalog, CinematicPath};
use benilla_ui::script::UiScript;
use benilla_world::schedule::WorldStage;
use benilla_world::view::{WorldCamera, CAM_FOVY};
use benilla_world::view::WorldCamera;
use bevy::prelude::*;
use crate::char_select::ClientState;
@@ -399,38 +400,24 @@ fn start_pending(
/// nothing accumulates and the *pose* needs no restore — the next frame's `control` simply seats
/// it again.
///
/// The **FOV does** need one, and it is taken here rather than left to a neighbour. `scoped_view`
/// happens to rewrite the projection unconditionally every frame ahead of `control`, so today the
/// narrow cinematic FOV would be undone anyway — but that is its ordering, not our release, and a
/// reorder would leave the world permanently telephoto with nothing pointing at why. Ending a
/// cinematic puts [`CAM_FOVY`] back itself; the spyglass re-asserts its own value next frame if
/// one is up.
/// **The FOV is not touched at all**, and that is the correction decision 1711 landed. A fly-by is
/// rendered through the world camera's own optics, re-stamped every frame — the M2 camera record's
/// `fov` is written at model load and read by nothing on this path (wow-re
/// `ui/scratch/cinematic-camera-law.md`, a 24-site census; its one raw reader is reachable only
/// from the portrait and `<Model>` frames). Feeding the authored 45 degrees through the reference's
/// own `theta_v = F / sqrt(aspect^2 + 1)` builder — which is otherwise right, and angle-space
/// division really is what `0x5c3cc0` does — rendered fifteen of the sixteen shipped shots at
/// **half** the intended vertical FOV, i.e. visibly over-zoomed. Nothing to write means nothing to
/// release.
fn drive(
mut cine: ResMut<Cinematic>,
time: Res<Time>,
net: Option<Res<NetCommands>>,
mut camera: Query<(&mut Transform, &mut Projection), With<WorldCamera>>,
windows: Query<&Window>,
mut was_playing: Local<bool>,
mut camera: Query<&mut Transform, With<WorldCamera>>,
) {
let release_fov = |camera: &mut Query<(&mut Transform, &mut Projection), With<WorldCamera>>| {
if let Ok((_, mut projection)) = camera.single_mut() {
if let Projection::Perspective(p) = projection.as_mut() {
p.fov = CAM_FOVY;
}
}
};
// Every exit lands here: the natural end below releases immediately, and the two that happen
// outside this system — an ESC skip, and leaving the world — are caught on the next frame by
// this edge. One release path, so no exit can be the one that forgets.
let Some(play) = cine.playing.as_mut() else {
if std::mem::take(&mut *was_playing) {
release_fov(&mut camera);
}
return;
};
*was_playing = true;
play.elapsed += time.delta();
// Walk past any shot this frame's delta ran clean through (a long stall, a debugger pause),
@@ -442,8 +429,6 @@ fn drive(
cine.playing = None;
info!("cinematic: {id} finished");
ack(net.as_deref());
*was_playing = false;
release_fov(&mut camera);
return;
}
play.index += 1;
@@ -453,7 +438,7 @@ fn drive(
}
}
let Ok((mut cam, mut projection)) = camera.single_mut() else {
let Ok(mut cam) = camera.single_mut() else {
return;
};
let shot = play.shot();
@@ -475,17 +460,6 @@ fn drive(
if forward != Vec3::ZERO {
cam.look_at(target, up);
}
// The authored FOV is a **diagonal** opening angle in the reference's convention, and it is
// not uniform across the corpus — the Undead intro is 90° where the other nine are 45° — so
// it is read per shot and converted against the live viewport, never assumed.
let aspect = windows
.iter()
.next()
.map_or(4.0 / 3.0, |w| w.width() / w.height().max(1.0));
if let Projection::Perspective(p) = projection.as_mut() {
p.fov = shot.vertical_fov(aspect);
}
}
/// ESC, or anything else that asks for the skip: end the cinematic and ack it.
+4
View File
@@ -476,6 +476,10 @@ pub(super) struct SkinKey {
pub(super) hair_style: u8,
pub(super) hair_color: u8,
pub(super) equip: [u32; 8],
/// The wearer's guild tabard (decision 1704) — part of the key, not of `equip`, because two
/// guilds' members wear the *same* tabard display and must not share one atlas. Without it the
/// first guild to composite would lend its crest to every other guild on screen.
pub(super) emblem: Option<benilla_formats::GuildEmblem>,
}
/// Marks a net entity whose visual (model children or fallback cube) has been attached, so the attach
@@ -149,6 +149,11 @@ pub(super) struct WornEquip {
pub(super) bodyslots: [u32; 8],
pub(super) cloak: u32,
pub(super) helm: u32,
/// The wearer's guild tabard (decision 1704) — a **player's** only, off their resolved
/// [`super::Equipment`]. A character-model NPC has no guild: CreatureDisplayInfoExtra carries
/// no guild column, and a display-driven body never joins one, so this stays `None` there and a
/// tabard in an NPC's bodyslot-9 column keeps its own art.
pub(super) emblem: Option<benilla_formats::GuildEmblem>,
}
pub(super) fn resolve_worn_equip(
@@ -166,6 +171,7 @@ pub(super) fn resolve_worn_equip(
bodyslots: e.bodyslots,
cloak: e.cloak,
helm: e.helm,
emblem: e.emblem,
})
.unwrap_or_default(),
// A character-model NPC's worn gear ships in its display's CreatureDisplayInfoExtra columns
@@ -177,6 +183,7 @@ pub(super) fn resolve_worn_equip(
bodyslots: std::array::from_fn(|i| npc.equipment[i + 2]),
cloak: 0,
helm: npc.equipment[0],
emblem: None,
})
.unwrap_or_default(),
_ => WornEquip::default(),
@@ -260,6 +267,10 @@ pub(super) fn build_char_skin_materials(
// equip ids don't paint here — they drive the geosets only. `[0; 8]`/`0`/`None` = the naked body.
equip: [u32; 8],
cloak: u32,
// The wearer's guild tabard (decision 1704), painted over the torso layers of a tabard whose
// display asks for it. `None` = no guild, or its identity has not arrived; both leave the
// tabard garment showing its own art.
emblem: Option<benilla_formats::GuildEmblem>,
displays: Option<&super::super::ItemDisplays>,
sections: Option<&SkinSections>,
world_assets: Option<&WorldAssets>,
@@ -301,6 +312,7 @@ pub(super) fn build_char_skin_materials(
hair_style: look.hair_style,
hair_color: look.hair_color,
equip,
emblem,
};
match skin_cache.fetch(&key) {
Some(handle) => Some(handle),
@@ -329,6 +341,7 @@ pub(super) fn build_char_skin_materials(
key.hair_style,
key.hair_color,
worn,
key.emblem,
)
.ok()??;
// Through the upload gate like every other texture: a composite is
@@ -685,6 +685,7 @@ pub(super) fn attach_entity_visuals(
l,
equip,
worn.cloak,
worn.emblem,
displays.as_deref(),
sections.as_deref(),
world_assets.as_deref(),
@@ -104,6 +104,8 @@ pub(in crate::entities) struct PreviewSpec {
/// Worn **ItemDisplayInfo** ids by equipment slot (`EQUIPMENT_SLOT_*`) — no template hop; the
/// caller has already resolved entries to displays.
pub(in crate::entities) equipment: [CharEnumItem; 19],
/// The wearer's guild tabard (decision 1704), or `None` for no crest.
pub(in crate::entities) emblem: Option<benilla_formats::GuildEmblem>,
/// `CHARACTER_FLAG_*` bits; only hide-helm / hide-cloak are read here.
pub(in crate::entities) flags: u32,
/// **Whose held law this look follows** (decision 1076) — the one place the two previews
@@ -247,6 +249,11 @@ pub(in crate::entities) fn build_glue_preview(
hair_color,
facial_hair,
equipment,
// No crest at character select (decision 1704). `SMSG_CHAR_ENUM` carries a `guildId` but
// not the five emblem indices, and there is no world session to `CMSG_GUILD_QUERY` with —
// so a Guild Tabard on the mannequin wears its own `Tabard_A_05Default` art, which is what
// the reference's own empty guild cache leaves it with too.
emblem: None,
flags,
// The mannequin's own law: the select build skips EQUIPMENT_SLOT_RANGED (0x472bfe).
ranged_in_hand: false,
@@ -353,6 +360,7 @@ pub(in crate::entities) fn build_dressup_preview(
hair_color: look.hair_color,
facial_hair: look.facial_hair,
equipment: look.equipment,
emblem: look.emblem,
// **No hide flags here, because the hiding already happened upstream** (decision 1472).
// The dressing room DOES honour show-helm/show-cloak — byte-verified: `SetUnit 0x476cb0`
// clones the live player's per-bodyslot display pointers, so a suppressed piece is not in
@@ -508,6 +516,7 @@ fn assemble(spec: &PreviewSpec, ctx: &mut PreviewCtx<'_, '_>) -> Option<Assemble
&char_look,
bodyslots,
cloak,
spec.emblem,
ctx.displays.as_deref(),
ctx.sections,
ctx.world_assets,
@@ -153,6 +153,7 @@ pub(in crate::entities) fn redress_player_looks(
l,
worn.bodyslots,
worn.cloak,
worn.emblem,
displays.as_deref(),
sections.as_deref(),
world_assets.as_deref(),
+51 -13
View File
@@ -40,20 +40,51 @@
//! branch is dead. We reproduce the *outcome* (a corpse wears armour, not weapons), not the
//! mechanism: aping a dead lookup would be aping a quirk, which §3 of the contract is against.
//!
//! ## The ground decal, and the one divergence
//!
//! `[corpse+0x2b0]` is **not** a render scale (its old ledger gloss); it is `0.5 ×` the MD20 render
//! bounding-sphere radius, read by exactly one site — `0x5d6fe0`, which sizes the corpse's
//! `UnitSelectTexture` ground decal by it × `OBJECT_FIELD_SCALE_X`. That decal is the corpse's twin
//! of a unit's selection ring, so it belongs with the right-click loot/reclaim route this arc still
//! defers (0308 §7), not here.
//!
//! **The divergence to know about** (flagged open by wow-re's §5, not settled at the bytes): the
//! reference computes the drowned verdict *inside* the create, from the scene node's cached liquid
//! probe — and its own `[node+0x90] & 0x20` gate means an un-probed node falls through to `Dead`.
//! Whether the node is already probed that early is a byte question nobody has closed. Ours asks
//! the world directly at attach time, a frame or more later, so our `Drowned` leg is reachable
//! whether or not theirs is. The failure is one-sided — we can only ever show `Drowned` where the
//! reference might show `Dead`, never the reverse — and it needs a drowned corpse to observe.
//!
//! ## The pose
//!
//! `0x5d63de`/`0x5d6402` arm bone 0 through the shared M2 arm `0x7121a0` with **AnimationData id 6
//! (`Dead`)**, or **132 (`Drowned`)** when `0x5d6540` says so. That predicate is a **liquid** query:
//! `0x670630` over the corpse's scene node (the same call and the same `surfaceZ − subject.z` shape
//! wow-re records for the breath classifier `0x607710` and the pivot-height glide), compared against
//! the f32 at `[0x80abfc]` = **0.66666669** — i.e. a corpse submerged by more than ⅔ yd lies drowned
//! rather than merely dead. Both ids resolve through the model's own fallback table before playing:
//! `HumanMale.m2` authors neither 6 nor 132's head directly, and `AnimationData.dbc` walks
//! `Dead → Death(1)` and `Drowned → Drown(131)`.
//! (`Dead`)**, or **132 (`Drowned`)** when `0x5d6540` says so — all of it byte-VERIFIED by wow-re's
//! §5 round (`object-layer/scratch/corpse-drowned-pose.md`, 2026-08-29, commissioned by this work).
//! That predicate is a **liquid** query: `0x670630` reads the corpse scene node's cached probe,
//! whose `[node+0x98]` is named at its *writer* (`0x69e280` → terrain's own bit-exact
//! `liquid_status`) as the **liquid surface height**. The comparison is
//!
//! The clip is armed **once, seeked to its end**. A corpse object never collapses in front of you —
//! it is created after the release, already lying down — so this is the settled-pose arm the unit
//! driver already uses for a body that streamed in dead, not a replay.
//! ```text
//! liquidSurfaceZ − CORPSE_FIELD_POS_Z > [0x80abfc] // 0x3f2aaaab = 0.66666669
//! ```
//!
//! strict (`test ah,0x41` read by `jne`, so equality and NaN are both false), and 2/3 yd is this
//! client's submersion depth generally — `0x60a740`, whose result is pushed as the `$FSD` **wading**
//! flag, runs the same subtraction against the same constant.
//!
//! Both ids resolve through the model's own fallback table before playing (which is what
//! `0x7121a0` itself does: `0x711c10` playableAnimationLookup, then `0x712470` animationLookup,
//! before any `nSequences` bounds check): `HumanMale.m2` authors neither 6 nor 132's head directly,
//! and `AnimationData.dbc` walks `Dead → Death(1)` and `Drowned → Drown(131)`.
//!
//! The clip is armed **once, seeked to its end**, which is the reference's own shape: `0x5d6260`
//! runs exactly once per object (one call site, no data-section pointer to it anywhere in the PE),
//! it caches the verdict in `[corpse+0x2ac]`, and nothing ever recomputes it. `0x5d6850` re-arms
//! from that cached byte, but it is the **model-ready callback** (vtable `+0x34`, fired by the
//! trampoline `0x613d70` that SetModel registers), not a per-frame tick. A corpse object never
//! collapses in front of you either — it is created after the release, already lying down — so this
//! is a settled pose, never a replay.
use std::collections::HashMap;
@@ -151,8 +182,8 @@ pub(in crate::entities) fn ensure_bones_display(
bones.0.insert(key, dm);
}
/// This corpse's held pose has been armed — the arm is once-only, like the reference's, whose
/// per-frame `0x5d6850` re-arms the *same* id and so is a hold, not a replay.
/// This corpse's held pose has been armed — once-only, like the reference's: `0x5d6260` computes
/// the verdict once per object and caches it in `[corpse+0x2ac]`, and no site recomputes it.
#[derive(Component)]
pub(super) struct CorpsePosed;
@@ -186,7 +217,14 @@ pub(super) fn pose_corpses(
}
let wow = benilla_assets::coords::bevy_to_wow(tf.translation());
// The reference's `0x5d6540`: ANY liquid, not water alone — it is the generic `0x670630`
// query, the same one the breath classifier runs, so lava and slime count too.
// probe, the same one the breath classifier runs, so lava and slime count too. Strict `>`,
// like the emitted `jne` (equality and NaN both read as not-submerged).
//
// The reference subtracts `CORPSE_FIELD_POS_Z` (its position getter `0x5d7690` copies
// `[[corpse+0x110]+0xc/0x10/0x14]`), where this reads the entity's world pose. They are the
// same number: vmangos writes the descriptor POS fields and the movement block's
// `HAS_POSITION` pose from one `Relocate` (`Corpse.cpp:100-103`), and nothing moves a
// corpse afterwards.
let submerged = world
.liquid_at(benilla_world::world_point::Subject::Unit(entity), wow)
.is_some_and(|hit| hit.surface_z - wow[2] > DROWNED_DEPTH);
@@ -128,6 +128,15 @@ pub(crate) struct Equipment {
/// The head slot's display id (0 = no helm) — an attach sub-model, plus the HelmetGeosetVisData
/// hide-masks that tuck hair/facial/ears under it (wow-re RF-0083).
pub(crate) helm: u32,
/// The wearer's guild tabard (decision 1704) — the emblem five, joined off their own PUBLIC
/// `PLAYER_GUILDID` against the guild identity cache. `None` for a guildless player **and**
/// for a guild whose `CMSG_GUILD_QUERY` has not answered yet; the tabard shows its own art
/// until it does.
///
/// It lives here rather than in a component of its own because it is part of *what this body
/// wears*: the composite key includes it, so the re-dress diff below is the one place a change
/// of guild, of tabard, and a late-arriving identity all converge — no second latch.
pub(crate) emblem: Option<benilla_formats::GuildEmblem>,
pub(crate) settled: bool,
}
@@ -203,12 +203,23 @@ pub(in crate::entities) fn resolve_equipment(
// explicit counters and never resource change ticks: `templates` is `ResMut` in this very
// system (ask-once misses write it), so a tick gate would read its own writes and never
// close.
mut last_epochs: Local<Option<(u64, u64)>>,
mut last_epochs: Local<Option<(u64, u64, u64)>>,
// The guild identity cache (decision 1257) — `ResMut` because it is LAZY: the miss below is
// what sends the `CMSG_GUILD_QUERY` whose answer paints the tabard. `Option` for the same
// reason `creatures` is: a harness without the UI plugins still resolves equipment.
mut guilds: Option<ResMut<crate::ui_guild::GuildState>>,
) {
let Some(mut held) = held else {
return;
};
let epochs = (templates.object_epoch(), templates.template_epoch());
// The guild cache's landed counter joins the item epochs for the same reason those are here:
// a `CMSG_GUILD_QUERY` answered three frames after a player spawned changes what that player's
// tabard paints, and nothing about their descriptor or the item cache moves to say so.
let epochs = (
templates.object_epoch(),
templates.template_epoch(),
guilds.as_ref().map_or(0, |g| g.identity_generation()),
);
let caches_moved = last_epochs.replace(epochs) != Some(epochs)
|| creatures.as_ref().is_some_and(|c| c.is_changed())
|| enchants.as_ref().is_some_and(|e| e.is_changed());
@@ -311,6 +322,13 @@ pub(in crate::entities) fn resolve_equipment(
if hide_helm {
eq.helm = 0;
}
// The guild tabard (decision 1704). Resolved for every player, tabard worn or not —
// the composite's own gate is the tabard DISPLAY's flag, and asking here keeps the
// query on the same lazy-cache idiom as every other read of that cache. A miss answers
// `None` for this frame and re-runs when the response bumps the counter above.
eq.emblem = guilds
.as_deref_mut()
.and_then(|g| crate::ui_guild::unit_guild_emblem(s, g, &net));
if current_equipment != Some(&eq) {
commands.entity(entity).insert(eq);
}
+31
View File
@@ -111,6 +111,7 @@ impl Plugin for NetPlugin {
.add_message::<SpeedChangeMessage>()
.add_message::<ClientControlMessage>()
.add_message::<MoveModeMessage>()
.add_message::<KnockBackMessage>()
.add_message::<ServerSoundMessage>()
.add_message::<EmoteMessage>()
.add_message::<AiReactionMessage>()
@@ -1715,6 +1716,24 @@ pub(crate) enum ClientCommand {
pos: [f32; 3],
orientation: f32,
},
/// **Acknowledge the knockback we just flew** (`CMSG_MOVE_KNOCK_BACK_ACK`, decision 1702): the
/// echoed `counter`, our live pose/flags, and `launch` — the server's own quad, echoed back as
/// the `MovementInfo` jump tail.
///
/// Sent by the controller on the frame the mover actually takes off, never on receipt, and never
/// if the launch was refused. That order is the reference's: the knockback is queued as a
/// due-timed record, and the frame update drains it **apply-then-send inside one call**
/// (`0x61624d call 0x6179c0` → `0x616261 push 0xf0`), so the `MovementInfo` on the wire is
/// always the post-launch one.
KnockBackAck {
guid: u64,
counter: u32,
launch: JumpInfo,
flags: u32,
pos: [f32; 3],
orientation: f32,
transport: Option<TransportPose>,
},
/// Release the spirit (`CMSG_REPOP_REQUEST`, empty body) — the DEATH popup's Release Spirit
/// (and its timeout's server-forced twin is server-side; decision 0308 slice 1).
RepopRequest,
@@ -2274,6 +2293,18 @@ pub(crate) struct MoveModeMessage {
pub(crate) apply: bool,
}
/// **The server aimed a knockback at our mover** (`SMSG_MOVE_KNOCK_BACK`, decision 1702). Written by
/// [`apply_net_updates`] for our own guid only, read by the player controller — the one place that
/// owns both the mover the launch acts on and the honest post-launch pose the ack must carry.
#[derive(Message, Clone, Copy)]
pub(crate) struct KnockBackMessage {
/// Our mover's guid (the bridge only emits ours) — echoed in the ack as a **full** u64.
pub(crate) guid: u64,
pub(crate) counter: u32,
/// The launch quad, in the jump tail's own convention (`zspeed` down-positive).
pub(crate) launch: JumpInfo,
}
/// A cross-map worldport (`.tele Orgrimmar`, initial-login map, a boat crossing the sea): snap
/// the avatar, swap the map's ADTs, and ack if required. Written by [`apply_net_updates`], read
/// by the player controller.
+14 -4
View File
@@ -116,9 +116,14 @@ pub(super) fn apply_net_updates(
MessageWriter<EnteredWorldMessage>,
MessageWriter<LoggedOutMessage>,
MessageWriter<super::SpeedChangeMessage>,
// The ack'd movement-mode family (decisions 0308, 0866): a mode the server granted our
// mover, which the player controller applies and answers with its live pose.
MessageWriter<super::MoveModeMessage>,
// The two server-authored mover edges the controller both *applies* and *answers*, paired to
// stay inside Bevy's 16-element tuple limit — and they do belong together: a granted mode
// (decisions 0308, 0866) and a knockback launch (decision 1702) are the same handshake, an
// edge the server may not act on until our own live pose comes back.
(
MessageWriter<super::MoveModeMessage>,
MessageWriter<super::KnockBackMessage>,
),
// The login screen's dialog + reconnect-policy feed (decision 0539).
MessageWriter<super::LoginStageMessage>,
// The login queue's position feed (decision 1681).
@@ -448,7 +453,7 @@ pub(super) fn apply_net_updates(
mut entered_world,
mut logged_out,
mut speed_changes,
mut move_modes,
(mut move_modes, mut knockbacks),
mut login_stages,
mut login_queued,
mut login_failures,
@@ -1258,6 +1263,11 @@ pub(super) fn apply_net_updates(
&mut death_net,
&mut move_modes,
),
SessionEvent::KnockBack {
guid,
counter,
launch,
} => session::knock_back(guid, counter, launch, &self_guid, &mut knockbacks),
SessionEvent::ItemTemplate { entry, info } => {
item_template(entry, info.map(|b| *b), &mut items)
}
+29 -3
View File
@@ -5,6 +5,7 @@
//! dispatcher, one call per arm.
use benilla_protocol::messages::Character;
use benilla_protocol::JumpInfo;
use bevy::prelude::*;
use crate::items::Items;
@@ -21,9 +22,9 @@ use crate::ui_trainer::TrainerOpen;
use super::super::{
CharActionResultMessage, CharListMessage, CinematicTriggeredMessage, DisconnectedMessage,
DroppedOpcodes, EnteredWorldMessage, GameTime, GuidIndex, LoggedOutMessage, LoginFailedMessage,
LoginStageMessage, NetStatus, PendingTransfer, PingShared, Reputations, SelfGuid, ServerTime,
ServerWallClock, TeleportMessage, WorldportMessage,
DroppedOpcodes, EnteredWorldMessage, GameTime, GuidIndex, KnockBackMessage, LoggedOutMessage,
LoginFailedMessage, LoginStageMessage, NetStatus, PendingTransfer, PingShared, Reputations,
SelfGuid, ServerTime, ServerWallClock, TeleportMessage, WorldportMessage,
};
/// The pre-logon handshake reached a new stage (decision 0539) — the login screen's dialog reads it.
@@ -285,6 +286,31 @@ pub(super) fn teleport(
}
}
/// **A knockback the server aimed at our mover** (`SMSG_MOVE_KNOCK_BACK`, decision 1702) — a
/// ballistic launch the controlling client flies itself, not a spline and not a teleport. Forwarded
/// to the controller, which owns the take-off, the arc, and the ack the launch owes.
///
/// The guid guard is the same one every self-addressed movement edge here carries. The reference
/// registers this opcode for the **controller** only; the observer's knockback arrives on a
/// different opcode with a different handler (`MSG_MOVE_KNOCK_BACK`, wow-re
/// `system/collision/scratch/knockback-law.md`), so a knockback naming somebody else is not ours to
/// fly.
pub(super) fn knock_back(
guid: u64,
counter: u32,
launch: JumpInfo,
self_guid: &SelfGuid,
knockbacks: &mut MessageWriter<KnockBackMessage>,
) {
if self_guid.0 == Some(guid) {
knockbacks.write(KnockBackMessage {
guid,
counter,
launch,
});
}
}
/// The far-teleport preamble (`SMSG_TRANSFER_PENDING`): latch it for the coming worldport —
/// its transport block decides whether NEW_WORLD's coordinates are boat-local (decision 0455).
pub(super) fn transfer_pending(
+11
View File
@@ -1039,6 +1039,17 @@ fn writer_loop(
pos,
orientation,
} => w.move_mode_ack(guid, counter, mode, apply, flags, (pos, orientation)),
ClientCommand::KnockBackAck {
guid,
counter,
launch,
flags,
pos,
orientation,
transport,
} => {
w.knock_back_ack(guid, counter, launch, flags, (pos, orientation), transport)
}
ClientCommand::RepopRequest => w.repop_request(),
ClientCommand::CorpseQuery => w.corpse_query(),
ClientCommand::ReclaimCorpse { corpse } => w.reclaim_corpse(corpse),
+57 -3
View File
@@ -27,6 +27,7 @@ use crate::creature_anim::{move_flags, wrap_pi, BodyTwist, MovementState};
use crate::net::{ClientCommand, Embodied, NetCommands, TeleportMessage, WorldportMessage};
use crate::ui_script::InspectMode;
use crate::ui_script::PointerOverUi;
use benilla_assets::coords::wow_to_bevy;
use benilla_assets::AssetSet;
use benilla_world::interact::{WorldClick, WorldRightClick, WorldRightPress};
use benilla_world::schedule::WorldStage;
@@ -423,6 +424,9 @@ fn control(
// than at the net drain because both answers it needs — the mover claim and the parting
// pose — are the controller's to give.
MessageReader<crate::net::ClientControlMessage>,
// A knockback the server aimed at our mover (decision 1702) — `wire_in` latches it, the
// take-off site below flies it, and the movement stream acks it with the post-launch pose.
MessageReader<crate::net::KnockBackMessage>,
),
// Nested into one param to stay within Bevy's 16-element system-param tuple limit (see `mouse`).
speed_capsule: (
@@ -757,6 +761,7 @@ fn control(
&mut net.2,
&mut net.4,
&mut net.5,
&mut net.12,
&mut net.9,
&mut net.11,
self_guid,
@@ -1117,7 +1122,14 @@ fn control(
// director's ref observation (it stands you up) is the ground truth this keeps; the
// byte trigger is flagged LIVE-CAPTURE in the wow-re note.
let turned = turn_delta != 0.0 || mouse_turned;
if (moving || turned || binds.fired(crate::bindings::cmd::JUMP))
// **A knockback stands you up too** (decision 1702) — the one entry here that is nobody's
// input. The reference's knockback apply carries it as a side effect of the launch (the
// `0x60e139` block, whose indirect `call [edx+0xa4]` resolves to `GetStandState 0x60be50`),
// which is the same guarded wrapper every trigger above reaches. Read off the armed latch
// rather than the take-off, because this block runs before the mover: the latch was written
// by [`wire_in::apply_server_moves`] at the top of the frame and is still there.
let knocked_out_of_it = player.knockback.is_some();
if (moving || turned || knocked_out_of_it || binds.fired(crate::bindings::cmd::JUMP))
&& stand_state != 0
&& request_stand.is_none()
{
@@ -1276,12 +1288,28 @@ fn control(
// (`0x7c625c test ah,0x30`); the seed select at `0x7c6261` is shared, so the swim/land
// choice is made below by the same two take-off sites Space uses.
let wire_jump = player.take_wire_jump();
// **The knockback the server aimed at us** (decision 1702) — taken at the same site as the
// other two take-offs so all three enter the mover through one door. Resolved here from the
// wire quad into one Bevy launch velocity: the horizontal is `(cos, sin)·xy_speed` in
// **absolute world XY** (nothing to do with our facing or our keys), and the vertical is
// `−zspeed`, because the wire's take-off speed is DOWN-positive — the same convention the
// jump tail this quad is about to be echoed back as already uses (decision 0054).
let knockback = player.take_knockback();
let knock_launch = knockback.map(|k| {
let (c, sn, xy) = (k.launch.cos_angle, k.launch.sin_angle, k.launch.xy_speed);
wow_to_bevy([c * xy, sn * xy, -k.launch.zspeed])
});
// A knockback lifts a swimmer clear of the water like any other take-off — it sets FALLING,
// and FALLING and SWIMMING are exclusive. It is NOT routed through `breach_step`, though:
// that arm exists to seed `SWIM_JUMP_SPEED`, and a knockback brings its own seed on both
// axes, so it leaves the water here and is flown by the ordinary land mover below.
let breach = swimming && (want_jump && !player.modes.hover || wire_jump);
if breach {
let knock_breach = swimming && knock_launch.is_some();
if breach || knock_breach {
player.swimming = false;
}
let swimming = swimming && !breach;
let swimming = swimming && !breach && !knock_breach;
// The swim translation amounts — read by the swim mover arm AND the flag build, so the
// two can never disagree (decision 0056: the flags mirror the avatar's motion). W/S,
@@ -1382,6 +1410,7 @@ fn control(
held,
grounded,
jumped,
knocked,
air_nudged,
ground,
} = if breach {
@@ -1460,6 +1489,10 @@ fn control(
held: false,
grounded: out.grounded,
jumped: false,
// A knockback never resolves here: it clears SWIMMING at the take-off site above
// (the reference's `StartFalling 0x7c61f0` clears the bit outright rather than
// testing it), so a knocked swimmer is flown by the land mover.
knocked: false,
air_nudged: false,
ground: None, // swimming detaches from any platform frame below
}
@@ -1494,10 +1527,18 @@ fn control(
speed,
want_jump,
wire_jump,
knock_launch,
water_floor,
)
};
// The knockback's own trace line (decision 1702) — flown or refused, with the quad. Emitted
// here because this is the first point where both halves are known: the latch we took, and
// the mover's verdict on it.
if let Some(k) = knockback {
move_trace::knockback(knocked, k.launch);
}
let now = time.elapsed_secs();
// Airborne is a walk-only concept — swimming never falls, so the body-heading / anim-flags
// logic below reads this hoisted value (false while swimming) instead of the walk branch's.
@@ -2007,12 +2048,25 @@ fn control(
swim_pitch,
movement_net::ArcEdges {
jumped,
// **The wire's own take-offs announce themselves, and not with `MSG_MOVE_JUMP`**
// (decision 1702). `wire_jump` covers the hover grant's `CMovement::Jump(force = 0)`,
// whose handler `0x61a620` sends nothing at all — and it cannot be racing a keyboard
// jump, because the body it launches was granted HOVER in the same breath and hover
// is the first refusal Space takes (`0x7c623a`). `knocked` covers the knockback,
// which pushes its ack instead. This is the correction 1620 needed: benilla streamed
// a JUMP for the hover launch, and the reference streams nothing.
wire_launch: knocked || wire_jump,
air_nudged,
landed,
fall_time: wire_fall_time,
},
now,
&speed_acks,
// Owed only if the mover actually flew it: a launch the settle hold or the root anchor
// refused is dropped in silence, exactly as the reference discards an already-popped
// knockback record under `MOVEFLAG_ROOT` (`0x615c71 test ah,0x10` → the kind-28 table
// byte is 0 → `0x615c80 je 0x616539`: no apply, and no ack).
knockback.filter(|_| knocked),
wire_transport,
);
} else {
@@ -18,6 +18,10 @@
//! a granted sit and a refused one look identical on screen, and the granted-underwater bug and a
//! gate that refuses everything are the same picture. Covers the `X` key and the `/sit` family
//! alike, because both land on the one commit it is emitted from.
//! - **`knb`** — one line per **knockback** ([`knockback`]): the launch quad the server aimed and
//! whether the mover flew it. The one mover edge with no observable of its own — its ack is not a
//! `MSG_MOVE_*` so `snd` misses it, its refusal is silent by design, and a wrong ack's whole
//! symptom is *other players seeing nothing* (decision 1702).
//! - **`snd`** — one line per outbound `MSG_MOVE_*` ([`sent`]), the send-side twin of `net::motion`'s
//! `rly`: it makes **our own wire cadence measurable** (decision 0617), which is the only way to
//! compare it against the reference's — the 1.12.1 sniff's client stream is a list of exactly these
@@ -102,6 +106,37 @@ pub(super) fn posture(what: &str, state: u8, from: u8, flags: u32) {
);
}
/// One `knb` line per **knockback**, whether we flew it or refused it (decision 1702).
///
/// The tag exists because a knockback is the one mover edge with **no observable of its own on
/// either side of the failure**. Its ack is not a `MSG_MOVE_*`, so `snd` never shows it; its refusal
/// is silent by design (the reference discards the record under a root, and vmangos logs
/// `OnFailedToAckChange` server-side where we cannot see it); and if the ack's four floats are ever
/// wrong the server drops it as `OnWrongAckData` and simply never relays the knockback — a bug whose
/// entire symptom is *other people not seeing something*, which no local capture can show.
///
/// So the line carries the quad as it arrived, next to the verdict. `up` is the derived +Z take-off
/// speed, printed beside the wire's own down-positive `zspeed` precisely because the sign flip
/// between them is where a knockback that drives you into the ground would come from — the two must
/// always read as negatives of each other.
pub(super) fn knockback(flown: bool, launch: benilla_protocol::JumpInfo) {
if !trace::enabled() {
return;
}
trace::line(
"knb",
&format!(
"{} cos={:.4} sin={:.4} xy={:.3} zspeed={:.3} up={:.3}",
if flown { "flown" } else { "REFUSED" },
launch.cos_angle,
launch.sin_angle,
launch.xy_speed,
launch.zspeed,
-launch.zspeed,
),
);
}
/// One `mvr` line per **mover-claim** packet — `CMSG_SET_ACTIVE_MOVER` and
/// `CMSG_MOVE_NOT_ACTIVE_MOVER`. Neither is a `MSG_MOVE_*`, so neither appears under `snd`, and yet
/// the pair decides whether every packet that *does* is accepted at all: vmangos matches each
+199 -2
View File
@@ -93,8 +93,16 @@ const IN_MOTION: u32 = move_flags::ANY_MOVE
/// than four adjacent bools in the argument list, where a miscount is silent and the symptom is a
/// wrong opcode on the wire.
pub(super) struct ArcEdges {
/// A jump launched this frame → `MSG_MOVE_JUMP`, carrying the ballistic tail.
/// A take-off launched this frame — the arc opened.
pub(super) jumped: bool,
/// …and it came from the **wire**, not the key: a knockback, or the jump a `SetHover(true)`
/// grant owes ([`super::Player::hover_launch`]). Suppresses `MSG_MOVE_JUMP`, which is the
/// *keyboard's* packet and nothing else's: `0x615ed1`, in the move-command drain's jump arm, is
/// its one live emission site image-wide, and neither wire door passes through it — the hover
/// handler `0x61a620` sends nothing at all, and the knockback's own arm of that same drain
/// pushes `0xf0`, its ack. (Decision 1702; this corrects 1620, which streamed a JUMP for the
/// hover launch.)
pub(super) wire_launch: bool,
/// The standstill air nudge fired ([`super::mover::step`]) — the one mid-air press that really
/// moves us, and so the one that breaks the airborne send silence (decision 0627).
pub(super) air_nudged: bool,
@@ -135,10 +143,12 @@ pub(super) fn stream_self_movement(
arc: ArcEdges,
now: f32,
speed_acks: &[crate::net::SpeedChangeMessage],
knock_ack: Option<super::state::PendingKnockback>,
transport: Option<TransportPose>,
) {
let ArcEdges {
jumped,
wire_launch,
air_nudged,
landed,
fall_time,
@@ -198,6 +208,33 @@ pub(super) fn stream_self_movement(
// it is also a position report — the reconcile below must not re-send what it just told.
player.last_pos = wow_pos;
}
// **The knockback ack** (decision 1702) — owed only if the mover actually took off this frame,
// and carrying the pose it took off from. `apply → send, inside one call` is the reference's own
// order (`0x61624d call 0x6179c0` → `0x616261 push 0xf0`), which is why this sits here and not
// back at the opcode: everything on the wire must describe the arc we are already flying.
//
// `launch` goes out verbatim rather than re-derived from `wire_jump` below, though the two agree
// exactly for every real knockback (the mover seeded its velocity from this quad, and the round
// trip through the coordinate basis is sign flips and a hypot). The reference copies its own
// live fields as plain dwords (`+0xa0`→`+0x3c`, `+0x68`→`+0x40`, `+0x6c`→`+0x44`,
// `+0x84`→`+0x48`) so its echo is bit-identical to what arrived, and vmangos's `0.01` tolerance
// is never exercised. Sending the quad itself is how we keep that true in the one case where a
// re-derivation could not: `xy_speed == 0`, where a direction has no length to recover and the
// tail below substitutes the facing.
if let Some(k) = knock_ack {
let _ = sender.send(ClientCommand::KnockBackAck {
guid: k.guid,
counter: k.counter,
launch: k.launch,
flags: wire_flags,
pos: wow_pos,
orientation: facing,
transport,
});
// Like a speed ack, this relocates us server-side — so it is a position report too.
player.last_pos = wow_pos;
}
let prev = player.move_flags;
let added = wire_flags & !prev;
let removed = prev & !wire_flags;
@@ -284,7 +321,13 @@ pub(super) fn stream_self_movement(
// 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 {
// **…and a WIRE-driven take-off sends no JUMP at all** (decision 1702). Same law read the other
// way — `MSG_MOVE_JUMP` is the keyboard's packet ([`ArcEdges::wire_launch`] carries the bytes).
// For a knockback the server agrees from its own side: `CHEAT_TYPE_OVERSPEED_JUMP` is one of the
// few vmangos checks with **no** knockback exemption (`MovementAnticheat.cpp:650`), and a
// knockback's horizontal speed is over run speed by design — so a JUMP here would be a logged
// cheat on every single one.
if jumped && !wire_launch {
send_move!(MoveKind::Jump);
} else if landed {
send_move!(MoveKind::FallLand);
@@ -472,6 +515,7 @@ mod tests {
0.0,
ArcEdges {
jumped: false,
wire_launch: false,
air_nudged: false,
landed: false,
fall_time: 0,
@@ -479,6 +523,7 @@ mod tests {
0.0,
&[],
None,
None,
);
let ClientCommand::Move { orientation, .. } = rx
@@ -514,6 +559,7 @@ mod tests {
0.0,
ArcEdges {
jumped: false,
wire_launch: false,
air_nudged: false,
landed: true,
fall_time: 1700,
@@ -522,6 +568,7 @@ mod tests {
0.0,
&[],
None,
None,
);
let ClientCommand::Move {
@@ -556,6 +603,7 @@ mod tests {
0.0,
ArcEdges {
jumped: false,
wire_launch: false,
air_nudged: false,
landed: false,
fall_time: 300,
@@ -563,6 +611,7 @@ mod tests {
0.2,
&[],
None,
None,
);
assert!(rx.try_recv().is_err(), "a mid-air release sends nothing");
assert_eq!(
@@ -578,6 +627,7 @@ mod tests {
0.0,
ArcEdges {
jumped: false,
wire_launch: false,
air_nudged: false,
landed: true,
fall_time: 800,
@@ -585,6 +635,7 @@ mod tests {
0.8,
&[],
None,
None,
);
let ClientCommand::Move { kind, flags, .. } = rx.try_recv().expect("the landing packet")
else {
@@ -620,6 +671,7 @@ mod tests {
0.0,
ArcEdges {
jumped: false,
wire_launch: false,
air_nudged: false,
landed: false,
fall_time: (now * 1000.0) as u32,
@@ -627,6 +679,7 @@ mod tests {
now,
&[],
None,
None,
);
};
@@ -683,6 +736,7 @@ mod tests {
0.0,
ArcEdges {
jumped: false,
wire_launch: false,
air_nudged: true,
landed: false,
fall_time: 300,
@@ -690,6 +744,7 @@ mod tests {
0.3,
&[],
None,
None,
);
let ClientCommand::Move {
kind, flags, jump, ..
@@ -721,6 +776,7 @@ mod tests {
0.0,
ArcEdges {
jumped: false,
wire_launch: false,
air_nudged: false,
landed: false,
fall_time: 300,
@@ -728,6 +784,7 @@ mod tests {
0.3,
&[],
None,
None,
);
assert!(
rx.try_recv().is_err(),
@@ -753,6 +810,7 @@ mod tests {
0.0,
ArcEdges {
jumped: false,
wire_launch: false,
air_nudged: false,
landed: false,
fall_time: 200,
@@ -760,6 +818,7 @@ mod tests {
0.2,
&[],
None,
None,
);
let ClientCommand::Move { kind, flags, .. } =
rx.try_recv().expect("a mid-air turn broadcasts")
@@ -780,6 +839,7 @@ mod tests {
0.0,
ArcEdges {
jumped: false,
wire_launch: false,
air_nudged: false,
landed: false,
fall_time: 900,
@@ -787,6 +847,7 @@ mod tests {
1.5,
&[],
None,
None,
);
let kinds: Vec<_> = rx
.try_iter()
@@ -822,6 +883,7 @@ mod tests {
0.0,
ArcEdges {
jumped: false,
wire_launch: false,
air_nudged: false,
landed: false,
fall_time: 0,
@@ -829,6 +891,7 @@ mod tests {
0.05,
&[],
None,
None,
);
let ClientCommand::Move { kind, flags, .. } = rx
.try_recv()
@@ -855,6 +918,7 @@ mod tests {
0.0,
ArcEdges {
jumped: false,
wire_launch: false,
air_nudged: false,
landed: false,
fall_time: 0,
@@ -862,6 +926,7 @@ mod tests {
0.06,
&[],
None,
None,
);
assert!(rx.try_recv().is_err(), "an unchanged facing is silent");
}
@@ -886,6 +951,7 @@ mod tests {
0.0,
ArcEdges {
jumped: false,
wire_launch: false,
air_nudged: false,
landed: false,
fall_time: 0,
@@ -893,6 +959,7 @@ mod tests {
0.05 * (i as f32 + 1.0),
&[],
None,
None,
);
assert!(
rx.try_recv().is_err(),
@@ -908,6 +975,7 @@ mod tests {
0.0,
ArcEdges {
jumped: false,
wire_launch: false,
air_nudged: false,
landed: false,
fall_time: 0,
@@ -915,6 +983,7 @@ mod tests {
0.2,
&[],
None,
None,
);
let ClientCommand::Move { kind, .. } = rx.try_recv().expect("the STOP_TURN") else {
panic!("expected a Move command");
@@ -944,6 +1013,7 @@ mod tests {
0.0,
ArcEdges {
jumped: false,
wire_launch: false,
air_nudged: false,
landed: false,
fall_time: 0,
@@ -951,6 +1021,7 @@ mod tests {
0.05,
&[],
None,
None,
);
let kinds: Vec<_> = rx
.try_iter()
@@ -972,6 +1043,7 @@ mod tests {
0.0,
ArcEdges {
jumped: false,
wire_launch: false,
air_nudged: false,
landed: false,
fall_time: 0,
@@ -979,6 +1051,7 @@ mod tests {
0.0,
&[],
None,
None,
);
}
@@ -1069,6 +1142,130 @@ mod tests {
assert_eq!(player.move_flags, 0, "bookkeeping is zeroed after parking");
}
/// **A knockback launch acks, and sends no `MSG_MOVE_JUMP`** (decision 1702).
///
/// Two laws in one frame, and they are the same law read from both ends. `MSG_MOVE_JUMP` has a
/// single live emission site image-wide — `0x615ed1`, the move-command drain's jump arm — and
/// the knockback's own arm of that same drain pushes `0xf0`, its ack, instead. So a knockback
/// announces itself once, on the ack, whose `MovementInfo` is what the server turns into every
/// observer's `MSG_MOVE_KNOCK_BACK`.
///
/// The regression this pins is not cosmetic: `CHEAT_TYPE_OVERSPEED_JUMP` is one of the few
/// vmangos checks with no knockback exemption, and it fires on any `MSG_MOVE_JUMP` whose
/// `xyspeed` exceeds run speed — which a knockback's does by design.
#[test]
fn a_knockback_acks_and_sends_no_jump() {
let (tx, rx) = crossbeam_channel::unbounded();
// Mid-launch: the mover has seeded the arc from the quad below (Bevy −Z is WoW +X, so a
// due-west push of 25 yd/s lands as `horiz_vel.z = −25`), and `jump_zspeed` is +up where the
// wire's is down-positive.
let launch = benilla_protocol::JumpInfo {
zspeed: -12.0,
cos_angle: 1.0,
sin_angle: 0.0,
xy_speed: 25.0,
};
let mut player = Player {
horiz_vel: bevy::prelude::Vec3::new(0.0, 0.0, -25.0),
jump_zspeed: 12.0,
..Default::default()
};
stream_self_movement(
&tx,
&mut player,
move_flags::FALLING,
0.0,
ArcEdges {
jumped: true, // the arc really opened — the bookkeeping cannot tell the two apart
wire_launch: true,
air_nudged: false,
landed: false,
fall_time: 0,
},
0.0,
&[],
Some(super::super::state::PendingKnockback {
guid: 0x1234,
counter: 7,
launch,
}),
None,
);
let ClientCommand::KnockBackAck {
guid,
counter,
launch: acked,
flags,
..
} = rx.try_recv().expect("the launch owes an ack")
else {
panic!("expected the knockback ack first");
};
assert_eq!((guid, counter), (0x1234, 7), "the counter must be echoed");
assert_eq!(
acked, launch,
"the four floats go back bit-for-bit — vmangos matches them within 0.01 before it will \
relay the knockback to anyone"
);
assert_ne!(
flags & move_flags::FALLING,
0,
"the ack's MovementInfo must carry JUMPING, or the jump tail it needs is never written"
);
for extra in rx.try_iter() {
if let ClientCommand::Move { kind, .. } = extra {
assert_ne!(
kind,
MoveKind::Jump,
"a knockback must not put MSG_MOVE_JUMP on the wire — instant \
CHEAT_TYPE_OVERSPEED_JUMP, and the reference's jump arm is a different drain arm"
);
}
}
}
/// The other half of the same gate: an ordinary jump on an identical frame still sends its JUMP.
/// Without this, "no JUMP for a knockback" could be satisfied by never sending one at all.
#[test]
fn an_ordinary_jump_still_sends_its_jump() {
let (tx, rx) = crossbeam_channel::unbounded();
let mut player = Player {
jump_zspeed: 7.955547,
..Default::default()
};
stream_self_movement(
&tx,
&mut player,
move_flags::FALLING,
0.0,
ArcEdges {
jumped: true,
wire_launch: false,
air_nudged: false,
landed: false,
fall_time: 0,
},
0.0,
&[],
None,
None,
);
let ClientCommand::Move { kind, jump, .. } =
rx.try_recv().expect("a jump take-off is announced")
else {
panic!("expected a Move command");
};
assert_eq!(kind, MoveKind::Jump);
let tail = jump.expect("the JUMP carries the ballistic tail");
assert!(
(tail.zspeed + 7.955547).abs() < 1.0e-4,
"the wire zspeed is down-positive: a rising jump reports negative, got {}",
tail.zspeed
);
}
#[test]
fn park_mover_is_a_noop_once_already_stopped() {
// Idempotent: with no stale flags there's nothing to clear, so no packet goes out — safe to call
+162 -1
View File
@@ -90,6 +90,15 @@ pub(super) struct Outcome {
pub grounded: bool,
/// A jump took off this frame.
pub jumped: bool,
/// This frame's take-off was a **server-aimed knockback**, not a jump (decision 1702). Implies
/// [`Self::jumped`] — the arc bookkeeping cannot tell them apart and must not — but the wire can:
/// `MSG_MOVE_JUMP` has exactly one emission site in the reference image, the move-command drain's
/// jump arm (wow-re, decision 1464), so a knockback's launch announces itself with
/// `CMSG_MOVE_KNOCK_BACK_ACK` and nothing else. Sending a JUMP for it would also be an
/// instant `CHEAT_TYPE_OVERSPEED_JUMP` — the one jump check vmangos does *not* exempt during a
/// knockback (`MovementAnticheat.cpp:650`), and a knockback's horizontal speed is well over run
/// speed by design.
pub knocked: bool,
/// The standstill-jump air nudge fired (re-seeds the frozen airborne direction flags).
pub air_nudged: bool,
/// The collider entity of the walkable floor supporting us — the end-of-frame snap probe's
@@ -113,6 +122,7 @@ pub(super) fn step(
speed: f32,
want_jump: bool,
wire_jump: bool,
knockback: Option<Vec3>,
water_floor: Option<f32>,
) -> Outcome {
let dt = time.delta_secs();
@@ -235,11 +245,37 @@ pub(super) fn step(
let grounded = on_floor || player.wedged;
let mut jumped = false;
let mut knocked = false;
if held || anchored {
// Frozen: the settle's hold (no velocity until the ground loads under us) or the root's
// anchor. The mover cannot tell them apart — both mean no gravity and no carried momentum.
// **A knockback aimed at a frozen body is dropped here**, not deferred: the latch was already
// consumed by the caller, and `knocked` stays false so no ack goes out — we did not fly it.
// The root case is the reference's own law (`CMovement::Jump` refuses under `0x1000`, and
// `SetRoot`'s StopFalling means nothing can enter FALLING while rooted at all — 0880), and
// the server will not have sent one anyway (`Unit::KnockBack` returns early on
// `UNIT_STATE_STUNNED | UNIT_STATE_ROOT`).
player.vel_y = 0.0;
player.horiz_vel = Vec3::ZERO;
} else if let Some(launch) = knockback {
// **The knockback launch** (decision 1702) — the server aims a jump and we fly it. It is the
// one take-off that seeds BOTH axes at once: the vertical is not `JUMP_SPEED` and the
// horizontal is not our input, they are the spell's, absolute in world XY and unrelated to
// where we are facing or which keys are down. Everything downstream is a jump's: the arc
// opens (`jumped`), the horizontal is frozen momentum for its whole flight, gravity carries
// it, and it closes with an ordinary `MSG_MOVE_FALL_LAND` — which is also what clears the
// server's own knockback grace (`IsFallEndOpcode` -> `m_knockBack = false`).
//
// It takes no hover refusal and no already-falling refusal. Hover's is
// `CMovement::Jump(force = 1)`'s alone (`0x7c623a`, skipped by the wire's `force = 0` door —
// 1620), and being knocked back mid-air is exactly what a knockback is for; the server's own
// gate (`Unit::KnockBack`) tests stun, root and a live spline, never FALLING.
player.vel_y = launch.y;
player.horiz_vel = Vec3::new(launch.x, 0.0, launch.z);
player.wedged = false;
player.steep_support = false;
jumped = true;
knocked = true;
} else if grounded {
player.vel_y = 0.0;
// **HOVER refuses the jump** — the FIRST test in `CMovement::Jump 0x7c6230`
@@ -282,7 +318,10 @@ pub(super) fn step(
// frozen takeoff momentum. (Both arms below are already inert under a root — the caller zeroes
// `dir`, so `moving` is false and `input_horiz` is zero — but the anchor says it itself rather
// than inheriting it from a gate three functions away.)
if grounded && !anchored {
if knocked {
// The launch owns both axes — a knockback fired from standing ground is still `grounded`
// this frame, and the walk arm below would hand our momentum straight back to the keys.
} else if grounded && !anchored {
player.horiz_vel = input_horiz;
} else if !held && !anchored && moving && player.horiz_vel.length_squared() < 0.01 {
// Air control: one nudge to steer a jump that took off from a standstill (a moving jump
@@ -467,6 +506,7 @@ pub(super) fn step(
held,
grounded,
jumped,
knocked,
air_nudged,
ground: if grounded && !held {
ground_entity
@@ -2079,6 +2119,7 @@ mod tests {
7.0,
false,
false,
None,
Some(SURFACE),
);
}
@@ -2150,6 +2191,7 @@ mod tests {
7.0,
false,
false,
None,
Some(SURFACE),
);
}
@@ -2232,6 +2274,7 @@ mod tests {
SPEED,
false,
false,
None,
Some(SURFACE),
);
}
@@ -2309,6 +2352,7 @@ mod tests {
true,
false,
None,
None,
);
(out.jumped, player.vel_y)
})
@@ -2358,6 +2402,7 @@ mod tests {
want_jump,
wire_jump,
None,
None,
);
(out.jumped, player.vel_y)
};
@@ -2376,6 +2421,120 @@ mod tests {
);
}
/// **The knockback launch seeds BOTH axes, overrides the keys, and is refused only by the
/// anchor** (decision 1702).
///
/// The three things that make it a different take-off from every other one, in one fixture. Its
/// vertical is not `JUMP_SPEED`; its horizontal is the server's, not `input_horiz` — the arm
/// that would otherwise hand our momentum straight back to whatever key is held, because a
/// knockback fired from standing ground is still `grounded` on its launch frame; and the refusal
/// is `MOVEFLAG_ROOT`, which is the *only* thing the reference's drain tests before applying one
/// (`0x615c71 test ah,0x10` → the kind-28 table byte is 0 → the popped record is discarded, so
/// no apply and no ack). The control is the same launch, unrooted, on a body running the other
/// way.
#[test]
fn a_knockback_launch_overrides_the_keys_and_only_a_root_refuses_it() {
let flat: [(f32, f32); 2] = [(-3.0, 0.0), (3.0, 0.0)];
// Due east at 25 yd/s with 12 yd/s of lift, while the player holds "run west" at 7 yd/s.
const PUSH: Vec3 = Vec3::new(25.0, 12.0, 0.0);
let (free, rooted) = world_from_profile(&flat)
.world_mut()
.run_system_once(|world: benilla_world::collision::WorldCollision| {
let capsule = player_capsule();
let mut time = Time::default();
time.advance_by(std::time::Duration::from_secs_f32(1.0 / 60.0));
let knock = |rooted: bool| {
let mut player = Player {
modes: super::super::state::MoveModes {
rooted,
..Default::default()
},
..Default::default()
};
let out = step(
&mut player,
&time,
&world,
&capsule,
true, // moving …
Vec3::NEG_X, // … the other way, at a run
7.0,
false,
false,
Some(PUSH),
None,
);
(out.jumped, out.knocked, player.vel_y, player.horiz_vel)
};
(knock(false), knock(true))
})
.unwrap();
let (jumped, knocked, vel_y, horiz) = free;
assert!(
jumped && knocked,
"the launch opens an arc, and says which kind it was: {free:?}"
);
assert_eq!(
vel_y, PUSH.y,
"the vertical is the server's take-off speed, not JUMP_SPEED ({JUMP_SPEED})"
);
assert_eq!(
horiz,
Vec3::new(PUSH.x, 0.0, PUSH.z),
"the horizontal is the server's too — the held key must not claw it back"
);
assert_eq!(
rooted,
(false, false, 0.0, Vec3::ZERO),
"a rooted body is anchored: no launch, and `knocked` false so no ack goes out either"
);
}
/// **The wire's down-positive `zspeed` becomes upward lift, and comes back out unchanged.**
///
/// The one place a sign error would be silent in both directions at once: the launch quad's
/// `zspeed` is negative for a *rising* knockback (`0x7c61f0` stores it verbatim into the same
/// `CMovement+0xa0` a jump seeds with `0xc0fe93d8` = −7.955547), our mover works in +up, and the
/// ack has to echo the original value. Flip the convention anywhere along that path and the
/// body is driven into the ground while the server is told we rose.
#[test]
fn a_rising_knockback_carries_negative_wire_zspeed_and_positive_lift() {
let launch = benilla_protocol::JumpInfo {
zspeed: -12.0, // down-positive: negative IS upward
cos_angle: 1.0,
sin_angle: 0.0,
xy_speed: 25.0,
};
// The controller's resolve, verbatim (`player::control`).
let bevy_launch = benilla_assets::coords::wow_to_bevy([
launch.cos_angle * launch.xy_speed,
launch.sin_angle * launch.xy_speed,
-launch.zspeed,
]);
assert_eq!(bevy_launch.y, 12.0, "a negative wire zspeed lifts the body");
// …and the round trip back out, as `stream_self_movement` builds the tail: `jump_zspeed` is
// the mover's +up take-off speed, and the wire negates it again.
let jump_zspeed = bevy_launch.y;
assert_eq!(
-jump_zspeed, launch.zspeed,
"the echo must be bit-identical — the server matches it within 0.01 before relaying"
);
// The horizontal survives the basis round trip exactly, which is what lets the ack's tail
// and the mover's live state agree without either being re-derived from the other.
let back = benilla_assets::coords::bevy_to_wow(bevy_launch.with_y(0.0));
assert_eq!(
[back[0], back[1]],
[
launch.cos_angle * launch.xy_speed,
launch.sin_angle * launch.xy_speed
],
"world XY is absolute and survives WoW → Bevy → WoW"
);
}
/// **The waterline is a floor, and a floor does not catch a body on its way up** (decision
/// 1620, B322). The depenetration half of the water resolve is ours, not the reference's — over
/// there the surface is a swept solid and the sweep is directional for free — so it needs the
@@ -2413,6 +2572,7 @@ mod tests {
0.0,
false,
false,
None,
Some(SURFACE),
);
(player.pos.y, player.vel_y)
@@ -2489,6 +2649,7 @@ mod tests {
7.0,
false,
false,
None,
Some(SURFACE),
);
low = low.min(player.pos.y);
+43 -1
View File
@@ -4,7 +4,7 @@
//! the concern modules beside it (`mover`, `swim`, `arc`, `movement_net`, …) all borrow from here.
use avian3d::prelude::*;
use benilla_protocol::MoveMode;
use benilla_protocol::{JumpInfo, MoveMode};
use bevy::prelude::*;
/// Backpedal speed as a fraction of run: vanilla `MOVE_RUN_BACK` 4.5 / `MOVE_RUN` 7.0. Moving backward
@@ -706,6 +706,11 @@ pub(crate) struct Player {
/// on the next step. Consumed at the two take-off sites in [`super::control`], where the
/// keyboard jump is consumed, so the seed is picked by the same code that picks it for Space.
pub(super) hover_launch: bool,
/// **The knockback waiting to be flown** — one [`PendingKnockback`], armed by
/// [`super::wire_in`] the frame `SMSG_MOVE_KNOCK_BACK` lands and consumed by the take-off site in
/// [`super::control`] (the same place the keyboard jump and the hover launch are consumed, so
/// all three enter the mover through one door). `None` the rest of the time.
pub(super) knockback: Option<PendingKnockback>,
/// The take-off vertical speed (yd/s, WoW +Z up) snapshotted when the airborne phase began — the
/// client's `StartFalling` argument (`+0xa0`, constant per arc) and the `zspeed` we send in the
/// jump tail: `JUMP_SPEED` for a jump, **exactly 0** for a step-off (the walk election calls
@@ -832,6 +837,27 @@ pub(super) struct PlayerRide {
pub(super) boat_yaw: f32,
}
/// **A knockback the server has aimed at our mover, latched until the mover flies it**
/// (`SMSG_MOVE_KNOCK_BACK`, decision 1702). A latch and not a direct write for [`Player::hover_launch`]'s
/// reason: the reference commits the launch inside its handler and `MOVEFLAG_FALLING` then keeps the
/// walk resolver off the body, while our mover re-derives ground contact from probes every frame and
/// would zero the velocity again on the next step. So the take-off is taken where every other
/// take-off is taken — inside [`super::mover::step`], by the same code that seeds a jump.
///
/// It carries the handshake with it because the two are inseparable: the ack is owed *only* if the
/// launch actually happened, and it must echo `launch` back verbatim as the `MovementInfo` jump tail
/// (the server matches all four floats within `0.01` before it will relay the knockback to anyone).
#[derive(Debug, Clone, Copy, PartialEq)]
pub(super) struct PendingKnockback {
/// Our own mover guid, echoed in the ack (a **full** u64 there, packed on the way in).
pub(super) guid: u64,
/// The server's movement counter for this change — echoed, or the ack is a logged cheat.
pub(super) counter: u32,
/// The launch quad: world-XY direction + horizontal speed + the take-off vertical speed in the
/// jump tail's **down-positive** convention (negative is upward — decision 0054).
pub(super) launch: JumpInfo,
}
impl Player {
/// **Take the jump a `SetHover(true)` owes**, consuming the latch — the reference's
/// `CMovement::Jump(force = 0)` at `0x61a630`, minus the seed select and the commit, which are
@@ -852,6 +878,22 @@ impl Player {
fire
}
/// **Take the knockback the server aimed at us**, consuming the latch (decision 1702).
///
/// Unconditional here, unlike [`Self::take_wire_jump`]: the refusals that matter are the mover's
/// own — the settle hold and the root anchor, both of which freeze *every* axis and are already
/// the reason a rooted body cannot be launched by anything. Taking it here and letting the mover
/// decline keeps one refusal in one place, and keeps the ack honest: the caller sends
/// `CMSG_MOVE_KNOCK_BACK_ACK` only if the launch actually happened, because that ack's whole
/// content is a claim about the arc we are now flying.
///
/// Take-and-clear, not a level read, for the same reason the hover launch is: the reference's
/// handler fires once per opcode. A knockback that arrives on a frozen body is *dropped*, never
/// deferred until the freeze lifts.
pub(super) fn take_knockback(&mut self) -> Option<PendingKnockback> {
self.knockback.take()
}
/// Turn the avatar's **aim** by `radians` — the scripted mouse-turn's one lever
/// (`capture::probe_look`, decision 0621). Writing `face_yaw` is deliberately the whole of it:
/// from here on this is the identical path a real mouse-turn takes, and it is the same value
+1
View File
@@ -284,6 +284,7 @@ pub(super) fn breach_step(
held: false,
grounded: false,
jumped: true,
knocked: false,
air_nudged: false,
ground: None,
}
+28 -2
View File
@@ -21,8 +21,8 @@ use benilla_assets::coords::{bevy_to_wow, wow_to_bevy};
use crate::creature_anim::{move_flags, wrap_pi};
use crate::net::{
ClientCommand, ClientControlMessage, Embodied, Guid, MoveModeMessage, NetCommands,
SelfMoveMessage, SpeedChangeMessage, TeleportMessage, WorldportMessage,
ClientCommand, ClientControlMessage, Embodied, Guid, KnockBackMessage, MoveModeMessage,
NetCommands, SelfMoveMessage, SpeedChangeMessage, TeleportMessage, WorldportMessage,
};
use crate::transport::Transport;
use benilla_world::world_map::CurrentMap;
@@ -117,6 +117,7 @@ pub(super) fn apply_server_moves(
worldports: &mut MessageReader<WorldportMessage>,
speed_msgs: &mut MessageReader<SpeedChangeMessage>,
mode_msgs: &mut MessageReader<MoveModeMessage>,
knock_msgs: &mut MessageReader<KnockBackMessage>,
self_moves: &mut MessageReader<SelfMoveMessage>,
control_msgs: &mut MessageReader<ClientControlMessage>,
self_guid: Option<u64>,
@@ -392,6 +393,31 @@ pub(super) fn apply_server_moves(
);
}
// **The knockback** (decision 1702) — the one server-authored movement edge here that is neither
// a snap nor a granted mode: a ballistic launch we fly ourselves, with the ack owed only if we
// actually fly it. Nothing is applied here and nothing is acked here, and both halves are the
// reference's own shape: `0x617a30` merely **enqueues** the knockback as a due-timed record, and
// the frame update `0x616620` drains it apply-then-send inside one call
// (`0x61624d call 0x6179c0` → `0x616261 push 0xf0`), so the `MovementInfo` that goes on the wire
// is always the post-launch one. Arming a latch the take-off site consumes is that ordering,
// written the way our mover works — and it is the same latch shape the hover launch uses, for
// the same reason (decision 1620): our mover re-derives ground contact from probes every frame
// and would zero a velocity written from out here on its very next step.
//
// **Two in one frame collapse to the last, and that is ours, not the reference's.** Its queue
// holds both, sorted by due time, and drains them in order; our latch holds one. The *motion* is
// the same either way — `0x6179c0` replaces the velocity outright, so flying both in sequence
// inside one frame ends exactly where flying only the second does — but the first one's ack is
// lost, which the server counts as `OnFailedToAckChange`. It takes two knockbacks inside 17 ms
// to reach, and sub-frame launch timing is already the open item in 1702.
for k in knock_msgs.read() {
player.knockback = Some(super::state::PendingKnockback {
guid: k.guid,
counter: k.counter,
launch: k.launch,
});
}
// Take control once the server first reports our position (the streamed mover entity, whose
// transform is already in Bevy space). From here the controller drives that entity directly;
// the entity renderer attaches its body model (0041) the same way it does for any other player.
@@ -62,6 +62,10 @@ pub(crate) struct DressUpLook {
/// Worn **ItemDisplayInfo** ids by equipment slot, the `SMSG_CHAR_ENUM` shape the shared
/// assembly reads (helm 0 · shoulder 2 · … · main hand 15 · off hand 16 · ranged 17 · tabard 18).
pub(crate) equipment: [CharEnumItem; 19],
/// The player's own guild tabard (decision 1704) — so a tried-on Guild Tabard previews *their*
/// crest, not the blank default. Part of the look's `PartialEq`, so an identity that lands
/// while the window is open re-assembles the booth exactly as a try-on does.
pub(crate) emblem: Option<benilla_formats::GuildEmblem>,
}
/// The dressing room's live input: who is standing in it (`None` = the window is closed / has
+14 -1
View File
@@ -221,6 +221,9 @@ fn feed_dressup(
mut items: ResMut<Items>,
commands: Res<NetCommands>,
self_q: Query<(&ObjectStore, &NetEntity), With<SelfPlayer>>,
// The guild identity cache (1257) — `ResMut` because it is lazy: a miss is what sends the
// `CMSG_GUILD_QUERY` whose answer paints the tabard the room may be previewing (1704).
mut guilds: ResMut<crate::ui_guild::GuildState>,
) {
let Some(mut script) = script else {
return;
@@ -233,8 +236,16 @@ fn feed_dressup(
room.resolve_pending(&mut items, &commands);
// The guild join is the caller's, not [`player_look`]'s: that function resolves an *outfit* out
// of the descriptor and the room's substitutions, and the crest is neither — it comes off a
// separate lazy cache this system holds the handle to.
let look = match (room.open, self_q.single().ok()) {
(true, Some((store, net))) => player_look(store, net, &room, &mut items, &commands),
(true, Some((store, net))) => {
player_look(store, net, &room, &mut items, &commands).map(|l| DressUpLook {
emblem: crate::ui_guild::unit_guild_emblem(&store.0, &mut guilds, &commands),
..l
})
}
_ => None,
};
if preview.look != look {
@@ -299,6 +310,8 @@ fn player_look(
hair_color: s.player_hair_color().unwrap_or(0),
facial_hair: s.player_facial_hair().unwrap_or(0),
equipment,
// The outfit's, not the crest's: [`feed_dressup`] stamps that in (see its note there).
emblem: None,
})
}
+45
View File
@@ -37,6 +37,7 @@
use std::collections::{HashMap, HashSet};
use benilla_formats::GuildEmblem;
use benilla_protocol::messages::{
guild_event, GuildCommandResult, GuildEventNotice, GuildQueryResponse, GuildRoster,
GuildRosterMember, GUILD_RANKS_MAX_COUNT,
@@ -68,6 +69,11 @@ struct Identity {
name: String,
/// The ten rank names, index 0 = guild master; empty past the guild's real rank count.
rank_names: [String; GUILD_RANKS_MAX_COUNT],
/// The guild's tabard — the five emblem indices, which are what the **body composite** paints
/// onto a member's guild tabard (decision 1704). This is the second consumer of the identity
/// cache and the reason it is not a UI-only structure: the reference reads the same cached
/// record from the character compositor (`0x6d6d20` → `0x47a610`) and from `GetGuildInfo`.
emblem: GuildEmblem,
}
impl Identity {
@@ -281,6 +287,13 @@ impl GuildState {
Identity {
name: response.name,
rank_names: response.rank_names,
emblem: GuildEmblem {
emblem_style: response.emblem_style,
emblem_color: response.emblem_color,
border_style: response.border_style,
border_color: response.border_color,
background_color: response.background_color,
},
},
);
self.identity_generation = self.identity_generation.wrapping_add(1);
@@ -510,6 +523,37 @@ pub(crate) fn unit_guild(
})
}
/// A unit's guild **tabard**, for the body composite — the emblem five of `SMSG_GUILD_QUERY_RESPONSE`,
/// joined off the unit's own PUBLIC `PLAYER_GUILDID` and asking for the identity if we do not hold
/// it, exactly like [`unit_guild`].
///
/// `None` — no crest painted, so a Guild Tabard keeps its own `Tabard_A_05Default` art — covers
/// **four** cases, and the reference reaches the same nil on all four (wow-re
/// `rf89-guild-tabard-emblem-install.md` §Q1/§Q6):
///
/// 1. a guildless wearer — `0x560e30` returns NULL at `0x560e3f` before it even queries;
/// 2. a creature, which has no player block at all;
/// 3. **the query has not answered yet** — the same NULL leg, which is also what *sends* the query.
/// Transient and self-healing: the response bumps [`GuildState::identity_generation`], and the
/// equipment resolver's gate re-runs on that counter, so the tabard re-composites the frame the
/// answer lands. The reference does not even poll for it — `0x5e0650` is a registered arrival
/// callback that re-runs the install;
/// 4. **a guild that has never designed a tabard**, i.e. the `-1` sentinel
/// ([`GuildEmblem::is_designed`]) — the case this must not paint through, because painting it
/// would take cell 4 from the garment and then resolve to no file, leaving a blank tabard.
///
/// All four end in `0x47a610` never being entered, which is exactly why "no crest" and "the
/// garment's own art" are the same outcome: the clear of the three cells lives *inside* the install.
pub(crate) fn unit_guild_emblem(
fields: &ObjectFields,
guild: &mut GuildState,
commands: &NetCommands,
) -> Option<GuildEmblem> {
let guild_id = fields.player_guild_id();
let emblem = guild.resolve_identity(guild_id, commands)?.emblem;
emblem.is_designed().then_some(emblem)
}
/// The net drain's `SessionEvent::Guild*` arms, factored here so the wire laws live beside the
/// state they drive ([`crate::ui_social::apply`]'s shape). The ones that owe chat lines push what
/// [`lines`] composed, the way `crate::net::apply`'s group shims do.
@@ -658,6 +702,7 @@ mod tests {
Identity {
name: name.to_string(),
rank_names,
emblem: GuildEmblem::default(),
}
}
@@ -24,8 +24,8 @@
use anyhow::{Context, Result};
use benilla_formats::{
equip_blits, equip_region_candidates, equip_tile, load_item_display_catalog, Chain,
CharSections, CharacterGeosets, EquipGeosets, ItemDisplay,
equip_blits, equip_tile, load_item_display_catalog, BlitSource, Chain, CharSections,
CharacterGeosets, EmblemLayer, EquipGeosets, GuildEmblem, ItemDisplay,
};
/// The ten atlas tiles by group, for the per-tile report — the five head/left-column ones included,
@@ -60,6 +60,10 @@ pub struct Look {
pub hair_color: u8,
/// The eight worn display ids in `equipment` order; `0` = the slot is empty.
pub slots: [u32; 8],
/// The wearer's guild tabard, or `None` for "no guild" — the same input the world composite
/// takes (decision 1704). It only paints over a tabard whose display asks for it, so passing
/// one without a guild-emblem tabard in the tabard slot is a no-op, on purpose.
pub emblem: Option<GuildEmblem>,
}
/// A BLP's header as the composite cares about it: the dimensions it blits at and the alpha depth
@@ -108,11 +112,26 @@ pub fn charatlas(chain: &mut Chain, look: &Look, out: Option<&std::path::Path>)
}
}
// (1) The plan — the composite's own order, with what each name resolved to.
// (1) The plan — the composite's own order, with what each name resolved to. Worn garments and
// the guild tabard's three layers come through the same list, because they land in the same
// rows and the question ("what repainted this cell?") is the same one.
println!("\nequipment blits (by ascending cell; later covers earlier within a tile):");
for step in equip_blits(&equipment) {
let (x, y, w, h) = equip_tile(step.layer).expect("layer < 8");
let resolved = equip_region_candidates(step.layer, step.texture, look.sex)
for step in equip_blits(&equipment, look.emblem) {
let (_x, y, w, h) = equip_tile(step.layer).expect("layer < 8");
let candidates = step.candidates(look.sex);
let basename = |p: &str| p.rsplit('\\').next().unwrap_or(p).to_string();
let (who, name) = match step.source {
BlitSource::Worn { slot, texture } => (SLOT_NAMES[slot], texture.to_string()),
BlitSource::Emblem { part, .. } => (
match part {
EmblemLayer::Background => "gBack",
EmblemLayer::Border => "gBrdr",
EmblemLayer::Symbol => "gEmbl",
},
candidates.first().map_or_else(String::new, |p| basename(p)),
),
};
let resolved = candidates
.into_iter()
.find_map(|p| blp_shape(chain, &p).map(|s| (p, s)));
match resolved {
@@ -133,13 +152,12 @@ pub fn charatlas(chain: &mut Chain, look: &Look, out: Option<&std::path::Path>)
y,
y + h,
step.column,
SLOT_NAMES[step.slot],
step.texture,
path.rsplit('\\').next().unwrap_or(&path),
who,
name,
basename(&path),
bw,
bh,
);
let _ = x;
}
None => println!(
" g{} y{:>3}..{:<3} cell {} {:6} {:34} → MISSING (region left as base skin)",
@@ -147,8 +165,8 @@ pub fn charatlas(chain: &mut Chain, look: &Look, out: Option<&std::path::Path>)
y,
y + h,
step.column,
SLOT_NAMES[step.slot],
step.texture,
who,
name,
),
}
}
@@ -165,6 +183,7 @@ pub fn charatlas(chain: &mut Chain, look: &Look, out: Option<&std::path::Path>)
look.hair_style,
look.hair_color,
[None; 8],
None,
)?
.context("no base skin row for this appearance")?;
let dressed = sections
@@ -178,6 +197,7 @@ pub fn charatlas(chain: &mut Chain, look: &Look, out: Option<&std::path::Path>)
look.hair_style,
look.hair_color,
equipment,
look.emblem,
)?
.context("no base skin row for this appearance")?;
@@ -91,6 +91,12 @@ enum Command {
/// leaves the rest empty.
#[arg(long, value_delimiter = ',', default_value = "0")]
slots: Vec<u32>,
/// The wearer's guild tabard, comma-separated in
/// `emblemStyle,emblemColor,borderStyle,borderColor,backgroundColor` order — the five
/// indices `SMSG_GUILD_QUERY_RESPONSE` carries (decision 1704). Omit for "no guild". Only
/// paints when the tabard slot holds a guild-emblem display (20621 is the shipped one).
#[arg(long, value_delimiter = ',')]
emblem: Option<Vec<i32>>,
/// Write the composited atlas here as a PNG (256²).
#[arg(long)]
out: Option<PathBuf>,
@@ -697,6 +703,26 @@ fn yn(b: bool) -> &'static str {
}
}
/// `--emblem` → a [`benilla_formats::GuildEmblem`]: the five wire indices in
/// `SMSG_GUILD_QUERY_RESPONSE` order.
fn guild_emblem(v: Vec<i32>) -> Result<benilla_formats::GuildEmblem> {
let [emblem_style, emblem_color, border_style, border_color, background_color]: [i32; 5] =
v.try_into().map_err(|v: Vec<i32>| {
anyhow::anyhow!(
"--emblem takes 5 comma-separated indices \
(emblemStyle,emblemColor,borderStyle,borderColor,backgroundColor), got {}",
v.len()
)
})?;
Ok(benilla_formats::GuildEmblem {
emblem_style,
emblem_color,
border_style,
border_color,
background_color,
})
}
fn main() -> Result<()> {
let cli = Cli::parse();
let mut chain = open_chain(&cli.path)?;
@@ -719,6 +745,7 @@ fn main() -> Result<()> {
hair_style,
hair_color,
slots,
emblem,
out,
} => {
let mut worn = [0u32; 8];
@@ -736,6 +763,7 @@ fn main() -> Result<()> {
hair_style,
hair_color,
slots: worn,
emblem: emblem.map(guild_emblem).transpose()?,
},
out.as_deref(),
)?;
+2 -2
View File
@@ -26,8 +26,8 @@ mod sections;
pub use customization::{CharCreateCatalog, DialRanges, StartOutfitItem};
pub use geosets::{CharacterGeosets, EquipGeosets};
pub use sections::{
equip_blits, equip_column, equip_region_candidates, equip_tex_dir, equip_tile, CharSections,
EquipBlit,
equip_blits, equip_column, equip_region_candidates, equip_tex_dir, equip_tile, BlitSource,
CharSections, EmblemLayer, EquipBlit, GuildEmblem,
};
// The appearance tables' schemas, for the `benilla-extract dbc` CSV dump (`crate::schema_for`) —
+693 -41
View File
@@ -117,6 +117,26 @@ const SLOT_CHEST: usize = 1;
const SLOT_PANTS: usize = 3;
const SLOT_BOOTS: usize = 4;
const SLOT_GLOVES: usize = 6;
/// The tabard's worn-slot index (bodyslot 9 − 2) — the slot whose display decides whether the
/// guild-emblem layers install at all ([`ItemDisplay::takes_guild_emblem`]).
const SLOT_TABARD: usize = 7;
/// Where the guild tabard's art lives.
const GUILD_EMBLEM_DIR: &str = "Textures\\GuildEmblems";
/// The two compositor layers the guild tabard paints, and the filename half each names: TorsoUpper
/// takes the `_TU_` files, TorsoLower the `_TL_` ones (wow-re RF-0086: `0x47a610` stores the `_TU_`
/// trio into group 3's cells and the `_TL_` twins into group 4's). No other layer is touched — the
/// emblem is a torso garment, and the arms/legs keep whatever the rest of the outfit painted.
const EMBLEM_LAYERS: [(usize, &str); 2] = [(3, "TU"), (4, "TL")];
/// The filename half layer `layer` reads its guild-emblem art from, or `None` for a layer the
/// tabard never reaches.
fn emblem_half(layer: usize) -> Option<&'static str> {
EMBLEM_LAYERS
.iter()
.find_map(|&(l, half)| (l == layer).then_some(half))
}
/// CharSections texture lookup: (race, sex, sectionType, variation, colorIndex) → that row's up-to-3
/// `TextureName` columns (empty strings for absent columns). Feeds both the base body skin
@@ -221,6 +241,9 @@ impl CharSections {
/// is empty). Their region textures blit into the eight equipment tiles after the skin sections,
/// stacked per layer by the client's priority table — and they also **gate** the underwear, which
/// is the tile's fallback rather than an under-layer ([`UNDERWEAR_TILES`]).
///
/// `emblem` is the wearer's guild tabard (decision 1704), which paints the torso layers' cells
/// 2/3/4 over the garment's own — see [`equip_blits`] for when it installs and when it does not.
#[allow(clippy::too_many_arguments)]
pub fn composite_body(
&self,
@@ -233,6 +256,7 @@ impl CharSections {
hair_style: u8,
hair_color: u8,
equipment: [Option<&ItemDisplay>; 8],
emblem: Option<GuildEmblem>,
) -> Result<Option<BlpMipChain>> {
let Some(base_path) = self.skin_texture(race, sex, skin) else {
return Ok(None);
@@ -264,7 +288,7 @@ impl CharSections {
}
// The equipment plan, built once: the blits below consume it, and the underwear reads it as its
// gate. One plan, so "what dresses this tile?" has a single answer (decision 0074's `equip_blits`).
let plan = equip_blits(&equipment);
let plan = equip_blits(&equipment, emblem);
// The underwear ([`UNDERWEAR_TILES`]) — the tile's fallback, not an under-layer: a contribution
// in any of the group's TESTED columns and the client draws no underwear there at all, leaving
// the base skin it already blitted. Drawn before the equipment because the untested columns (the
@@ -280,10 +304,17 @@ impl CharSections {
blit_over(&mut atlas, &overlay, EQUIP_TILES[layer]);
}
}
// The equipment layers (decision 0074), in the one order [`equip_blits`] decides, each read
// `_U`-first with the wearer's gender letter as the fallback (RF-0088 §7).
// The equipment layers (decision 0074) and the guild tabard's three (decision 1704), in the
// one order [`equip_blits`] decides, each taking the first of its own
// [`EquipBlit::candidates`] that decodes. A name that resolves to nothing is skipped —
// best-effort, like the skin overlays; on the model it reads as a garment that stops early,
// which is what the instrument's `MISSING` line is for.
for step in &plan {
if let Some(overlay) = read_equip_region(chain, step.layer, step.texture, sex) {
if let Some(overlay) = step
.candidates(sex)
.iter()
.find_map(|path| read_texture_mip_chain(chain, path).ok())
{
blit_over(&mut atlas, &overlay, EQUIP_TILES[step.layer]);
}
}
@@ -339,16 +370,178 @@ impl CharSections {
/// question both reported outfit bugs turned on, and it was unanswerable without this.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct EquipBlit<'a> {
/// The worn bodyslot, indexed as [`CharSections::composite_body`]'s `equipment` is (bodyslot − 2:
/// 0 shirt · 1 chest · 2 belt · 3 pants · 4 boots · 5 wrist · 6 gloves · 7 tabard).
pub slot: usize,
/// The compositor layer — `ItemDisplayInfo` texture column *i* = layer *i* = atlas tile g*i*.
pub layer: usize,
/// The cell this contribution occupies in the layer's row — [`equip_column`]'s answer, which is
/// the `[0x803bf8]` default unless the layer's chooser overruled it. Ascending = blitted later.
/// The cell this contribution occupies in the layer's row — [`equip_column`]'s answer for a worn
/// garment, or the guild tabard's fixed 2/3/4. Ascending = blitted later.
pub column: i8,
/// The `ItemDisplayInfo` region-texture name (no directory, no gender suffix).
pub texture: &'a str,
/// Where the art comes from.
pub source: BlitSource<'a>,
}
/// What one [`EquipBlit`] paints with — a worn garment's own region texture, or one of the three
/// guild-tabard layers. Two different filename laws, which is why this is an enum and not a path:
/// a garment resolves `Item\TextureComponents\<dir>\<name>_<U|M|F>.blp` (`_U` first, the
/// wearer's gender letter as the fallback), the tabard `Textures\GuildEmblems\<…>_<TU|TL>_U.blp`
/// with no gender variant at all.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum BlitSource<'a> {
/// A worn garment's `ItemDisplayInfo` region texture: the bodyslot it came from, indexed as
/// [`CharSections::composite_body`]'s `equipment` is (bodyslot − 2: 0 shirt · 1 chest · 2 belt ·
/// 3 pants · 4 boots · 5 wrist · 6 gloves · 7 tabard), and the row's name for this layer (no
/// directory, no gender suffix).
Worn { slot: usize, texture: &'a str },
/// One of the wearer's guild-tabard layers — see [`EmblemLayer`].
Emblem {
part: EmblemLayer,
emblem: GuildEmblem,
},
}
impl EquipBlit<'_> {
/// The chain paths this contribution is looked up under, **in the order the composite tries
/// them**, first hit wins. Two for a worn garment (`_U`, then the wearer's gender letter — the
/// RF-0088 §7 order); one for a guild-tabard layer, which ships a single ungendered name.
///
/// The composite runs this and the instrument reports it, so "which file did that cell take?"
/// has one answer and cannot drift into two transcriptions.
pub fn candidates(&self, sex: u8) -> Vec<String> {
match self.source {
BlitSource::Worn { texture, .. } => {
equip_region_candidates(self.layer, texture, sex).into()
}
BlitSource::Emblem { part, emblem } => emblem_half(self.layer)
.map(|half| vec![part.path(&emblem, half)])
.unwrap_or_default(),
}
}
}
/// A guild's tabard — the five indices `SMSG_GUILD_QUERY_RESPONSE` carries, and the whole input to
/// the guild-emblem composite. They are file indices, not colours: each names a shipped BLP under
/// `Textures\GuildEmblems\`, and an index with no file simply paints nothing (the client's
/// `**** Unable to Load Texture %s` leaves the cell null), so nothing here is range-checked.
///
/// **Signed on purpose.** A guild that has never bought a tabard carries `-1` in all five (vmangos
/// `Guild/Guild.cpp:86`; `int32` on the wire), and `{:02}` renders that as `-1` — the same
/// `Emblem_-1_-1_TU_U` the client's own `%02d` builds, which resolves to no file and so paints
/// nothing. Clamping, or casting through unsigned, would invent a crest for a guild that has none.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub struct GuildEmblem {
/// The emblem symbol — `Emblem_<style>_<color>_…`; 170 shipped (000–169), a complete 170 × 17.
pub emblem_style: i32,
/// The emblem symbol's colour — 17 shipped (00–16), every style authoring all of them.
pub emblem_color: i32,
/// The border style — `Border_<style>_<color>_…`. **Only 00–05 are reachable**: the client's own
/// count table `[0x808220] = {170, 17, 6, 17, 51}` caps the designer at six, and the 52
/// `Border_06..09_*` files it ships are dead art (wow-re `rf89`). A server that put 7 on the
/// wire would simply resolve nothing, which is the same outcome the reference reaches.
pub border_style: i32,
/// The border's colour — 17 for the six reachable styles; the shipped table is ragged (118
/// files per half, not 170) only because the dead styles author 4 colours each.
pub border_color: i32,
/// The background colour — `Background_<color>_…`; 51 shipped (00–50), and the only layer with
/// a single index. **29 ships uppercase** (`BACKGROUND_29_TU_U.blp`, no lowercase sibling), so
/// the chain lookup this feeds must stay case-insensitive — pinned by a test.
pub background_color: i32,
}
impl GuildEmblem {
/// Whether this guild has actually **designed** a tabard — every one of the five indices is a
/// real one. `false` = the `-1` sentinel is present, and the crest must not install at all.
///
/// This is the client's own guard, not a convenience: `0x6d6d20` returns success **iff** the
/// guild record is cached AND none of its five fields is `-1` (five `cmp …,-1; je` at
/// `0x6d6d73`/`0x6d6d78`/`0x6d6d81`/`0x6d6d8a`/`0x6d6d93`, before any value reaches a path
/// builder, over out-params pre-initialised to `-1`). On failure the install `0x47a610` is never
/// entered — and since the clear of cells 4→2 lives *inside* that function
/// (`0x47a616 push 4; call 0x47a5c0`), the equipped tabard's own art is left standing.
///
/// **Getting this wrong is visible, not academic.** Painting the crest unconditionally builds
/// `Emblem_-1_-1_TU_U`, which names no file — but the plan has already taken cell 4 away from
/// the garment, so an undesigned guild's tabard renders as bare skin. It is `-1` on **any** of
/// the five, not all: the reference tests them one at a time and bails on the first.
/// (wow-re `rf89-guild-tabard-emblem-install.md` §Q6.)
pub fn is_designed(&self) -> bool {
[
self.emblem_style,
self.emblem_color,
self.border_style,
self.border_color,
self.background_color,
]
.iter()
.all(|&i| i != NO_TABARD)
}
}
/// The "this guild has no tabard" sentinel, in every one of [`GuildEmblem`]'s five fields — the
/// value vmangos constructs a guild with (`Guild/Guild.cpp:86`) and the one the reference's
/// `0x6d6d20` tests for. See [`GuildEmblem::is_designed`].
const NO_TABARD: i32 = -1;
/// One of the three layers a guild tabard paints, in blit order — the cells `0x47a610` refills
/// after clearing 4→2, so background sits under border sits under symbol.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum EmblemLayer {
/// Cell 2 — the tabard's field colour, and the **only emblem layer inside TorsoUpper's
/// underwear-suppression prefix**: cell 2 < the 3 tested columns, so a guild tabard hides the
/// bra where a plain one (cell 4) does not ([`UNDERWEAR_TILES`], decision 1614).
///
/// That gate is on the *cell being filled*, not on coverage, and the distinction is real here:
/// the background is a **cutout**, not a full-tile paste — `Background_12_TU_U` measures 79.5%
/// opaque, 16.8% fully transparent, the transparent part being outside the tabard's silhouette.
/// So the suppressed bra leaves bare skin at the sides, which is the unconditional base-skin
/// blit showing through, exactly as RF-0086 describes.
Background,
/// Cell 3 — the trim around the field, mostly transparent (`Border_01_07_TU_U`: 65% clear).
Border,
/// Cell 4 — the symbol itself, over everything, and over the tabard garment's own art (the
/// cell the equipped tabard would otherwise own).
Symbol,
}
impl EmblemLayer {
/// The three layers in blit order.
pub const ALL: [EmblemLayer; 3] = [
EmblemLayer::Background,
EmblemLayer::Border,
EmblemLayer::Symbol,
];
/// The cell this layer occupies in its row — fixed, not chosen: no bodyslot chooser can write
/// cells 2/3/4 of layers 3/4, so these three are the guild tabard's alone (wow-re RF-0088).
pub fn column(self) -> i8 {
match self {
EmblemLayer::Background => 2,
EmblemLayer::Border => 3,
EmblemLayer::Symbol => 4,
}
}
/// This layer's chain path for one emblem and one tabard half (`"TU"` / `"TL"`).
///
/// The reference's own format strings carry **no extension** (`0x838dd8` is
/// `Textures\GuildEmblems\Background_%02d_TU_U`, flat); its texture loader appends `".blp"`
/// at `0x449641` after truncating any trailing `.tga`/`.blp`. We build the finished path in one
/// step, which is the same string — and `%02d` is a **minimum** width, not a truncation, so
/// emblem style 169 is `Emblem_169_…` and not `Emblem_16_…`.
pub fn path(self, emblem: &GuildEmblem, half: &str) -> String {
match self {
EmblemLayer::Background => format!(
"{GUILD_EMBLEM_DIR}\\Background_{:02}_{half}_U.blp",
emblem.background_color
),
EmblemLayer::Border => format!(
"{GUILD_EMBLEM_DIR}\\Border_{:02}_{:02}_{half}_U.blp",
emblem.border_style, emblem.border_color
),
EmblemLayer::Symbol => format!(
"{GUILD_EMBLEM_DIR}\\Emblem_{:02}_{:02}_{half}_U.blp",
emblem.emblem_style, emblem.emblem_color
),
}
}
}
/// The cell a worn slot's contribution to `layer` occupies — the `[0x803bf8]` default, unless the
@@ -398,7 +591,20 @@ pub fn equip_column(equipment: &[Option<&ItemDisplay>; 8], slot: usize, layer: u
/// later writer replaces the earlier, which is what the client's `0x478900` store does. Reachable
/// only when robe-trousers meet a robe chest and booted feet; `equipment` order decides it here,
/// where the client's is equip order.
pub fn equip_blits<'a>(equipment: &[Option<&'a ItemDisplay>; 8]) -> Vec<EquipBlit<'a>> {
///
/// `emblem` is the wearer's guild tabard, or `None` when they have no guild / the guild's identity
/// has not arrived yet. It installs **only** over a tabard whose display asks for it
/// ([`ItemDisplay::takes_guild_emblem`]) — a faction tabard that clears the flag keeps its own art,
/// and a guild member wearing no tabard shows no emblem. When it does install it writes cells 2/3/4
/// of layers 3 and 4 **last**, so the symbol replaces the tabard garment's own cell-4 contribution:
/// that is the client's ordering, not a priority rule (`0x478a53` clears cells 4→2 of both layers
/// when the tabard slot is set, then `0x47a610` refills all three — wow-re RF-0086/RF-0088).
pub fn equip_blits<'a>(
equipment: &[Option<&'a ItemDisplay>; 8],
emblem: Option<GuildEmblem>,
) -> Vec<EquipBlit<'a>> {
let emblem = emblem
.filter(|_| equipment[SLOT_TABARD].is_some_and(|d: &ItemDisplay| d.takes_guild_emblem()));
let mut plan = Vec::new();
for (layer, _tile) in EQUIP_TILES.iter().enumerate() {
let mut row: [Option<EquipBlit<'a>>; 8] = [None; 8];
@@ -416,13 +622,24 @@ pub fn equip_blits<'a>(equipment: &[Option<&'a ItemDisplay>; 8]) -> Vec<EquipBli
let column = equip_column(equipment, slot, layer);
if let Some(cell) = row.get_mut(column as usize) {
*cell = Some(EquipBlit {
slot,
layer,
column,
texture,
source: BlitSource::Worn { slot, texture },
});
}
}
if let Some(emblem) = emblem.filter(|_| emblem_half(layer).is_some()) {
for part in EmblemLayer::ALL {
let column = part.column();
if let Some(cell) = row.get_mut(column as usize) {
*cell = Some(EquipBlit {
layer,
column,
source: BlitSource::Emblem { part, emblem },
});
}
}
}
plan.extend(row.into_iter().flatten());
}
plan
@@ -439,18 +656,6 @@ pub fn equip_tex_dir(layer: usize) -> Option<&'static str> {
EQUIP_TEX_DIRS.get(layer).copied()
}
/// Read one equipment region texture off the chain: `Item\TextureComponents\<dir>\<name>_<L>.blp`,
/// **`_U` first, the wearer's gender letter only if `_U` does not exist**. `None` when neither
/// decodes — the layer is skipped, best-effort like the skin overlays.
fn read_equip_region(chain: &mut Chain, layer: usize, name: &str, sex: u8) -> Option<BlpMipChain> {
for path in equip_region_candidates(layer, name, sex) {
if let Ok(mips) = read_texture_mip_chain(chain, &path) {
return Some(mips);
}
}
None
}
/// The chain paths a region texture is looked up under, in the order the composite tries them:
/// **`_U`, then the wearer's gender letter** (wow-re RF-0088 §7, byte-true).
///
@@ -553,6 +758,15 @@ pub(crate) fn char_sections_schema() -> Schema {
#[cfg(test)]
mod tests {
/// The `(cell, texture)` pair of a **worn** contribution — the shape every cell-order assertion
/// below reads. These fixtures pass no emblem, so a guild-tabard layer here is a bug in the
/// test, not a case to handle.
fn worn_cell<'a>(step: &EquipBlit<'a>) -> (i8, &'a str) {
match step.source {
BlitSource::Worn { texture, .. } => (step.column, texture),
BlitSource::Emblem { .. } => panic!("unexpected guild-emblem layer in this fixture"),
}
}
use super::*;
/// The base skin resolves the full-body BLP for (race, sex, skinColor) — the path the renderer
@@ -667,10 +881,10 @@ mod tests {
Some(&plain_gloves),
None,
];
let g1: Vec<_> = equip_blits(&eq)
.into_iter()
let g1: Vec<_> = equip_blits(&eq, None)
.iter()
.filter(|s| s.layer == 1)
.map(|s| (s.column, s.texture))
.map(worn_cell)
.collect();
assert_eq!(g1, [(1, "al"), (2, "bracer_al"), (3, "glove_al")]);
let eq = [
@@ -683,10 +897,10 @@ mod tests {
Some(&geoset_gloves),
None,
];
let g1: Vec<_> = equip_blits(&eq)
.into_iter()
let g1: Vec<_> = equip_blits(&eq, None)
.iter()
.filter(|s| s.layer == 1)
.map(|s| (s.column, s.texture))
.map(worn_cell)
.collect();
assert_eq!(
g1,
@@ -695,6 +909,328 @@ mod tests {
);
}
/// The guild tabard's three layers (decision 1704). Two facts, and they are the whole law:
/// the emblem installs **only** over a tabard whose display asks for it
/// ([`ItemDisplay::takes_guild_emblem`]), and when it does it takes cells 2/3/4 of layers 3 and
/// 4 — **including the cell the tabard garment itself had**, because the client clears 4→2 and
/// refills all three (wow-re RF-0086/RF-0088).
#[test]
fn the_guild_emblem_replaces_the_tabard_it_is_worn_on() {
let torso = [
None,
None,
None,
Some("tabard_tu"),
Some("tabard_tl"),
None,
None,
None,
];
let guild_tabard = ItemDisplay {
flags: 1,
..worn(torso, [1, 0, 0])
};
let plain_tabard = worn(torso, [1, 0, 0]);
let shirt = worn(
[
Some("shirt_au"),
Some("shirt_al"),
None,
Some("shirt_tu"),
Some("shirt_tl"),
None,
None,
None,
],
[0, 0, 0],
);
let emblem = GuildEmblem {
emblem_style: 42,
emblem_color: 3,
border_style: 1,
border_color: 7,
background_color: 12,
};
// The `(layer, cell)` pairs a plan fills, and what filled each.
fn cells(plan: &[EquipBlit<'_>]) -> Vec<(usize, i8, String)> {
plan.iter()
.map(|s| {
let what = match s.source {
BlitSource::Worn { texture, .. } => texture.to_string(),
BlitSource::Emblem { part, .. } => format!("{part:?}"),
};
(s.layer, s.column, what)
})
.collect()
}
let want = |v: &[(usize, i8, &str)]| -> Vec<(usize, i8, String)> {
v.iter().map(|(l, c, t)| (*l, *c, t.to_string())).collect()
};
// Worn alone with a guild: background/border/symbol, ascending, on BOTH torso layers — and
// the garment's own `tabard_tu`/`tabard_tl` are gone, not stacked under.
let eq = [
None,
None,
None,
None,
None,
None,
None,
Some(&guild_tabard),
];
assert_eq!(
cells(&equip_blits(&eq, Some(emblem))),
want(&[
(3, 2, "Background"),
(3, 3, "Border"),
(3, 4, "Symbol"),
(4, 2, "Background"),
(4, 3, "Border"),
(4, 4, "Symbol"),
]),
"the emblem owns cells 2/3/4 of the two torso layers, the tabard's own art none"
);
// No guild (or the query hasn't answered yet) — the garment keeps its own art. This is the
// look a guildless player wearing a Guild Tabard gets, and the look everyone gets for the
// frames between spawning and `SMSG_GUILD_QUERY_RESPONSE`.
assert_eq!(
cells(&equip_blits(&eq, None)),
want(&[(3, 4, "tabard_tu"), (4, 4, "tabard_tl")])
);
// A tabard that does not ask for the emblem keeps its art even for a guilded wearer: the
// gate is the DISPLAY's flag, not "does this player have a guild".
let plain = [
None,
None,
None,
None,
None,
None,
None,
Some(&plain_tabard),
];
assert_eq!(
cells(&equip_blits(&plain, Some(emblem))),
want(&[(3, 4, "tabard_tu"), (4, 4, "tabard_tl")])
);
// And no tabard at all is no emblem, however guilded the wearer.
assert!(equip_blits(&[None; 8], Some(emblem)).is_empty());
// Under a shirt: the shirt keeps cell 0, the emblem stacks above it — and nothing reaches
// the arm layers, which the shirt still owns alone.
let eq = [
Some(&shirt),
None,
None,
None,
None,
None,
None,
Some(&guild_tabard),
];
assert_eq!(
cells(&equip_blits(&eq, Some(emblem))),
want(&[
(0, 0, "shirt_au"),
(1, 0, "shirt_al"),
(3, 0, "shirt_tu"),
(3, 2, "Background"),
(3, 3, "Border"),
(3, 4, "Symbol"),
(4, 0, "shirt_tl"),
(4, 2, "Background"),
(4, 3, "Border"),
(4, 4, "Symbol"),
])
);
}
/// The six filenames an emblem builds, byte-for-byte — the one place a `%02d` transcription
/// error would show up as "the crest is just missing" and nothing else. Three cases:
///
/// - a real crest, with the **two-digit** padding the format string's `%02d` gives;
/// - a three-digit style, which `%02d` does **not** truncate (170 emblem styles ship, so
/// `Emblem_169_…` is a real file and a `{:02.2}`-shaped mistake would break the top third of
/// the table);
/// - the **undesigned** guild, whose five `-1`s render as `-1` and resolve to nothing — which
/// is why [`GuildEmblem`] is signed. Casting through unsigned would build
/// `Emblem_4294967295_…`: also a miss, but for the wrong reason and off a value that could
/// collide with a real index if anything ever clamped it.
#[test]
fn emblem_layer_paths_are_the_clients_own_format() {
let e = GuildEmblem {
emblem_style: 7,
emblem_color: 3,
border_style: 1,
border_color: 12,
background_color: 5,
};
assert_eq!(
EmblemLayer::ALL.map(|p| p.path(&e, "TU")),
[
"Textures\\GuildEmblems\\Background_05_TU_U.blp",
"Textures\\GuildEmblems\\Border_01_12_TU_U.blp",
"Textures\\GuildEmblems\\Emblem_07_03_TU_U.blp",
]
);
assert_eq!(
EmblemLayer::Symbol.path(&e, "TL"),
"Textures\\GuildEmblems\\Emblem_07_03_TL_U.blp",
"the TorsoLower half is the same name with the other tag"
);
// `%02d` is a MINIMUM width, not a truncation — the 170th emblem style is three digits.
assert_eq!(
EmblemLayer::Symbol.path(
&GuildEmblem {
emblem_style: 169,
emblem_color: 16,
..e
},
"TU"
),
"Textures\\GuildEmblems\\Emblem_169_16_TU_U.blp"
);
// A guild that never bought a tabard: `-1` in all five, and no file for any of them.
let none = GuildEmblem {
emblem_style: -1,
emblem_color: -1,
border_style: -1,
border_color: -1,
background_color: -1,
};
assert_eq!(
EmblemLayer::ALL.map(|p| p.path(&none, "TU")),
[
"Textures\\GuildEmblems\\Background_-1_TU_U.blp",
"Textures\\GuildEmblems\\Border_-1_-1_TU_U.blp",
"Textures\\GuildEmblems\\Emblem_-1_-1_TU_U.blp",
],
"the sentinel renders as the client's own `%02d` does, and names nothing"
);
}
/// The `-1` guard (wow-re `rf89` §Q6). A guild that has never designed a tabard carries the
/// sentinel, and the crest must not install **at all** — not "install and resolve to nothing",
/// which is the failure this pins: the plan takes cell 4 away from the garment before the file
/// lookup happens, so painting through the sentinel leaves a blank tabard rather than the
/// default one. The reference bails on the **first** `-1` of the five, so a half-designed
/// record (impossible from its own designer UI, but the wire is the server's) is also nil.
#[test]
fn an_undesigned_guild_has_no_crest_to_paint() {
let designed = GuildEmblem {
emblem_style: 42,
emblem_color: 3,
border_style: 1,
border_color: 7,
background_color: 12,
};
assert!(designed.is_designed());
// Index 0 is a real index in every field — `Emblem_00_00` etc. all ship.
assert!(GuildEmblem::default().is_designed());
// The fresh-guild record: every field the sentinel.
assert!(!GuildEmblem {
emblem_style: -1,
emblem_color: -1,
border_style: -1,
border_color: -1,
background_color: -1,
}
.is_designed());
// …and any ONE field is enough, which is the half the "all five" reading gets wrong.
for spoil in [
GuildEmblem {
emblem_style: -1,
..designed
},
GuildEmblem {
emblem_color: -1,
..designed
},
GuildEmblem {
border_style: -1,
..designed
},
GuildEmblem {
border_color: -1,
..designed
},
GuildEmblem {
background_color: -1,
..designed
},
] {
assert!(!spoil.is_designed(), "{spoil:?} still reads as designed");
}
}
/// The underwear consequence, which is the one thing about the emblem that is *not* confined to
/// its own cells: TorsoUpper's bra is suppressed by a contribution in cells 0/1/**2**, and cell 2
/// is the guild tabard's **background** ([`UNDERWEAR_TILES`]). So a guild tabard hides the bra
/// and a plain one — which only ever reaches cell 4 — does not, exactly as wow-re RF-0086 says.
#[test]
fn only_a_guild_tabards_background_reaches_the_underwear_prefix() {
let torso = [
None,
None,
None,
Some("tabard_tu"),
Some("tabard_tl"),
None,
None,
None,
];
let guild_tabard = ItemDisplay {
flags: 1,
..worn(torso, [1, 0, 0])
};
let plain_tabard = worn(torso, [1, 0, 0]);
let emblem = GuildEmblem::default();
let suppresses = |eq: &[Option<&ItemDisplay>; 8], em: Option<GuildEmblem>| {
let (col, layer, tested) = UNDERWEAR_TILES[1];
assert_eq!(
(col, layer),
(1, 3),
"the bra is TextureName[1] into TorsoUpper"
);
equip_blits(eq, em)
.iter()
.any(|s| s.layer == layer && s.column < tested)
};
let guild = [
None,
None,
None,
None,
None,
None,
None,
Some(&guild_tabard),
];
let plain = [
None,
None,
None,
None,
None,
None,
None,
Some(&plain_tabard),
];
assert!(
suppresses(&guild, Some(emblem)),
"a guild tabard's background sits in cell 2, inside the tested prefix"
);
assert!(
!suppresses(&guild, None),
"with no guild the same tabard only reaches cell 4, past the prefix"
);
assert!(
!suppresses(&plain, Some(emblem)),
"a plain tabard never hides the bra"
);
}
/// **B327, the carve.** On LegLower the chooser `0x4793f0` lifts a *robe* (chest `geosetGroup[2]`)
/// to cell 4 — above a boot's 3-or-2 — so footwear paints under a robe's skirt and can never
/// repaint its hem. The control that must not move: a **plain** chest stays at 1 and boots still
@@ -733,10 +1269,13 @@ mod tests {
);
fn g6(eq: &[Option<&ItemDisplay>; 8]) -> Vec<(i8, String)> {
equip_blits(eq)
.into_iter()
equip_blits(eq, None)
.iter()
.filter(|s| s.layer == 6)
.map(|s| (s.column, s.texture.to_string()))
.map(|s| {
let (c, t) = worn_cell(s);
(c, t.to_string())
})
.collect()
}
/// The expected cells, spelled the way the assertions read.
@@ -831,7 +1370,7 @@ mod tests {
);
let eq = [None, None, None, None, Some(&odd), None, None, None];
assert!(
equip_blits(&eq).is_empty(),
equip_blits(&eq, None).is_empty(),
"a -1 cell drops the contribution entirely"
);
@@ -841,7 +1380,7 @@ mod tests {
);
let eq = [None, Some(&blank), None, None, None, None, None, None];
assert!(
equip_blits(&eq).is_empty(),
equip_blits(&eq, None).is_empty(),
"an empty texture name is not a contribution"
);
}
@@ -926,7 +1465,7 @@ mod tests {
read_texture_mip_chain(&mut chain, "Character\\Human\\Male\\HumanMaleSkin00_03.blp")
.expect("read base skin");
let comp = cs
.composite_body(&mut chain, 1, 0, 3, 0, 1, 0, 0, [None; 8])
.composite_body(&mut chain, 1, 0, 3, 0, 1, 0, 0, [None; 8], None)
.expect("composite ok")
.expect("base skin row present");
@@ -1085,7 +1624,7 @@ mod tests {
"the sheet is authored exactly tile-sized"
);
let comp = cs
.composite_body(&mut chain, race, sex, skin, 0, 0, 0, 0, [None; 8])
.composite_body(&mut chain, race, sex, skin, 0, 0, 0, 0, [None; 8], None)
.expect("composite ok")
.expect("base skin row present");
@@ -1223,7 +1762,7 @@ mod tests {
];
for (label, equipment, (g3_want, g3), (g5_want, g5)) in cases {
let comp = cs
.composite_body(&mut chain, race, sex, skin, 0, 0, 0, 0, equipment)
.composite_body(&mut chain, race, sex, skin, 0, 0, 0, 0, equipment, None)
.expect("composite ok")
.expect("base skin row present");
for (tile, name, want_name, want) in [
@@ -1242,6 +1781,119 @@ mod tests {
assert_ne!(pelvis_sheet, base_g5, "the panties differ from bare skin");
}
/// The guild emblem on the **real** files (decision 1704). Display **20621** is the row item
/// 5976 *Guild Tabard* wears, and the only guild-emblem display any 1.12.1 item template points
/// at. Three things this pins that the synthetic plan tests cannot:
///
/// 1. the shipped row really does set the flag (so the gate is not vacuous on real data);
/// 2. all six `Textures\GuildEmblems\` names an emblem builds resolve, and each is authored
/// **exactly tile-sized** — the RF-0067 overlay src convention (`src = (0,0)`, extent =
/// the tile), which is what lets them blit from their own origin;
/// 3. the emblem repaints the two torso tiles and **nothing else** — no arm, leg, head or foot
/// texel moves — and a different index set paints a different tabard.
#[test]
fn composite_body_paints_the_guild_emblem_on_the_real_tabard() {
let data = crate::wow_data_or_skip!();
let mut chain = crate::open_chain(&data).expect("open chain");
let cs = CharSections::load(&mut chain).expect("load CharSections");
let items = crate::load_item_display_catalog(&mut chain).expect("load ItemDisplayInfo");
let tabard = items.get(20621).expect("the Guild Tabard display");
assert!(
tabard.takes_guild_emblem(),
"display 20621 is the guild-emblem tabard"
);
assert!(
!items.get(9891).expect("shirt display").takes_guild_emblem(),
"an ordinary garment is not"
);
let emblem = GuildEmblem {
emblem_style: 42,
emblem_color: 3,
border_style: 1,
border_color: 7,
background_color: 12,
};
let equipment: [Option<&ItemDisplay>; 8] =
[None, None, None, None, None, None, None, Some(tabard)];
// (2) — every layer resolves, and each is authored at its tile's exact extent.
for step in equip_blits(&equipment, Some(emblem)) {
let path = step
.candidates(0)
.into_iter()
.find(|p| read_texture_mip_chain(&mut chain, p).is_ok())
.unwrap_or_else(|| panic!("{:?} resolves no file", step.source));
let mips = read_texture_mip_chain(&mut chain, &path).expect("re-read");
let (_, _, tw, th) = EQUIP_TILES[step.layer];
assert_eq!(
(mips.width, mips.height),
(tw, th),
"{path} is authored tile-sized"
);
}
// (3) — the repaint is confined to the two torso tiles.
let mut compose = |em: Option<GuildEmblem>| {
cs.composite_body(&mut chain, 1, 0, 3, 0, 1, 0, 0, equipment, em)
.expect("composite ok")
.expect("base skin row present")
};
let plain = compose(None);
let crested = compose(Some(emblem));
let in_torso = |i: usize| {
let (x, y) = ((i % 256) as u32, (i / 256) as u32);
[EQUIP_TILES[3], EQUIP_TILES[4]]
.iter()
.any(|(tx, ty, tw, th)| x >= *tx && x < tx + tw && y >= *ty && y < ty + th)
};
let moved: Vec<usize> = plain.mips[0]
.chunks_exact(4)
.zip(crested.mips[0].chunks_exact(4))
.enumerate()
.filter(|(_, (a, b))| a != b)
.map(|(i, _)| i)
.collect();
assert!(
!moved.is_empty(),
"the emblem must repaint something — a silent no-op is the bug this fixes"
);
let strays: Vec<usize> = moved.iter().copied().filter(|i| !in_torso(*i)).collect();
assert!(
strays.is_empty(),
"the emblem repainted {} texel(s) outside the torso tiles, first at {:?}",
strays.len(),
strays.first().map(|i| (i % 256, i / 256))
);
// …and the indices are actually consumed: a different background is a different tabard.
let other = compose(Some(GuildEmblem {
background_color: 30,
..emblem
}));
assert_ne!(
crested.mips[0], other.mips[0],
"the background index must reach the file name"
);
// **Background 29 ships UPPERCASE and has no lowercase sibling** — `BACKGROUND_29_TU_U.blp`
// is the one odd name in 6118 files (wow-re `rf89`). A case-SENSITIVE chain lookup would
// lose exactly one background colour on exactly one tile, which is the kind of defect that
// reads as "that guild's tabard is subtly wrong" and nothing else. Ours is insensitive;
// this is the tripwire that keeps it that way.
let odd = GuildEmblem {
background_color: 29,
..emblem
};
for half in ["TU", "TL"] {
let path = EmblemLayer::Background.path(&odd, half);
assert!(
read_texture_mip_chain(&mut chain, &path).is_ok(),
"{path} must resolve despite the shipped name's casing"
);
}
}
/// Equipment layers on the **real** files (decision 0074): dressing the Human male in One's
/// starter kit (shirt 9891 / pants 9892 / boots 10141) must repaint exactly the tiles those
/// displays' region columns name — torso from the shirt, LegUpper from the pants, Foot from the
@@ -1260,7 +1912,7 @@ mod tests {
items.get(10141).expect("boots display"),
);
let mut compose = |equipment: [Option<&ItemDisplay>; 8]| {
cs.composite_body(&mut chain, 1, 0, 3, 0, 1, 0, 0, equipment)
cs.composite_body(&mut chain, 1, 0, 3, 0, 1, 0, 0, equipment, None)
.expect("composite ok")
.expect("base skin row present")
};
+50 -26
View File
@@ -18,14 +18,24 @@
//! shipped values include `3.14159` and `4.71239` (π and 3π/2) to five decimals, which no degree
//! table would.
//!
//! **The world transform** — `world = origin + Rz(facing)·local`, `z` straight through — is the
//! one non-obvious step, and it is checked against an independent oracle rather than assumed:
//! vmangos walks its own server-side copy of these paths (`Player::UpdateCinematic`) off a
//! hand-built `cinematic_waypoints` table of sampled world positions, and our evaluated path lands
//! on those samples (tens of yards over ~600-yard offsets, on a table whose z column is
//! ground-level, not camera-level). The three sign/axis alternatives miss by 700–1800 yards. See
//! **The world transform** — `world = origin + Rz(+facing)·local`, `z` straight through — is the
//! one non-obvious step, and it is now settled twice over.
//!
//! It was first checked against an independent oracle rather than assumed: vmangos walks its own
//! server-side copy of these paths (`Player::UpdateCinematic`) off a hand-built
//! `cinematic_waypoints` table of sampled world positions, and our evaluated path lands on those
//! samples (tens of yards over ~600-yard offsets, on a table whose z column is ground-level, not
//! camera-level). The three sign/axis alternatives miss by 700–1800 yards. See
//! [`CinematicPath::sample`].
//!
//! It is now **byte-verified too** (wow-re `ui/scratch/cinematic-camera-law.md`, a §5 with a
//! Unicorn run of the binary's own bytes as arbiter), and the earlier round's contradicting
//! answer is explained rather than left hanging. The client applies affines as a **row vector on
//! the left** (`out = in·M`), and the stored 3×3 is `[[cos,sin,0],[−sin,cos,0],[0,0,1]]`. Read as
//! a diagram acting on a *column* vector that is `Rz(−facing)` — which is what a matrix picture
//! invites, and what the earlier worker reported. The client never applies it that way. The bytes
//! and the oracle never actually disagreed.
//!
//! **The shipped corpus, read out of the ten `Cameras\*.m2` files** (`fov` radians, `d₀` = how far
//! the shot's first eye position sits from its own origin, horizontally):
//!
@@ -52,12 +62,18 @@
//! while one runs (decision 0196), and why the client has to stream the world from the *camera*
//! and not the avatar for the duration.
//!
//! **What this module deliberately does not decide.** The authored `near`/`far` clip (`0.22222` /
//! `27.7778` = 8/36 and 1000/36, identical on all ten) are carried, not applied: a 27.8-yard far
//! plane would render the dwarf intro's mountains as empty sky, so whatever the reference does
//! with those two floats, it is not "install them as the world clips". Which camera of a
//! multi-camera row plays, what ends a shot, and how `fov` reaches the world projection are the
//! reference's to state (the wow-re cinematic dispatch, 2026-08-29).
//! **The optics in this table are data, not the shot's framing** (decision 1711, off wow-re
//! `ui/scratch/cinematic-camera-law.md`, VERIFIED). A 24-site census settles it: the M2 camera
//! record's `fov`, `nearClip` and `farClip` are written at model load and read only by `0x7ac640`,
//! which is reachable solely from the portrait and `<Model>` frame paths. **On the cinematic path
//! nothing reads any of the three.** A fly-by is rendered through the *world camera's own* optics,
//! re-stamped every frame.
//!
//! That dissolves the puzzle this note used to record: the authored clips are `0.22222` / `27.7778`
//! (8/36 and 1000/36, identical on every shipped shot), and a 27.8-yard far plane would render the
//! dwarf intro's mountains as empty sky. They are not world clips because they are not clips at
//! all here — which is what two floats nobody consumes look like. They stay parsed because they
//! are genuinely in the record and the portrait path does read them.
use std::collections::HashMap;
@@ -245,14 +261,25 @@ pub struct CinematicPath {
/// The row's `SoundEntries.dbc` narration id, `0` for a silent shot — carried here so a
/// consumer that has the shot has everything the shot plays.
pub sound_id: u32,
/// The authored **diagonal** field of view, radians — `0.7854` (45°) on nine of the ten shots
/// and `1.5708` (90°) on the Undead intro, so it is read per shot and never assumed. The
/// reference's perspective builder takes the half-angle as `θ = (fov/2)/√(aspect²+1)`, so a
/// vertical FOV for a modern projection is `fov / √(aspect²+1)` — see [`Self::vertical_fov`].
/// The authored field of view, radians — `0.7854` (45°) on fifteen of the sixteen shipped
/// shots and `1.5708` (90°) on the Undead intro.
///
/// **The reference reads this from nothing on the cinematic path** (wow-re
/// `ui/scratch/cinematic-camera-law.md`, VERIFIED by a 24-site census: the M2 camera's fov and
/// the two clips are written at model load and read only by `0x7ac640`, which is reachable
/// solely from the portrait and `<Model>` frame paths). A fly-by is rendered through the
/// **world camera's own** optics, re-stamped every frame. So this is a real field of the
/// record, and it is not what a fly-by is framed with — see decision 1711 for what benilla did
/// with it before that was known.
pub fov: f32,
/// The authored near clip, carried unapplied — see the module doc.
/// The authored near clip, radians-free yards — read by nothing on this path, like [`Self::fov`].
///
/// It is carried because it is *there*, and because its value is the tell: every shipped shot
/// authors `8/36` or `1000/36` — `0.2222` and `27.7778` yards. As world clips those are
/// nonsense (a 27.8-yard far plane renders the dwarf intro's mountains as empty sky), which is
/// exactly what you would expect of two floats nothing consumes.
pub near_clip: f32,
/// The authored far clip, carried unapplied — see the module doc.
/// The authored far clip — see [`Self::near_clip`].
pub far_clip: f32,
/// How long the shot runs, milliseconds: the sequence band the tracks are keyed inside.
pub duration_ms: u32,
@@ -344,13 +371,6 @@ impl CinematicPath {
}
}
/// The vertical FOV a modern projection wants, for a viewport of aspect `width/height`:
/// `fov / √(aspect² + 1)`, inverting the reference's diagonal-FOV perspective builder
/// (`m11 = 1/tan(θ)`, `θ = (fov/2)/√(aspect²+1)`, wow-re `portrait-projection-aspect.md`).
pub fn vertical_fov(&self, aspect: f32) -> f32 {
self.fov / (aspect * aspect + 1.0).sqrt()
}
fn to_world(&self, local: [f32; 3]) -> [f32; 3] {
let (s, c) = self.facing_sin_cos;
[
@@ -465,7 +485,11 @@ mod tests {
);
}
// The clips are uniform across the corpus; the FOV is NOT — the Undead intro is 90°
// where the rest are 45°, which is exactly why it is read per shot.
// where the rest are 45°. Kept as a **data** assertion, and no longer as a reason to
// read it per shot: decision 1711 established that the reference's cinematic path
// reads none of these three, so the split is a fact about the files and not about how
// any shot is framed. It stays because a parser that silently started returning zeros
// for all three would otherwise pass every other test in this file.
assert!((path.near_clip - 8.0 / 36.0).abs() < 1e-6);
assert!((path.far_clip - 1000.0 / 36.0).abs() < 1e-4);
let want_fov = if id == 2 {
+31 -3
View File
@@ -5,7 +5,9 @@
//! `record_count=29604`; wow-re's `charactermodel` disasm; wow-re's independent dbc-node schema
//! catalog): 23 fields / 92-byte records. Columns: `0` id (key) · `1`/`2` model name L/R (string) ·
//! `3`/`4` model texture L/R (string) · `5` icon (string, unread — no consumer yet) · `6`-`8`
//! geosetGroup[0..2] (u32) · `9` unpinned int (unread) · `10` the ranged-weapon `SpellVisual.dbc`
//! geosetGroup[0..2] (u32) · `9` **flags** (u32 — the client's `ItemDisplayInfo_inmem[+0x24]`, of
//! which only **bit 0** is read: the guild-emblem tabard, [`ItemDisplay::takes_guild_emblem`]) ·
//! `10` the ranged-weapon `SpellVisual.dbc`
//! id (u32 — the substitute visual a RANGED-attribute spell with no own visual borrows for its
//! fire animation; byte-verified `0x60d493: mov eax,[eax+0x28]`, wow-re
//! `throw-ranged-attack-anim.md`) · `11` the `ItemGroupSounds.dbc` id
@@ -76,6 +78,11 @@ pub struct ItemDisplay {
/// `0` on every non-ranged display; only three distinct nonzero ids exist across the real
/// table (thrown 98 · bow 5 · gun/rifle 224).
pub spell_visual: u32,
/// The flags word (col 9) — the client's `ItemDisplayInfo_inmem[+0x24]`. Only **bit 0** has a
/// reader in the image, and it means *this garment takes the wearer's guild emblem*
/// ([`Self::takes_guild_emblem`]); bit 1 appears on four chest/robe rows with no reader found.
/// Carried whole rather than as a bool so the unread bits stay visible.
pub flags: u32,
/// The `ItemVisuals.dbc` id (col 22) — the display's **intrinsic glow**: the permanent weapon
/// glows, resolved to up to five `Spells\Enchantments\*.mdx` models by
/// [`crate::ItemVisualCatalog`] (decision 0805). **Signed**, because the client reads it that
@@ -110,8 +117,27 @@ impl ItemDisplay {
pub fn worn_helm_vis(&self) -> Option<[u32; 2]> {
self.model[0].is_some().then_some(self.helmet_vis)
}
/// Whether this display is a **guild-emblem tabard** — the garment whose torso art the wearer's
/// guild tabard replaces (`flags & 1`, the client's `0x472ca4 test byte ptr [rec+0x24],1` on the
/// tabard equip slot). A tabard that clears the bit keeps its own `TorsoUpper`/`TorsoLower`
/// columns; one that sets it has those columns overwritten by the three
/// `Textures\GuildEmblems\` layers ([`crate::GuildEmblem`]) whenever the wearer's guild
/// identity is known.
///
/// **Eleven** of the 29604 shipped rows carry a nonzero `flags`; **seven** set bit 0, and they
/// are exactly the tabard-shaped ones (Crusader ×2, Default ×2, Stromgard, Hillsbrad, and
/// **20621** — the row item 5976 *Guild Tabard* wears, the only one any 1.12.1 item template
/// points at). The other four set bit **1** on chest/robe rows (Samurai plate, the Horde robe
/// family) and nothing reads it.
pub fn takes_guild_emblem(&self) -> bool {
self.flags & FLAG_GUILD_EMBLEM_TABARD != 0
}
}
/// [`ItemDisplay::flags`] bit 0 — see [`ItemDisplay::takes_guild_emblem`].
const FLAG_GUILD_EMBLEM_TABARD: u32 = 0x1;
/// `ItemDisplayInfo.dbc`, keyed by `displayId` (the id `ItemDisplayInfoID`/`UNIT_VIRTUAL_ITEM_SLOT_DISPLAY`
/// resolve into — decision 0072).
pub struct ItemDisplayCatalog {
@@ -163,10 +189,10 @@ pub(crate) fn item_display_info_schema() -> Schema {
] {
s.add_field(SchemaField::new(name, ty));
}
// fields 9..14 (positional): 9 unpinned, 10 the ranged-weapon SpellVisual id, 11 the
// fields 9..14 (positional): 9 the flags word, 10 the ranged-weapon SpellVisual id, 11 the
// ItemGroupSounds id, 12/13 helm-vis.
for name in [
"Unk9",
"Flags",
"SpellVisualID",
"ItemGroupSoundsID",
"HelmVisMale",
@@ -211,6 +237,7 @@ fn catalog_from_records(rs: RecordSet) -> ItemDisplayCatalog {
let icon = str_at(&rs, r, 5).map(|i| format!("Interface\\Icons\\{i}"));
let group_sounds = u32_at(r, 11).unwrap_or(0);
let spell_visual = u32_at(r, 10).unwrap_or(0);
let flags = u32_at(r, 9).unwrap_or(0);
let item_visual = u32_at(r, 22).unwrap_or(0) as i32;
displays.insert(
id,
@@ -223,6 +250,7 @@ fn catalog_from_records(rs: RecordSet) -> ItemDisplayCatalog {
icon,
group_sounds,
spell_visual,
flags,
item_visual,
},
);
+3 -3
View File
@@ -29,9 +29,9 @@ mod install;
pub use install::{candidates, wow_data};
mod characters;
pub use characters::{
equip_blits, equip_column, equip_region_candidates, equip_tex_dir, equip_tile,
CharCreateCatalog, CharSections, CharacterGeosets, DialRanges, EquipBlit, EquipGeosets,
StartOutfitItem,
equip_blits, equip_column, equip_region_candidates, equip_tex_dir, equip_tile, BlitSource,
CharCreateCatalog, CharSections, CharacterGeosets, DialRanges, EmblemLayer, EquipBlit,
EquipGeosets, GuildEmblem, StartOutfitItem,
};
mod camera_shakes;
mod cinematics;
+14
View File
@@ -1066,6 +1066,20 @@ pub enum SessionEvent {
mode: crate::messages::MoveMode,
apply: bool,
},
/// **A knockback aimed at our own mover** (`SMSG_MOVE_KNOCK_BACK`, decision 1702) — the server
/// hands the controlling client a ballistic launch and lets it fly the arc itself; nothing about
/// it is a spline or a teleport. `launch` is the packet's quad as the jump tail it becomes:
/// world-XY direction (`cos_angle`, `sin_angle`), horizontal speed (`xy_speed`), and the
/// take-off vertical speed (`zspeed`, **down-positive** — negative is upward, decision 0054).
///
/// Must be acked with the echoed `counter` and a `MovementInfo` whose jump tail is exactly this
/// quad, or the server logs a cheat and observers never see the knockback at all
/// ([`crate::messages::opcode::SMSG_MOVE_KNOCK_BACK`]).
KnockBack {
guid: u64,
counter: u32,
launch: crate::messages::JumpInfo,
},
/// Someone invited us to their group (`SMSG_GROUP_INVITE`) — the invite popup.
GroupInvite { inviter: String },
/// An invite we sent was declined (`SMSG_GROUP_DECLINE`).
@@ -478,6 +478,15 @@ pub fn decode(packet: ServerPacket) -> Vec<SessionEvent> {
mode,
apply,
}],
ServerPacket::KnockBack {
guid,
counter,
launch,
} => vec![SessionEvent::KnockBack {
guid,
counter,
launch,
}],
ServerPacket::GroupInvite { inviter } => vec![SessionEvent::GroupInvite { inviter }],
ServerPacket::GroupDecline { name } => vec![SessionEvent::GroupDecline { name }],
ServerPacket::GroupUninvited => vec![SessionEvent::GroupUninvited],
@@ -360,6 +360,22 @@ pub fn move_flag_ack(guid: u64, counter: u32, info: &MovementInfo, apply: Option
body
}
/// Body of `CMSG_MOVE_KNOCK_BACK_ACK` (decision 1702) — **the same shape as a movement-mode ack
/// without its trailing `apply`**: a full `u64` guid, the echoed `u32` counter, and our
/// `MovementInfo`. Not a coincidence of layout: both land on a `WorldSession::HandleMove*Ack` that
/// reads exactly `guid >> counter >> movementInfo` (vmangos `Server/Packets/Movement.cpp:38-68`),
/// so they are one wire shape with two meanings, and delegating says so.
///
/// **`info` must carry `MOVEFLAG_JUMPING` and the launch quad in its jump tail.**
/// `Unit::FindPendingMovementKnockbackChange` walks the pending changes and matches on the counter
/// *plus* all four floats within `0.01`; miss, and the ack is discarded as `OnWrongAckData` and the
/// knockback never reaches observers (the server builds their `MSG_MOVE_KNOCK_BACK` from this very
/// `MovementInfo`). Send nothing at all and it is `OnFailedToAckChange` — a knockback is the one
/// pending change vmangos refuses to re-send (`Unit.cpp:6887`).
pub fn knock_back_ack(guid: u64, counter: u32, info: &MovementInfo) -> Vec<u8> {
move_flag_ack(guid, counter, info, None)
}
#[cfg(test)]
mod tests {
use super::*;
+25 -16
View File
@@ -308,20 +308,29 @@ pub struct GuildQueryResponse {
/// array is normally empty — "how many ranks exist" is the count of leading non-empty entries,
/// or better, the roster's `rank_rights` length.
pub rank_names: [String; GUILD_RANKS_MAX_COUNT],
/// Tabard emblem symbol index.
pub emblem_style: u32,
/// Tabard emblem colour index.
pub emblem_color: u32,
/// Tabard border style index.
pub border_style: u32,
/// Tabard border colour index.
pub border_color: u32,
/// Tabard background colour index.
pub background_color: u32,
/// Tabard emblem symbol index — the file index the body composite formats into
/// `Textures\GuildEmblems\Emblem_<style>_<color>_…` (decision 1704).
///
/// **Signed, and `-1` is the common case.** These five are `int32` on the wire (vmangos
/// `Server/Packets/Guild.h:208`), a guild is *constructed* with all five at `-1`
/// (`Guild/Guild.cpp:86`) and reloaded with `GetInt32()` (`:308`), and only the tabard designer
/// ever writes a real index. So "this guild has never bought a tabard" arrives as `-1`, not as
/// `0` — decoding these unsigned would turn that into 4294967295 and lose the one value that is
/// not a file index. What the renderer does with it is 1704's business, not the decoder's.
pub emblem_style: i32,
/// Tabard emblem colour index. Signed, `-1` = no tabard designed — see [`Self::emblem_style`].
pub emblem_color: i32,
/// Tabard border style index. Signed, `-1` = no tabard designed.
pub border_style: i32,
/// Tabard border colour index. Signed, `-1` = no tabard designed.
pub border_color: i32,
/// Tabard background colour index. Signed, `-1` = no tabard designed.
pub background_color: i32,
}
/// Read `SMSG_GUILD_QUERY_RESPONSE` (VERIFIED **both ends**): `u32 guildId`, the guild name, then
/// **exactly ten** rank-name cstrings, then the five tabard `u32`s.
/// **exactly ten** rank-name cstrings, then the five tabard `i32`s (signed — see
/// [`GuildQueryResponse::emblem_style`] for why the sign is load-bearing).
///
/// Server: vmangos `Server/Packets/Guild.cpp:118-131` `GuildQueryResponse::AppendBodyTo`, filled by
/// `Guild/Guild.cpp:862-880` `SendQueryResponse`. Client: wow-re
@@ -345,11 +354,11 @@ pub(super) fn read_guild_query_response(r: &mut impl Read) -> io::Result<GuildQu
guild_id,
name,
rank_names,
emblem_style: read_u32_le(r)?,
emblem_color: read_u32_le(r)?,
border_style: read_u32_le(r)?,
border_color: read_u32_le(r)?,
background_color: read_u32_le(r)?,
emblem_style: read_u32_le(r)? as i32,
emblem_color: read_u32_le(r)? as i32,
border_style: read_u32_le(r)? as i32,
border_color: read_u32_le(r)? as i32,
background_color: read_u32_le(r)? as i32,
})
}
+4 -3
View File
@@ -95,9 +95,10 @@ pub use client::{
auth_session, channel_announcements, channel_ban, channel_invite, channel_kick, channel_list,
channel_moderate, channel_moderator, channel_mute, channel_owner, channel_password,
channel_set_owner, channel_unban, channel_unmoderator, channel_unmute, char_create,
creature_query, force_speed_ack, full_guid, join_channel, leave_channel, messagechat,
messagechat_channel, messagechat_kind, messagechat_whisper, move_flag_ack, move_spline_done,
movement, pet_name_query, ping, played_time, query_time, random_roll, teleport_ack, text_emote,
creature_query, force_speed_ack, full_guid, join_channel, knock_back_ack, leave_channel,
messagechat, messagechat_channel, messagechat_kind, messagechat_whisper, move_flag_ack,
move_spline_done, movement, pet_name_query, ping, played_time, query_time, random_roll,
teleport_ack, text_emote,
};
pub use combat_log::{
DamageShield, EnvironmentalDamageLog, PeriodicAuraLog, PeriodicTick, SpellDamageLog,
@@ -929,6 +929,43 @@ pub const SMSG_MOVE_UNSET_HOVER: u16 = 0x00F5; // 245
pub const CMSG_MOVE_HOVER_ACK: u16 = 0x00F6; // 246
pub const CMSG_MOVE_FEATHER_FALL_ACK: u16 = 0x02CF; // 719
// **The knockback handshake** — the server aims a launch at our own mover, the mover flies it, and
// the ack is what the server relays onward (decision 1702). Numbers VERIFIED vmangos
// `Opcodes_1_11_2.h:242-244` (unchanged into 1.12.1) and cross-checked against the client's own name
// table ([`super::opcode_names`]).
//
// **The shape.** `SMSG_MOVE_KNOCK_BACK` carries `packed guid + u32 counter + f32 vcos + f32 vsin +
// f32 speedXY + f32 speedZ` (vmangos `MovementPacketSender::SendKnockBackToController`, the
// `> CLIENT_BUILD_1_9_4` branch that carries the counter). Those last four floats are a
// [`super::JumpInfo`] in a different field order — the same launch quad, and that is not a
// coincidence: the ack must echo them back as the `MovementInfo` **jump tail**, which is why
// `speedZ` rides the jump tail's **down-positive** convention (vmangos negates the caller's
// upward `verticalSpeed` on the way out, with the source comment "!! notice the - sign in front of
// speedZ !!").
//
// **The ack is mandatory and checked value-by-value.** `CMSG_MOVE_KNOCK_BACK_ACK` is
// `full u64 guid + u32 counter + MovementInfo` (`Server/Packets/Movement.cpp:61-68` — the same
// shape as the movement-mode family's ack minus its trailing `apply` dword), and
// `Unit::FindPendingMovementKnockbackChange` refuses it unless the counter matches **and** all four
// jump-tail floats are within `0.01` of what was sent — so `MOVEFLAG_JUMPING` must be set and the
// tail must be the launch quad. A mismatched ack is `OnWrongAckData`; a missing one is
// `OnFailedToAckChange` (a knockback is the one pending change the server will not re-send,
// `Unit.cpp:6887`). The ack is also the observers' packet: the server answers it with
// `MSG_MOVE_KNOCK_BACK` built from the `MovementInfo` we sent.
pub const SMSG_MOVE_KNOCK_BACK: u16 = 0x00EF; // 239
pub const CMSG_MOVE_KNOCK_BACK_ACK: u16 = 0x00F0; // 240
//
// **And the observers hear it — `MSG_MOVE_KNOCK_BACK` is NOT dead** (decision 1702, correcting
// decision 0277's "no observer-side knockback signal exists in 1.12 at all"). The reference
// registers `0xF1` at its own dispatch entry `0x603bb0`, which reaches `CGUnit_C::OnKnockBack`
// `0x6026f0` — it reads the four floats *after* a full `MovementInfo` and re-launches the unit
// locally through the very same `0x6179c0` the controller's own knockback uses, acking nothing.
// That body (`packed guid + MovementInfo + vcos + vsin + speedXY + speedZ`) is exactly what vmangos
// sends (`SendKnockBackToObservers`), and its leading `[packed guid][MovementInfo]` is the ordinary
// relay shape — the launch quad also rides that `MovementInfo`'s own jump tail, so a receiver that
// replays the relayed arc reproduces the re-launch without reading the trailing four.
pub const MSG_MOVE_KNOCK_BACK: u16 = 0x00F1; // 241
// The social family — the friend list, the ignore list, and `/who` (VERIFIED vmangos
// `Opcodes_1_12_1.h` + `Server/Packets/Social.{h,cpp}`, `Handlers/MiscHandler.cpp`,
// `SocialMgr.{h,cpp}`; client side wow-re's `FriendList.cpp` TU, `system/net/scratch/w2b.md`).
@@ -949,6 +949,21 @@ pub enum ServerPacket {
mode: MoveMode,
apply: bool,
},
/// **The server aimed a knockback at our mover** (`SMSG_MOVE_KNOCK_BACK`, decision 1702) —
/// a ballistic launch it hands the controlling client to fly. `launch` is the packet's four
/// floats read into the launch quad they *are*: `cos_angle`/`sin_angle` the world-XY direction,
/// `xy_speed` the horizontal speed, `zspeed` the take-off vertical speed in the jump tail's
/// **down-positive** convention (negative = upward). The wire order is `vcos, vsin, speedXY,
/// speedZ` — NOT [`JumpInfo`]'s own `zspeed`-first serialization order, which is why this is a
/// read into the type rather than the type's own reader.
///
/// Owes `CMSG_MOVE_KNOCK_BACK_ACK` with the echoed `counter` and a `MovementInfo` whose jump
/// tail is exactly this quad ([`opcode::SMSG_MOVE_KNOCK_BACK`]'s note has the server's checks).
KnockBack {
guid: u64,
counter: u32,
launch: JumpInfo,
},
/// `SMSG_LOGOUT_COMPLETE` — the world session is over; we are back at character select.
LogoutComplete,
/// `SMSG_LOGOUT_RESPONSE` — the server's answer to `CMSG_LOGOUT_REQUEST`, and the ONLY thing
@@ -1515,6 +1530,7 @@ impl ServerPacket {
(MoveMode::Hover, true) => "SMSG_MOVE_SET_HOVER".into(),
(MoveMode::Hover, false) => "SMSG_MOVE_UNSET_HOVER".into(),
},
ServerPacket::KnockBack { .. } => "SMSG_MOVE_KNOCK_BACK".into(),
ServerPacket::LogoutComplete => "SMSG_LOGOUT_COMPLETE".into(),
ServerPacket::LogoutResponse { .. } => "SMSG_LOGOUT_RESPONSE".into(),
ServerPacket::LogoutCancelAck => "SMSG_LOGOUT_CANCEL_ACK".into(),
+30 -1
View File
@@ -15,7 +15,7 @@ use super::{
duel, gameobject, gm_ticket, gossip, group, guild, items, loot, mail, mirror_timer,
monster_move, movement, opcode, page_text, pet, petition, progression, pvp, quest, social,
spellbook, spells, stable, taxi, trade, trainer, update_object, vendor, world_state, Character,
CreatureQueryInfo, MoveMode, ServerPacket, SpeedKind,
CreatureQueryInfo, JumpInfo, MoveMode, ServerPacket, SpeedKind,
};
/// Read one `SMSG_FORCE_*_SPEED_CHANGE` body — `[packed mover guid][u32 counter][f32 speed]`,
@@ -149,6 +149,11 @@ const fn is_movement_relay(o: u16) -> bool {
| opcode::MSG_MOVE_SET_FACING
| opcode::MSG_MOVE_SET_PITCH
| opcode::MSG_MOVE_HEARTBEAT
// Somebody else was knocked back (decision 1702). It carries four extra floats after the
// `MovementInfo` — the launch quad — which this reader simply doesn't reach, and doesn't
// need to: the server built that `MovementInfo` from the victim's own ack, so its jump
// tail already IS the quad, and the arc replays from it like any other relayed jump.
| opcode::MSG_MOVE_KNOCK_BACK
)
}
@@ -833,6 +838,30 @@ pub fn parse_server(opcode: u16, body: &[u8]) -> io::Result<ServerPacket> {
apply,
}
}
// **The knockback the server aims at our mover** (decision 1702): `packed guid + u32
// counter`, then the launch quad `vcos, vsin, speedXY, speedZ` (VERIFIED vmangos
// `MovementPacketSender.cpp:261-277`, the `> CLIENT_BUILD_1_9_4` branch). The quad reads
// straight into a [`JumpInfo`] — it *is* the jump tail the ack must echo — but the wire
// order here is direction-first, where `JumpInfo`'s own serialization is `zspeed` first, so
// the four reads are named rather than delegated.
opcode::SMSG_MOVE_KNOCK_BACK => {
let guid = read_packed_guid(&mut r)?;
let counter = read_u32_le(&mut r)?;
let cos_angle = read_f32_le(&mut r)?;
let sin_angle = read_f32_le(&mut r)?;
let xy_speed = read_f32_le(&mut r)?;
let zspeed = read_f32_le(&mut r)?;
ServerPacket::KnockBack {
guid,
counter,
launch: JumpInfo {
zspeed,
cos_angle,
sin_angle,
xy_speed,
},
}
}
opcode::SMSG_INITIALIZE_FACTIONS => {
let count = read_u32_le(&mut r)?;
let mut standings = Vec::with_capacity(count as usize);
@@ -191,4 +191,33 @@ impl WorldWriter {
&messages::move_flag_ack(guid, counter, &info, trailing),
)
}
/// **Acknowledge the knockback we are now flying** (`CMSG_MOVE_KNOCK_BACK_ACK`, decision 1702):
/// full guid + the echoed counter + our `MovementInfo` at the moment of launch, whose jump tail
/// is the server's own `launch` quad echoed back.
///
/// The echo is not politeness — it is the server's whole validation. `flags` must carry
/// `MOVEFLAG_JUMPING`, and `launch` must be bit-for-bit what arrived (vmangos matches all four
/// floats within `0.01`); miss either and `FindPendingMovementKnockbackChange` fails, the ack is
/// logged as `OnWrongAckData`, and no observer ever sees the knockback — the server builds their
/// `MSG_MOVE_KNOCK_BACK` from the `MovementInfo` sent here. `fall_time` is the launch's fresh
/// clock (0).
pub fn knock_back_ack(
&mut self,
guid: u64,
counter: u32,
launch: JumpInfo,
flags: u32,
pose: ([f32; 3], f32),
transport: Option<TransportPose>,
) -> Result<()> {
let mut info = movement_info(pose.0, pose.1, flags);
info.fall_time = 0;
info.jump = Some(launch);
info.transport = transport;
self.send(
opcode::CMSG_MOVE_KNOCK_BACK_ACK,
&messages::knock_back_ack(guid, counter, &info),
)
}
}
+104
View File
@@ -309,6 +309,110 @@ fn force_speed_change_parses_and_ack_body_golden() {
assert_eq!(SpeedKind::TurnRate.ack_opcode(), 0x02DF);
}
/// **The knockback handshake, byte-exact both ways** (decision 1702).
///
/// The two halves are one test because the second is only correct *relative to* the first: the ack's
/// jump tail has to be the arriving quad, and the two packets order those same four floats
/// differently. `SMSG_MOVE_KNOCK_BACK` is direction-first (`vcos, vsin, speedXY, speedZ`); the
/// `MovementInfo` jump tail is `zspeed, cos, sin, xyspeed`. Read one in the other's order and every
/// field is still a plausible float, the arc flies off at a wrong angle, and
/// `FindPendingMovementKnockbackChange` rejects the ack — so this is exactly the transposition a
/// type alone cannot catch.
///
/// `speedZ` is negative here because it is **down-positive**: this is a knockback that throws the
/// body *upward* at 12 yd/s (wow-re: `0x7c61f0` stores the wire value verbatim into the same
/// `CMovement+0xa0` a land jump seeds with `0xc0fe93d8` = −7.955547).
#[test]
fn knock_back_parses_and_ack_body_golden() {
// packed guid 8 (mask 0x01, one byte), counter 7, then vcos 0.6, vsin 0.8, speedXY 25, speedZ −12.
let body = hx("0108070000009a99193fcdcc4c3f0000c841000040c1");
let packet = messages::parse_server(messages::opcode::SMSG_MOVE_KNOCK_BACK, &body).unwrap();
let launch = match &packet {
ServerPacket::KnockBack {
guid: 8,
counter: 7,
launch,
} => *launch,
other => panic!(
"expected KnockBack (guid 8, counter 7), got {}",
other.name()
),
};
assert_eq!(launch.cos_angle, 0.6, "first float on the wire is vcos");
assert_eq!(launch.sin_angle, 0.8, "second is vsin");
assert_eq!(launch.xy_speed, 25.0, "third is the horizontal speed");
assert_eq!(
launch.zspeed, -12.0,
"fourth is speedZ, down-positive — negative throws the body up"
);
match decode(packet).as_slice() {
[SessionEvent::KnockBack {
guid: 8,
counter: 7,
launch: got,
}] => assert_eq!(*got, launch),
other => panic!("expected one KnockBack event, got {other:?}"),
}
// The ACK body golden: a **full** 8-byte guid (packed on the way in, never on the way out), the
// echoed counter, and a `MovementInfo` carrying `MOVEFLAG_JUMPING` (0x2000) — without which the
// jump tail is not serialized at all and the server has nothing to match against.
let info = MovementInfo {
flags: 0x2000,
timestamp: 12345,
position: Vector3d {
x: 1.0,
y: 2.0,
z: 3.0,
},
orientation: 0.5,
transport: None,
pitch: 0.0,
fall_time: 0,
jump: Some(launch),
};
assert_eq!(
messages::knock_back_ack(8, 7, &info),
hx("08000000000000000700000000200000393000000000803f00000040000040400000003f00000000000040c19a99193fcdcc4c3f0000c841"),
"CMSG_MOVE_KNOCK_BACK_ACK body"
);
assert_eq!(messages::opcode::SMSG_MOVE_KNOCK_BACK, 0x00EF);
assert_eq!(messages::opcode::CMSG_MOVE_KNOCK_BACK_ACK, 0x00F0);
}
/// **Somebody else's knockback reaches us, and replays as an arc** (decision 1702, correcting
/// decision 0277's "no observer-side knockback signal exists in 1.12 at all").
///
/// `MSG_MOVE_KNOCK_BACK` is an ordinary `[packed guid][MovementInfo]` relay with the launch quad
/// appended — and the appendix is redundant by construction, because the server builds that
/// `MovementInfo` from the victim's own ack, whose jump tail already carries the quad. So the
/// trailing four floats go unread and the arc still replays: this test's assertion is that the tail
/// the reader *does* reach is the launch, not that the packet was consumed to the last byte.
#[test]
fn an_observed_knockback_relays_the_arc() {
// packed guid 0xAA, a JUMPING MovementInfo whose tail is (zspeed −12, cos 0.6, sin 0.8, xy 25),
// then the same quad again in the SMSG's own direction-first order.
let body = hx("01aa00200000393000000000803f00000040000040400000003f00000000000040c19a99193fcdcc4c3f0000c8419a99193fcdcc4c3f0000c841000040c1");
let packet = messages::parse_server(messages::opcode::MSG_MOVE_KNOCK_BACK, &body).unwrap();
match &packet {
ServerPacket::PlayerMove {
guid: 0xAA,
flags: 0x2000,
jump: Some(j),
..
} => {
assert_eq!(j.zspeed, -12.0, "the relayed arc rises, down-positive");
assert_eq!((j.cos_angle, j.sin_angle, j.xy_speed), (0.6, 0.8, 25.0));
}
other => panic!("expected a relayed PlayerMove, got {}", other.name()),
}
match decode(packet).as_slice() {
[SessionEvent::UnitMove { guid: 0xAA, .. }] => {}
other => panic!("expected one UnitMove event, got {other:?}"),
}
assert_eq!(messages::opcode::MSG_MOVE_KNOCK_BACK, 0x00F1);
}
/// The OBSERVER speed legs, byte-exact (decision 0441 — how an observed unit's mounted speed
/// reaches us; no counter, no ack on either): `SMSG_SPLINE_SET_*_SPEED` is `[packed guid]
/// [f32 speed]` (vmangos `SendSpeedChangeToAll` + the mid-spline observer branch), and
+60 -27
View File
@@ -49,7 +49,7 @@ pub(super) fn load_wmo_areas(
use benilla_assets::coords::{bevy_to_wow, wow_to_bevy};
use benilla_assets::{AdtTile, WmoModel};
use super::seed::{area_down_ray, down_ray_claim, down_ray_seeds, DownRayClaim};
use super::seed::{area_down_ray, down_ray_claim, down_ray_seeds, up_ray_claim, DownRayClaim};
use super::{WmoPortalInstance, WmoRoom, WorldCamera, EXTERIOR, EXTERIOR_LIT};
use crate::terrain_stream::{terrain_height_under, PropLobeLight, TerrainStreamer};
@@ -175,7 +175,7 @@ pub(super) fn track_area_interior(
}
let local_from_world = inst.world_from_local.inverse();
let eye_local = bevy_to_wow(local_from_world.transform_point3(eye_world));
if !column_in_collision_bounds(model.collision_bounds, eye_local) {
if !column_in_collision_bounds(model.collision_bounds, eye_local, false) {
continue; // no face of this building can own the probe column
}
let terrain_local = terrain.map(|z| terrain_z_local(&local_from_world, eye_world, z));
@@ -404,7 +404,7 @@ fn room_cast(
}
let local_from_world = inst.world_from_local.inverse();
let probe_local = bevy_to_wow(local_from_world.transform_point3(probe_world));
if !column_in_collision_bounds(model.collision_bounds, probe_local) {
if !column_in_collision_bounds(model.collision_bounds, probe_local, false) {
continue; // no face of this building can own the probe column
}
let terrain_local = terrain.map(|z| terrain_z_local(&local_from_world, probe_world, z));
@@ -538,29 +538,53 @@ pub fn indoor_verdict_at<'a>(
let probe_world = anchor_world + Vec3::Y * POSITION_PROBE_LIFT;
let terrain = terrain_height_under(streamer, adt_tiles, probe_world);
let mut best: Option<(DownRayClaim, &WmoModel, &WmoPortalInstance, [f32; 3])> = None;
for inst in instances {
let Some(model) = wmos.get(&inst.handle) else {
continue;
};
let local_from_world = inst.world_from_local.inverse();
let probe_local = bevy_to_wow(local_from_world.transform_point3(probe_world));
if !column_in_collision_bounds(model.collision_bounds, probe_local) {
continue; // no face of this building can own the probe column
// The containment lane's UPWARD retry (`0x6a8ed0`'s `6a9093 fadd ds:0x8022cc`): a GameObject's
// light node anchors at its authored-box centre, which for a particle-heavy model sits well
// BELOW the floor it stands on — 104 of Onyxia's lava traps anchor ~40 yd under the chamber
// (their authored box reaches 98 yd past their own geometry). The reference recovers by casting
// up; run it only when the whole downward pass came back empty, so a claim is never arbitrated
// against one made in the other direction. See [`up_ray_claim`].
let instances: Vec<&WmoPortalInstance> = instances.into_iter().collect();
for retry_up in [false, true] {
if retry_up && (best.is_some() || !matches!(attach, LightAttach::Containment)) {
break;
}
let terrain_local = terrain.map(|z| terrain_z_local(&local_from_world, probe_world, z));
let Some(claim) = down_ray_claim(
&model.group_collision_tris,
&model.group_collision_bounds,
&model.group_collision_grids,
&model.group_nav,
probe_local,
terrain_local,
EXTERIOR | EXTERIOR_LIT,
) else {
continue;
};
if best.as_ref().is_none_or(|(b, ..)| claim.depth < b.depth) {
best = Some((claim, model, inst, probe_local));
for inst in instances.iter().copied() {
let Some(model) = wmos.get(&inst.handle) else {
continue;
};
let local_from_world = inst.world_from_local.inverse();
let probe_local = bevy_to_wow(local_from_world.transform_point3(probe_world));
if !column_in_collision_bounds(model.collision_bounds, probe_local, retry_up) {
continue; // no face of this building can own the probe column
}
let terrain_local = terrain.map(|z| terrain_z_local(&local_from_world, probe_world, z));
let claim = if retry_up {
up_ray_claim(
&model.group_collision_tris,
&model.group_collision_bounds,
&model.group_collision_grids,
&model.group_nav,
probe_local,
EXTERIOR | EXTERIOR_LIT,
)
} else {
down_ray_claim(
&model.group_collision_tris,
&model.group_collision_bounds,
&model.group_collision_grids,
&model.group_nav,
probe_local,
terrain_local,
EXTERIOR | EXTERIOR_LIT,
)
};
let Some(claim) = claim else {
continue;
};
if best.as_ref().is_none_or(|(b, ..)| claim.depth < b.depth) {
best = Some((claim, model, inst, probe_local));
}
}
}
let Some((claim, model, inst, probe_local)) = best else {
@@ -757,13 +781,22 @@ fn footprint_scan(
/// inside a triangle's XY projection at z ≥ the set's min — so a skipped instance could never have
/// answered. This is what keeps a camera in open country at one AABB test per resident building
/// instead of a full face scan (the 2026-07-12 fps regression).
fn column_in_collision_bounds(bounds: Option<([f32; 3], [f32; 3])>, probe: [f32; 3]) -> bool {
fn column_in_collision_bounds(
bounds: Option<([f32; 3], [f32; 3])>,
probe: [f32; 3],
up: bool,
) -> bool {
bounds.is_some_and(|(min, max)| {
probe[0] >= min[0]
&& probe[0] <= max[0]
&& probe[1] >= min[1]
&& probe[1] <= max[1]
&& min[2] <= probe[2]
// The cast's own half-space: a DOWNWARD ray can hit nothing in a building that ends
// above the probe, an UPWARD one nothing in a building that ends below it. Keeping the
// downward spelling for the upward retry is what silently kept that retry inert — a
// probe 40 yd under Onyxia's floor sits below the lair's own collision floor, so the
// instance was culled here and the retry never ran on it at all.
&& if up { max[2] >= probe[2] } else { min[2] <= probe[2] }
})
}
+101 -4
View File
@@ -306,6 +306,64 @@ pub(crate) fn down_ray_claim(
eye: [f32; 3],
terrain_z: Option<f32>,
outdoor_mask: u32,
) -> Option<DownRayClaim> {
ray_claim(
tris,
bounds,
grids,
nav,
eye,
terrain_z,
outdoor_mask,
false,
)
}
/// The same claim cast **upward** — the containment lane's retry, and only its retry.
///
/// `0x6a8ed0` anchors a GameObject's light node at the world bounding-box CENTRE (`[node+0x5c]`,
/// decision 0776), and that box is the M2's **authored header** box — `0x713640` copies
/// `MD20+0xB4` into it, where the sibling `0x713700` copies the *collision* box at `+0xD0`. For a
/// particle-heavy model the authored box is enormous: `ONYZIASLAIRLAVATRAP.M2` authors one
/// reaching 98.42 yd past its own bind pose (`benilla-extract animboundscan`), putting the anchor
/// ~40 yd under the floor the object is standing on.
///
/// **The reference's own anchor is underground too**, and it recovers with a `+1000`-yd upward
/// retry on the face ray — `6a908d fld [ebp-0x38]` / `6a9093 fadd ds:0x8022cc`, inside the
/// containment body `0x6a8ed0` — which finds the floor from beneath it. wow-re
/// `gobj-light-probe-points.md`.
///
/// [`footprint_sample_above`](super::interior::footprint_sample_above) is the SAME retry one stage
/// later, and benilla had that half and not this one: 104 of Onyxia's lava traps never reached the
/// footprint at all, because the claim that gates it came back empty and the verdict was
/// "outdoors" before any colour was sampled.
///
/// **No terrain race here.** The downward cast loses its column to strictly-nearer terrain
/// (standing over open ground); a cast going *up* from under a floor cannot meaningfully lose to
/// the ground below it.
pub(crate) fn up_ray_claim(
tris: &[Vec<[[f32; 3]; 3]>],
bounds: &[Option<([f32; 3], [f32; 3])>],
grids: &[Option<ColumnGrid>],
nav: &[WmoGroupNav],
eye: [f32; 3],
outdoor_mask: u32,
) -> Option<DownRayClaim> {
ray_claim(tris, bounds, grids, nav, eye, None, outdoor_mask, true)
}
/// The shared body: `up` flips which side of the probe a face must lie on and which candidate
/// wins — the nearest one in the cast's own direction, exactly as `footprint_scan` does it.
#[allow(clippy::too_many_arguments)]
fn ray_claim(
tris: &[Vec<[[f32; 3]; 3]>],
bounds: &[Option<([f32; 3], [f32; 3])>],
grids: &[Option<ColumnGrid>],
nav: &[WmoGroupNav],
eye: [f32; 3],
terrain_z: Option<f32>,
outdoor_mask: u32,
up: bool,
) -> Option<DownRayClaim> {
// Candidate selection is per FACE — the client's query is bbox-free (`zonetext-indoor-bit.md`
// (b): "No bbox, no portals, no camera"; the column containment lives inside `floor_z_at`).
@@ -325,10 +383,14 @@ pub(crate) fn down_ray_claim(
&& eye[0] <= max[0]
&& eye[1] >= min[1]
&& eye[1] <= max[1]
&& min[2] <= eye[2]
&& if up {
max[2] >= eye[2]
} else {
min[2] <= eye[2]
}
})
};
let mut best_z = f32::NEG_INFINITY;
let mut best_z = if up { f32::INFINITY } else { f32::NEG_INFINITY };
let mut best: Option<usize> = None;
for (gi, group_tris) in tris.iter().enumerate() {
if !column_owned(gi) {
@@ -339,7 +401,9 @@ pub(crate) fn down_ray_claim(
// an exact `z` match below resolves exactly as the linear scan resolved it.
let mut consider = |tri: &[[f32; 3]; 3]| {
if let Some(z) = floor_z_at(tri, eye[0], eye[1]) {
if z <= eye[2] && z > best_z {
let ahead = if up { z >= eye[2] } else { z <= eye[2] };
let nearer = if up { z < best_z } else { z > best_z };
if ahead && nearer {
best_z = z;
best = Some(gi);
}
@@ -358,7 +422,7 @@ pub(crate) fn down_ray_claim(
}
let in_group = best?;
// Beyond the ray → no claim at all.
let depth = eye[2] - best_z;
let depth = if up { best_z - eye[2] } else { eye[2] - best_z };
if depth > ZONE_RAY_LEN {
return None;
}
@@ -905,6 +969,39 @@ mod tests {
);
}
#[test]
fn the_upward_retry_recovers_a_floor_from_beneath_it() {
// The Onyxia lava trap's shape. A GameObject's light node anchors at its AUTHORED-box
// centre, and that model's box reaches 98 yd past its own geometry, so the anchor sits
// ~40 yd under the chamber floor it stands on. The downward cast finds nothing — every
// face is ABOVE the probe — and the reference recovers with the containment lane's
// `+1000` upward retry (`6a908d`/`6a9093`). Before it, 104 traps in a WMO-only interior
// classified as outdoors and took the day/night sun, meshes and particles alike.
let room = nav(0, [-10.0, -10.0, 0.0], [10.0, 10.0, 10.0], 0, 0);
let tris = [quad(0.0, 10.0)]; // the chamber floor at z = 0
let (bounds, _) = benilla_assets::collision_tri_bounds(&tris);
let mask = EXTERIOR | EXTERIOR_LIT;
let under = [0.0, 0.0, -40.0];
assert_eq!(
down_ray_claim(&tris, &bounds, &[], &[room], under, None, mask),
None,
"nothing is below an anchor that is itself below the floor"
);
let c = up_ray_claim(&tris, &bounds, &[], &[room], under, mask)
.expect("the floor above claims the column on the retry");
assert!(!c.outdoor && c.group == 0);
assert!(
(c.depth - 40.0).abs() < 1e-6,
"depth is measured along the cast's own direction, got {}",
c.depth
);
// The retry is still a RAY, not a licence: a face below the probe never claims it.
assert_eq!(
up_ray_claim(&tris, &bounds, &[], &[room], [0.0, 0.0, 40.0], mask),
None
);
}
#[test]
fn down_ray_claim_distinguishes_deck_from_terrain() {
// The Booty Bay boardwalk shape (decision 0477): the un-collapsed claim keeps the