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:
@@ -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.
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -284,6 +284,7 @@ pub(super) fn breach_step(
|
||||
held: false,
|
||||
grounded: false,
|
||||
jumped: true,
|
||||
knocked: false,
|
||||
air_nudged: false,
|
||||
ground: None,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
)?;
|
||||
|
||||
@@ -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`) —
|
||||
|
||||
@@ -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")
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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] }
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user