diff --git a/crates/benilla-app/src/go_anim.rs b/crates/benilla-app/src/go_anim.rs index 7110e189..ef2d6623 100644 --- a/crates/benilla-app/src/go_anim.rs +++ b/crates/benilla-app/src/go_anim.rs @@ -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, + mover: Query<&ObjectStore, With>, + doors: Query<(Entity, &ObjectStore, &crate::net::NetEntity), With>, +) { + 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). diff --git a/crates/benilla-app/src/net/apply.rs b/crates/benilla-app/src/net/apply.rs index 5ab055c5..73b881f9 100644 --- a/crates/benilla-app/src/net/apply.rs +++ b/crates/benilla-app/src/net/apply.rs @@ -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) diff --git a/crates/benilla-app/src/net/apply/chat.rs b/crates/benilla-app/src/net/apply/chat.rs index 34f10d01..a32bb481 100644 --- a/crates/benilla-app/src/net/apply/chat.rs +++ b/crates/benilla-app/src/net/apply/chat.rs @@ -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). diff --git a/crates/benilla-app/src/net/motion/remote.rs b/crates/benilla-app/src/net/motion/remote.rs index 596c79ee..f9002431 100644 --- a/crates/benilla-app/src/net/motion/remote.rs +++ b/crates/benilla-app/src/net/motion/remote.rs @@ -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::(); app.add_plugins(( MinimalPlugins, bevy::transform::TransformPlugin, diff --git a/crates/benilla-app/src/net/motion/spline.rs b/crates/benilla-app/src/net/motion/spline.rs index b67518fc..f8e9e9a4 100644 --- a/crates/benilla-app/src/net/motion/spline.rs +++ b/crates/benilla-app/src/net/motion/spline.rs @@ -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::(); app.add_plugins(( MinimalPlugins, bevy::transform::TransformPlugin, diff --git a/crates/benilla-app/src/net/motion/tests.rs b/crates/benilla-app/src/net/motion/tests.rs index ecfd3b3e..2c937b6c 100644 --- a/crates/benilla-app/src/net/motion/tests.rs +++ b/crates/benilla-app/src/net/motion/tests.rs @@ -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::(); app.add_plugins(( MinimalPlugins, TransformPlugin, diff --git a/crates/benilla-app/src/player/controller.rs b/crates/benilla-app/src/player/controller.rs index 68862690..cd323091 100644 --- a/crates/benilla-app/src/player/controller.rs +++ b/crates/benilla-app/src/player/controller.rs @@ -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). diff --git a/crates/benilla-app/src/player/mover.rs b/crates/benilla-app/src/player/mover.rs index 37df9382..88a57f8f 100644 --- a/crates/benilla-app/src/player/mover.rs +++ b/crates/benilla-app/src/player/mover.rs @@ -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::(); // avian's collider backend reads `Assets` 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); diff --git a/crates/benilla-app/src/player/posture.rs b/crates/benilla-app/src/player/posture.rs index a0786b0b..400ec01a 100644 --- a/crates/benilla-app/src/player/posture.rs +++ b/crates/benilla-app/src/player/posture.rs @@ -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`), diff --git a/crates/benilla-app/src/ui_chat.rs b/crates/benilla-app/src/ui_chat.rs index 00f0b0af..f2c27530 100644 --- a/crates/benilla-app/src/ui_chat.rs +++ b/crates/benilla-app/src/ui_chat.rs @@ -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. diff --git a/crates/benilla-app/src/ui_chat/broadcast.rs b/crates/benilla-app/src/ui_chat/broadcast.rs new file mode 100644 index 00000000..a35bc850 --- /dev/null +++ b/crates/benilla-app/src/ui_chat/broadcast.rs @@ -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>) { + 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, +) -> Vec { + 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 { + 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, + script: Option>, + channels: Res, + areas: Option>, + messages: Option>, + 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] : ` — 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, + 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" + ); + } +} diff --git a/crates/benilla-app/src/ui_chat/feed.rs b/crates/benilla-app/src/ui_chat/feed.rs index c6fc6900..1ff9c91a 100644 --- a/crates/benilla-app/src/ui_chat/feed.rs +++ b/crates/benilla-app/src/ui_chat/feed.rs @@ -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, + broadcasts: Vec, +} + +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 { + std::mem::take(&mut self.broadcasts) + } } impl ChatLog { diff --git a/crates/benilla-app/tests/world_api_wall.rs b/crates/benilla-app/tests/world_api_wall.rs index dee03b1a..fff4c239 100644 --- a/crates/benilla-app/tests/world_api_wall.rs +++ b/crates/benilla-app/tests/world_api_wall.rs @@ -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 diff --git a/crates/benilla-formats/src/lib.rs b/crates/benilla-formats/src/lib.rs index 77e886c7..de3d353b 100644 --- a/crates/benilla-formats/src/lib.rs +++ b/crates/benilla-formats/src/lib.rs @@ -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; diff --git a/crates/benilla-formats/src/server_messages.rs b/crates/benilla-formats/src/server_messages.rs new file mode 100644 index 00000000..80f1382d --- /dev/null +++ b/crates/benilla-formats/src/server_messages.rs @@ -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 { + 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"); + } +} diff --git a/crates/benilla-protocol/src/events.rs b/crates/benilla-protocol/src/events.rs index 83ec0313..c2967ea6 100644 --- a/crates/benilla-protocol/src/events.rs +++ b/crates/benilla-protocol/src/events.rs @@ -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 }, diff --git a/crates/benilla-protocol/src/events/decode.rs b/crates/benilla-protocol/src/events/decode.rs index 9d7c8f04..406bec6c 100644 --- a/crates/benilla-protocol/src/events/decode.rs +++ b/crates/benilla-protocol/src/events/decode.rs @@ -162,6 +162,16 @@ pub fn decode(packet: ServerPacket) -> Vec { 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 }] } diff --git a/crates/benilla-protocol/src/messages/broadcast.rs b/crates/benilla-protocol/src/messages/broadcast.rs new file mode 100644 index 00000000..bd8eb7e9 --- /dev/null +++ b/crates/benilla-protocol/src/messages/broadcast.rs @@ -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 { + 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()) + ); + } +} diff --git a/crates/benilla-protocol/src/messages/mod.rs b/crates/benilla-protocol/src/messages/mod.rs index 99a0c77c..23cb6479 100644 --- a/crates/benilla-protocol/src/messages/mod.rs +++ b/crates/benilla-protocol/src/messages/mod.rs @@ -22,6 +22,7 @@ mod attack; mod auction; mod bank; mod binder; +mod broadcast; mod channel; mod chat; mod client; diff --git a/crates/benilla-protocol/src/messages/opcode.rs b/crates/benilla-protocol/src/messages/opcode.rs index cbd7323a..cb2b45f7 100644 --- a/crates/benilla-protocol/src/messages/opcode.rs +++ b/crates/benilla-protocol/src/messages/opcode.rs @@ -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 diff --git a/crates/benilla-protocol/src/messages/packet.rs b/crates/benilla-protocol/src/messages/packet.rs index 3196ec1a..a561291c 100644 --- a/crates/benilla-protocol/src/messages/packet.rs +++ b/crates/benilla-protocol/src/messages/packet.rs @@ -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(), diff --git a/crates/benilla-protocol/src/messages/parse.rs b/crates/benilla-protocol/src/messages/parse.rs index ac799aa9..e64d3f98 100644 --- a/crates/benilla-protocol/src/messages/parse.rs +++ b/crates/benilla-protocol/src/messages/parse.rs @@ -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 { 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 } diff --git a/crates/benilla-protocol/tests/broadcast.rs b/crates/benilla-protocol/tests/broadcast.rs new file mode 100644 index 00000000..3d1641b0 --- /dev/null +++ b/crates/benilla-protocol/tests/broadcast.rs @@ -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])); +} diff --git a/crates/benilla-ui/src/script/bank.rs b/crates/benilla-ui/src/script/bank.rs index 14152a22..fc62c9ef 100644 --- a/crates/benilla-ui/src/script/bank.rs +++ b/crates/benilla-ui/src/script/bank.rs @@ -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)| { - 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)| { + 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::("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::(&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::("return BankButtonIDToInvSlotID(25)").unwrap(), - 0 + 64 ); assert_eq!( - s.eval::("return BankButtonIDToInvSlotID(4, 1)") + s.eval::("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::("return BankButtonIDToInvSlotID(-2.9)") + .unwrap(), + 37, + "trunc toward zero gives -2, not -3" + ); + assert_eq!( + s.eval::("return BankButtonIDToInvSlotID(\"7\")") + .unwrap(), + 46, + "a numeric string passes the gate" + ); + let err = s + .eval::("return BankButtonIDToInvSlotID({})") + .unwrap_err() + .to_string(); + assert!( + err.contains("Usage: BankButtonIDToInvSlotID(buttonID)"), + "the reference's own usage string, verbatim: {err}" ); } } diff --git a/crates/benilla-world/src/collision.rs b/crates/benilla-world/src/collision.rs index 253465a7..dbb55d18 100644 --- a/crates/benilla-world/src/collision.rs +++ b/crates/benilla-world/src/collision.rs @@ -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 { - 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 { + 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 { + 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::faces_near(&self.ms, at, half, &Self::body_filter(), limit) diff --git a/crates/benilla-world/src/terrain_stream/collider.rs b/crates/benilla-world/src/terrain_stream/collider.rs index 4d9d0cc8..bd68fcc7 100644 --- a/crates/benilla-world/src/terrain_stream/collider.rs +++ b/crates/benilla-world/src/terrain_stream/collider.rs @@ -508,6 +508,9 @@ mod tests { // one without. let run_east = |walls: bool, z: f32| -> Option { 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::(); app.add_plugins(( MinimalPlugins, bevy::transform::TransformPlugin, diff --git a/crates/benilla-world/src/world_plugins.rs b/crates/benilla-world/src/world_plugins.rs index 96f5875e..2dddf9bc 100644 --- a/crates/benilla-world/src/world_plugins.rs +++ b/crates/benilla-world/src/world_plugins.rs @@ -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::() + .init_resource::() .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