the server's broadcasts land, and a ghost walks through closed doors
- the four world broadcasts decode and land — a shutdown countdown, the civilian-kill alarm, a defense-tower capture and the whisper-cap refusal all reached benilla as unhandled opcodes; `SMSG_SERVER_MESSAGE` carries a row id and a fill, never a finished line, so `ServerMessages.dbc` ships (the five sentences a shutdown has, plus the client's own `[%d]: %s` row-missing fallback, which is mechanism, not an error path); the two defense broadcasts are CHANNEL lines, not system lines — delivered once per joined channel whose row carries DEFENSE, the LocalDefense copy zone-matched against the PARENT area while the text names the packet's own — so a character in neither channel sees nothing, faithfully, and since nothing auto-joins WorldDefense in 1.12 an Eastern Plaguelands tower capture reaches only players standing there unless they typed /join; the wow-re cross-check then made the length prefix load-bearing — the client borrows a pointer into the receive buffer and composes NULL on an overrun, so an overrunning length is a refusal here too: the packet drops with a log line, nothing displayed - `BankButtonIDToInvSlotID` is carved: `isBag` is a TYPE test, not truthiness — `lua_isnumber` refuses every non-number tag without ever loading the payload, so `true` takes the same arm as `nil` while `0` and `"1"` take the bag arm — the function is TOTAL (our out-of-range 0 was an invention; the only compares in all 118 bytes are on `lua_isnumber`'s return), and the +59/+39 arithmetic stands confirmed, byte-identical to `ContainerIDToInventoryID`'s second arm - a deliberate right-drag turn no longer stands you while seated — the observation was right and our attribution was wrong: the body-facing commit is refused by two independent gates and the stand arm is skipped outright while the button is held; what stands you is the RELEASE, a press under 200 ms with under 2.25 degrees of yaw dispatched as a right-CLICK whose interact reaches `SetStandState(0)` — this lands the deletion half, with the click's own stand held for one more wow-re answer - a ghost walks through closed doors — the whole mechanism is one instruction image-wide, reading bit 15 through the sign flag to answer "not a collision candidate" for a DOOR when the traced body is a player in ghost form; the bit is a property of whose trace it is, not of the door, so the exclusion rides the two mover-only casts and the set is empty on every living frame — the particle snap, the precipitation probe, the mouse pick and the creature conform keep the door's collider untouched, exactly as the binary leaves them Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -242,6 +242,11 @@ pub(crate) fn go_animates(type_id: i32) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
/// `GAMEOBJECT_TYPE_DOOR` — type id `0`, the one type a ghost's mover trace drops
|
||||
/// ([`publish_ghost_door_exclusions`]). Also the wire default: vmangos omits zero-valued fields, so
|
||||
/// an absent `GAMEOBJECT_TYPE_ID` means DOOR.
|
||||
const GO_TYPE_DOOR: i32 = 0;
|
||||
|
||||
/// Which types drop their collision when open (decision 0249): **DOOR(0) / BUTTON(1)** only. A door's
|
||||
/// static hull can't swing with the mesh, so an open door is made walkable by disabling the collider —
|
||||
/// keyed off the server's wire state, which *is* the door's real passability. A **CHEST(3)** keeps its
|
||||
@@ -850,6 +855,62 @@ fn drive_go_collision(
|
||||
}
|
||||
}
|
||||
|
||||
/// **A ghost walks through closed doors** — publish the door set the local mover's trace must
|
||||
/// drop, which is [`benilla_world::collision::MoverTraceExclusions`]' only producer.
|
||||
///
|
||||
/// The reference builds a per-trace collision mask, and the local mover's mask gains bit `0x8000`
|
||||
/// when the body it drives is a player carrying `PLAYER_FLAGS` bit `0x10` (GHOST) — `0x631658`,
|
||||
/// off `OBJECT_FIELD_TYPE & TYPEMASK_PLAYER` plus the flag, pinned against the `UnitIsGhost`
|
||||
/// registrar pair. The consumer is a single instruction, `0x5f85f6 test ah,ah` inside `0x5f85f0` —
|
||||
/// `CGGameObject_C`'s collision-candidacy virtual at primary-vtable slot `+0x50` — which reads the
|
||||
/// bit through the sign flag and answers *no candidate* for a GameObject whose
|
||||
/// `GAMEOBJECT_TYPE_ID` is `0`, DOOR. Nothing else about a ghost's collision differs (wow-re
|
||||
/// `collision/scratch/ghost-door-tracemask.md`, decision 1767).
|
||||
///
|
||||
/// **Off the MOVER's descriptor, not ours.** The producer tests the object being traced, so a
|
||||
/// possessed creature is not a player and never sets the bit; `player_is_ghost` reading a field a
|
||||
/// creature has no block for answers false on its own, which is the same guard the byte order
|
||||
/// gives.
|
||||
///
|
||||
/// **DOOR only** — not BUTTON, which shares the state-driven collider lane in
|
||||
/// [`drive_go_collision`] but is a different type id, and the predicate is an equality against `0`.
|
||||
/// Note that an absent `GAMEOBJECT_TYPE_ID` *is* DOOR (vmangos omits zero-valued fields), which is
|
||||
/// why the scan is scoped by [`EntityKind::GameObject`]: read across every `ObjectStore` the same
|
||||
/// absence would make a DOOR of every unit in the world.
|
||||
fn publish_ghost_door_exclusions(
|
||||
mut exclusions: ResMut<benilla_world::collision::MoverTraceExclusions>,
|
||||
mover: Query<&ObjectStore, With<crate::net::Embodied>>,
|
||||
doors: Query<(Entity, &ObjectStore, &crate::net::NetEntity), With<Collider>>,
|
||||
) {
|
||||
let ghost = mover.single().is_ok_and(|s| s.0.player_is_ghost());
|
||||
if !ghost {
|
||||
// The overwhelmingly common frame. Clearing only when non-empty keeps this off the
|
||||
// change-detection wire for the whole of a living session.
|
||||
if !exclusions.0.is_empty() {
|
||||
exclusions.0.clear();
|
||||
}
|
||||
return;
|
||||
}
|
||||
exclusions.0.clear();
|
||||
exclusions.0.extend(
|
||||
doors
|
||||
.iter()
|
||||
.filter(|(_, store, net)| ghost_passable(net.kind, store.0.gameobject_type_id()))
|
||||
.map(|(entity, _, _)| entity),
|
||||
);
|
||||
}
|
||||
|
||||
/// Is this collider one a **ghost's** mover trace drops — a GameObject of type DOOR?
|
||||
///
|
||||
/// The kind test is not redundant with the type test, and that is the whole reason this is a named
|
||||
/// function. An absent `GAMEOBJECT_TYPE_ID` reads as `0` = DOOR (vmangos omits zero-valued fields,
|
||||
/// the law `go_templates` already documents), and a **unit** has no GameObject block at all — so a
|
||||
/// scan that asked only about the type id would answer DOOR for every creature and player in the
|
||||
/// world and quietly delete them from the mover's trace.
|
||||
fn ghost_passable(kind: benilla_protocol::EntityKind, type_id: i32) -> bool {
|
||||
kind == benilla_protocol::EntityKind::GameObject && type_id == GO_TYPE_DOOR
|
||||
}
|
||||
|
||||
/// Fire the event keyframes an animated GameObject's playing clip crossed this frame — the GO
|
||||
/// half of the M2 event-kernel surface (wow-re `go-display-sound-events.md`, the 1086 fold-back
|
||||
/// record): the reference registers an event callback per **family-A** GO at create
|
||||
@@ -932,12 +993,49 @@ pub(crate) fn plugin(app: &mut App) {
|
||||
.chain()
|
||||
.in_set(WorldStage::Present),
|
||||
);
|
||||
// Ahead of the mover, not with the GO drive above: the set it publishes is read by
|
||||
// `WorldCollision::cast_mover` inside the player controller, which runs in `Input`.
|
||||
// Publishing in `Present` would hand the mover the previous frame's doors — imperceptible,
|
||||
// but there is no reason to owe it.
|
||||
app.add_systems(
|
||||
Update,
|
||||
publish_ghost_door_exclusions.in_set(WorldStage::Input),
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// **A ghost's mover drops DOOR GameObjects — and nothing else** (decision 1767).
|
||||
///
|
||||
/// The third case is the one this test exists for. `GAMEOBJECT_TYPE_ID` is absent on a unit,
|
||||
/// and an absent field reads `0`, which *is* DOOR — so the type id alone says "door" for every
|
||||
/// creature and player in the world. Excluding those from the mover's trace would mean walking
|
||||
/// through units while dead, from a change that never mentions them.
|
||||
#[test]
|
||||
fn a_ghosts_trace_drops_doors_and_only_doors() {
|
||||
use benilla_protocol::EntityKind;
|
||||
|
||||
assert!(
|
||||
ghost_passable(EntityKind::GameObject, 0),
|
||||
"a DOOR GameObject is what the reference's `0x5f85f0` refuses to make a candidate"
|
||||
);
|
||||
assert!(
|
||||
!ghost_passable(EntityKind::GameObject, 1),
|
||||
"a BUTTON is not — it shares the state-driven collider lane, not this one; the \
|
||||
reference's predicate is an equality against 0"
|
||||
);
|
||||
assert!(
|
||||
!ghost_passable(EntityKind::Unit, 0),
|
||||
"and a UNIT reading type 0 is the absent field, not a door: units keep colliding"
|
||||
);
|
||||
assert!(
|
||||
!ghost_passable(EntityKind::Player, 0),
|
||||
"same for a player — the absent-field trap is not unit-specific"
|
||||
);
|
||||
}
|
||||
|
||||
/// The wire default that let open doors block (decision 0757). `None` must read ACTIVE/open,
|
||||
/// never "unknown" — vmangos omits the zero-valued field, so `None` IS the open state for
|
||||
/// every door that spawns open (ZG's Forcefield, Stratholme's small portcullises).
|
||||
|
||||
@@ -1047,6 +1047,23 @@ pub(super) fn apply_net_updates(
|
||||
chat::chat_player_not_found(&name, &mut chat_log)
|
||||
}
|
||||
SessionEvent::ChatWrongFaction => chat::chat_wrong_faction(&mut chat_log),
|
||||
// The four world broadcasts — parked for `ui_chat::broadcast`'s resolve pass, which
|
||||
// owns the AreaTable/ServerMessages lookups and the joined-defense-channel walk.
|
||||
SessionEvent::ZoneUnderAttack { area_id } => chat::broadcast(
|
||||
crate::ui_chat::Broadcast::ZoneUnderAttack { area_id },
|
||||
&mut chat_log,
|
||||
),
|
||||
SessionEvent::DefenseMessage { zone_id, text } => chat::broadcast(
|
||||
crate::ui_chat::Broadcast::Defense { zone_id, text },
|
||||
&mut chat_log,
|
||||
),
|
||||
SessionEvent::ServerMessage { message_type, text } => chat::broadcast(
|
||||
crate::ui_chat::Broadcast::Server { message_type, text },
|
||||
&mut chat_log,
|
||||
),
|
||||
SessionEvent::ChatRestricted => {
|
||||
chat::broadcast(crate::ui_chat::Broadcast::ChatRestricted, &mut chat_log)
|
||||
}
|
||||
SessionEvent::Notification { text } => chat::notification(text, &mut ui_actions.14),
|
||||
SessionEvent::AreaTriggerMessage { text } => {
|
||||
chat::area_trigger_message(text, &mut ui_actions.14)
|
||||
|
||||
@@ -11,7 +11,7 @@ use benilla_protocol::messages::{ChatMessage, CHAT_MSG_WHISPER};
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::ui_action::UiErrorTexts;
|
||||
use crate::ui_chat::{ChatEvent, ChatEventKind, ChatLog};
|
||||
use crate::ui_chat::{Broadcast, ChatEvent, ChatEventKind, ChatLog};
|
||||
use crate::ui_social::SocialState;
|
||||
|
||||
use super::super::{ClientCommand, NetCommands, ServerSaidMessage};
|
||||
@@ -212,6 +212,23 @@ pub(super) fn area_trigger_message(text: String, errors: &mut UiErrorTexts) {
|
||||
errors.info(text);
|
||||
}
|
||||
|
||||
/// The four **world broadcasts** — parked on [`ChatLog`]'s broadcast queue for
|
||||
/// [`crate::ui_chat`]'s resolve pass, which holds the AreaTable/ServerMessages catalogs and the
|
||||
/// joined-channel walk this site does not (`ui_chat::broadcast` carries the whole mechanism).
|
||||
///
|
||||
/// Only the parking happens here, deliberately: half-resolving at the packet — naming the area
|
||||
/// here and picking the channels there — would put one mechanism in two files.
|
||||
///
|
||||
/// They are logged at `info!` for the same reason [`notification`] is, and more so: three of the
|
||||
/// four are things the *world* did, so "the server is restarting in 15 minutes" and "nothing
|
||||
/// arrived" have to be distinguishable in a log afterwards. A defense broadcast that reaches a
|
||||
/// character in neither defense channel prints nothing on screen and is faithful in doing so — the
|
||||
/// log line is the only trace it happened at all.
|
||||
pub(super) fn broadcast(b: Broadcast, chat_log: &mut ChatLog) {
|
||||
info!("net: world broadcast — {b:?}");
|
||||
chat_log.push_broadcast(b);
|
||||
}
|
||||
|
||||
/// The `/played` answer (`SMSG_PLAYED_TIME`) — TIME_PLAYED_TOTAL/LEVEL over
|
||||
/// TIME_DAYHOURMINUTESECOND (GlobalStrings:4243-4247; the ref's
|
||||
/// ChatFrame_DisplayTimePlayed breakdown).
|
||||
|
||||
@@ -926,6 +926,9 @@ mod under_floor {
|
||||
/// wire gave it.
|
||||
fn half_arrived_world(flags: u32) -> (App, Entity) {
|
||||
let mut app = App::new();
|
||||
// `WorldCollision` takes the mover's trace exclusions (the ghost/DOOR set, 1767); the
|
||||
// real one is initialised by the world plugins, which a headless harness does not run.
|
||||
app.init_resource::<benilla_world::collision::MoverTraceExclusions>();
|
||||
app.add_plugins((
|
||||
MinimalPlugins,
|
||||
bevy::transform::TransformPlugin,
|
||||
|
||||
@@ -940,6 +940,9 @@ mod under_floor {
|
||||
/// standing at the Z the server sent for it.
|
||||
fn half_arrived_world() -> (App, Entity) {
|
||||
let mut app = App::new();
|
||||
// `WorldCollision` takes the mover's trace exclusions (the ghost/DOOR set, 1767); the
|
||||
// real one is initialised by the world plugins, which a headless harness does not run.
|
||||
app.init_resource::<benilla_world::collision::MoverTraceExclusions>();
|
||||
app.add_plugins((
|
||||
MinimalPlugins,
|
||||
bevy::transform::TransformPlugin,
|
||||
|
||||
@@ -959,6 +959,9 @@ fn a_flag_still_remote_is_left_where_the_wire_put_it() {
|
||||
use bevy::transform::TransformPlugin;
|
||||
|
||||
let mut app = App::new();
|
||||
// `WorldCollision` takes the mover's trace exclusions (the ghost/DOOR set, 1767); the
|
||||
// real one is initialised by the world plugins, which a headless harness does not run.
|
||||
app.init_resource::<benilla_world::collision::MoverTraceExclusions>();
|
||||
app.add_plugins((
|
||||
MinimalPlugins,
|
||||
TransformPlugin,
|
||||
|
||||
@@ -287,7 +287,7 @@ pub(super) fn control(
|
||||
// a body that does not turn — which is what a stunned character looks like, and what the
|
||||
// reference produces by never running the turn emitter at all. Restoring rather than gating
|
||||
// inside the session keeps the approved camera path (0050/0366's right-drag coupling) untouched.
|
||||
// `mouse_turned` then reads false by construction, so the seated-turn stand-up cannot fire either.
|
||||
// (Nothing downstream can tell: the mouse turn no longer stands a seated player either — 1766.)
|
||||
// Losing the reins has the same shape, and the binary says so explicitly: with the mover global
|
||||
// zeroed, `0x514640` skips the whole tick at `51466c` — input is still *sampled*, the body turn
|
||||
// is skipped at `514474` — while the camera rotate at `514444` happens BEFORE the mover lookup
|
||||
@@ -319,7 +319,6 @@ pub(super) fn control(
|
||||
if !may_turn || player.control_lost || player.reseat {
|
||||
player.face_yaw = yaw_before_look;
|
||||
}
|
||||
let mouse_turned = player.face_yaw != yaw_before_look;
|
||||
{
|
||||
let cur = cursor_opts.bypass_change_detection();
|
||||
if cur.visible != opts_shadow.visible
|
||||
@@ -561,10 +560,21 @@ pub(super) fn control(
|
||||
dir = Vec3::ZERO;
|
||||
}
|
||||
let moving = dir != Vec3::ZERO;
|
||||
// The character's own turn this frame — a keyboard turn (or the drunk veer, which rides
|
||||
// `turn_delta` the same way) or a real mouse TURN of the body. One of the movement inputs
|
||||
// that stands a seated avatar back up, and the camera's rigid carry below.
|
||||
let turned = turn_delta != 0.0 || mouse_turned;
|
||||
// The character's own KEYBOARD turn this frame (or the drunk veer, which rides `turn_delta`
|
||||
// the same way) — one of the movement inputs that stands a seated avatar back up.
|
||||
//
|
||||
// **A mouse turn is deliberately not in this set** (decision 1766). It was, on the strength
|
||||
// of a director observation that a right-drag stands you, which wow-re could not reproduce
|
||||
// statically and carried as an open anomaly for weeks. The round that closed it found the
|
||||
// observation right and the attribution wrong: the body-facing commit is refused for a
|
||||
// seated player by two independent gates (`0x5145e0` @`0x51460c` on the prediction cache,
|
||||
// `0x5151b0` @`0x51520a` on the raw descriptor byte), and `0x514f50` skips its stand arm
|
||||
// outright while the RMB bit is held (`0x514f6d test al,1; jne`). What stands you is the
|
||||
// **release**: a press-to-release under 200 ms with under 2.25° of yaw is dispatched as a
|
||||
// right-CLICK (`0x514ae0`, which is [`camera::PressGesture::is_click`] here), and the
|
||||
// click's INTERACT reaches `SetStandState(0)`. So the stand belongs to the click, and it
|
||||
// lives in [`crate::target::click`] now — a deliberate turn-drag leaves you seated.
|
||||
let turned = turn_delta != 0.0;
|
||||
// The gait toggle (`TOGGLERUN`) — the walk/run latch, run here so the speed select
|
||||
// below reads the bit this frame's press left, which is the reference's own order
|
||||
// (`ToggleRun` is an input-phase command; the mover reads `CMovement+0x40` after it).
|
||||
|
||||
@@ -136,7 +136,7 @@ pub(super) fn step(
|
||||
let mut center = player.pos + half_h;
|
||||
// Player body collides with terrain/doodads/GameObjects + the WMO *walking* faces (not the
|
||||
// camera-only ones); the camera sweep uses its own filter (see `benilla_world::collision`).
|
||||
let cast = |from: Vec3, disp: Vec3| world.cast_body(capsule, from, disp, SKIN_WIDTH);
|
||||
let cast = |from: Vec3, disp: Vec3| world.cast_mover(capsule, from, disp, SKIN_WIDTH);
|
||||
let probe_down = |c: Vec3, dist: f32| cast(c, Vec3::NEG_Y * dist);
|
||||
|
||||
// While airborne, "on the ground" means where the slide actually contacts the floor
|
||||
@@ -660,7 +660,7 @@ pub(crate) fn grounded_step(
|
||||
support: Support,
|
||||
) -> GroundedStep {
|
||||
let surface_offset = support.offset;
|
||||
let cast = |from: Vec3, disp: Vec3| world.cast_body(capsule, from, disp, SKIN_WIDTH);
|
||||
let cast = |from: Vec3, disp: Vec3| world.cast_mover(capsule, from, disp, SKIN_WIDTH);
|
||||
let speed = horiz_vel.length();
|
||||
// The step-up (decision 0209): ATOMIC — a steep face in the way triggers rise →
|
||||
// advance-this-frame's-travel-at-the-raised-height → settle onto a walkable floor, all
|
||||
@@ -774,7 +774,7 @@ pub(crate) fn grounded_step(
|
||||
};
|
||||
let start = center + Vec3::Y * popped.unwrap_or(0.0);
|
||||
let mut rode = false;
|
||||
let out = world.slide_body(
|
||||
let out = world.slide_mover(
|
||||
capsule,
|
||||
start,
|
||||
horiz_vel,
|
||||
@@ -1031,7 +1031,7 @@ pub(crate) fn airborne_step(
|
||||
dt: std::time::Duration,
|
||||
) -> Vec3 {
|
||||
world
|
||||
.slide_body(
|
||||
.slide_mover(
|
||||
capsule,
|
||||
center,
|
||||
velocity,
|
||||
@@ -1373,6 +1373,9 @@ mod tests {
|
||||
fn world_from_profile(profile: &[(f32, f32)]) -> App {
|
||||
const W: f32 = 3.0;
|
||||
let mut app = App::new();
|
||||
// `WorldCollision` takes the mover's trace exclusions (the ghost/DOOR set, 1767); the
|
||||
// real one is initialised by the world plugins, which a headless harness does not run.
|
||||
app.init_resource::<benilla_world::collision::MoverTraceExclusions>();
|
||||
// avian's collider backend reads `Assets<Mesh>` and `SceneSpawner` even in a meshless
|
||||
// world, so the headless asset/scene plugins ride along.
|
||||
app.add_plugins((
|
||||
@@ -1419,7 +1422,7 @@ mod tests {
|
||||
.run_system_once(move |world: benilla_world::collision::WorldCollision| {
|
||||
let capsule = player_capsule();
|
||||
let cast =
|
||||
|from: Vec3, disp: Vec3| world.cast_body(&capsule, from, disp, SKIN_WIDTH);
|
||||
|from: Vec3, disp: Vec3| world.cast_mover(&capsule, from, disp, SKIN_WIDTH);
|
||||
// Approach from 1 yd back along +X at street level and stop where the kerb stops us.
|
||||
let start = Vec3::new(-1.0, CAPSULE_HEIGHT * 0.5, 0.0);
|
||||
let run = cast(start, Vec3::X).map_or(1.0, |h| h.distance);
|
||||
@@ -1458,7 +1461,7 @@ mod tests {
|
||||
.run_system_once(move |world: benilla_world::collision::WorldCollision| {
|
||||
let capsule = player_capsule();
|
||||
let cast =
|
||||
|from: Vec3, disp: Vec3| world.cast_body(&capsule, from, disp, SKIN_WIDTH);
|
||||
|from: Vec3, disp: Vec3| world.cast_mover(&capsule, from, disp, SKIN_WIDTH);
|
||||
let start = start + Vec3::Y * (CAPSULE_HEIGHT * 0.5);
|
||||
let run = cast(start, dir).map_or(1.0, |h| h.distance);
|
||||
let mut center = start + dir * run;
|
||||
@@ -1987,7 +1990,7 @@ mod tests {
|
||||
.run_system_once(move |world: benilla_world::collision::WorldCollision| {
|
||||
let capsule = player_capsule();
|
||||
let cast =
|
||||
|from: Vec3, disp: Vec3| world.cast_body(&capsule, from, disp, SKIN_WIDTH);
|
||||
|from: Vec3, disp: Vec3| world.cast_mover(&capsule, from, disp, SKIN_WIDTH);
|
||||
// Stand the body a full kerb below the tread — the same geometry read as a 2.3 yd
|
||||
// wall by dropping the approach to y = −2.0, where the tread is far overhead.
|
||||
let start = Vec3::new(-1.0, CAPSULE_HEIGHT * 0.5 - 2.0, 0.0);
|
||||
|
||||
@@ -67,10 +67,15 @@ pub(super) fn update(
|
||||
// set is byte-pinned (wow-re `standstate-movement-trigger.md`, §5 2026-07-14): the net
|
||||
// input axes (translation), keyboard turn, and jump all reach the guarded stand wrapper
|
||||
// `0x60be30(0)`; a left-drag camera orbit provably does not; sit(1)/chair(2)/sleep(3)
|
||||
// all stand identically (the value-agnostic `GetStandState() != 0` gate). The one open
|
||||
// corner: no static path was found for a pure right-drag MOUSE turn while seated — the
|
||||
// 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.
|
||||
// all stand identically (the value-agnostic `GetStandState() != 0` gate).
|
||||
//
|
||||
// **The MOUSE turn is not in this set, and that corner is now closed** (decision 1766; the
|
||||
// note's B3, which stood open for weeks). A deliberate right-drag turn cannot stand a seated
|
||||
// player — two independent gates refuse the body-facing commit for a seated body, and
|
||||
// `0x514f50` skips its stand arm outright while the RMB bit is held. The director's
|
||||
// observation was right and this file's attribution was wrong: what stands you is the
|
||||
// sub-200 ms RELEASE being dispatched as a right-CLICK, so the stand belongs to the click's
|
||||
// action and not here.
|
||||
// **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`),
|
||||
|
||||
@@ -14,6 +14,9 @@ use crate::ui_script::UiInput;
|
||||
|
||||
#[cfg(test)]
|
||||
mod ace_gate_tests;
|
||||
/// The world broadcasts (`SMSG_ZONE_UNDER_ATTACK`/`_DEFENSE_MESSAGE`/`_SERVER_MESSAGE`) — the
|
||||
/// AreaTable/ServerMessages resolve and the joined-defense-channel walk they land on.
|
||||
mod broadcast;
|
||||
mod channels;
|
||||
/// The combat log's chat lines (B297) — classification, chat type, and the GlobalString key each
|
||||
/// combat packet's sentence is built from.
|
||||
@@ -32,6 +35,7 @@ mod settings;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
pub(crate) use broadcast::Broadcast;
|
||||
/// The joined-channel roster + the `ChatChannels.dbc` catalog. Read outside this module by the
|
||||
/// world-state readout ([`crate::world_state_ui`]), whose `Type == 1` gate is "has the player
|
||||
/// joined a zone-dependent defense channel".
|
||||
@@ -62,9 +66,15 @@ impl Plugin for UiChatPlugin {
|
||||
// `crate::area`'s `AreaTable.dbc` load carries the same ordering for the same reason.
|
||||
// `EmotesText.dbc` × `EmotesTextData.dbc` (decision 1274) rides the same ordering for
|
||||
// the same reason: the sentence tables are read once, off the open patch chain.
|
||||
// `ServerMessages.dbc` (the five shutdown/restart sentences) rides the same ordering
|
||||
// for the same reason.
|
||||
.add_systems(
|
||||
Startup,
|
||||
(channels::load_chat_channels, feed::load_emote_texts)
|
||||
(
|
||||
channels::load_chat_channels,
|
||||
feed::load_emote_texts,
|
||||
broadcast::load_server_messages,
|
||||
)
|
||||
.after(benilla_assets::AssetSet::Open),
|
||||
)
|
||||
// The slash-command table (decision 0881), built from the reference's own alias strings
|
||||
@@ -98,6 +108,10 @@ impl Plugin for UiChatPlugin {
|
||||
)
|
||||
.before(feed::feed_chat),
|
||||
)
|
||||
// The world broadcasts' resolve pass — before the drain that renders what it produces,
|
||||
// so an alarm or a shutdown countdown lands on the frame it decodes like every other
|
||||
// chat source.
|
||||
.add_systems(Update, broadcast::feed_broadcasts.before(feed::feed_chat))
|
||||
.add_systems(Update, feed::feed_chat.before(UiInput))
|
||||
// A fresh VM gets the joined-channel mirror re-pushed once (decision 1291) — before
|
||||
// the feed, so the reload frame's first routed line already renders numbered.
|
||||
|
||||
@@ -0,0 +1,422 @@
|
||||
//! The world broadcasts — the three that become chat lines, and the resolve pass they need first.
|
||||
//!
|
||||
//! `SMSG_ZONE_UNDER_ATTACK`, `SMSG_DEFENSE_MESSAGE` and `SMSG_SERVER_MESSAGE` arrive as ids and
|
||||
//! fills, never as finished lines: an `AreaTable.dbc` id to name, a `ServerMessages.dbc` row to
|
||||
//! format, and — for the two defense broadcasts — **the joined-channel list to walk**. That last one
|
||||
//! is why they cannot ride [`super::feed`]'s drain: the walk needs the AreaTable catalog and the
|
||||
//! zone under the player, which the chat drain does not hold. So the wire arms park them on
|
||||
//! [`super::ChatLog`]'s second queue and this module's system resolves each into an ordinary
|
||||
//! [`ChatEvent`], one frame earlier in the same schedule.
|
||||
//!
|
||||
//! ## The defense broadcasts are CHANNEL lines, not system lines
|
||||
//!
|
||||
//! This is the part a reimplementation gets wrong, and the reference is unambiguous about it. Both
|
||||
//! handlers (`0x49dcc0` for ZONE_UNDER_ATTACK, `0x49de30` for DEFENSE_MESSAGE) end in the same
|
||||
//! loop over the client's own joined-channel array (`[0xb4fe04]`, count `[0xb4fe00]`, stride
|
||||
//! `0xa0` — wow-re `system/ui/scratch/chat-msg-event-args.md` §7 owns that record), delivering the
|
||||
//! text once per surviving channel as chat type **`0xE` = `CHAT_MSG_CHANNEL`** with a NULL sender
|
||||
//! and language `0`. `0x49de04` and `0x49df4c` are the **only two sites in the whole image** that
|
||||
//! hand the composer a literal chat type `0xE`: engine-composed channel chat exists for these two
|
||||
//! opcodes and nothing else. A channel survives when:
|
||||
//!
|
||||
//! - its slot is taken — `[e+0x00]` is the **1-based slot number**, `0` meaning free, not a
|
||||
//! liveness flag — and its join state is `joined` (`[e+0x9c] == 0`);
|
||||
//! - its `ChatChannels.dbc` row exists and carries `DEFENSE` (`0x10000`) — `0x49dd94`
|
||||
//! `test ecx,0x10000`. In the shipped table that is exactly rows 22 (LocalDefense) and 23
|
||||
//! (WorldDefense);
|
||||
//! - and, **if** the row also carries `ZONE_DEP` (`0x2` — LocalDefense but not WorldDefense), the
|
||||
//! broadcast's zone equals the player's current zone (`0x49dda4`
|
||||
//! `cmp ecx, ds:0xb6e5d0`).
|
||||
//!
|
||||
//! So a character who has joined neither channel sees **nothing at all**, and that is the faithful
|
||||
//! answer rather than a dropped packet. The whole test is [`defense_targets`].
|
||||
//!
|
||||
//! **In practice that means LocalDefense alone**, and it is worth writing down because it later
|
||||
//! reads as a bug: only rows 1, 2 and 22 carry `INITIAL`, so the auto-join walk never joins
|
||||
//! **WorldDefense** — the reference does not either (its own `chat-cache.txt` persists
|
||||
//! `ZONECHANNELS 0x200003`, General + Trade + LocalDefense). vmangos sends `SMSG_DEFENSE_MESSAGE`
|
||||
//! to the whole *map*, but the client's filter narrows it to players standing in the broadcast's
|
||||
//! zone unless they typed `/join WorldDefense` themselves. An Eastern Plaguelands tower capture
|
||||
//! reaching only players in the Eastern Plaguelands is the mechanism working.
|
||||
//!
|
||||
//! **The zone compared is the PARENT.** Both handlers resolve the packet's id through
|
||||
//! `AreaTable.dbc` and replace it with the row's parent (`[rec+0x8]`) when that is nonzero
|
||||
//! (`0x49dd2b`, `0x49de80`) — so "Sentinel Hill is under attack!" is matched against *Westfall*, and
|
||||
//! a player standing anywhere in Westfall hears it. ZONE_UNDER_ATTACK's *text* still names the
|
||||
//! packet's own area, because the name is read off the row **before** the remap (`0x49dd05`).
|
||||
//!
|
||||
//! **A missing row is not the same refusal in the two handlers**, and that difference is
|
||||
//! deliberate on the client's side: ZONE_UNDER_ATTACK bails outright (`0x49dcdc`/`0x49dce8`/
|
||||
//! `0x49dcf9` all jump to the epilogue — with no row there is no name, so there is no line),
|
||||
//! while DEFENSE_MESSAGE keeps its wire text and simply skips the remap (`0x49de8a`), leaving the
|
||||
//! raw zone id as the comparison subject.
|
||||
//!
|
||||
//! ## `SMSG_SERVER_MESSAGE` is the ordinary one
|
||||
//!
|
||||
//! One `CHAT_MSG_SYSTEM` line (chat type `0xA`, `0x49e047`), composed by
|
||||
//! [`benilla_formats::ServerMessagesCatalog::compose`] — the DBC row is the format string, the
|
||||
//! packet's text is its `%s`, and a type with no row falls back to the client's own `"[%d]: %s"`.
|
||||
//!
|
||||
//! ## `SMSG_CHAT_RESTRICTED` is a system line too, and that is not the obvious answer
|
||||
//!
|
||||
//! Its arm reads nothing off the wire — `0x5e4a09` is `push 0x1c3; call 0x496720`, three
|
||||
//! instructions — so the whole packet is "show message 451". benilla already models
|
||||
//! `CGGameUI::DisplayError 0x496720` and its message registry
|
||||
//! ([`crate::ui_action::MsgSurface`]); what this packet needed was the two facts that registry
|
||||
//! entry carries, and both are VERIFIED here:
|
||||
//!
|
||||
//! - **451 is `ERR_CHAT_RESTRICTED`.** The string `"ERR_CHAT_RESTRICTED"` (`0x83f6d4`) has exactly
|
||||
//! ONE pointer anywhere in the image, at `0x4882f1`, inside the table's initializer — and the
|
||||
//! record it is written into is `0xb4d7d4`, which is `0xb4b498 + 451 × 20` on the nose.
|
||||
//! - **Its surface is the chat window, not the red toast.** The registrar `0x488410` stores its
|
||||
//! second argument at `[rec+0x04]`, and that call site pushes **`0`** — the arm `0x496822`, which
|
||||
//! ends `call 0x49a870` with `edx = [rec+0x10]`. That field is `esi` throughout the initializer,
|
||||
//! loaded once at `0x484cac` as **`0xA` = `CHAT_MSG_SYSTEM`**. (Its neighbours in the same block
|
||||
//! — `ERR_MAIL_REACHED_CAP`, `ERR_INVALID_RAID_TARGET` — push `2`, the red line, which is what
|
||||
//! makes the `0` here a deliberate difference rather than a default.)
|
||||
//!
|
||||
//! So a trial account's whisper cap prints "Trial accounts cannot send unlimited tells…" into chat.
|
||||
//! It is a dead letter against vmangos, which has no trial accounts and never sends it — but the
|
||||
//! reference is the spec, and an opcode we drop is one we cannot notice arriving.
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
use benilla_assets::{LockRecover, WorldAssets};
|
||||
use benilla_formats::{chat_channel_flags as chan_flags, ServerMessagesCatalog};
|
||||
|
||||
use crate::area::AreaTableRes;
|
||||
|
||||
use super::edit::ChannelState;
|
||||
use super::event::{ChatEvent, ChatEventKind};
|
||||
use super::feed::ChatLog;
|
||||
|
||||
/// `ServerMessages.dbc`, read once at Startup — the five shutdown/restart sentences.
|
||||
#[derive(Resource)]
|
||||
pub(crate) struct ServerMessages(pub(crate) ServerMessagesCatalog);
|
||||
|
||||
/// One parked world broadcast, awaiting the catalogs and the joined-channel walk.
|
||||
///
|
||||
/// Kept as the *wire* facts rather than a composed line for the reason the module doc gives: the
|
||||
/// resolve needs resources the packet arm does not hold, and half-resolving at the arm would put
|
||||
/// the AreaTable lookup in one place and the channel walk in another.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) enum Broadcast {
|
||||
/// `SMSG_ZONE_UNDER_ATTACK` — the `AreaTable.dbc` id of the area under attack.
|
||||
ZoneUnderAttack { area_id: u32 },
|
||||
/// `SMSG_DEFENSE_MESSAGE` — the zone the broadcast is about, and the server's own text.
|
||||
Defense { zone_id: u32, text: String },
|
||||
/// `SMSG_SERVER_MESSAGE` — the `ServerMessages.dbc` row id and the text filling its `%s`.
|
||||
Server { message_type: u32, text: String },
|
||||
/// `SMSG_CHAT_RESTRICTED` — no payload at all. Parked here rather than on
|
||||
/// [`crate::ui_action::UiErrorKeys`] because its registry entry names the **chat** surface, and
|
||||
/// that queue's drain only knows the two `UIErrorsFrame` arms; the key still resolves through
|
||||
/// the VM's own `GlobalStrings.lua`, like every other `DisplayError` tenant.
|
||||
ChatRestricted,
|
||||
}
|
||||
|
||||
/// Load `ServerMessages.dbc`. `.after(benilla_assets::AssetSet::Open)` at the call site is
|
||||
/// load-bearing for the reason [`super::channels::load_chat_channels`] records: without it this
|
||||
/// runs before the patch chain exists, takes its `None` arm and silently loads nothing.
|
||||
pub(super) fn load_server_messages(mut commands: Commands, assets: Option<Res<WorldAssets>>) {
|
||||
let Some(assets) = assets else { return };
|
||||
let loaded = {
|
||||
let mut chain = assets.chain.lock_recover();
|
||||
benilla_formats::load_server_messages_catalog(&mut chain)
|
||||
};
|
||||
match loaded {
|
||||
// Logged like its two neighbours, and for their reason: a silently-empty load is the exact
|
||||
// failure `load_chat_channels`'s comment describes, and a count in the run log is the only
|
||||
// thing that distinguishes it from a healthy one.
|
||||
Ok(cat) => {
|
||||
info!("chat: {} ServerMessages rows", cat.len());
|
||||
commands.insert_resource(ServerMessages(cat));
|
||||
}
|
||||
Err(e) => warn!("chat: ServerMessages.dbc failed to load: {e:#}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The joined channels a defense broadcast about `subject_zone` reaches, in slot order.
|
||||
///
|
||||
/// The reference's per-entry test, transcribed (module doc): live + joined, a `ChatChannels.dbc`
|
||||
/// row carrying `DEFENSE`, and — only for a `ZONE_DEP` row — the zone match. `player_zone` is
|
||||
/// `None` before the world under the player has streamed, which fails the zone-dependent arm and
|
||||
/// leaves the global one (WorldDefense) intact: the same split the reference gets from a
|
||||
/// not-yet-written `[0xb6e5d0]`.
|
||||
///
|
||||
/// benilla's joined list holds names, not records, so `+0x9c`'s join state has no representative
|
||||
/// here — an entry in [`ChannelState::joined`] is one the server confirmed with YOU_JOINED, which
|
||||
/// is the state the reference's `== 0` test selects. The suspended state (`3`) is unmodeled on
|
||||
/// both sides of that list (see [`super::feed::deliver`]'s note).
|
||||
pub(crate) fn defense_targets(
|
||||
channels: &ChannelState,
|
||||
subject_zone: u32,
|
||||
player_zone: Option<u32>,
|
||||
) -> Vec<String> {
|
||||
channels
|
||||
.joined
|
||||
.iter()
|
||||
.flatten()
|
||||
.filter(|name| {
|
||||
let Some(row) = channels.channels.row_for_name(name) else {
|
||||
return false; // a custom channel — no DBC row, no defense flag
|
||||
};
|
||||
if row.flags & chan_flags::DEFENSE == 0 {
|
||||
return false;
|
||||
}
|
||||
row.flags & chan_flags::ZONE_DEP == 0 || player_zone == Some(subject_zone)
|
||||
})
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The `ZONE_UNDER_ATTACK` line — FrameXML's own template with the area's name in its `%s`.
|
||||
///
|
||||
/// `replacen(.., 1)` because the reference's `snprintf` at `0x49dd26` takes **one** vararg: a
|
||||
/// localization carrying a second `%s` would fill it with stack garbage there, and filling only the
|
||||
/// first is the closest honest thing (the [`ServerMessagesCatalog::compose`] argument exactly).
|
||||
///
|
||||
/// The template is `"|cffffff00%s is under attack!|r"` in the shipped enUS strings, but it is never
|
||||
/// quoted here: the engine reads it out of the VM's globals (`FrameScript_GetText`, `0x49dd14`),
|
||||
/// which is what keeps a localized install localized.
|
||||
fn zone_under_attack_line(template: &str, area_name: &str) -> String {
|
||||
template.replacen("%s", area_name, 1)
|
||||
}
|
||||
|
||||
/// The parent zone a broadcast's area id resolves to — `[rec+0x8]` when nonzero, else the id
|
||||
/// itself (`0x49dd2b`/`0x49de80`). `None` when the table has no such row, which the two callers
|
||||
/// answer differently (module doc).
|
||||
fn parent_zone(areas: &AreaTableRes, id: u32) -> Option<u32> {
|
||||
let row = areas.0.get(id)?;
|
||||
Some(if row.zone_id == 0 { id } else { row.zone_id })
|
||||
}
|
||||
|
||||
/// Resolve every parked broadcast into chat events. Runs before [`super::feed::feed_chat`] so a
|
||||
/// broadcast lands the same frame it decodes, like every other chat source.
|
||||
pub(super) fn feed_broadcasts(
|
||||
mut log: ResMut<ChatLog>,
|
||||
script: Option<NonSendMut<benilla_ui::script::UiScript>>,
|
||||
channels: Res<ChannelState>,
|
||||
areas: Option<Res<AreaTableRes>>,
|
||||
messages: Option<Res<ServerMessages>>,
|
||||
world: benilla_world::world_point::WorldPoint,
|
||||
) {
|
||||
if log.broadcasts_pending() == 0 {
|
||||
return;
|
||||
}
|
||||
// The `ZONE_UNDER_ATTACK` format string is FrameXML's, read out of the VM's own globals — so
|
||||
// there is nothing to resolve until the VM exists. Park rather than drop: the queue is cleared
|
||||
// at session end like the rest of the log.
|
||||
let Some(script) = script else { return };
|
||||
// The zone under the player — the reference's `[0xb6e5d0]`. `top_zone` is `crate::area`'s and
|
||||
// `channels`' own answer to "which zone am I in", and equals the single-hop parent on 5875 data.
|
||||
let player_zone = areas
|
||||
.as_ref()
|
||||
.and_then(|a| world.area().and_then(|leaf| a.0.top_zone(leaf)));
|
||||
for item in log.take_broadcasts() {
|
||||
match item {
|
||||
Broadcast::ZoneUnderAttack { area_id } => {
|
||||
// No catalog and no row are the same refusal here: with no name there is no line.
|
||||
let Some((areas, fmt)) = areas
|
||||
.as_ref()
|
||||
.zip(super::combat::global_string(&script, "ZONE_UNDER_ATTACK"))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let (Some(name), Some(zone)) = (areas.0.name(area_id), parent_zone(areas, area_id))
|
||||
else {
|
||||
debug!("chat: ZONE_UNDER_ATTACK for unknown area {area_id}");
|
||||
continue;
|
||||
};
|
||||
let text = zone_under_attack_line(&fmt, name);
|
||||
push_channel_lines(&mut log, &channels, zone, player_zone, &text);
|
||||
}
|
||||
Broadcast::Defense { zone_id, text } => {
|
||||
// The wire text stands on its own, so an unresolvable zone only costs the remap.
|
||||
let zone = areas
|
||||
.as_ref()
|
||||
.and_then(|a| parent_zone(a, zone_id))
|
||||
.unwrap_or(zone_id);
|
||||
push_channel_lines(&mut log, &channels, zone, player_zone, &text);
|
||||
}
|
||||
Broadcast::ChatRestricted => {
|
||||
// An absent key shows nothing — this crate's uniform GlobalStrings stance, and a
|
||||
// deliberate half-step short of the reference: `0x496720` suppresses on the message
|
||||
// record's own key field being NULL or empty (`0x4967c5`), but `FrameScript_GetText`
|
||||
// returns `""` rather than NULL for a missing global (VERIFIED, wow-re
|
||||
// `world-broadcast-opcodes.md` §5), so a broken `GlobalStrings.lua` would make the
|
||||
// reference print an EMPTY line here rather than none. Showing nothing is what every
|
||||
// other tenant of this route does; diverging in one module would be the
|
||||
// inconsistency.
|
||||
if let Some(text) = super::combat::global_string(&script, "ERR_CHAT_RESTRICTED") {
|
||||
log.push_event(ChatEvent::text_only(ChatEventKind::System, text));
|
||||
}
|
||||
}
|
||||
Broadcast::Server { message_type, text } => {
|
||||
let Some(messages) = messages.as_ref() else {
|
||||
debug!("chat: SMSG_SERVER_MESSAGE {message_type} dropped: no catalog");
|
||||
continue;
|
||||
};
|
||||
log.push_event(ChatEvent::text_only(
|
||||
ChatEventKind::System,
|
||||
messages.0.compose(message_type, &text),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One `CHAT_MSG_CHANNEL` line per surviving defense channel — the reference's loop body.
|
||||
///
|
||||
/// `sender` and `language` stay empty: the handlers push NULL for both (`0x49ddf5`/`0x49ddf7`), so
|
||||
/// arg2 and arg3 are `""` and `ChatFrame_OnEvent` renders `[LocalDefense] : <text>` — the bare
|
||||
/// colon is the reference's own output for a senderless channel line, not a gap here.
|
||||
fn push_channel_lines(
|
||||
log: &mut ChatLog,
|
||||
channels: &ChannelState,
|
||||
zone: u32,
|
||||
player_zone: Option<u32>,
|
||||
text: &str,
|
||||
) {
|
||||
for channel in defense_targets(channels, zone, player_zone) {
|
||||
log.push_event(ChatEvent {
|
||||
kind: Some(ChatEventKind::Channel),
|
||||
text: text.to_string(),
|
||||
channel,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use benilla_formats::{ChatChannelRow, ChatChannelsCatalog};
|
||||
|
||||
/// The three shipped rows that matter here, as the real DBC carries them (asserted against the
|
||||
/// file by `benilla_formats::chat_channels`'s own test).
|
||||
fn catalog() -> ChatChannelsCatalog {
|
||||
ChatChannelsCatalog::from_rows(vec![
|
||||
ChatChannelRow {
|
||||
id: 1,
|
||||
flags: 0x0_0003,
|
||||
pattern: "General - %s".into(),
|
||||
shortcut: "General".into(),
|
||||
},
|
||||
ChatChannelRow {
|
||||
id: 22,
|
||||
flags: 0x1_0003,
|
||||
pattern: "LocalDefense - %s".into(),
|
||||
shortcut: "LocalDefense".into(),
|
||||
},
|
||||
ChatChannelRow {
|
||||
id: 23,
|
||||
flags: 0x1_0004,
|
||||
pattern: "WorldDefense".into(),
|
||||
shortcut: "WorldDefense".into(),
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
fn state(joined: &[&str]) -> ChannelState {
|
||||
ChannelState {
|
||||
joined: joined.iter().map(|n| Some((*n).to_string())).collect(),
|
||||
channels: catalog(),
|
||||
}
|
||||
}
|
||||
|
||||
const WESTFALL: u32 = 40;
|
||||
const ELWYNN: u32 = 12;
|
||||
|
||||
/// The whole selection in one: General is skipped (no DEFENSE bit), WorldDefense always fires,
|
||||
/// LocalDefense only when the broadcast is about the zone the player is standing in.
|
||||
#[test]
|
||||
fn only_defense_channels_hear_it_and_local_only_in_zone() {
|
||||
let s = state(&[
|
||||
"General - Westfall",
|
||||
"LocalDefense - Westfall",
|
||||
"WorldDefense",
|
||||
]);
|
||||
assert_eq!(
|
||||
defense_targets(&s, WESTFALL, Some(WESTFALL)),
|
||||
vec!["LocalDefense - Westfall", "WorldDefense"]
|
||||
);
|
||||
// Standing in Elwynn: the local channel we hold is Elwynn's, so a Westfall alarm reaches
|
||||
// only the global one.
|
||||
let s = state(&[
|
||||
"General - Elwynn Forest",
|
||||
"LocalDefense - Elwynn Forest",
|
||||
"WorldDefense",
|
||||
]);
|
||||
assert_eq!(
|
||||
defense_targets(&s, WESTFALL, Some(ELWYNN)),
|
||||
vec!["WorldDefense"]
|
||||
);
|
||||
}
|
||||
|
||||
/// Joined nothing that carries the flag ⇒ the broadcast is silent. This is the faithful
|
||||
/// answer, and the reason a "why did nothing print?" report is not automatically a bug.
|
||||
#[test]
|
||||
fn a_player_in_no_defense_channel_hears_nothing() {
|
||||
let s = state(&["General - Westfall"]);
|
||||
assert!(defense_targets(&s, WESTFALL, Some(WESTFALL)).is_empty());
|
||||
}
|
||||
|
||||
/// A custom channel has no DBC row, so it can never carry the flag — and asking must not
|
||||
/// panic on the missing row.
|
||||
#[test]
|
||||
fn a_custom_channel_is_never_a_defense_channel() {
|
||||
let s = state(&["mydefense"]);
|
||||
assert!(defense_targets(&s, WESTFALL, Some(WESTFALL)).is_empty());
|
||||
}
|
||||
|
||||
/// Before the world under the player has streamed there is no zone to compare, which fails the
|
||||
/// zone-dependent arm and leaves the global one — not a dropped broadcast.
|
||||
#[test]
|
||||
fn an_unknown_player_zone_still_reaches_world_defense() {
|
||||
let s = state(&["LocalDefense - Westfall", "WorldDefense"]);
|
||||
assert_eq!(defense_targets(&s, WESTFALL, None), vec!["WorldDefense"]);
|
||||
}
|
||||
|
||||
/// Freed slots are holes in the list ([`ChannelState::free_slot`] clears in place so the
|
||||
/// numbers above do not move); the walk skips them rather than stopping at the first.
|
||||
#[test]
|
||||
fn a_freed_slot_does_not_end_the_walk() {
|
||||
let mut s = state(&["LocalDefense - Westfall", "WorldDefense"]);
|
||||
s.joined[0] = None;
|
||||
assert_eq!(
|
||||
defense_targets(&s, WESTFALL, Some(WESTFALL)),
|
||||
vec!["WorldDefense"]
|
||||
);
|
||||
}
|
||||
|
||||
/// The template's `%s` takes the **area**'s name, not the zone's — the subject is Sentinel Hill
|
||||
/// even though the channel test ran against Westfall. The color codes ride through untouched:
|
||||
/// they are the reference's own, and the renderer eats them downstream.
|
||||
#[test]
|
||||
fn the_line_fills_the_frame_xml_template_with_the_area_name() {
|
||||
assert_eq!(
|
||||
zone_under_attack_line("|cffffff00%s is under attack!|r", "Sentinel Hill"),
|
||||
"|cffffff00Sentinel Hill is under attack!|r"
|
||||
);
|
||||
}
|
||||
|
||||
/// **The rendered line has a bare colon, and that is the reference's output, not a gap.**
|
||||
/// `ChatFrame_OnEvent` (l.1449-1452) formats `CHAT_CHANNEL_GET` = `"%s: "` with `pflag..arg2`,
|
||||
/// and the two handlers push a NULL sender — so the `%s` is empty and the colon is left
|
||||
/// hanging. Pinned here because it looks exactly like a bug, and re-"fixing" it would be a
|
||||
/// divergence.
|
||||
#[test]
|
||||
fn a_defense_line_renders_with_the_references_bare_colon() {
|
||||
let event = ChatEvent {
|
||||
kind: Some(ChatEventKind::Channel),
|
||||
text: "|cffffff00Sentinel Hill is under attack!|r".into(),
|
||||
// arg4 as `stamp_channel` leaves it once the channel is joined: numbered.
|
||||
channel: "3. LocalDefense - Westfall".into(),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
super::super::frames::compose(&event, ChatEventKind::Channel, "Common").unwrap(),
|
||||
"[3. LocalDefense] : |cffffff00Sentinel Hill is under attack!|r"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -189,9 +189,34 @@ pub(super) fn text_emote_event(
|
||||
|
||||
/// The pending chat items the net/loot/quest feeds fill and [`feed_chat`] drains. Cleared on
|
||||
/// disconnect (a half-resolved line from a dead session must not leak into the next).
|
||||
///
|
||||
/// **Two queues, two drains.** [`Self::pending`] is the ordinary one. [`Self::broadcasts`] is the
|
||||
/// world broadcasts' ([`super::broadcast`]), and it is separate for one reason: their resolve needs
|
||||
/// the `AreaTable.dbc` catalog, the zone under the player and the joined-channel walk, which
|
||||
/// [`feed_chat`] does not hold and cannot grow to hold (it is at Bevy's `SystemParam` ceiling).
|
||||
/// `feed_broadcasts` drains this one a step earlier in the same schedule and pushes the resolved
|
||||
/// lines back onto [`Self::pending`], so a broadcast still lands on the frame it decodes.
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct ChatLog {
|
||||
pending: Vec<Pending>,
|
||||
broadcasts: Vec<super::broadcast::Broadcast>,
|
||||
}
|
||||
|
||||
impl ChatLog {
|
||||
/// Queue one world broadcast for [`super::broadcast::feed_broadcasts`]'s resolve pass.
|
||||
pub(crate) fn push_broadcast(&mut self, b: super::broadcast::Broadcast) {
|
||||
self.broadcasts.push(b);
|
||||
}
|
||||
|
||||
/// How many broadcasts are waiting — the drain's cheap early-out.
|
||||
pub(crate) fn broadcasts_pending(&self) -> usize {
|
||||
self.broadcasts.len()
|
||||
}
|
||||
|
||||
/// Take the parked broadcasts, leaving the queue empty.
|
||||
pub(crate) fn take_broadcasts(&mut self) -> Vec<super::broadcast::Broadcast> {
|
||||
std::mem::take(&mut self.broadcasts)
|
||||
}
|
||||
}
|
||||
|
||||
impl ChatLog {
|
||||
|
||||
@@ -319,7 +319,20 @@ fn is_instrument_consumer(rel: &str) -> bool {
|
||||
/// renderer a roster to read to keep a count flat (decision 1731). Note the direction of travel:
|
||||
/// this replaced `FfxGlow::state_scale`, a float that let a bake inherit a player-state lane by
|
||||
/// arithmetic — the enum that took its place makes report B49's invariant structural.
|
||||
const CEILING: usize = 165;
|
||||
/// And 165 → 166: `collision::MoverTraceExclusions`, a PUBLISH in the same class as the two
|
||||
/// one-bit resources above — a resource the ENGINE reads and the GAME writes. The reference's
|
||||
/// world trace carries a per-trace mask, and the local mover's gains bit `0x8000` when the body it
|
||||
/// drives is a player in ghost form; the GameObject collision-candidacy virtual `0x5f85f0` reads
|
||||
/// it and drops every `GAMEOBJECT_TYPE_ID == 0` object, so a ghost walks through closed doors
|
||||
/// (decision 1767). The engine cannot compute that set — "a DOOR GameObject, while the player is a
|
||||
/// ghost" names two gameplay concepts, which is precisely what the wall pointing the other way
|
||||
/// forbids an engine file from knowing. The alternatives were both worse: dropping or re-laning
|
||||
/// the door's collider makes it stop existing for the particle snap, the precipitation probe, the
|
||||
/// mouse pick and the creature conform, none of which the binary touches; and threading a filter
|
||||
/// through `step`/`grounded_step`/`airborne_step` puts a gameplay argument into the movement
|
||||
/// core's signature. An `EntityHashSet` that is empty on every living frame is the smallest honest
|
||||
/// expression, and it makes the living player's query provably the one it always was.
|
||||
const CEILING: usize = 166;
|
||||
|
||||
/// How far under [`CEILING`] the real count may sit before this test asks for the ceiling to be
|
||||
/// lowered. Slack, not tolerance: it keeps a single closure from failing the gate, while making it
|
||||
|
||||
@@ -284,6 +284,8 @@ mod chat_channels;
|
||||
pub use chat_channels::{
|
||||
flags as chat_channel_flags, load_chat_channels_catalog, ChatChannelRow, ChatChannelsCatalog,
|
||||
};
|
||||
mod server_messages;
|
||||
pub use server_messages::{load_server_messages_catalog, ServerMessagesCatalog};
|
||||
mod race_sound;
|
||||
pub use race_sound::{load_exploration_sound_catalog, ExplorationSoundCatalog};
|
||||
mod zone_map;
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
//! `ServerMessages.dbc` — the five sentences the server can say to everybody at once.
|
||||
//!
|
||||
//! `SMSG_SERVER_MESSAGE` carries a **row id and a fill**, never a finished line: the client indexes
|
||||
//! this table by the packet's `messageType` (`0x49dfbb`, the store at `[0xc0d990]`), takes the
|
||||
//! row's localized text as a **format string**, and fills its `%s` with the packet's own text
|
||||
//! (`snprintf(buf, 0x400, rowText, packetText)` at `0x49dff0`). A packet with an empty text copies
|
||||
//! the row verbatim instead (`0x49dfc8` tests the first byte, `0x64a5a0` copies) — which is what
|
||||
//! rows 4 and 5 need, having no `%s` at all.
|
||||
//!
|
||||
//! Layout — the real 5875 file (326 bytes, 5 records × **10 × u32** cols, 106-byte string block):
|
||||
//! `ID(0), Text[8](1..8), TextMask(9)`. The whole shipped table, verbatim:
|
||||
//!
|
||||
//! | ID | enUS text | vmangos sends it with |
|
||||
//! |---|---|---|
|
||||
//! | 1 | `[SERVER] Shutdown in %s` | `secsToTimeString(remaining)` |
|
||||
//! | 2 | `[SERVER] Restart in %s` | `secsToTimeString(remaining)` |
|
||||
//! | 3 | `%s` | the operator's own text |
|
||||
//! | 4 | `[SERVER] Shutdown cancelled` | `""` |
|
||||
//! | 5 | `[SERVER] Restart cancelled` | `""` |
|
||||
//!
|
||||
//! The ids are vmangos's `enum ServerMessageType` (`src/game/World.h:62`) exactly, and its comment
|
||||
//! on that enum is the single word `ServerMessages.dbc` — the server is reading this file's ids out
|
||||
//! of the client's own data.
|
||||
//!
|
||||
//! **The row-missing arm is part of the mechanism, not an error path.** A type with no row renders
|
||||
//! through the client's own fallback format `"[%d]: %s"` (`0x844864`, read out of the image), so an
|
||||
//! unknown broadcast still reaches the player with its type visible rather than being dropped.
|
||||
//! [`ServerMessagesCatalog::compose`] is that whole decision in one function.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use benilla_dbc::{FieldType, Schema, SchemaField};
|
||||
|
||||
use crate::chain::Chain;
|
||||
use crate::dbc::{parse, str_at, u32_at};
|
||||
|
||||
const SERVER_MESSAGES: &str = "DBFilesClient\\ServerMessages.dbc";
|
||||
|
||||
/// The five rows, keyed by id.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct ServerMessagesCatalog {
|
||||
rows: Vec<(u32, String)>,
|
||||
}
|
||||
|
||||
impl ServerMessagesCatalog {
|
||||
/// A catalog over rows given directly — for a test that must run without an install.
|
||||
pub fn from_rows(rows: Vec<(u32, String)>) -> Self {
|
||||
ServerMessagesCatalog { rows }
|
||||
}
|
||||
|
||||
/// The localized format string for a `messageType`, if the table has that row.
|
||||
pub fn text(&self, message_type: u32) -> Option<&str> {
|
||||
self.rows
|
||||
.iter()
|
||||
.find(|(id, _)| *id == message_type)
|
||||
.map(|(_, t)| t.as_str())
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.rows.is_empty()
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.rows.len()
|
||||
}
|
||||
|
||||
/// The line `SMSG_SERVER_MESSAGE` becomes — the client's `0x49dfab`–`0x49e030` in full.
|
||||
///
|
||||
/// Three arms, in the reference's own order: a row plus a non-empty fill formats; a row plus an
|
||||
/// empty fill copies the row (so `[SERVER] Restart cancelled` never grows a stray tail); no row
|
||||
/// falls back to `"[%d]: %s"` with the raw type.
|
||||
///
|
||||
/// The substitution is `replacen(.., 1)` because the reference's is `snprintf` with **one**
|
||||
/// vararg: a row carrying two `%s` would fill the second with stack garbage, and filling only
|
||||
/// the first is the closest honest thing. No shipped row has two.
|
||||
pub fn compose(&self, message_type: u32, fill: &str) -> String {
|
||||
match self.text(message_type) {
|
||||
Some(row) if !fill.is_empty() => row.replacen("%s", fill, 1),
|
||||
Some(row) => row.to_string(),
|
||||
None => format!("[{message_type}]: {fill}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn schema() -> Schema {
|
||||
let mut s = Schema::new("ServerMessages");
|
||||
s.add_field(SchemaField::new("ID", FieldType::UInt32));
|
||||
s.add_field(SchemaField::new_array("Text", FieldType::String, 8));
|
||||
s.add_field(SchemaField::new("TextMask", FieldType::UInt32));
|
||||
s.set_key_field("ID");
|
||||
s
|
||||
}
|
||||
|
||||
pub fn load_server_messages_catalog(chain: &mut Chain) -> Result<ServerMessagesCatalog> {
|
||||
let bytes = chain
|
||||
.read_file(SERVER_MESSAGES)
|
||||
.context("reading ServerMessages.dbc")?;
|
||||
let rs = parse(&bytes, schema(), "ServerMessages")?;
|
||||
let mut rows = Vec::with_capacity(rs.records().len());
|
||||
for r in rs.records() {
|
||||
let (Some(id), Some(text)) = (u32_at(r, 0), str_at(&rs, r, 1)) else {
|
||||
continue;
|
||||
};
|
||||
rows.push((id, text));
|
||||
}
|
||||
Ok(ServerMessagesCatalog { rows })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The real shipped table, row for row — the module doc's table asserted rather than trusted,
|
||||
/// because these five strings are the whole vocabulary of a server shutdown. Skips without
|
||||
/// client data.
|
||||
#[test]
|
||||
fn the_real_table_is_the_five_documented_rows() {
|
||||
let data = crate::wow_data_or_skip!();
|
||||
let mut chain = crate::open_chain(&data).expect("open chain");
|
||||
let cat = load_server_messages_catalog(&mut chain).expect("load ServerMessages");
|
||||
let got: Vec<(u32, &str)> = cat.rows.iter().map(|(i, t)| (*i, t.as_str())).collect();
|
||||
assert_eq!(
|
||||
got,
|
||||
vec![
|
||||
(1, "[SERVER] Shutdown in %s"),
|
||||
(2, "[SERVER] Restart in %s"),
|
||||
(3, "%s"),
|
||||
(4, "[SERVER] Shutdown cancelled"),
|
||||
(5, "[SERVER] Restart cancelled"),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
fn shipped() -> ServerMessagesCatalog {
|
||||
ServerMessagesCatalog::from_rows(vec![
|
||||
(1, "[SERVER] Shutdown in %s".into()),
|
||||
(2, "[SERVER] Restart in %s".into()),
|
||||
(3, "%s".into()),
|
||||
(4, "[SERVER] Shutdown cancelled".into()),
|
||||
(5, "[SERVER] Restart cancelled".into()),
|
||||
])
|
||||
}
|
||||
|
||||
/// A countdown: the row is the format, the packet is the fill.
|
||||
#[test]
|
||||
fn a_countdown_fills_its_row() {
|
||||
assert_eq!(
|
||||
shipped().compose(1, "15 Minutes"),
|
||||
"[SERVER] Shutdown in 15 Minutes"
|
||||
);
|
||||
assert_eq!(shipped().compose(3, "back in five"), "back in five");
|
||||
}
|
||||
|
||||
/// A cancellation carries no fill, and the row is already a whole sentence — the reference
|
||||
/// copies rather than formats, so nothing is appended and no `%s` survives.
|
||||
#[test]
|
||||
fn a_cancellation_copies_its_row_whole() {
|
||||
assert_eq!(shipped().compose(5, ""), "[SERVER] Restart cancelled");
|
||||
}
|
||||
|
||||
/// A row-less type still reaches the player, through the client's own `"[%d]: %s"`.
|
||||
#[test]
|
||||
fn an_unknown_type_falls_back_to_the_bracketed_form() {
|
||||
assert_eq!(shipped().compose(9, "something new"), "[9]: something new");
|
||||
}
|
||||
|
||||
/// The empty-fill test is on the FILL, not on whether the row has a `%s`: a row with a `%s` and
|
||||
/// no fill copies verbatim, leaving the token visible — which is what the reference does, and
|
||||
/// what no shipped row can actually reach (rows 1/2/3 always come with text).
|
||||
#[test]
|
||||
fn an_empty_fill_never_formats() {
|
||||
assert_eq!(shipped().compose(1, ""), "[SERVER] Shutdown in %s");
|
||||
}
|
||||
}
|
||||
@@ -641,6 +641,19 @@ pub enum SessionEvent {
|
||||
/// cannot enter … while in ghost form."). The reference sends it to the same system-message
|
||||
/// sink as [`Self::Notification`].
|
||||
AreaTriggerMessage { text: String },
|
||||
/// A shutdown/restart countdown or an operator broadcast (`SMSG_SERVER_MESSAGE`): the
|
||||
/// `ServerMessages.dbc` row id and the text that fills its `%s`. The reference resolves the row
|
||||
/// itself and shows the formatted line as `CHAT_MSG_SYSTEM`.
|
||||
ServerMessage { message_type: u32, text: String },
|
||||
/// An area is under attack by enemy players (`SMSG_ZONE_UNDER_ATTACK`): one `AreaTable.dbc`
|
||||
/// id. Becomes a `CHAT_MSG_CHANNEL` line on the joined **defense** channels, not a system line.
|
||||
ZoneUnderAttack { area_id: u32 },
|
||||
/// A world-defense broadcast (`SMSG_DEFENSE_MESSAGE` — the EPL tower captures): the zone it is
|
||||
/// about, and the server-composed text. [`Self::ZoneUnderAttack`]'s destination exactly.
|
||||
DefenseMessage { zone_id: u32, text: String },
|
||||
/// A trial account hit its whisper cap (`SMSG_CHAT_RESTRICTED`); empty body. The reference
|
||||
/// answers it with `DisplayError(0x1c3)` and reads nothing off the wire.
|
||||
ChatRestricted,
|
||||
/// Answers `/played` (`SMSG_PLAYED_TIME`, our `CMSG_PLAYED_TIME`): total played time + time
|
||||
/// since the last level-up, both in seconds.
|
||||
PlayedTime { total: u32, level: u32 },
|
||||
|
||||
@@ -162,6 +162,16 @@ pub fn decode(packet: ServerPacket) -> Vec<SessionEvent> {
|
||||
ServerPacket::AreaTriggerMessage { text } => {
|
||||
vec![SessionEvent::AreaTriggerMessage { text }]
|
||||
}
|
||||
ServerPacket::ServerMessage { message_type, text } => {
|
||||
vec![SessionEvent::ServerMessage { message_type, text }]
|
||||
}
|
||||
ServerPacket::ZoneUnderAttack { area_id } => {
|
||||
vec![SessionEvent::ZoneUnderAttack { area_id }]
|
||||
}
|
||||
ServerPacket::DefenseMessage { zone_id, text } => {
|
||||
vec![SessionEvent::DefenseMessage { zone_id, text }]
|
||||
}
|
||||
ServerPacket::ChatRestricted => vec![SessionEvent::ChatRestricted],
|
||||
ServerPacket::PlayedTime { total, level } => {
|
||||
vec![SessionEvent::PlayedTime { total, level }]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
//! The four **world broadcasts** — the packets the server sends to everybody, or to everybody in a
|
||||
//! zone, rather than to one player about their own doing.
|
||||
//!
|
||||
//! They have no shared wire family (two carry a zone id, one a DBC index, one nothing at all), and
|
||||
//! they are grouped here because they share a *destination*: all four end at a chat line or a UI
|
||||
//! toast the player did not ask for. The client's own handlers are three adjacent functions plus
|
||||
//! one arm of a shared dispatcher, and two of them are the same function twice over.
|
||||
//!
|
||||
//! | opcode | handler | what it becomes |
|
||||
//! |---|---|---|
|
||||
//! | `SMSG_ZONE_UNDER_ATTACK` `0x254` | `0x49dcc0` | `CHAT_MSG_CHANNEL` on the defense channels |
|
||||
//! | `SMSG_DEFENSE_MESSAGE` `0x33B` | `0x49de30` | `CHAT_MSG_CHANNEL` on the defense channels |
|
||||
//! | `SMSG_SERVER_MESSAGE` `0x291` | `0x49df80` | `CHAT_MSG_SYSTEM` |
|
||||
//! | `SMSG_CHAT_RESTRICTED` `0x2FD` | `0x5e38c0` arm `0x5e4a09` | `DisplayError(451)` → `CHAT_MSG_SYSTEM` |
|
||||
//!
|
||||
//! **The two defense broadcasts do not go where a reimplementation guesses.** Neither is a system
|
||||
//! line: the client walks its own **joined-channel list** (`[0xb4fe04]`, count `[0xb4fe00]`, stride
|
||||
//! `0xa0` — the record wow-re's `chat-msg-event-args.md` §7 owns) and delivers the text on every
|
||||
//! joined channel whose `ChatChannels.dbc` row carries the `DEFENSE` flag `0x10000`, as chat type
|
||||
//! `0xE` = `CHAT_MSG_CHANNEL` with **no sender** and language `0`. A `ZONE_DEP` (`0x2`) channel —
|
||||
//! LocalDefense — additionally requires the broadcast's zone to be the player's current one; a
|
||||
//! GLOBAL one — WorldDefense — always passes. So a player who has joined neither channel sees
|
||||
//! nothing at all, which is the faithful answer and not a dropped packet.
|
||||
//!
|
||||
//! The zone test is on the **parent** zone, not the packet's area: both handlers resolve the id
|
||||
//! through `AreaTable.dbc` and replace it with the row's parent (`[rec+0x8]`) when that is nonzero.
|
||||
//! `SMSG_ZONE_UNDER_ATTACK`'s *text* still names the packet's own area — the name is read off the
|
||||
//! row before the remap — so "Sentinel Hill is under attack!" is matched against Westfall.
|
||||
//!
|
||||
//! The wire shapes below are VERIFIED against vmangos source with a file:line cite per field, and
|
||||
//! confirmed against the client's own reads in the handlers above. The goldens
|
||||
//! (`tests/broadcast.rs`) are built the way the server builds each body.
|
||||
|
||||
use std::io;
|
||||
|
||||
use crate::wire::{read_cstring, read_u32_le};
|
||||
|
||||
/// Read `SMSG_SERVER_MESSAGE` (VERIFIED vmangos `WorldPackets::Misc::ServerMessage::AppendBodyTo`,
|
||||
/// `Server/Packets/Misc.cpp:341-345`): `u32 messageType` + one cstring.
|
||||
///
|
||||
/// `messageType` is a **`ServerMessages.dbc` row id**, not a free enum — the client indexes the
|
||||
/// store at `[0xc0d990]` by it directly (`0x49dfbb`) and takes the row's localized text as a
|
||||
/// **format string**, which the packet's own text fills. vmangos's producers are the five rows the
|
||||
/// shipped table has (`World.h:62`, whose comment says "ServerMessages.dbc"):
|
||||
///
|
||||
/// | type | `ServerMessages.dbc` text (enUS) | vmangos sends |
|
||||
/// |---|---|---|
|
||||
/// | 1 | `[SERVER] Shutdown in %s` | `secsToTimeString(remaining)` |
|
||||
/// | 2 | `[SERVER] Restart in %s` | `secsToTimeString(remaining)` |
|
||||
/// | 3 | `%s` | the operator's own text (`SERVER_MSG_CUSTOM`) |
|
||||
/// | 4 | `[SERVER] Shutdown cancelled` | `""` |
|
||||
/// | 5 | `[SERVER] Restart cancelled` | `""` |
|
||||
///
|
||||
/// The text is carried whole rather than being resolved here: the DBC lives on the *client* side
|
||||
/// of this crate's boundary (`benilla_formats::ServerMessagesCatalog`), and the row-missing
|
||||
/// fallback the client itself uses — `"[%d]: %s"` at `0x844864` — needs the raw type to render.
|
||||
pub(super) fn read_server_message(r: &mut &[u8]) -> io::Result<(u32, String)> {
|
||||
let message_type = read_u32_le(r)?;
|
||||
let text = read_cstring(r)?;
|
||||
Ok((message_type, text))
|
||||
}
|
||||
|
||||
/// Read `SMSG_ZONE_UNDER_ATTACK` (VERIFIED vmangos
|
||||
/// `WorldPackets::Misc::ZoneUnderAttack::AppendBodyTo`, `Server/Packets/Misc.cpp:451-454`): one
|
||||
/// `u32`, the `AreaTable.dbc` id of the area under attack.
|
||||
///
|
||||
/// vmangos sends it from `Creature::SendZoneUnderAttackMessage`
|
||||
/// (`src/game/Objects/Creature.cpp:2889`), whose sole caller is the creature-death path
|
||||
/// (`Objects/Unit.cpp:1200`) when the victim `IsGuard()` or carries
|
||||
/// `CREATURE_STATIC_FLAG_PVP_ENABLING` and the killer was a player. It goes **map-wide to the
|
||||
/// opposing team**, throttled to one per area per 10 s — and the function's own comment reads
|
||||
/// *"Send a message to LocalDefense channel for players opposition team in the zone"*, which is the
|
||||
/// server independently naming the destination the client's handler picks.
|
||||
pub(super) fn read_zone_under_attack(r: &mut &[u8]) -> io::Result<u32> {
|
||||
read_u32_le(r)
|
||||
}
|
||||
|
||||
/// Read `SMSG_DEFENSE_MESSAGE` (VERIFIED vmangos `Map::SendDefenseMessage`,
|
||||
/// `src/game/Maps/Map.cpp:1868-1884`): `u32 zoneId`, `u32 length`, then `length` bytes of text.
|
||||
///
|
||||
/// **Built raw, not through a packet class** — the whole body is three `ByteBuffer <<` writes
|
||||
/// inside a `#if SUPPORTED_CLIENT_BUILD > CLIENT_BUILD_1_11_2`, so this packet exists for 1.12 and
|
||||
/// no earlier build. The length is written as `strlen(text) + 1` and the `char const*` write
|
||||
/// appends the NUL, so it counts the terminator.
|
||||
///
|
||||
/// **The length is load-bearing here, unlike [`super::area_trigger::read_area_trigger_message`]'s.**
|
||||
/// The client does not read a C string at all: `0x49de5c` calls the `CDataStore` **borrow-and-skip**
|
||||
/// `0x419ac0`, which copies nothing, hands back a live pointer into the receive buffer and advances
|
||||
/// the cursor by exactly `length` — and which **zeroes its out-parameter before any bounds check**,
|
||||
/// so a `length` that overruns the packet reaches the chat composer as a NULL text and
|
||||
/// `0x49a870`'s NULL guard at `0x49a889` drops the line entirely (wow-re
|
||||
/// `system/net/scratch/cdatastore-get-primitives.md` §5, `system/ui/scratch/world-broadcast-opcodes.md`
|
||||
/// §3 — a §5 cross-check that corrected two committed notes calling `0x419ac0` a copy).
|
||||
///
|
||||
/// So an overrunning length is a **refusal**, not a hint to ignore: it errors here, and the session
|
||||
/// drops the packet, which is the same nothing-displayed the reference produces. Within the bounds
|
||||
/// the text is the borrowed window up to its first NUL — the reference reads past `length` when
|
||||
/// there is no NUL in it (an unmitigated OOB read, wow-re's words), which is the one place a
|
||||
/// faithful reimplementation stops short on purpose.
|
||||
///
|
||||
/// The Eastern Plaguelands tower captures are its only vmangos sender.
|
||||
pub(super) fn read_defense_message(r: &mut &[u8]) -> io::Result<(u32, String)> {
|
||||
let zone_id = read_u32_le(r)?;
|
||||
let length = read_u32_le(r)? as usize;
|
||||
if length > r.len() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::UnexpectedEof,
|
||||
format!(
|
||||
"SMSG_DEFENSE_MESSAGE: length {length} overruns the {} body bytes left",
|
||||
r.len()
|
||||
),
|
||||
));
|
||||
}
|
||||
let (window, rest) = r.split_at(length);
|
||||
*r = rest;
|
||||
let text = window.split(|b| *b == 0).next().unwrap_or(window);
|
||||
Ok((zone_id, String::from_utf8_lossy(text).into_owned()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_server_message_is_a_type_and_a_string() {
|
||||
let mut body = 2u32.to_le_bytes().to_vec();
|
||||
body.extend_from_slice(b"15 Minutes\0");
|
||||
assert_eq!(
|
||||
read_server_message(&mut &body[..]).unwrap(),
|
||||
(2, "15 Minutes".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
/// The cancel messages carry an EMPTY text, and the row's own string is the whole line — so an
|
||||
/// empty string here is a value, never a parse failure.
|
||||
#[test]
|
||||
fn a_cancel_message_carries_an_empty_text() {
|
||||
let mut body = 4u32.to_le_bytes().to_vec();
|
||||
body.push(0);
|
||||
assert_eq!(
|
||||
read_server_message(&mut &body[..]).unwrap(),
|
||||
(4, String::new())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zone_under_attack_is_the_bare_area_id() {
|
||||
let body = 40u32.to_le_bytes();
|
||||
assert_eq!(read_zone_under_attack(&mut &body[..]).unwrap(), 40);
|
||||
}
|
||||
|
||||
/// The length prefix is skipped and the text read whole — built the way `Map.cpp` builds it,
|
||||
/// terminator counted.
|
||||
#[test]
|
||||
fn a_defense_message_skips_its_length_and_reads_the_text() {
|
||||
let text = b"The Eastern Plaguelands tower has been taken!";
|
||||
let mut body = 139u32.to_le_bytes().to_vec();
|
||||
body.extend_from_slice(&(text.len() as u32 + 1).to_le_bytes());
|
||||
body.extend_from_slice(text);
|
||||
body.push(0);
|
||||
let (zone, out) = read_defense_message(&mut &body[..]).unwrap();
|
||||
assert_eq!(zone, 139);
|
||||
assert_eq!(out, String::from_utf8_lossy(text));
|
||||
}
|
||||
|
||||
/// A truncated body is an error, not a silently empty line — and it is the *declared length*
|
||||
/// that decides, because that is what the reference bounds-checks. An overrun leaves its
|
||||
/// out-pointer NULL and the chat composer drops the line; erroring here reproduces the same
|
||||
/// nothing-displayed, with a log line instead of silence.
|
||||
#[test]
|
||||
fn a_length_that_overruns_the_body_is_refused() {
|
||||
let mut body = 139u32.to_le_bytes().to_vec();
|
||||
body.extend_from_slice(&8u32.to_le_bytes());
|
||||
assert!(read_defense_message(&mut &body[..]).is_err());
|
||||
|
||||
// Even with bytes present: 8 declared, 4 supplied.
|
||||
let mut body = 139u32.to_le_bytes().to_vec();
|
||||
body.extend_from_slice(&8u32.to_le_bytes());
|
||||
body.extend_from_slice(b"abc\0");
|
||||
assert!(read_defense_message(&mut &body[..]).is_err());
|
||||
}
|
||||
|
||||
/// A window with no NUL in it is the whole window — the reference would read on past `length`
|
||||
/// (an OOB read it mitigates nowhere); stopping at the bound is the deliberate divergence.
|
||||
#[test]
|
||||
fn a_window_without_a_terminator_is_taken_whole() {
|
||||
let mut body = 139u32.to_le_bytes().to_vec();
|
||||
body.extend_from_slice(&3u32.to_le_bytes());
|
||||
body.extend_from_slice(b"abcTRAILING");
|
||||
assert_eq!(
|
||||
read_defense_message(&mut &body[..]).unwrap(),
|
||||
(139, "abc".to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ mod attack;
|
||||
mod auction;
|
||||
mod bank;
|
||||
mod binder;
|
||||
mod broadcast;
|
||||
mod channel;
|
||||
mod chat;
|
||||
mod client;
|
||||
|
||||
@@ -447,6 +447,32 @@ pub const SMSG_FISH_ESCAPED: u16 = 0x01C9; // 457
|
||||
/// `Opcodes_1_12_1.h`: 459): one cstring (`WorldSession::SendNotification`,
|
||||
/// `Server/WorldSession.cpp:900-915`) — "You do not know that language", trade refusals, and kin.
|
||||
pub const SMSG_NOTIFICATION: u16 = 0x01CB; // 459
|
||||
|
||||
// The **world broadcasts** — sent to everyone, or to everyone in a zone, rather than to one player
|
||||
// about their own doing. Bodies and the client's own handlers: [`super::broadcast`].
|
||||
/// An area is under attack by enemy players (VERIFIED vmangos `Opcodes_1_12_1.h`: 596): one `u32`
|
||||
/// `AreaTable.dbc` id (`WorldPackets::Misc::ZoneUnderAttack::AppendBodyTo`,
|
||||
/// `Server/Packets/Misc.cpp:451-454`). The client (`0x49dcc0`) formats FrameXML's
|
||||
/// `ZONE_UNDER_ATTACK` global string with the area's name and delivers it as `CHAT_MSG_CHANNEL` on
|
||||
/// the joined defense channels — **not** as a system line.
|
||||
pub const SMSG_ZONE_UNDER_ATTACK: u16 = 0x0254; // 596
|
||||
/// A shutdown/restart countdown or an operator's broadcast (VERIFIED vmangos `Opcodes_1_12_1.h`:
|
||||
/// 657): `u32 messageType` + one cstring (`WorldPackets::Misc::ServerMessage::AppendBodyTo`,
|
||||
/// `Server/Packets/Misc.cpp:341-345`; layout in [`super::broadcast::read_server_message`]). The
|
||||
/// type indexes `ServerMessages.dbc`, whose text is the format string the packet's text fills; the
|
||||
/// client (`0x49df80`) shows the result as `CHAT_MSG_SYSTEM`.
|
||||
pub const SMSG_SERVER_MESSAGE: u16 = 0x0291; // 657
|
||||
/// A trial account hit its whisper cap (VERIFIED vmangos `Opcodes_1_12_1.h`: 765); **empty body**
|
||||
/// (`WorldPackets::Chat::ChatRestricted::AppendBodyTo`, `Server/Packets/Chat.cpp:21-23`), and the
|
||||
/// client's arm (`0x5e4a09`) reads none — it is three instructions, `DisplayError(0x1c3)`.
|
||||
pub const SMSG_CHAT_RESTRICTED: u16 = 0x02FD; // 765
|
||||
/// A world-defense broadcast — the Eastern Plaguelands tower captures (VERIFIED vmangos
|
||||
/// `Opcodes_1_12_1.h`: 827): `u32 zoneId`, `u32 length`, then the text (`Map::SendDefenseMessage`,
|
||||
/// `src/game/Maps/Map.cpp:1868-1884`, built raw rather than through a packet class and
|
||||
/// **1.12-only**). Same destination as [`SMSG_ZONE_UNDER_ATTACK`] and the same handler shape
|
||||
/// (`0x49de30`), but the text rides the wire instead of a global string.
|
||||
pub const SMSG_DEFENSE_MESSAGE: u16 = 0x033B; // 827
|
||||
|
||||
/// `/played` (VERIFIED vmangos `Opcodes_1_12_1.h`: 460/461): empty CMSG body
|
||||
/// (`NullClientPacket`), SMSG body `u32 total + u32 level` seconds
|
||||
/// (`WorldPackets::Misc::PlayedTime::AppendBodyTo`, `Server/Packets/Misc.cpp:278-282`; layout in
|
||||
|
||||
@@ -482,6 +482,27 @@ pub enum ServerPacket {
|
||||
AreaTriggerMessage {
|
||||
text: String,
|
||||
},
|
||||
/// `SMSG_SERVER_MESSAGE` — a shutdown/restart countdown or an operator broadcast: the
|
||||
/// `ServerMessages.dbc` row id and the text that fills its `%s` (layout in
|
||||
/// [`super::broadcast::read_server_message`]).
|
||||
ServerMessage {
|
||||
message_type: u32,
|
||||
text: String,
|
||||
},
|
||||
/// `SMSG_ZONE_UNDER_ATTACK` — an area is under attack by enemy players; one `AreaTable.dbc` id
|
||||
/// (layout in [`super::broadcast::read_zone_under_attack`]).
|
||||
ZoneUnderAttack {
|
||||
area_id: u32,
|
||||
},
|
||||
/// `SMSG_DEFENSE_MESSAGE` — a world-defense broadcast (the EPL tower captures): the zone it is
|
||||
/// about and the server-composed text (layout in [`super::broadcast::read_defense_message`]).
|
||||
DefenseMessage {
|
||||
zone_id: u32,
|
||||
text: String,
|
||||
},
|
||||
/// `SMSG_CHAT_RESTRICTED` — a trial account hit its whisper cap; empty body (vmangos
|
||||
/// `Server/Packets/Chat.cpp:21-23`). The [`Self::ChatWrongFaction`] shape exactly.
|
||||
ChatRestricted,
|
||||
/// `SMSG_PLAYED_TIME` — answers our `CMSG_PLAYED_TIME` (`/played`): total played time + time
|
||||
/// since the last level-up, both in seconds (layout in [`super::chat::read_played_time`]).
|
||||
PlayedTime {
|
||||
@@ -1504,6 +1525,10 @@ impl ServerPacket {
|
||||
ServerPacket::ChatWrongFaction => "SMSG_CHAT_WRONG_FACTION".into(),
|
||||
ServerPacket::Notification { .. } => "SMSG_NOTIFICATION".into(),
|
||||
ServerPacket::AreaTriggerMessage { .. } => "SMSG_AREA_TRIGGER_MESSAGE".into(),
|
||||
ServerPacket::ServerMessage { .. } => "SMSG_SERVER_MESSAGE".into(),
|
||||
ServerPacket::ZoneUnderAttack { .. } => "SMSG_ZONE_UNDER_ATTACK".into(),
|
||||
ServerPacket::DefenseMessage { .. } => "SMSG_DEFENSE_MESSAGE".into(),
|
||||
ServerPacket::ChatRestricted => "SMSG_CHAT_RESTRICTED".into(),
|
||||
ServerPacket::PlayedTime { .. } => "SMSG_PLAYED_TIME".into(),
|
||||
ServerPacket::RandomRoll { .. } => "MSG_RANDOM_ROLL".into(),
|
||||
ServerPacket::InitialSpells { .. } => "SMSG_INITIAL_SPELLS".into(),
|
||||
|
||||
@@ -11,11 +11,11 @@ use crate::wire::{
|
||||
};
|
||||
|
||||
use super::{
|
||||
action_bar, area_trigger, attack, auction, bank, binder, channel, chat, combat_log, death,
|
||||
duel, gameobject, gm_ticket, gossip, group, guild, instance, items, loot, mail, mirror_timer,
|
||||
monster_move, movement, opcode, page_text, pet, petition, progression, pvp, quest, social,
|
||||
spellbook, spells, stable, summon, taxi, trade, trainer, update_object, vendor, world_state,
|
||||
Character, CreatureQueryInfo, JumpInfo, MoveMode, ServerPacket, SpeedKind,
|
||||
action_bar, area_trigger, attack, auction, bank, binder, broadcast, channel, chat, combat_log,
|
||||
death, duel, gameobject, gm_ticket, gossip, group, guild, instance, items, loot, mail,
|
||||
mirror_timer, monster_move, movement, opcode, page_text, pet, petition, progression, pvp,
|
||||
quest, social, spellbook, spells, stable, summon, taxi, trade, trainer, update_object, vendor,
|
||||
world_state, Character, CreatureQueryInfo, JumpInfo, MoveMode, ServerPacket, SpeedKind,
|
||||
};
|
||||
|
||||
/// Read one `SMSG_FORCE_*_SPEED_CHANGE` body — `[packed mover guid][u32 counter][f32 speed]`,
|
||||
@@ -456,6 +456,18 @@ pub fn parse_server(opcode: u16, body: &[u8]) -> io::Result<ServerPacket> {
|
||||
opcode::SMSG_AREA_TRIGGER_MESSAGE => ServerPacket::AreaTriggerMessage {
|
||||
text: area_trigger::read_area_trigger_message(&mut r)?,
|
||||
},
|
||||
opcode::SMSG_SERVER_MESSAGE => {
|
||||
let (message_type, text) = broadcast::read_server_message(&mut r)?;
|
||||
ServerPacket::ServerMessage { message_type, text }
|
||||
}
|
||||
opcode::SMSG_ZONE_UNDER_ATTACK => ServerPacket::ZoneUnderAttack {
|
||||
area_id: broadcast::read_zone_under_attack(&mut r)?,
|
||||
},
|
||||
opcode::SMSG_DEFENSE_MESSAGE => {
|
||||
let (zone_id, text) = broadcast::read_defense_message(&mut r)?;
|
||||
ServerPacket::DefenseMessage { zone_id, text }
|
||||
}
|
||||
opcode::SMSG_CHAT_RESTRICTED => ServerPacket::ChatRestricted,
|
||||
opcode::SMSG_PLAYED_TIME => {
|
||||
let (total, level) = chat::read_played_time(&mut r)?;
|
||||
ServerPacket::PlayedTime { total, level }
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
//! World-broadcast wire decode goldens (mirrors `src/messages/broadcast.rs`):
|
||||
//! `SMSG_SERVER_MESSAGE`, `SMSG_ZONE_UNDER_ATTACK`, `SMSG_DEFENSE_MESSAGE` and the bodyless
|
||||
//! `SMSG_CHAT_RESTRICTED`. Bytes hand-computed from the vmangos layout (citations inline),
|
||||
//! independent of the Rust decoder — see `tests/common` for the shared `hx` fixture helper.
|
||||
//!
|
||||
//! These four are the packets nobody asks for: they arrive because the world did something, not
|
||||
//! because this client did. Three of them had no decode at all until now, which meant a shutdown
|
||||
//! countdown, a civilian-kill alarm and an Eastern Plaguelands tower capture all reached benilla as
|
||||
//! an unhandled opcode — the server drops you with no warning, and the tower changes hands in
|
||||
//! silence.
|
||||
|
||||
mod common;
|
||||
|
||||
use benilla_protocol::events::{decode, SessionEvent};
|
||||
use benilla_protocol::messages;
|
||||
use benilla_protocol::ServerPacket;
|
||||
use common::hx;
|
||||
|
||||
/// `SMSG_SERVER_MESSAGE` (VERIFIED vmangos `WorldPackets::Misc::ServerMessage::AppendBodyTo`,
|
||||
/// `Server/Packets/Misc.cpp:341-345`: `buffer << messageType; buffer << text`): `u32` + cstring.
|
||||
///
|
||||
/// Both shapes the five `ServerMessageType` producers actually send (`World.cpp:2742`/`2761`): a
|
||||
/// countdown carrying `secsToTimeString(...)` for its row's `%s`, and a cancellation carrying an
|
||||
/// **empty** string, because rows 4 and 5 have no `%s` to fill. The empty text is a value, not a
|
||||
/// short packet — the client's own handler tests `[ebp-0x400] == 0` at `0x49dfc8` and takes a
|
||||
/// straight copy of the row instead of a format.
|
||||
#[test]
|
||||
fn server_message_decodes_a_countdown_and_a_cancellation() {
|
||||
let body = hx("020000003135204d696e7574657300"); // type 2 (RESTART_TIME), "15 Minutes"
|
||||
match messages::parse_server(messages::opcode::SMSG_SERVER_MESSAGE, &body).unwrap() {
|
||||
ServerPacket::ServerMessage {
|
||||
message_type,
|
||||
ref text,
|
||||
} => {
|
||||
assert_eq!(message_type, 2);
|
||||
assert_eq!(text, "15 Minutes");
|
||||
}
|
||||
other => panic!("expected ServerMessage, got {}", other.name()),
|
||||
}
|
||||
match &decode(messages::parse_server(messages::opcode::SMSG_SERVER_MESSAGE, &body).unwrap())[..]
|
||||
{
|
||||
[SessionEvent::ServerMessage { message_type, text }] => {
|
||||
assert_eq!(*message_type, 2);
|
||||
assert_eq!(text, "15 Minutes");
|
||||
}
|
||||
other => panic!("server message decode: {} events", other.len()),
|
||||
}
|
||||
|
||||
let body = hx("0400000000"); // type 4 (SHUTDOWN_CANCELLED), empty text
|
||||
match messages::parse_server(messages::opcode::SMSG_SERVER_MESSAGE, &body).unwrap() {
|
||||
ServerPacket::ServerMessage {
|
||||
message_type,
|
||||
ref text,
|
||||
} => {
|
||||
assert_eq!(message_type, 4);
|
||||
assert!(text.is_empty());
|
||||
}
|
||||
other => panic!("expected ServerMessage, got {}", other.name()),
|
||||
}
|
||||
}
|
||||
|
||||
/// `SMSG_ZONE_UNDER_ATTACK` (VERIFIED vmangos
|
||||
/// `WorldPackets::Misc::ZoneUnderAttack::AppendBodyTo`, `Server/Packets/Misc.cpp:451-454`): one
|
||||
/// `u32` `AreaTable.dbc` id and nothing else. 40 is Westfall.
|
||||
#[test]
|
||||
fn zone_under_attack_decodes() {
|
||||
let body = hx("28000000");
|
||||
let p = messages::parse_server(messages::opcode::SMSG_ZONE_UNDER_ATTACK, &body).unwrap();
|
||||
assert!(matches!(p, ServerPacket::ZoneUnderAttack { area_id: 40 }));
|
||||
assert!(matches!(
|
||||
decode(p)[..],
|
||||
[SessionEvent::ZoneUnderAttack { area_id: 40 }]
|
||||
));
|
||||
}
|
||||
|
||||
/// `SMSG_DEFENSE_MESSAGE` (VERIFIED vmangos `Map::SendDefenseMessage`, `Maps/Map.cpp:1868-1884`):
|
||||
/// `u32 zoneId`, `u32 strlen(text) + 1`, then the NUL-terminated text. 139 is the Eastern
|
||||
/// Plaguelands, whose tower captures are this packet's only vmangos sender.
|
||||
///
|
||||
/// The length counts the terminator (`0x30` = 48 for a 47-byte string) and is redundant with the
|
||||
/// string; the reference reads and discards it too.
|
||||
#[test]
|
||||
fn defense_message_decodes() {
|
||||
let body = hx(
|
||||
"8b000000300000004e6f7274687061737320546f77657220686173206265656e2\
|
||||
074616b656e2062792074686520416c6c69616e63652100",
|
||||
);
|
||||
match messages::parse_server(messages::opcode::SMSG_DEFENSE_MESSAGE, &body).unwrap() {
|
||||
ServerPacket::DefenseMessage { zone_id, ref text } => {
|
||||
assert_eq!(zone_id, 139);
|
||||
assert_eq!(text, "Northpass Tower has been taken by the Alliance!");
|
||||
}
|
||||
other => panic!("expected DefenseMessage, got {}", other.name()),
|
||||
}
|
||||
match &decode(messages::parse_server(messages::opcode::SMSG_DEFENSE_MESSAGE, &body).unwrap())[..]
|
||||
{
|
||||
[SessionEvent::DefenseMessage { zone_id, text }] => {
|
||||
assert_eq!(*zone_id, 139);
|
||||
assert_eq!(text, "Northpass Tower has been taken by the Alliance!");
|
||||
}
|
||||
other => panic!("defense message decode: {} events", other.len()),
|
||||
}
|
||||
}
|
||||
|
||||
/// `SMSG_CHAT_RESTRICTED` (VERIFIED vmangos `WorldPackets::Chat::ChatRestricted::AppendBodyTo`,
|
||||
/// `Server/Packets/Chat.cpp:21-23`): an **empty** body, exactly like `SMSG_CHAT_WRONG_FACTION`.
|
||||
/// The client's arm reads nothing off the wire either (`0x5e4a09` is `push 0x1c3; call 0x496720`).
|
||||
#[test]
|
||||
fn chat_restricted_is_bodyless() {
|
||||
let p = messages::parse_server(messages::opcode::SMSG_CHAT_RESTRICTED, &[]).unwrap();
|
||||
assert!(matches!(p, ServerPacket::ChatRestricted));
|
||||
assert!(matches!(decode(p)[..], [SessionEvent::ChatRestricted]));
|
||||
}
|
||||
@@ -131,29 +131,52 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
|
||||
// BankButtonIDToInvSlotID(id, isBag) — the pure button→live-slot map (module doc).
|
||||
//
|
||||
// **The bag arm takes the CONTAINER id 5..=10, not a bag number 1..=6**, and getting that
|
||||
// wrong reads six slots off the end of the band. The reference's own file is the oracle
|
||||
// (0675) and says so four times over: `BankFrame.xml` gives `BankFrameBag1..BankFrameBag6`
|
||||
// the ids **5..10** (the generic buttons `BankFrameItem1..24` take 1..24);
|
||||
// `ButtonInventorySlot` hands `this:GetID()` straight in; and both
|
||||
// `UpdateBagButtonHighlight(id)` (`"BankFrameBag"..(id - NUM_BAG_SLOTS)`) and
|
||||
// `BankFrameItemButton_UpdateLock` (`(this:GetID() - 4) > GetNumBankSlots()`) subtract 4 from
|
||||
// that same id to get back to 1..6. So the bag arm is `id + 59` — byte-identical to
|
||||
// `ContainerIDToInventoryID`'s carved second arm (`t = id - 1; t >= 4 → t + 60`,
|
||||
// `crate::script::container`), which is the same map under the other name.
|
||||
// CARVED (`0x4f8530`, 118 bytes; wow-re `system/ui/scratch/bank-button-invslot-law.md`, §5
|
||||
// trio + orchestrator byte arbitration 2026-08-31). Three findings, and two of them corrected
|
||||
// this binding:
|
||||
//
|
||||
// Out-of-range answers 0 (the reference binding is total over its buttons; our XML never asks
|
||||
// outside them — 0 is the visible "wired wrong" tell rather than a silent misroute).
|
||||
// 1 · **The arithmetic is `+59` / `+39`, and the bag arm takes the CONTAINER id 5..10** — not
|
||||
// a bag number 1..6, which is what this used to compute. `0x4f8575 dec esi` runs BEFORE the
|
||||
// branch and `0x4f8587 inc esi` AFTER it, so the pair cancels and the constants stand as
|
||||
// written (`0x4f857f add esi,0x3b` / `0x4f8584 add esi,0x27`). The bag arm is numerically
|
||||
// identical to `ContainerIDToInventoryID 0x4f94e0`'s own `id >= 5` arm — two functions, two
|
||||
// instruction sequences, one constant. The reference's file pins the same numbering four
|
||||
// times over: `BankFrame.xml` gives `BankFrameBag1` `id="5"`, `ButtonInventorySlot` hands
|
||||
// `this:GetID()` straight in, and `UpdateBagButtonHighlight`/`BankFrameItemButton_UpdateLock`
|
||||
// both subtract 4 from it to get back to 1..6.
|
||||
//
|
||||
// 2 · **`isBag` is a TYPE test, not a truthiness test.** `0x4f8576 call 0x6f34d0` is
|
||||
// `lua_isnumber(L, 2)`, which accepts tag 3 or a tag-4 string `luaO_str2d` fully consumes,
|
||||
// and refuses every other tag at `0x6f7c35` without ever loading the value. So **`true`
|
||||
// takes the same arm as `nil`, `false` and a missing argument**, while `0` and `"1"` take
|
||||
// the BAG arm. `BankFrame.lua:24` writes `this.isBag = 1` — a NUMBER — so the stock UI is
|
||||
// correct either way; a client that models the flag as a boolean diverges the moment an
|
||||
// addon passes `true`, and would then resolve the six bank bags to 44..49, colliding with
|
||||
// `BankFrameItem5..10`.
|
||||
//
|
||||
// 3 · **Total, with no range check of any kind.** The only `test`/`cmp` in all 118 bytes are
|
||||
// on `lua_isnumber`'s return value: no clamp, no mask, no `nil`. This used to answer 0
|
||||
// outside 1..24 / 5..10 as a "wired wrong" tell; that was our invention, and the reference
|
||||
// simply returns the arithmetic. Its immediate neighbour `GetNumBankSlots 0x4f85b0` carries
|
||||
// a real `cmp esi,6; jl`, so a bound would have been visible here if one existed.
|
||||
//
|
||||
// Argument 1 is shape A ([`binding_abi::number_arg`]): `lua_isnumber` gated, truncated toward
|
||||
// zero by the `0x40a2b0` ftol, low dword only, and raising the `.data` usage string verbatim
|
||||
// — which names only `buttonID`, the reference's own omission of the second parameter.
|
||||
g.set(
|
||||
"BankButtonIDToInvSlotID",
|
||||
lua.create_function(|_, (id, is_bag): (u32, Option<Value>)| {
|
||||
let is_bag = is_bag.is_some_and(|v| v.as_boolean().unwrap_or(true));
|
||||
let live = match (is_bag, id) {
|
||||
(false, 1..=24) => 39 + id,
|
||||
(true, 5..=10) => 59 + id,
|
||||
_ => 0,
|
||||
};
|
||||
Ok(i64::from(live))
|
||||
lua.create_function(|lua, (id, is_bag): (Value, Option<Value>)| {
|
||||
let n = super::binding_abi::number_arg(
|
||||
lua,
|
||||
id,
|
||||
"Usage: BankButtonIDToInvSlotID(buttonID)",
|
||||
)?;
|
||||
// Finding 2: `lua_isnumber(L, 2)`, asked of the VALUE's type — never its truthiness.
|
||||
let is_bag = is_bag
|
||||
.and_then(|v| lua.coerce_number(v).ok().flatten())
|
||||
.is_some();
|
||||
let step = if is_bag { 59 } else { 39 };
|
||||
Ok(i64::from(n.wrapping_add(step)))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
@@ -326,12 +349,11 @@ mod tests {
|
||||
|
||||
/// The button→live-slot map: item buttons 1..24 → 40..63; bag buttons — **whose ids are the
|
||||
/// container ids 5..10, not bag numbers** — → 64..69 (live id − 1 = the wire slot: bank items
|
||||
/// 39..62, bank bags 63..68). The reference calls it with `this.isBag` = 1 or nil, so
|
||||
/// nil/false both mean "item button".
|
||||
/// 39..62, bank bags 63..68).
|
||||
///
|
||||
/// The bag arm's numbering is the reference file's, checked here because getting it wrong
|
||||
/// reads six slots off the end of the band and draws an empty bag row: `BankFrame.xml` gives
|
||||
/// `BankFrameBag1` `id="5"`, and `ButtonInventorySlot` hands that id straight in.
|
||||
/// The bag arm's numbering is checked here because getting it wrong reads six slots off the
|
||||
/// end of the band and draws an empty bag row — which is what this binding did until the
|
||||
/// `0x4f8530` carve.
|
||||
#[test]
|
||||
fn bank_button_to_inv_slot() {
|
||||
let s = UiScript::new().unwrap();
|
||||
@@ -366,21 +388,69 @@ mod tests {
|
||||
.unwrap()
|
||||
);
|
||||
}
|
||||
// false is "not a bag" (nil-or-false truthiness), out of range answers 0.
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return BankButtonIDToInvSlotID(2, false)")
|
||||
.unwrap(),
|
||||
41
|
||||
);
|
||||
}
|
||||
|
||||
/// The three things the `0x4f8530` carve corrected, each of which this binding had wrong or
|
||||
/// invented (wow-re `scratch/bank-button-invslot-law.md`).
|
||||
#[test]
|
||||
fn bank_button_to_inv_slot_follows_the_carved_abi() {
|
||||
let s = UiScript::new().unwrap();
|
||||
|
||||
// **`isBag` is a TYPE test** (`lua_isnumber(L,2)`), not a truthiness test. `true` is not a
|
||||
// number, so it takes the ITEM arm exactly as nil and false do; `0` and `"1"` are numbers
|
||||
// and take the BAG arm. The stock UI is safe either way — `BankFrame.lua:24` writes
|
||||
// `this.isBag = 1` — but an addon passing `true` would otherwise land the six bank bags on
|
||||
// 44..49, on top of `BankFrameItem5..10`.
|
||||
for (call, want) in [
|
||||
("BankButtonIDToInvSlotID(5, true)", 44),
|
||||
("BankButtonIDToInvSlotID(5, false)", 44),
|
||||
("BankButtonIDToInvSlotID(5, nil)", 44),
|
||||
("BankButtonIDToInvSlotID(5, {})", 44),
|
||||
("BankButtonIDToInvSlotID(5)", 44),
|
||||
("BankButtonIDToInvSlotID(5, 0)", 64),
|
||||
("BankButtonIDToInvSlotID(5, \"1\")", 64),
|
||||
] {
|
||||
assert_eq!(
|
||||
s.eval::<i64>(&format!("return {call}")).unwrap(),
|
||||
want,
|
||||
"{call}"
|
||||
);
|
||||
}
|
||||
|
||||
// **Total — no range check of any kind.** Answering 0 outside the button ranges was ours,
|
||||
// not the reference's; the only compares in the body are on `lua_isnumber`'s result.
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return BankButtonIDToInvSlotID(25)").unwrap(),
|
||||
0
|
||||
64
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return BankButtonIDToInvSlotID(4, 1)")
|
||||
s.eval::<i64>("return BankButtonIDToInvSlotID(-100)")
|
||||
.unwrap(),
|
||||
0,
|
||||
"4 is an EQUIPPED bag, not a bank one"
|
||||
-61
|
||||
);
|
||||
|
||||
// Argument 1 is shape A: `lua_isnumber` gated, truncated toward ZERO by the `0x40a2b0`
|
||||
// ftol (not floored), and raising the `.data` usage string — which names only `buttonID`,
|
||||
// the reference's own omission of the second parameter.
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return BankButtonIDToInvSlotID(-2.9)")
|
||||
.unwrap(),
|
||||
37,
|
||||
"trunc toward zero gives -2, not -3"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return BankButtonIDToInvSlotID(\"7\")")
|
||||
.unwrap(),
|
||||
46,
|
||||
"a numeric string passes the gate"
|
||||
);
|
||||
let err = s
|
||||
.eval::<i64>("return BankButtonIDToInvSlotID({})")
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(
|
||||
err.contains("Usage: BankButtonIDToInvSlotID(buttonID)"),
|
||||
"the reference's own usage string, verbatim: {err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,6 +124,18 @@ pub struct GroundDecalSurface;
|
||||
#[derive(Component)]
|
||||
pub struct PickOccluder;
|
||||
|
||||
/// **Colliders this frame's local mover must not see** — the game's half of the reference's
|
||||
/// per-trace collision mask.
|
||||
///
|
||||
/// The engine cannot compute this: "a GameObject whose type is DOOR, while the player is a ghost"
|
||||
/// names two gameplay concepts, and the engine names nothing of the game (this crate's other API
|
||||
/// wall). So the game publishes the entity set and [`WorldCollision::cast_mover`] applies it.
|
||||
///
|
||||
/// Empty on every ordinary frame, which is what makes the living player's query provably the one it
|
||||
/// always was.
|
||||
#[derive(Resource, Default)]
|
||||
pub struct MoverTraceExclusions(pub bevy::ecs::entity::EntityHashSet);
|
||||
|
||||
/// **Trace a body through the world** — the engine's movement-collision face.
|
||||
///
|
||||
/// Every one of the four one-sided casts took the same two leading arguments (avian's
|
||||
@@ -143,6 +155,7 @@ pub struct PickOccluder;
|
||||
#[derive(bevy::ecs::system::SystemParam)]
|
||||
pub struct WorldCollision<'w, 's> {
|
||||
ms: MoveAndSlide<'w, 's>,
|
||||
exclusions: bevy::ecs::system::Res<'w, MoverTraceExclusions>,
|
||||
}
|
||||
|
||||
impl WorldCollision<'_, '_> {
|
||||
@@ -175,14 +188,30 @@ impl WorldCollision<'_, '_> {
|
||||
movement: Vec3,
|
||||
skin_width: f32,
|
||||
) -> Option<MoveHitData> {
|
||||
one_sided::cast_move(
|
||||
&self.ms,
|
||||
shape,
|
||||
from,
|
||||
movement,
|
||||
skin_width,
|
||||
&Self::body_filter(),
|
||||
)
|
||||
self.cast_body_with(shape, from, movement, skin_width, &Self::body_filter())
|
||||
}
|
||||
|
||||
/// [`cast_body`](Self::cast_body) against a **caller-supplied** filter.
|
||||
///
|
||||
/// It exists for one caller and one reason: the reference's world trace carries a per-trace
|
||||
/// mask, and a GHOST's mask drops DOOR GameObjects from the gather — a ghost walks through
|
||||
/// closed doors (wow-re `collision/scratch/ghost-door-tracemask.md`). That is a property of
|
||||
/// *this mover's trace*, not of the door, so it cannot be expressed by re-laning the collider:
|
||||
/// [`body_filter`](Self::body_filter) is shared with the particle snap, the precipitation
|
||||
/// probe, the mouse pick and the creature conform, none of which may lose a door because the
|
||||
/// local player happens to be dead.
|
||||
///
|
||||
/// The living case passes [`body_filter`](Self::body_filter) itself, so it is the same query it
|
||||
/// always was — byte for byte, by construction rather than by inspection.
|
||||
fn cast_body_with(
|
||||
&self,
|
||||
shape: &Collider,
|
||||
from: Vec3,
|
||||
movement: Vec3,
|
||||
skin_width: f32,
|
||||
filter: &SpatialQueryFilter,
|
||||
) -> Option<MoveHitData> {
|
||||
one_sided::cast_move(&self.ms, shape, from, movement, skin_width, filter)
|
||||
}
|
||||
|
||||
/// Sweep the **camera boom** against the camera's world.
|
||||
@@ -224,8 +253,7 @@ impl WorldCollision<'_, '_> {
|
||||
config: &MoveAndSlideConfig,
|
||||
on_hit: impl FnMut(MoveAndSlideHitData) -> MoveAndSlideHitResponse,
|
||||
) -> MoveAndSlideOutput {
|
||||
one_sided::move_and_slide(
|
||||
&self.ms,
|
||||
self.slide_body_with(
|
||||
shape,
|
||||
shape_position,
|
||||
velocity,
|
||||
@@ -236,6 +264,88 @@ impl WorldCollision<'_, '_> {
|
||||
)
|
||||
}
|
||||
|
||||
/// [`slide_body`](Self::slide_body) against a caller-supplied filter — the ghost's door
|
||||
/// exclusion, for the reason spelled out on [`cast_body_with`](Self::cast_body_with).
|
||||
#[allow(clippy::too_many_arguments)] // `slide_body`'s list, plus the filter it defaults
|
||||
fn slide_body_with(
|
||||
&self,
|
||||
shape: &Collider,
|
||||
shape_position: Vec3,
|
||||
velocity: Vec3,
|
||||
delta_time: std::time::Duration,
|
||||
config: &MoveAndSlideConfig,
|
||||
filter: &SpatialQueryFilter,
|
||||
on_hit: impl FnMut(MoveAndSlideHitData) -> MoveAndSlideHitResponse,
|
||||
) -> MoveAndSlideOutput {
|
||||
one_sided::move_and_slide(
|
||||
&self.ms,
|
||||
shape,
|
||||
shape_position,
|
||||
velocity,
|
||||
delta_time,
|
||||
config,
|
||||
filter,
|
||||
on_hit,
|
||||
)
|
||||
}
|
||||
|
||||
/// Sweep the **local mover's own body** — [`cast_body`](Self::cast_body) with this frame's
|
||||
/// [`MoverTraceExclusions`] applied.
|
||||
///
|
||||
/// The reference's world trace carries a per-trace mask, and the local mover's mask gains bit
|
||||
/// `0x8000` when the body it is driving is a player **in ghost form** (`0x631658`); the
|
||||
/// GameObject collision-candidacy virtual `0x5f85f0` reads that bit and drops every
|
||||
/// `GAMEOBJECT_TYPE_ID == 0` (DOOR) object from the gather. So a ghost walks through closed
|
||||
/// doors, and nothing else about its collision differs (wow-re
|
||||
/// `collision/scratch/ghost-door-tracemask.md`).
|
||||
///
|
||||
/// **Why the exclusion rides the trace and not the collider.** It is tempting to drop the
|
||||
/// door's collider, or re-lane it — both are wrong, because the fact is about *whose trace this
|
||||
/// is*. [`body_filter`](Self::body_filter) is shared with the particle snap, the precipitation
|
||||
/// probe, the mouse pick, the taxi probe and the creature ground conform; a door that stopped
|
||||
/// existing for all of them because the local player happened to be dead would be a much larger
|
||||
/// claim than the binary makes. Only the mover's own sweeps take the exclusion.
|
||||
///
|
||||
/// With the set empty — every living frame — this is [`cast_body`](Self::cast_body) exactly.
|
||||
pub fn cast_mover(
|
||||
&self,
|
||||
shape: &Collider,
|
||||
from: Vec3,
|
||||
movement: Vec3,
|
||||
skin_width: f32,
|
||||
) -> Option<MoveHitData> {
|
||||
self.cast_body_with(shape, from, movement, skin_width, &self.mover_filter())
|
||||
}
|
||||
|
||||
/// [`slide_body`](Self::slide_body) for the local mover — see [`cast_mover`](Self::cast_mover).
|
||||
pub fn slide_mover(
|
||||
&self,
|
||||
shape: &Collider,
|
||||
shape_position: Vec3,
|
||||
velocity: Vec3,
|
||||
delta_time: std::time::Duration,
|
||||
config: &MoveAndSlideConfig,
|
||||
on_hit: impl FnMut(MoveAndSlideHitData) -> MoveAndSlideHitResponse,
|
||||
) -> MoveAndSlideOutput {
|
||||
self.slide_body_with(
|
||||
shape,
|
||||
shape_position,
|
||||
velocity,
|
||||
delta_time,
|
||||
config,
|
||||
&self.mover_filter(),
|
||||
on_hit,
|
||||
)
|
||||
}
|
||||
|
||||
/// [`body_filter`](Self::body_filter), plus whatever this frame's mover must not see.
|
||||
///
|
||||
/// Allocation-free while the set is empty (`EntityHashSet::default()` allocates nothing), which
|
||||
/// is every frame the player is alive.
|
||||
fn mover_filter(&self) -> SpatialQueryFilter {
|
||||
Self::body_filter().with_excluded_entities(self.exclusions.0.iter().copied())
|
||||
}
|
||||
|
||||
/// Every front-facing triangle in a box around `at` — the step probe's face gather.
|
||||
pub fn faces_near_body(&self, at: Vec3, half: Vec3, limit: usize) -> Vec<one_sided::FaceProbe> {
|
||||
one_sided::faces_near(&self.ms, at, half, &Self::body_filter(), limit)
|
||||
|
||||
@@ -508,6 +508,9 @@ mod tests {
|
||||
// one without.
|
||||
let run_east = |walls: bool, z: f32| -> Option<f32> {
|
||||
let mut app = App::new();
|
||||
// `WorldCollision` takes the mover's trace exclusions (the ghost/DOOR set, 1767), which the
|
||||
// world plugins own and a bare harness does not run.
|
||||
app.init_resource::<crate::collision::MoverTraceExclusions>();
|
||||
app.add_plugins((
|
||||
MinimalPlugins,
|
||||
bevy::transform::TransformPlugin,
|
||||
|
||||
@@ -265,6 +265,7 @@ impl Plugin for WorldFoundation {
|
||||
// (`collision::ColliderEpoch`). Tracked in `First` so a removal is stamped before any
|
||||
// consumer runs; the attach half is stamped by the streamer's own attach loop.
|
||||
.init_resource::<crate::collision::ColliderEpoch>()
|
||||
.init_resource::<crate::collision::MoverTraceExclusions>()
|
||||
.add_systems(First, crate::collision::track_collider_removals);
|
||||
// Avian's pre-step copy of Bevy's transform propagation is OFF by default (the 1370
|
||||
// bracket surfaced the lane; the 3-round SW split then measured the skip at −0.40
|
||||
|
||||
Reference in New Issue
Block a user