From 2f323ff0b30012fc5bfec2c7b68e8d2b91953efa Mon Sep 17 00:00:00 2001 From: Sam Sung <51686767+samwhosung@users.noreply.github.com> Date: Fri, 25 Sep 2026 14:54:23 +0700 Subject: [PATCH] Comments in benilla-app, the UI windows: one line of fact each, no history, no record numbers (#284) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of #41: the second of five `benilla-app` pieces. This one is the UI windows: chat, the NPC windows (gossip, merchant, trainer, taxi, bank, mail, auction, quest giver), the unit, party, raid and aura feeds, the action bar, bags, tooltips, macros, the spellbook, the world map and the smaller panels. Their comments are rewritten to the rule in `docs/METHOD.md`, which is what the code does and the reference fact behind it, in a line. 26,648 comment lines become 9,221. Fixes #98. - **The mechanical strip.** The citations that were only a private record number go first, and each edit is shown to be a pure removal. - **Wrong facts corrected.** The rewrite corrects the comments that stated wrong facts. Among them: - Several client addresses carried a `0x400000` slip (`UPDATE_MACROS` is `0x852460`, not `0x452460`; the macro store's globals likewise). - `QUEST_WATCH_UPDATE` carries the quest-log row (`0x4df880` scans the log table at `0xbb71c0`), not a watch-list position. - The stock `ChatFrame_DisplayTimePlayed` now ships, so benilla's own `/played` lines duplicate it (#194); trainer refusals are logged, not shown on the error line; the player's cast result is `SMSG_CAST_RESULT`. - Stock FrameXML and `Blizzard_*` line citations are checked against the stock 1.12 files, and vmangos line citations against the vmangos revision nearest the one the local server runs. - Dozens more turned up on the way: docs sitting on the wrong item, removed functions, stale counts, broken intra-doc links. - One test comment in `ui_script/questlog_tests.rs`, which landed with #184, carried the same watch-index slip and gets the same fix. - **Deviation labels.** "Deviation:" now marks only a choice with a reason. An unbuilt behaviour, or one whose reference side is untraced, says so plainly. - **Verification.** Every file strips to identical code, and `cargo fmt --check` is clean. Six second readers compared every file old against new, restored the facts the rewrite had dropped, and checked each new citation against the code, the stock files, vmangos and the reference. Behaviour differences the rewrite surfaced are filed as their own issues (#188, #204, #222 and #259 among them). The comments here state what the code does. String literals are untouched; they are #92. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/benilla-app/src/ui_action/cast_fail.rs | 508 ++---- crates/benilla-app/src/ui_action/drain.rs | 195 +- .../benilla-app/src/ui_action/drain_tests.rs | 16 +- crates/benilla-app/src/ui_action/drop_item.rs | 92 +- crates/benilla-app/src/ui_action/errors.rs | 466 ++--- crates/benilla-app/src/ui_action/feed.rs | 322 +--- .../benilla-app/src/ui_action/feed_tests.rs | 155 +- crates/benilla-app/src/ui_action/mod.rs | 222 +-- crates/benilla-app/src/ui_action/net.rs | 10 +- crates/benilla-app/src/ui_action/ranks.rs | 76 +- crates/benilla-app/src/ui_action/state.rs | 181 +- crates/benilla-app/src/ui_action/toggle.rs | 52 +- .../benilla-app/src/ui_action/weapon_icon.rs | 104 +- crates/benilla-app/src/ui_auction/mod.rs | 412 +---- crates/benilla-app/src/ui_auction/net.rs | 132 +- crates/benilla-app/src/ui_auction/sort.rs | 82 +- crates/benilla-app/src/ui_aura.rs | 635 ++----- crates/benilla-app/src/ui_bank/mod.rs | 93 +- crates/benilla-app/src/ui_bank/net.rs | 35 +- crates/benilla-app/src/ui_battlefield.rs | 113 +- .../src/ui_battlefield_positions.rs | 53 +- .../benilla-app/src/ui_battlefield_score.rs | 61 +- crates/benilla-app/src/ui_bind_confirm.rs | 165 +- crates/benilla-app/src/ui_binder.rs | 170 +- crates/benilla-app/src/ui_cast.rs | 189 +- crates/benilla-app/src/ui_char.rs | 628 ++----- crates/benilla-app/src/ui_chat.rs | 221 +-- .../benilla-app/src/ui_chat/ace_gate_tests.rs | 90 +- crates/benilla-app/src/ui_chat/away.rs | 217 +-- crates/benilla-app/src/ui_chat/broadcast.rs | 226 +-- crates/benilla-app/src/ui_chat/channels.rs | 504 ++---- crates/benilla-app/src/ui_chat/combat.rs | 653 ++----- .../benilla-app/src/ui_chat/combat/tests.rs | 147 +- .../benilla-app/src/ui_chat/combat/watch.rs | 122 +- crates/benilla-app/src/ui_chat/commands.rs | 148 +- crates/benilla-app/src/ui_chat/edit.rs | 342 +--- crates/benilla-app/src/ui_chat/event.rs | 279 +-- crates/benilla-app/src/ui_chat/feed.rs | 544 ++---- crates/benilla-app/src/ui_chat/frames.rs | 162 +- crates/benilla-app/src/ui_chat/idle.rs | 260 +-- crates/benilla-app/src/ui_chat/input/mod.rs | 569 ++---- crates/benilla-app/src/ui_chat/input/parse.rs | 316 +--- crates/benilla-app/src/ui_chat/language.rs | 133 +- crates/benilla-app/src/ui_chat/logging.rs | 33 +- crates/benilla-app/src/ui_chat/net.rs | 223 +-- crates/benilla-app/src/ui_chat/recruitment.rs | 123 +- crates/benilla-app/src/ui_chat/settings.rs | 511 ++---- crates/benilla-app/src/ui_chat/tests.rs | 757 ++------ crates/benilla-app/src/ui_craft.rs | 154 +- crates/benilla-app/src/ui_dialog_verbs.rs | 604 ++----- crates/benilla-app/src/ui_dressup.rs | 264 +-- crates/benilla-app/src/ui_duel.rs | 246 +-- crates/benilla-app/src/ui_follow.rs | 74 +- crates/benilla-app/src/ui_gamma.rs | 146 +- crates/benilla-app/src/ui_gm_ticket.rs | 318 +--- crates/benilla-app/src/ui_gossip/mod.rs | 350 ++-- crates/benilla-app/src/ui_gossip/net.rs | 41 +- crates/benilla-app/src/ui_guild/feed.rs | 166 +- crates/benilla-app/src/ui_guild/lines.rs | 151 +- crates/benilla-app/src/ui_guild/mod.rs | 570 ++---- crates/benilla-app/src/ui_guild/sort.rs | 123 +- crates/benilla-app/src/ui_hide.rs | 44 +- crates/benilla-app/src/ui_honor.rs | 189 +- crates/benilla-app/src/ui_inspect.rs | 128 +- crates/benilla-app/src/ui_instance.rs | 482 ++--- crates/benilla-app/src/ui_item_text.rs | 195 +- crates/benilla-app/src/ui_items/drain.rs | 476 ++--- .../benilla-app/src/ui_items/equip_error.rs | 111 +- crates/benilla-app/src/ui_items/feed.rs | 744 ++------ crates/benilla-app/src/ui_items/mod.rs | 881 +++------ crates/benilla-app/src/ui_items/net.rs | 53 +- crates/benilla-app/src/ui_layout.rs | 213 +-- crates/benilla-app/src/ui_logout.rs | 182 +- crates/benilla-app/src/ui_loot/mod.rs | 973 +++------- crates/benilla-app/src/ui_loot/net.rs | 346 +--- crates/benilla-app/src/ui_loot_roll.rs | 454 ++--- crates/benilla-app/src/ui_macro/mod.rs | 119 +- crates/benilla-app/src/ui_macro/run.rs | 174 +- crates/benilla-app/src/ui_macro/store.rs | 73 +- crates/benilla-app/src/ui_macro/tests.rs | 92 +- crates/benilla-app/src/ui_mail/invoice.rs | 79 +- crates/benilla-app/src/ui_mail/mod.rs | 379 ++-- crates/benilla-app/src/ui_mail/net.rs | 101 +- crates/benilla-app/src/ui_mail/pending.rs | 139 +- crates/benilla-app/src/ui_merchant/mod.rs | 256 +-- crates/benilla-app/src/ui_merchant/net.rs | 38 +- crates/benilla-app/src/ui_mirror.rs | 137 +- crates/benilla-app/src/ui_net.rs | 23 +- crates/benilla-app/src/ui_party/feed.rs | 653 ++----- crates/benilla-app/src/ui_party/mod.rs | 370 +--- crates/benilla-app/src/ui_party/net.rs | 148 +- crates/benilla-app/src/ui_pet/bar.rs | 170 +- crates/benilla-app/src/ui_pet/drain.rs | 281 +-- crates/benilla-app/src/ui_pet/menu.rs | 108 +- crates/benilla-app/src/ui_pet/mod.rs | 152 +- crates/benilla-app/src/ui_pet/net.rs | 196 +- crates/benilla-app/src/ui_pet/tests.rs | 229 +-- crates/benilla-app/src/ui_pet/unit.rs | 148 +- crates/benilla-app/src/ui_pet_book.rs | 154 +- crates/benilla-app/src/ui_pet_doll.rs | 94 +- crates/benilla-app/src/ui_pet_stats.rs | 309 +--- crates/benilla-app/src/ui_petition/feed.rs | 138 +- crates/benilla-app/src/ui_petition/lines.rs | 136 +- crates/benilla-app/src/ui_petition/mod.rs | 378 +--- crates/benilla-app/src/ui_quest/mod.rs | 449 ++--- crates/benilla-app/src/ui_quest/net.rs | 202 +-- crates/benilla-app/src/ui_quest_log.rs | 667 ++----- crates/benilla-app/src/ui_quest_share.rs | 141 +- crates/benilla-app/src/ui_reputation.rs | 200 +-- crates/benilla-app/src/ui_saved.rs | 93 +- .../src/ui_script/questlog_tests.rs | 4 +- crates/benilla-app/src/ui_session.rs | 300 +--- crates/benilla-app/src/ui_shapeshift.rs | 226 +-- crates/benilla-app/src/ui_social/feed.rs | 202 +-- crates/benilla-app/src/ui_social/mod.rs | 212 +-- crates/benilla-app/src/ui_social/query.rs | 91 +- crates/benilla-app/src/ui_spellbook.rs | 282 +-- crates/benilla-app/src/ui_stable/mod.rs | 231 +-- crates/benilla-app/src/ui_stable/net.rs | 68 +- crates/benilla-app/src/ui_summon.rs | 226 +-- crates/benilla-app/src/ui_tabard.rs | 90 +- crates/benilla-app/src/ui_talent.rs | 124 +- crates/benilla-app/src/ui_talent_wipe.rs | 136 +- crates/benilla-app/src/ui_taxi/mod.rs | 170 +- crates/benilla-app/src/ui_taxi/net.rs | 36 +- crates/benilla-app/src/ui_taxi/routing.rs | 168 +- crates/benilla-app/src/ui_tooltip/mod.rs | 476 ++--- crates/benilla-app/src/ui_tooltip/tests.rs | 156 +- crates/benilla-app/src/ui_trade/mod.rs | 733 ++------ crates/benilla-app/src/ui_trade/net.rs | 211 +-- crates/benilla-app/src/ui_tradeskill.rs | 204 +-- crates/benilla-app/src/ui_trainer/law.rs | 122 +- crates/benilla-app/src/ui_trainer/mod.rs | 266 +-- crates/benilla-app/src/ui_trainer/net.rs | 43 +- crates/benilla-app/src/ui_trainer/reeval.rs | 96 +- crates/benilla-app/src/ui_trainer/tests.rs | 223 +-- crates/benilla-app/src/ui_unit.rs | 1581 ++++------------- crates/benilla-app/src/ui_world_map.rs | 647 ++----- 138 files changed, 8661 insertions(+), 26114 deletions(-) diff --git a/crates/benilla-app/src/ui_action/cast_fail.rs b/crates/benilla-app/src/ui_action/cast_fail.rs index 7b1bd801..8e079127 100644 --- a/crates/benilla-app/src/ui_action/cast_fail.rs +++ b/crates/benilla-app/src/ui_action/cast_fail.rs @@ -1,78 +1,17 @@ -//! Cast-failure display strings β€” the reference's **two-layer** pipeline (decision 0427): +//! Cast-failure text, resolved in the three layers of the reference's handler `0x6e1a00`: //! -//! 1. `HandleCastFailed 0x6e1a00` resolves the wire reason through the name-identity table -//! `0x6e23e0` (wire order = the vmangos `SpellCastResult` enum) into -//! `GetText("SPELL_FAILED_")` β€” [`CAST_FAIL_KEYS`]. -//! 2. A per-reason **errorId** (default `0x2c` `ERR_SPELL_FAILED_S` = `"%s"`, a pure -//! passthrough) is handed to `CGGameUI::DisplayError 0x496720`; ~12 reasons override it, -//! REPLACING the message entirely (their `ERR_*` string has no `%s`). This is why the -//! screen shows "Spell is not ready yet." while `SPELL_FAILED_NOT_READY` reads -//! "Not yet recovered", and "Not enough rage" for a rage spell's NO_POWER. +//! 1. The wire reason names a `SPELL_FAILED_*` GlobalStrings key ([`CAST_FAIL_KEYS`]). +//! 2. A per-reason errorId goes to `CGGameUI::DisplayError` (`0x496720`): the default `0x2c` is +//! a bare `"%s"`, an override replaces the text. The pet's handler `0x6e8eb0` has its own +//! overrides ([`Caster`]); layers 1 and 3 are shared. +//! 3. An argument arm (`0x6e1d8e`) fills the template's `%s`: the DBC arms in [`FailArgs::fill`], +//! the item and subclass arms (`0x78`, `0x5c`, `0x19`-`0x1b`, `0x31`) in the drain. //! -//! **This layer β€” and only this layer β€” is per-CASTER** (decision 2033). `SMSG_PET_CAST_FAILED` -//! is not the same handler with a flag: it is `Spell_C::HandlePetCastFailed 0x6e8eb0`, a -//! separate switch over a separate 142-byte index table, and it overrides a different ten -//! reasons. Six of them are the `ERR_PET_SPELL_*` catalog rows, which have no other raise site -//! in the client β€” "Your pet is out of range." where the player reads "Out of range." β€” -//! and seven of the player's overrides are simply absent there. [`Caster`] picks the table; -//! layers 1 and 3 are shared code in the binary and shared code here. -//! -//! 3. A per-reason **argument arm** (the second dispatch `0x6e1d8e`, 13 targets) fills that -//! message's own `%s`/`%d` from a DBC or item name before it is displayed. The two arms whose -//! tables benilla already loads live in [`FailArgs::fill`]; the drain fills three more before -//! calling here (below), and the rest strip. -//! -//! Strings are never hardcoded here: every message resolves from the VM's loaded -//! `GlobalStrings.lua` by key, so localization rides for free. Suppression is faithful on -//! both mechanisms: reason `0x17` (DONT_REPORT) never reaches display (control-flow), and a -//! key absent from GlobalStrings displays as nothing (data β€” 0x08/0x21/0x75, happiness -//! NO_POWER). -//! -//! **The argument arms, and what is still approximate.** Filled: `0x5e` REQUIRES_SPELL_FOCUS and -//! `0x5d` REQUIRES_AREA here (decision 1313 β€” `0x6e1f62`/`0x6e1fad`), `0x8d` -//! PREVENTED_BY_MECHANIC (decision 1948 β€” `0x6e2190`, and the one arm whose word is produced -//! locally rather than read off the wire), `0x56` ONLY_SHAPESHIFT (decision 2280 β€” `0x6e1ff8`, -//! the one arm that reads a **mask** and joins several names), and `0x78` TOTEMS / `0x5c` -//! REAGENTS / `0x19`–`0x1b` EQUIPPED_ITEM_CLASS\* / `0x31` NEED_EXOTIC_AMMO in the drain, which -//! owns the totem/reagent pair because their fills need the item caches and the -//! query-then-redisplay cache-miss behavior ("Requires Mining Pick", decisions 0545 + 0552), and -//! the other two because they share `0x6e2380`'s subclass catalog. -//! -//! **That is every arm in the `0x6e1d8e` table, and the reasons that still read bare read bare -//! for reasons that are not "a DBC we do not load"** β€” decision 2292, which found that claim -//! false in all three places this paragraph used to make it β€” the same stale blocker that hid -//! `0x56` behind a DBC we had been loading for a month: -//! -//! - `0x30` NEED_AMMO_POUCH β€” **the reference leaves its own `%s` literal.** There is no arm to -//! write: `0x6e1e3d` only clears `[caster+0xd58]` bit `0x200`, and no word is ever supplied. -//! - `0x31` NEED_EXOTIC_AMMO β€” modeled, and argless on this stack. `Spell::SendCastResult` fills -//! `failureArg1` for four reasons and this is not one of them, so the arm declines. -//! - `0x84` PROSPECT_NEED_MORE and `0x90` MIN_SKILL β€” **unreachable on 5875, not -//! unimplemented.** Neither is server-sent, and their only raise sites image-wide (`0x49614e` -//! and `0x496128`, both inside the item-target validator `0x495d60`) sit behind that -//! function's third leg, `Effect[i] == 0x7f` SPELL_EFFECT_PROSPECTING β€” which no shipped spell -//! carries, pinned on the real file by `real_prospecting_effect_is_absent_from_5875`. -//! `SkillLine.dbc`, the DBC `0x90` was said to be waiting on, has been loaded since the -//! spellbook grew tabs. -//! -//! Stripping is a **deliberate divergence**, now byte-confirmed as one: on a -//! bad id or an absent word the reference jumps to the default arm with the pointer still on the -//! *unfilled* template, so it displays a literal `Requires %s` (the fill path's own buffer swap -//! `0x6e21d2` sits after the printf and is skipped). We show the bare stem instead: -//! "Requires" reads as terse, "Requires %s" reads as broken software (Β§7 β€” judge by the result). -//! **`0x56` is the exception, and it is the reference's own** ([`Fill::Nothing`]): its three -//! failure exits land on the function epilogue, not on that default arm, so an unfillable -//! ONLY_SHAPESHIFT displays nothing at all. -//! `0x0a`'s item-spell leg (`ERR_INVALID_ITEM_TARGET`) is unmodeled β€” the drain does not know -//! item-ness. -//! -//! 4. **A fourth thing leaves this function, and it is not the displayed text** -//! ([`CastFailLine::arg_text`], decision 2285). `0x6e1a00` also calls the combat-log formatter -//! `0x62c360`, and hands it `edi` β€” the **argText** buffer, which layer 2 never touches. So -//! the eighteen re-worded reasons say one thing on screen and another in the log: "Spell is -//! not ready yet." against "Not yet recovered". Modelling the log as "whatever the red line -//! said" is wrong on seven rows; re-deriving it from the key table is wrong on the filled -//! arms. Both buffers come out of the one resolution here. +//! The combat log (`0x62c360`) reads a second buffer the overrides never touch +//! ([`CastFailLine::arg_text`]). `0x30`'s arm (`0x6e1e3d`) fills nothing in the reference. +//! `0x84` and `0x90` are unreachable in 1.12: the server never sends them, and their only client +//! raise sites (`0x49614e`, `0x496128`) need the prospecting effect `0x7f`, which no shipped +//! spell has. `0x0a`'s item-target line (`ERR_INVALID_ITEM_TARGET`) is not built. use std::collections::HashMap; @@ -82,7 +21,8 @@ use benilla_formats::{ use super::Caster; -/// Wire reason β†’ its `SPELL_FAILED_*` GlobalStrings key (the `0x6e23e0` table, byte-exact). +/// Wire reason to its `SPELL_FAILED_*` GlobalStrings key: the reference's table `0x6e23e0`, in +/// vmangos `SpellCastResult` order (`SpellDefines.h:350`). pub(super) const CAST_FAIL_KEYS: [&str; 146] = [ "SPELL_FAILED_AFFECTING_COMBAT", // 0x00 "SPELL_FAILED_ALREADY_AT_FULL_HEALTH", // 0x01 @@ -232,8 +172,8 @@ pub(super) const CAST_FAIL_KEYS: [&str; 146] = [ "SPELL_FAILED_UNKNOWN", // 0x91 ]; -/// Vanilla power types (`SpellRec+0x7c`): the NO_POWER pick table `0x8118dc` and the -/// full-power `%s` fill. Health is the wire's -2. +/// A power type (`SpellRec+0x7c`) to its NO_POWER key (the reference's table `0x8118dc`) and +/// its power noun; health is -2. fn power_keys(power_type: u32) -> (&'static str, &'static str) { match power_type { 1 => ("ERR_OUT_OF_RAGE", "RAGE"), @@ -245,7 +185,7 @@ fn power_keys(power_type: u32) -> (&'static str, &'static str) { } } -/// The potion/food category test (`SpellRec+0x8`) the 0x28/0x3c errorId picks key on. +/// The spell categories (`SpellRec+0x8`) the `0x28` and `0x3c` cooldown picks test. fn is_potion(spell: Option<&SpellDisplay>) -> bool { spell.is_some_and(|d| matches!(d.category, 4 | 9)) } @@ -253,79 +193,50 @@ fn is_food(spell: Option<&SpellDisplay>) -> bool { spell.is_some_and(|d| matches!(d.category, 0xA | 0xB)) } -/// The argument arms' inputs: the wire's reason-specific word ([`super::CastFail::arg`]) and the -/// DBC name tables the arms read. Both catalogs are `Option` because a client without game data -/// has neither β€” the arm then declines and the template strips, exactly as an unmodeled arm does. +/// The argument arms' inputs: the wire's argument word ([`super::CastFail::arg`]) and the DBC +/// name tables; an arm whose table is missing declines. #[derive(Default, Clone, Copy)] pub(super) struct FailArgs<'a> { pub(super) arg: Option, - /// `SpellFocusObject.dbc` (`0xc0d800`) β€” the `0x5e` arm's names ("Anvil", "Forge", and the - /// Teldrassil moonwells the Crown of the Earth phials name). + /// `SpellFocusObject.dbc` (`0xc0d800`), for `0x5e`. pub(super) focus: Option<&'a SpellFocusCatalog>, - /// `AreaTable.dbc` (`0xc0e048`) β€” the `0x5d` arm's `AreaName`. + /// `AreaTable.dbc` (`0xc0e048`), for `0x5d`. pub(super) areas: Option<&'a AreaTableCatalog>, - /// `SpellMechanic.dbc` (`0xc0d7c4`) β€” the `0x8d` arm's mechanic name (decision 1948). + /// `SpellMechanic.dbc` (`0xc0d7c4`), for `0x8d`. pub(super) mechanics: Option<&'a SpellMechanicCatalog>, - /// `SpellShapeshiftForm.dbc` (`0xc0d76c`) β€” the `0x56` arm's form names, keyed by form id - /// exactly as [`crate::ui_action::Spells::forms`] holds them (decision 2280). + /// `SpellShapeshiftForm.dbc` (`0xc0d76c`), for `0x56`, keyed by form id. pub(super) forms: Option<&'a HashMap>, } -/// What an argument arm produced β€” **and, when it produced nothing, which exit the reference's -/// own arm takes**, because the two unfilled outcomes are not the same outcome. -/// -/// Every other arm in the `0x6e1d8e` table declines through a bounds test that jumps to the -/// shared default `0x6e21d8`, where `edi` still points at the *unfilled* `SPELL_FAILED_*` -/// template and the line is displayed anyway ([`Self::Template`] β€” benilla strips its tokens -/// rather than showing a raw `%s`). `0x56` alone jumps, from all three of its failure exits, to -/// the function **epilogue** `0x6e224f` -/// ([`Self::Nothing`]), past `DisplayError`, past the error sound and past the cast abort -/// `0x6e4940` alike. That is not a special case anyone chose: the `0x56` arm spends `edi` as its -/// own loop counter, so it has no text pointer left to hand the default arm. +/// What an argument arm produced, and when nothing, which exit the reference takes: every arm +/// but `0x56` declines to the shared default `0x6e21d8`, which still displays the template, while +/// `0x56` spends `edi` as its loop counter and exits to the epilogue `0x6e224f`, past the display, +/// the log and the cast abort `0x6e4940`. pub(super) enum Fill { - /// The arm resolved its word β€” the template's `%s` takes it. + /// The name for the template's `%s`. Filled(String), - /// The arm declined (or benilla does not model it) and the reference still displays the - /// template: the shared default `0x6e21d8`. + /// Declined; the reference displays the unfilled template (`0x6e21d8`). Template, - /// The arm declined and the reference displays **nothing**: the epilogue `0x6e224f`. + /// Declined; the reference displays nothing (`0x6e224f`). Nothing, } impl FailArgs<'_> { - /// The `%s` fill for the argument-formatted reasons this module owns, or `None` to leave the - /// template to the strip fallback. - /// - /// Each of the two arms reads the **wire's** first argument word β€” `[ebx+8]`, the handler's own - /// stack slot, never a re-read of `Spell.dbc` β€” indexes its DBC store, and `SStrPrintf`s the - /// reason's `SPELL_FAILED_*` template. The errorId stays the default `0x2c` (`"%s"`) for both, - /// so what the player reads IS the filled template. A single-`%s` template is what makes a - /// plain `replace` faithful here; the reference runs a real two-pass printf, which would - /// matter for a multi-specifier format. - /// - /// **`0x5e` REQUIRES_SPELL_FOCUS** (`0x6e1f62`) β†’ `SpellFocusObject.dbc` (`0xc0d800`) - /// `Name_Lang` at `row + 0x4 + locale*4`, so `"Requires %s"` reads "Requires Starbreeze - /// Village Moonwell". The failing spell's own `RequiresSpellFocus` column holds the same - /// number vmangos copied onto the wire, so we fall back to it when the word is absent β€” a - /// benilla-side robustness margin, not a transcription: the client itself never reads - /// `SpellRec+0x3c` on this path. - /// - /// **`0x5d` REQUIRES_AREA** (`0x6e1fad`) β†’ `AreaTable.dbc` (`0xc0e048`) `AreaName_Lang` at - /// `row + 0x2c + locale*4`, so `"You need to be in %s"` names the zone. This one has **no** - /// client-side stand-in: the server derives the id from its own `spell_area` rows and nothing - /// in `Spell.dbc` holds it, so an absent word means an unfilled message. + /// The `%s` fill for the arms this module owns; `replace` matches the reference's printf + /// because each template has one `%s`. `0x5e` (`0x6e1f62`) and `0x5d` (`0x6e1fad`) read the + /// wire's argument word, never `Spell.dbc`, and name a `SpellFocusObject` or `AreaTable` row. + /// Deviation: `0x5e` with no word falls back to the spell's own `RequiresSpellFocus`, which + /// the reference never reads here, because it is the id vmangos sends (`Spell.cpp:4426`). + /// `0x5d` has no such stand-in: vmangos takes its area from its own `spell_area` table + /// (`Spell.cpp:4429`), which `Spell.dbc` does not carry. fn fill(&self, caster: Caster, reason: u8, spell: Option<&SpellDisplay>) -> Fill { let named = |name: Option<&str>| match name { Some(name) => Fill::Filled(name.to_string()), None => Fill::Template, }; match reason { - // **`0x8d` PREVENTED_BY_MECHANIC** (`0x6e2190`) β†’ `SpellMechanic.dbc` (`0xc0d7c4`), - // so "Can't do that while %s" reads "Can't do that while stunned". Unlike its two - // neighbours the word is not the wire's: this refusal is raised locally and the id - // comes from the crowd-control ladder's own exemption scan (decisions 1941/1948). - // A `0` or unknown id leaves the template to the strip fallback, which is also what - // the arm does when the scan named no mechanic at all. + // `0x8d` (`0x6e2190`), "Can't do that while stunned": the word is the local + // crowd-control ladder's mechanic id, not the wire's. 0x8D => named(self.arg.and_then(|id| self.mechanics?.name(id))), 0x5D => named(self.arg.and_then(|id| self.areas?.name(id))), 0x5E => { @@ -340,34 +251,12 @@ impl FailArgs<'_> { } } - /// **`0x56` ONLY_SHAPESHIFT** (`0x6e1ff8`) β†’ `SpellShapeshiftForm.dbc` (`0xc0d76c`), so - /// `"Must be in %s"` reads "Must be in Cat Form" β€” the line a bear-form druid gets for - /// pressing Prowl. The one arm that reads a **mask** and can name several forms. - /// - /// Byte-exact, and unlike its neighbours it reads **no wire word at all**: `6e2021` re-reads - /// the failing spell's own record, `6e202d: test [eax+0x2c],edx` walks `SpellRec+0x2c` - /// (`Stances`, field 11) bit by bit, and `6e2050: mov eax,[esi+edx*4+0x8]` takes each row's - /// `Name` at `row + 0x8 + locale*4`. The walk is over the **record array** (`6e20b3: add - /// esi,0x38`, stride 56 = one row) with the bit index as the row index, never through the - /// id-lookup table β€” and 5875's file is 32 rows carrying ids 1..32 in file order (verified on - /// the extracted DBC), so row index `b` IS form id `b+1`. That is the same "bit b = form id - /// b+1" law the spell tooltip's own required-form line already runs on, which is why the - /// tooltip could read "Requires Cat Form" while the refusal read "Must be in". - /// - /// Joined with `", "` (`0x84480c`, `SStrCat 0x64a760`) β€” the reference's own separator, and - /// again the tooltip line's. A row whose `Name` is NULL or **empty** is skipped - /// (`6e2058: cmp BYTE PTR [eax],0x0`); 5875 ships fourteen such rows, so this is live data, - /// not a guard against nothing. - /// - /// Two earned negatives. The arm never tests `AttributesEx2` bit `0x80000` β€” the *permissive* - /// mask attribute the tooltip's required-form line does honour (`0x52f115`, decision 1483) β€” - /// so a permissive spell that somehow reached this reason would still name its forms; the - /// form gate, not this arm, is what keeps that unreachable. And `StancesNot` (`+0x30`) is - /// never read here either. - /// - /// **Nothing to say is said as nothing** ([`Fill::Nothing`]): an empty mask, an unnamed set - /// of forms, no spell record (`6e1ffd`), or an unloaded DBC (`6e2016`, the `[0xc0d770] == 0` - /// test) all leave the "nothing appended" flag set at `6e20c1` and jump to the epilogue. + /// `0x56` ONLY_SHAPESHIFT (`0x6e1ff8`), "Must be in Cat Form": no wire word, but the spell's + /// own `Stances` mask (`SpellRec+0x2c`), each set bit `b` naming the DBC's row `b`, which in + /// 1.12's file is form id `b+1`. Empty names are skipped (1.12 ships fourteen) and the rest + /// joined with `", "` (`0x84480c`). It never tests `StancesNot`, nor the permissive + /// `AttributesEx2` bit `0x80000` the tooltip honours (`0x52f115`). No spell, no DBC or no + /// name is [`Fill::Nothing`]. fn shapeshift_forms(&self, caster: Caster, spell: Option<&SpellDisplay>) -> Fill { let (Some(forms), Some(stances)) = (self.forms, spell.map(|d| d.stances)) else { return Fill::Nothing; @@ -378,14 +267,8 @@ impl FailArgs<'_> { .map(|f| f.name.as_str()) .filter(|n| !n.is_empty()); match caster { - // **The pet's arm does not join** (decision 2285). `0x6e921a` reads the same store - // the same way β€” `[0xc0d76c]` at `0x6e924f`, stride `0x38` at `0x6e9285`, - // `row+0x8+locale*4` at `0x6e9277`, the mask at `0x6e924c` β€” and then - // `0x6e9282: jne 0x6e929a` leaves the loop at the FIRST non-empty name. It is a - // break, not a continue: over `[0x6e8eb0, 0x6e93d0)` there is not one `SStrCat` - // (`0x64a760`) call and not one reference to the separator `0x84480c`. So a - // two-form spell the player reads as "Bear Form, Dire Bear Form" the pet reads as - // "Bear Form". + // The pet's arm (`0x6e921a`) reads the same rows but leaves the loop at the first + // name (`0x6e9282`); it never joins. Caster::Pet => named.next().map(str::to_string), Caster::Player => { let all: Vec<&str> = named.collect(); @@ -396,44 +279,26 @@ impl FailArgs<'_> { } } -/// The errorId every reason that overrides nothing falls through to β€” `0x2c`, whose text is the -/// bare `"%s"`, so what the player reads is the first layer's `SPELL_FAILED_*` string. benilla -/// short-circuits the identity substitution and displays that string directly; the id still -/// matters, because it is the id whose **record** decides the surface. +/// The default errorId `0x2c`, a bare `"%s"`: the line is the first layer's string, and this +/// id's record decides the surface. pub(super) const PASSTHROUGH: &str = "ERR_SPELL_FAILED_S"; -/// One resolved cast-failure line: the text to show, and the **message id's key** that decides -/// where to show it. -/// -/// The key rides along because the reference's second layer hands its errorId to -/// `CGGameUI::DisplayError`, and that function reads the record's kind β€” so the surface is a -/// property of the id this dispatch picked, not of "cast failures" as a category. Seventeen of -/// the overrides are red and `ERR_SPELL_FAILED_NOTUNSHEATHED` is **yellow**; before the catalog -/// (decision 1770) benilla painted all eighteen red, because nothing here knew which id it had -/// chosen by the time the line reached the frame. +/// One resolved cast-failure line: the text, and the errorId's key, whose catalog record picks +/// the surface (`DisplayError` reads its kind); `ERR_SPELL_FAILED_NOTUNSHEATHED` is the one yellow +/// override. #[derive(Debug, Clone, PartialEq, Eq)] pub(super) struct CastFailLine { pub key: &'static str, pub text: String, - /// **The reference's OTHER buffer** β€” `edi` at `0x6e21e2`, which is what the combat-log - /// formatter `0x62c360` receives, not the displayed text (decision 2285). - /// - /// `0x6e1d9a: lea edi,[ebp-0x488]` runs **once, before the argument dispatch**, on the buffer - /// `0x6e1d77` has just filled with `GetText("SPELL_FAILED_")` β€” and the errorId-override - /// chain (`0x6e1aab`–`0x6e1c5f`) writes only `[ebp-8]`, never `edi`. So an override changes - /// what the SCREEN says and leaves the log on the first-layer string: a cooldown refusal - /// toasts "Spell is not ready yet." and logs "Not yet recovered". Only `0x6e21d2` re-points - /// `edi`, at `[ebp-0x888]`, which just three arms write β€” a filled argument arm (the printf - /// output, so log == toast), `0x02` (the bare power noun), and `0x4d` (blanked). - /// - /// Empty means "read the last-error buffer `0xb4da40` instead" β€” see [`Self::logged`]. + /// The argText buffer the combat log (`0x62c360`) reads (`edi` at `0x6e21e2`): the + /// first-layer string (`0x6e1d77`), since an override changes only the displayed text. Only a + /// filled arm, `0x02` and `0x4d` rewrite it (`0x6e21d2`); empty means the log reads the + /// displayed text ([`Self::logged`]). pub arg_text: String, } impl CastFailLine { - /// A line the second layer left on its default errorId β€” the drain builds these for the three - /// argument arms it owns (`0x78` TOTEMS, `0x5c` REAGENTS, `0x19`-`0x1b` EQUIPPED_ITEM_CLASS*), - /// whose fills need the item caches. A filled arm's two buffers hold the same printf output. + /// A line on the default errorId, both buffers holding the same text, as after a filled arm. pub(super) fn passthrough(text: String) -> Self { Self { key: PASSTHROUGH, @@ -442,9 +307,8 @@ impl CastFailLine { } } - /// What `0x62c360` prints as the reason β€” `0x6e21e2`'s one-byte test: the argText buffer when - /// it is non-empty, else `0x4968a0`'s global last-error cell `0xb4da40`, which `DisplayError` - /// has just filled with the text it displayed (`0x496802`). + /// What the combat log prints (`0x6e21e2`): the argText buffer, or when it is empty the + /// last-error cell `0xb4da40`, which holds the displayed text (`0x496802`). pub(super) fn logged(&self) -> &str { if self.arg_text.is_empty() { &self.text @@ -453,12 +317,8 @@ impl CastFailLine { } } - /// The `0x4d` arm's `mov BYTE PTR [ebp-0x888],0` (`0x6e20fd` / `0x6e2113`): the argText - /// buffer is emptied and `edi` re-pointed at it, so the combat log falls through to the - /// last-error cell and reads what the screen reads. The one override whose two surfaces - /// agree, and it agrees by being blanked rather than by being equal. (Moot on the pet's - /// path, which never reaches a log formatter at all β€” it is applied there only so the two - /// casters' lines carry the same shape.) + /// The `0x4d` arm empties the argText buffer (`0x6e20fd`), so the log reads the displayed + /// text. The pet's line is never logged; it is blanked only to keep one shape. fn arm_blanked(self) -> Self { Self { arg_text: String::new(), @@ -474,9 +334,9 @@ impl CastFailLine { } } -/// The displayed line for a failed cast β€” `None` = the reference shows nothing. `get` is the -/// VM's GlobalStrings lookup (an absent or empty key resolves to `None`, the data-suppression -/// face). Reasons beyond the table print their code β€” our debug affordance, not a ref string. +/// The line for a failed cast, `None` where the reference shows nothing; `get` is the VM's +/// GlobalStrings lookup, and an absent or empty key shows nothing. Deviation: a reason past the +/// table prints its hex code, so an unknown reason is visible. pub(super) fn cast_fail_text( caster: Caster, reason: u8, @@ -485,11 +345,8 @@ pub(super) fn cast_fail_text( get: &dyn Fn(&str) -> Option, ) -> Option { let get_text = |key: &str| get(key).filter(|s| !s.is_empty()); - // `[ebp-0x488]` β€” the FIRST-layer string, `SStrCopy`d at `0x6e1d77` and pointed at by `edi` - // at `0x6e1d9a`, both of which run before the argument dispatch and neither of which any - // errorId override touches. It is therefore what the combat log reads for every reason no - // argument arm claims, *including* the eighteen the second layer re-words on screen - // ([`CastFailLine::arg_text`], decision 2285). Empty when 5875 ships no such key. + // `[ebp-0x488]`, the first-layer string (`edi` from `0x6e1d9a`): what the log reads for + // every reason no arm claims, overrides included. Empty when 1.12 ships no such key. let first_layer = CAST_FAIL_KEYS .get(usize::from(reason)) .and_then(|k| get_text(k)) @@ -502,15 +359,10 @@ pub(super) fn cast_fail_text( arg_text: first_layer.clone(), }) }; - // The errorId overrides β€” the replaced-message reasons. **Which table is asked is the - // caster's** (decision 2033): the reference does not flag one handler, it ships two, and they - // disagree on ten reasons. Everything past this match β€” the `SPELL_FAILED_*` vocabulary, the - // argument arms, the strip fallback β€” is shared, exactly as it is in the binary. + // The errorId overrides, from the caster's own handler; everything after this is shared. match caster { - // `Spell_C::HandlePetCastFailed 0x6e8eb0`: `cmp reason,0x8d; ja default`, then a 142-byte - // index table (`0x6e93d0`) over 15 jump targets (`0x6e9394`). Six of the ten overrides are - // the `ERR_PET_SPELL_*` catalog rows, which exist for this handler and nothing else β€” - // "Your pet is dead." where the player reads "You are dead". + // `0x6e8eb0`: reasons above `0x8d` take the default arm, the rest the index table + // `0x6e93d0`. Six overrides are the `ERR_PET_SPELL_*` rows only this handler raises. Caster::Pet => match reason { 0x00 => return get_display("ERR_PET_SPELL_AFFECTING_COMBAT"), // 0x14c @0x6e8fab 0x13 => return get_display("ERR_PET_SPELL_DEAD"), // 0x150 @0x6e9017 @@ -519,10 +371,8 @@ pub(super) fn cast_fail_text( 0x59 => return get_display("ERR_PET_SPELL_OUT_OF_RANGE"), // 0x14d @0x6e8fc6 0x5F => return get_display("ERR_PET_SPELL_ROOTED"), // 0x14b @0x6e8f4f 0x65 => return get_display("ERR_PET_SPELL_TARGETS_DEAD"), // 0x14f @0x6e8ffc - // `0x6e8f2a` computes its id: `((SpellRec+0x18 & 0x10) | 0x300) >> 4`, which is only - // ever `0x31` or `0x30`. The player's four-way (`0x6e1aab`) tests the spell CATEGORY - // first for food and potion; the pet's does not test it at all, because a pet eats - // and drinks nothing. + // `0x6e8f2a`: `((SpellRec+0x18 & 0x10) | 0x300) >> 4`, ability or spell only; unlike + // the player's `0x6e1aab`, no food or potion leg. 0x3C => { return get_display(if spell.is_some_and(|d| d.attributes & 0x10 != 0) { "ERR_ABILITY_COOLDOWN" @@ -530,51 +380,34 @@ pub(super) fn cast_fail_text( "ERR_SPELL_COOLDOWN" }); } - // `0x6e8f6a`: health (`SpellRec+0x7c` == `-2`) takes `0x123`, everything else indexes - // `[0x8118f0 + 4*power]`. Those five dwords are byte-identical to the player's table - // at `0x8118dc` (`0x11f 0x120 0x121 0x122 0x168`), so this is the SAME pick, not a - // pet-specific one β€” which is why [`power_keys`] serves both and a hunter pet's focus - // ability reads "Not enough focus" either way. + // `0x6e8f6a`: health takes `0x123`, the rest `[0x8118f0 + 4*power]`, the same five + // ids as the player's `0x8118dc`, so [`power_keys`] serves both. 0x4D => { let power = spell.map_or(0, |d| d.power_type); return get_display(power_keys(power).0).map(CastFailLine::arm_blanked); } - // What is deliberately absent is as load-bearing as what is here: the pet's index - // table sends `0x01`/`0x02` (already at full health/power), `0x09` (no target), - // `0x18` (equipped item), `0x28` (item cooldown), `0x41` (unsheathed) and `0x8e` - // (play time) to the generic arm, so a pet never says any of those seven lines. - // - // `0x17` DONT_REPORT is the one that looks like a divergence and is not: the player's - // handler hides it by control flow, the pet's routes it to the generic arm β€” but - // `SPELL_FAILED_DONT_REPORT` has no string in 5875's `GlobalStrings.lua`, so the - // passthrough below draws nothing and the two agree on screen. - // - // `0x56` ONLY_SHAPESHIFT has its own arm here (`0x6e921a`, errorId `0xd6`), and it - // is the one place the two casters' form lines actually differ: it names the FIRST - // matching form and stops, where the player's joins them all. [`FailArgs::fill`] owns - // that fork, below β€” the line still resolves through the shared layer, because - // `ERR_SPELL_FAILED_SHAPESHIFT_FORM_S` is the bare `"%s"` exactly as the passthrough - // `0x2c` is, and the message catalog gives ids `0x2c` and `0xd6` the same surface. - // What is NOT shared is the log: `0x6e8eb0` never calls `0x62c360` at all. + // The pet sends `0x01`, `0x02`, `0x09`, `0x18`, `0x28`, `0x41` and `0x8e` to its + // generic arm, so it never says those lines. `0x17` goes there too and shows nothing + // only because 1.12 ships no `SPELL_FAILED_DONT_REPORT`. `0x56`'s pet arm + // (`0x6e921a`, errorId `0xd6`, a bare `"%s"` like `0x2c`) is in [`FailArgs::fill`]. _ => {} }, - // `Spell_C::HandleCastFailed 0x6e1a00` (`0x6e1aab`–`0x6e1c5f`). + // `0x6e1a00`, overrides at `0x6e1aab`-`0x6e1c5f`. Caster::Player => match reason { 0x01 => return get_display("ERR_SPELL_FAILED_ALREADY_AT_FULL_HEALTH"), 0x02 => { let t = get_display("ERR_SPELL_FAILED_ALREADY_AT_FULL_POWER_S")?; let power = spell.map_or(0, |d| d.power_type); let name = get_text(power_keys(power).1).unwrap_or_default(); - // `0x6e211f` copies the localized power NOUN β€” and nothing else β€” into - // `[ebp-0x888]`, then points `edi` there. So the screen reads the sentence and - // the combat log reads the bare word: "You fail to cast Rejuvenation: Mana." + // `0x6e211f` puts only the power noun in the argText buffer: the log reads the + // bare word. return Some(CastFailLine { arg_text: name.clone(), ..t.fill(&name) }); } 0x09 => return get_display("ERR_GENERIC_NO_TARGET"), - 0x17 => return None, // DONT_REPORT: control-flow hidden (jumps past DisplayError) + 0x17 => return None, // DONT_REPORT jumps past DisplayError 0x18 => return get_display("ERR_SPELL_FAILED_EQUIPPED_ITEM"), 0x28 => { return get_display(if is_potion(spell) { @@ -605,33 +438,27 @@ pub(super) fn cast_fail_text( _ => {} }, } - // The passthrough layer: errorId 0x2c ("%s") displays GetText(SPELL_FAILED_) as-is. + // The passthrough: errorId `0x2c` displays the key's string as is. let Some(key) = CAST_FAIL_KEYS.get(usize::from(reason)) else { return Some(CastFailLine::passthrough(format!( "Spell failed ({reason:#04x})" ))); }; let text = get_text(key)?; - // On this layer the two buffers are one string: errorId `0x2c` is the bare `"%s"`, so what - // `DisplayError` formats IS `[ebp-0x488]`, the same buffer `edi` still points at. let line = CastFailLine::passthrough; - // The argument arms (`0x6e1d8e`): fill the template's `%s` from the reason's own DBC. + // The argument arms (`0x6e1d8e`). match args.fill(caster, reason, spell) { Fill::Filled(name) => return Some(line(text.replace("%s", &name))), - // The arm's own exit is the epilogue, not the shared default β€” nothing is displayed, and - // nothing is logged either (`0x6e224f` is past `0x62c360` too). See [`Fill`]. + // The epilogue `0x6e224f` skips the log too. Fill::Nothing => return None, Fill::Template => {} } - // An arm we don't model (or one whose lookup missed) β€” strip the tokens so the stem reads - // clean ("Missing reagent: %s" β†’ "Missing reagent"), never a raw % on screen. Some(line(strip_tokens(&text))) } -/// The unmodeled-arm fallback's token strip β€” "Missing reagent: %s" β†’ "Missing reagent", a -/// deliberate divergence from the reference's literal `%s` (module docs). Applied to **both** -/// buffers, so a declined arm never puts a raw token in the combat log either. A string with no -/// token is returned unchanged. +/// Deviation: a declined arm's template loses its tokens ("Missing reagent: %s" reads "Missing +/// reagent") where the reference shows the literal `%s`, because a raw token reads as broken. +/// Applied to both buffers. fn strip_tokens(text: &str) -> String { if !text.contains('%') { return text.to_string(); @@ -650,7 +477,7 @@ mod tests { use super::*; use std::collections::HashMap; - /// The shipped 1.12 GlobalStrings entries the tests rest on (extracted values). + /// Shipped 1.12 `GlobalStrings.lua` values. fn gs() -> HashMap<&'static str, &'static str> { HashMap::from([ ("SPELL_FAILED_NO_AMMO", "Out of ammo"), @@ -667,13 +494,11 @@ mod tests { ("ERR_OUT_OF_MANA", "Not enough mana"), ("ERR_OUT_OF_RAGE", "Not enough rage"), ( - // Verbatim from 5875 β€” it is NOT "You have nothing to attack with.", which is - // what this fixture claimed until the log leg made the difference observable. + // Verbatim from the shipped file. "ERR_SPELL_FAILED_NOTUNSHEATHED", "You have to be unsheathed to do that!", ), - // The pet's own six, verbatim from the shipped file β€” and NO `ERR_PET_SPELL_NOPATH`, - // because 5875 ships none, which is a fact these tests rest on. + // The pet's six; 1.12 ships no `ERR_PET_SPELL_NOPATH`. ("ERR_PET_SPELL_AFFECTING_COMBAT", "Your pet is in combat."), ("ERR_PET_SPELL_DEAD", "Your pet is dead."), ( @@ -695,11 +520,6 @@ mod tests { ]) } - /// **The six lines that exist for the pet and nobody else.** Each is a row the client raises - /// only from `Spell_C::HandlePetCastFailed 0x6e8eb0`, and each is checked against what the - /// *player* gets for the same wire reason β€” because "our pet reads the same string we do" is - /// exactly the shape of the defect decision 2033 corrects, and a one-sided assert would not - /// have caught it. #[test] fn the_pet_speaks_its_own_six_refusals() { let m = gs(); @@ -736,10 +556,7 @@ mod tests { } } - /// **NOPATH is silent for the pet and spoken for the player**, which reads like a bug and is - /// the reference: the pet arm raises errorId `0x151`, whose key `ERR_PET_SPELL_NOPATH` has no - /// string in 5875's `GlobalStrings.lua`, so `DisplayError` shows nothing. The player's `0x32` - /// takes the passthrough and reads "No path available". + /// The pet's `0x32` raises `0x151`, `ERR_PET_SPELL_NOPATH`; the player's is a passthrough. #[test] fn the_pets_nopath_is_silent_because_5875_ships_no_string() { let m = gs(); @@ -756,10 +573,6 @@ mod tests { ); } - /// The pet's `0x3c` computes its id from one bit β€” `((SpellRec+0x18 & 0x10) | 0x300) >> 4` β€” - /// so it has only the ability/spell split. The player's tests the spell CATEGORY first and has - /// a food and a potion leg. A pet eats nothing, so a potion-category spell that says "Item is - /// not ready yet." for us says "Spell is not ready yet." for it. #[test] fn the_pet_has_no_food_or_potion_cooldown_leg() { let m = gs(); @@ -773,15 +586,12 @@ mod tests { }; assert_eq!(text(Caster::Player, &potion), "Item is not ready yet."); assert_eq!(text(Caster::Pet, &potion), "Spell is not ready yet."); - // The one leg they share, so the collapse is a missing branch and not a missing table. + // The ability leg is shared. assert_eq!(text(Caster::Player, &ability), "Ability is not ready yet."); assert_eq!(text(Caster::Pet, &ability), "Ability is not ready yet."); } - /// The pet's index table sends seven of the player's overrides to its generic arm, so those - /// lines are the player's alone. Checked on the two with the sharpest tell: `0x09`, where the - /// text changes, and `0x41`, where the **surface** changes too β€” the player's override is the - /// one yellow cast failure in the game, and the pet's passthrough is red. + /// `0x41` also changes surface: the player's override is yellow, the pet's passthrough red. #[test] fn the_pet_does_not_take_the_players_overrides() { use benilla_ui::messages::{kind_of, MsgKind}; @@ -803,10 +613,7 @@ mod tests { assert_eq!(kind_of(its.key), MsgKind::Error); } - /// `0x4d` NO_POWER is the override the two handlers **agree** on, and that is a byte fact - /// rather than an assumption: the pet's table at `0x8118f0` holds the same five dwords as the - /// player's at `0x8118dc`. So [`power_keys`] serves both, and a hunter pet's focus ability - /// reads the focus line on either path. + /// The pet's table `0x8118f0` holds the player's five ids (`0x8118dc`). #[test] fn the_pets_no_power_pick_is_the_players() { let m = gs(); @@ -822,11 +629,8 @@ mod tests { } } - /// `0x17` DONT_REPORT looks like a divergence between the two handlers and is not: the - /// player's hides it by control flow, the pet's routes it to the generic arm β€” but - /// `SPELL_FAILED_DONT_REPORT` has no string in 5875, so both draw nothing. Worth a test - /// because the two mechanisms are different and only the *outcome* is shared, so a future - /// change to either one should have to notice. + /// The player's handler hides `0x17` by control flow; the pet's passes it through to a string + /// 1.12 does not ship. #[test] fn dont_report_is_silent_on_both_paths_for_two_different_reasons() { let m = gs(); @@ -840,18 +644,7 @@ mod tests { } } - /// **The one cast failure that is not a red line.** Reason `0x41` overrides to - /// `ERR_SPELL_FAILED_NOTUNSHEATHED` (id 320), and that record's kind is `1` β€” the yellow - /// `UI_INFO_MESSAGE`, not the red `UI_ERROR_MESSAGE` its seventeen override neighbours take. - /// - /// benilla painted it red until decision 1770, and could not have done otherwise: the drain - /// fired one event for every resolved cast-failure line, because by the time a line got there - /// nothing knew which errorId this dispatch had chosen. Carrying the key out is what makes the - /// surface answerable, and this test is the answer. - /// - /// Its two yellow siblings, `ERR_FISH_NOT_HOOKED` (318) and `ERR_FISH_ESCAPED` (319), sit one - /// and two ids below it and were hand-traced correctly by an earlier round β€” which is exactly - /// how a hand-swept surface goes wrong: two of the three neighbours seen, the third not. + /// `ERR_SPELL_FAILED_NOTUNSHEATHED` (id 320) has kind 1, the yellow `UI_INFO_MESSAGE`. #[test] fn the_unsheathed_refusal_is_yellow_and_its_neighbours_are_red() { use benilla_ui::messages::{kind_of, MsgKind}; @@ -863,7 +656,7 @@ mod tests { assert_eq!(line.key, "ERR_SPELL_FAILED_NOTUNSHEATHED"); assert_eq!(kind_of(line.key), MsgKind::Info); - // An override that IS red, and a passthrough that falls to errorId 0x2c. + // A red override, and a passthrough on errorId `0x2c`. let red = cast_fail_text(Caster::Player, 0x59, None, FailArgs::default(), &g).expect("a line"); assert_eq!(red.key, "ERR_SPELL_OUT_OF_RANGE"); @@ -875,13 +668,7 @@ mod tests { assert_eq!(kind_of(through.key), MsgKind::Error); } - /// **`0x56` ONLY_SHAPESHIFT, the arm's three shapes** (decision 2280): one form, several - /// joined with the reference's `", "`, and a row whose `Name` the DBC leaves empty. - /// - /// The bitβ†’id law is the reference's record walk read as a lookup β€” row index `b` is form id - /// `b+1` β€” so Prowl's `Stances = 0x1` names form 1 and Maul's `0x90` names forms 5 and 8, in - /// **mask order**, which is the order the loop visits them in and not the order anything - /// sorts them into. + /// Prowl's `Stances = 0x1` names form 1, Maul's `0x90` forms 5 and 8, in mask order. #[test] fn only_shapeshift_names_the_forms_the_stance_mask_asks_for() { let m = gs(); @@ -914,7 +701,7 @@ mod tests { text(0x90).as_deref(), Some("Must be in Bear Form, Dire Bear Form"), ); - // `6e2058: cmp BYTE PTR [eax],0x0` β€” an empty name is skipped, not joined as a gap. + // An empty name is skipped (`0x6e2058`). assert_eq!( text(0x90 | 0x100).as_deref(), Some("Must be in Bear Form, Dire Bear Form"), @@ -922,15 +709,7 @@ mod tests { assert_eq!(text(0x101).as_deref(), Some("Must be in Cat Form")); } - /// **The `0x56` arm's failure exit is the function epilogue, not the strip fallback** β€” an - /// unfillable ONLY_SHAPESHIFT displays NOTHING (`6e20c6 jne 0x6e224f`, past `DisplayError`), - /// where every other declining arm still shows its stripped stem. Four ways in: no spell - /// record (`6e1ffd`), an unloaded DBC (`6e2016`), an empty mask, and a mask naming only - /// unnamed rows. - /// - /// The control is the neighbour: `0x5c` REAGENTS declines through the SHARED default and so - /// still reads "Missing reagent". If this ever regresses, the tell is the words "Must be in" - /// alone on screen β€” which is exactly how the defect was reported. + /// `0x56` declines to the epilogue (`0x6e224f`) and shows nothing; other arms show the stem. #[test] fn an_unfillable_only_shapeshift_says_nothing_at_all() { let m = gs(); @@ -960,7 +739,7 @@ mod tests { None, "a mask naming only unnamed rows" ); - // The control: the arm next door still strips rather than falling silent. + // The control: `0x5c` declines to its stripped stem. assert_eq!( cast_fail_text(Caster::Player, 0x5C, None, FailArgs::default(), &g) .unwrap() @@ -969,13 +748,7 @@ mod tests { ); } - /// **The pet's `0x56` names ONE form** (`0x6e921a`; decision 2285 corrected 2280, which had - /// assumed the two casters produced the same line). `0x6e9282: jne 0x6e929a` leaves the loop - /// at the first non-empty name β€” a break, not a continue, and the earned negative is that the - /// whole pet handler contains no `SStrCat` and no reference to the separator. - /// - /// The single-form case is asserted beside it deliberately: it is where the two agree, and a - /// test written only on that case is exactly how the wrong claim passed its own test. + /// The pet's arm (`0x6e921a`) breaks at the first named form (`0x6e9282`). #[test] fn the_pets_only_shapeshift_names_the_first_form_and_never_joins() { let m = gs(); @@ -1007,20 +780,14 @@ mod tests { text(Caster::Pet, 0x90).as_deref(), Some("Must be in Bear Form") ); - // One form: the two agree, and the surface is the same despite the different errorId. assert_eq!(text(Caster::Player, 0x1), text(Caster::Pet, 0x1)); assert_eq!( text(Caster::Pet, 0x1).as_deref(), Some("Must be in Cat Form") ); - // Nothing to name is silence on both handlers, by two different routes. assert_eq!(text(Caster::Pet, 0x0), None); } - /// **The combat log reads the reference's OTHER buffer** ([`CastFailLine::logged`] β€” - /// `edi` at `0x6e21e2`), which decision 2285 exists to correct. Four classes, and the whole - /// point is that "log what the red line said" and "re-derive from the key table" each get - /// some of them wrong. #[test] fn the_combat_log_reads_the_argtext_buffer_not_the_displayed_line() { let m = gs(); @@ -1029,8 +796,7 @@ mod tests { cast_fail_text(Caster::Player, reason, spell, FailArgs::default(), &g).expect("a line") }; - // 1b Β· an errorId override with NO argument arm: `edi` never moved off the first-layer - // buffer, so the screen and the log disagree. This is the row 2280 got wrong. + // An override with no arm: the screen and the log differ. let plain = spell(0, 0, 0); let cooldown = line(0x3C, Some(&plain)); assert_eq!(cooldown.text, "Spell is not ready yet."); @@ -1044,26 +810,22 @@ mod tests { assert_eq!(unsheathed.text, "You have to be unsheathed to do that!"); assert_eq!(unsheathed.logged(), "You must be unsheathed"); - // 1a Β· no override, no arm: one string in both buffers. + // No override, no arm: one string. let ammo = line(0x43, None); assert_eq!(ammo.logged(), ammo.text); - // 4 Β· `0x4d` BLANKS the buffer (`0x6e20fd`), so the log falls through to the last-error - // cell and reads the screen's wording β€” the override whose two surfaces agree. + // `0x4d` blanks the buffer, so the log reads the screen's line. let rage = spell(1, 0, 0); let power = line(0x4D, Some(&rage)); assert_eq!(power.text, "Not enough rage"); assert_eq!(power.logged(), "Not enough rage"); - // A declined arm strips in BOTH buffers β€” never a raw token in the log either. + // A declined arm strips both buffers. let reagents = line(0x5C, None); assert_eq!(reagents.logged(), "Missing reagent"); assert!(!reagents.logged().contains('%')); } - /// The `0x02` arm (`0x6e211f`) copies only the localized power NOUN into the argText buffer, - /// so the screen reads a sentence and the log reads one word. The reference's own quirk, and - /// the one row where a "the log says what the screen says" model is visibly wrong. #[test] fn already_at_full_power_logs_the_bare_power_noun() { let mut m = gs(); @@ -1120,9 +882,6 @@ mod tests { assert_eq!(CAST_FAIL_KEYS[0x76], "SPELL_FAILED_TOO_CLOSE"); } - /// Passthrough reads the SPELL_FAILED string; the errorId overrides REPLACE it: 0x59 - /// shows the perioded ERR string, 0x3c the cooldown family (never "Not yet recovered"), - /// 0x09 the generic no-target line. #[test] fn overrides_replace_and_passthrough_reads() { let m = gs(); @@ -1151,7 +910,7 @@ mod tests { .text, "You have no target." ); - // 0x3c: plain spell β†’ spell cooldown; Attr&0x10 β†’ ability; potion category β†’ potion. + // 0x3c: a plain spell, an ability (`Attributes & 0x10`), a potion (category 4). let plain = spell(0, 0, 0); assert_eq!( cast_fail_text(Caster::Player, 0x3C, Some(&plain), FailArgs::default(), &g) @@ -1181,8 +940,6 @@ mod tests { ); } - /// NO_POWER picks the power family off the SPELL's power type β€” the warrior's rage - /// ability reads "Not enough rage", never the generic power line. #[test] fn no_power_reads_the_spells_power_family() { let m = gs(); @@ -1203,9 +960,7 @@ mod tests { ); } - /// Both suppression faces: 0x17 control-flow hidden, 0x08 data-hidden (key absent from - /// GlobalStrings); an off-table reason keeps the debug hex; an unfilled %s template - /// strips to its stem. + /// `0x17` is hidden by control flow and `0x08` by an unshipped key; `0x92` is off the table. #[test] fn suppression_hex_fallback_and_template_strip() { let m = gs(); @@ -1232,11 +987,7 @@ mod tests { ); } - /// The RUNTIME leg, end to end on the real data: the shipped `GlobalStrings.lua` executed - /// into a real VM (the boot's `load_global_strings` path), then the drain's exact lookup β€” - /// the leg whose absence shipped a fold where every red line silently vanished (the VM had - /// no GlobalStrings at all; the fake-getter tests above couldn't see it). Skips without - /// client data. + /// The shipped `GlobalStrings.lua` run in a real VM and read by the drain's lookup. #[test] fn the_real_boot_resolves_the_real_strings() { let data = benilla_formats::wow_data_or_skip!(); @@ -1273,9 +1024,6 @@ mod tests { .text, "Not enough rage" ); - // The environment gate's pair (decision 1056) β€” both are plain passthroughs, so what the - // player reads IS the GlobalStrings value. A typo'd key here would degrade a real refusal - // to a dead-looking button, which is what this test exists to catch. assert_eq!( cast_fail_text(Caster::Player, 0x50, None, FailArgs::default(), &g) .unwrap() @@ -1288,7 +1036,7 @@ mod tests { .text, "Can only use while swimming" ); - // The data-suppression face on the real file: the absent keys show nothing. + // Keys 1.12 does not ship show nothing. assert_eq!( cast_fail_text(Caster::Player, 0x08, None, FailArgs::default(), &g), None @@ -1298,10 +1046,7 @@ mod tests { None ); - // The pet's table against the player's own shipped file (decision 2033). Both halves - // matter: the six rows resolve to the pet wording, and `ERR_PET_SPELL_NOPATH` resolves to - // NOTHING β€” the claim the `SILENT_IN_5875` entry rests on, checked here against the real - // `GlobalStrings.lua` rather than against a fixture that could simply have omitted it. + // The pet's six and its unshipped NOPATH key, against the shipped file. for (reason, pet, player) in [ (0x00, "Your pet is in combat.", "You are in combat"), (0x13, "Your pet is dead.", "You are dead"), @@ -1344,9 +1089,7 @@ mod tests { whole trap" ); - // B255, end to end on the real data: the argument arms against the real DBCs and the real - // GlobalStrings templates. Without the fill these read as the bare stems "Requires" and - // "You need to be in" β€” which is exactly what shipped. + // The argument arms against the real DBCs and templates. let focus = benilla_formats::load_spell_focus_catalog(&mut chain).expect("SpellFocusObject"); let areas = benilla_formats::load_area_table_catalog(&mut chain).expect("AreaTable"); @@ -1361,10 +1104,6 @@ mod tests { mechanics: Some(&mechanics), forms: Some(&forms), }; - // **0x8d PREVENTED_BY_MECHANIC** (decision 1948) β€” the crowd-control ladder's renamed - // refusal, and the one argument arm whose word is produced locally rather than read off - // the wire. The names are lower-case adjectives in the shipped data, which is what makes - // them read as the tail of the sentence. assert_eq!( cast_fail_text(Caster::Player, 0x8D, None, args(12), &g) .unwrap() @@ -1377,14 +1116,12 @@ mod tests { .text, "Can't do that while fleeing" ); - // An unknown or absent mechanic strips to the bare stem rather than showing a raw `%s`. + // An unknown mechanic strips to the stem. assert!(!cast_fail_text(Caster::Player, 0x8D, None, args(999), &g) .unwrap() .text .contains('%')); - // 0x5e REQUIRES_SPELL_FOCUS: the Crown of the Earth phials' own refusal. Focus 12 is the - // Starbreeze Village moonwell β€” using the Jade Phial at any *other* pool is the report. assert_eq!( cast_fail_text(Caster::Player, 0x5E, None, args(12), &g) .unwrap() @@ -1397,15 +1134,13 @@ mod tests { .text, "Requires Anvil" ); - // 0x5d REQUIRES_AREA: area 1657 is Darnassus. assert_eq!( cast_fail_text(Caster::Player, 0x5D, None, args(1657), &g) .unwrap() .text, "You need to be in Darnassus" ); - // The fallbacks. An id the DBC doesn't name, and a wire word the server never sent, both - // decline the arm and fall through to the strip β€” never a raw `%s` on screen. + // An unnamed id and an absent word both decline to the stem. assert_eq!( cast_fail_text(Caster::Player, 0x5E, None, args(999_999), &g) .unwrap() @@ -1418,10 +1153,7 @@ mod tests { .text, "You need to be in" ); - // **0x56 ONLY_SHAPESHIFT on the real chain β€” the reported defect** (decision 2280): the - // real `Spell.dbc` `Stances` column, the real `SpellShapeshiftForm.dbc` names and the - // real `"Must be in %s"`. Prowl in bear form is the report; Maul is the multi-form join, - // which a single-bit test could not tell apart from "name the lowest bit". + // `0x56` on the real `Spell.dbc`; Maul's two forms tell a join from naming the lowest bit. let catalog = benilla_formats::load_spell_catalog(&mut chain).expect("Spell.dbc"); let shifted = |spell_id: u32| { let d = catalog.get(spell_id).expect("a shipped spell"); @@ -1437,8 +1169,7 @@ mod tests { ) .map(|l| l.text) }; - // Prowl, all three ranks β€” `Stances = 0x1`, the one the spellbook tooltip already read as - // "Requires Cat Form" while this line read the bare stem. + // Prowl's three ranks, `Stances = 0x1`. for prowl in [5215, 6783, 9913] { assert_eq!( shifted(prowl).as_deref(), @@ -1463,9 +1194,8 @@ mod tests { "Barkskin carries an empty Stances mask" ); - // 0x5e alone has a client-side stand-in: the failing spell's own `RequiresSpellFocus` - // column is the very number the server copied onto the wire, so an absent word still - // fills. (Spell 4976 "Filling" β€” the Crystal Phial's β€” carries focus 11.) + // With no word, `0x5e` falls back to the spell's focus; spell 4976, the Crystal Phial's + // "Filling", has focus 11. let filling = SpellDisplay { requires_spell_focus: 11, ..Default::default() diff --git a/crates/benilla-app/src/ui_action/drain.rs b/crates/benilla-app/src/ui_action/drain.rs index 804102f7..3f655b1b 100644 --- a/crates/benilla-app/src/ui_action/drain.rs +++ b/crates/benilla-app/src/ui_action/drain.rs @@ -1,18 +1,6 @@ -//! **Outward** β€” the two action-bar drains, and the law that decides what a click *does*. -//! -//! - **Use** ([`drain_action_uses`]): a queued `UseAction(n)` becomes wire. A SPELL action goes -//! through the one cast-send path ([`crate::spell::CastLadder::send`]); the auto-attack action -//! (6603) sends `CMSG_ATTACKSWING` at the selection, or acquires the nearest enemy when there is -//! none; an ITEM action names an *entry*, not a position, so it must first find a copy and then -//! decide equip-vs-use β€” [`item_action_route`], the byte-verified two-stage law of decision 0666. -//! A MACRO action runs its body's lines through the chat-input door (`crate::ui_macro::run`, -//! decision 0983) β€” the `0x4f1460` fork of the reference's own `UseAction`. -//! - **Set** ([`drain_action_sets`]): a queued `PickupAction`/`PlaceAction` mutation becomes one -//! `CMSG_SET_ACTION_BUTTON` per entry (0218 Β§4: the bar is client-authoritative, there is no -//! answer packet to lock against, and a drag-swap is two independent sends β€” never atomic). -//! -//! Both run `.after(UiInput)` so a click's intent goes out the same frame it was made. The two -//! queues are disjoint per gesture, so their relative order does not matter. +//! The action-bar drains: a queued `UseAction` becomes a cast, a swing, an item use or a macro +//! run; a queued `PickupAction`/`PlaceAction` becomes one `CMSG_SET_ACTION_BUTTON`, which gets no +//! reply, so a drag-swap is two independent sends. use bevy::prelude::*; @@ -27,35 +15,19 @@ use crate::spell::{cast_target, CastCommit, CastLadder}; use super::{attack_actor_refusal, PlayerActions, UiErrorKeys, SPELL_ATTACK}; -/// What clicking an ITEM action does, and to which copy β€” [`item_action_route`]'s verdict. +/// What clicking an item action does, and to which copy. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(super) enum ItemRoute { - /// Use this copy β€” the wire `(bag_index, slot)` plus the instance guid the shared use fork - /// needs (`ui_items::item_use_command`, decision 0664). + /// Use this copy: the wire `(bag_index, slot)` and its instance guid. Use((u8, u8, u64)), - /// Equip this copy β€” the same triple. Equip((u8, u8, u64)), - /// No copy anywhere the walk reaches β€” the click does nothing. + /// No copy found: the click does nothing. Nowhere, } -/// The reference's **two-stage** equip-vs-use decision for an ITEM action (`0x4e5fdd`–`0x4e5ff7`; -/// decision 0666, which supersedes 0216 Β§7's guessed one): -/// -/// ```text -/// InventoryType == 0 β†’ USE (a consumable is never equipped) -/// InventoryType != 0, worn β†’ USE IN PLACE (a copy is in equipment slots 0..18) -/// InventoryType != 0, not worn β†’ EQUIP (the full walk finds the copy to equip) -/// ``` -/// -/// The second stage is the whole point. A ONE-stage `equippable β†’ equip` fork can never *use* an -/// equipped trinket β€” it re-equips it forever β€” and before 0666 the walk did not look at the -/// equipment slots at all, so an equipped item's button was simply inert (reproduced live -/// 2026-07-26: `item action 1 (entry 25) not in any bag β€” skipped`). -/// -/// `find` is the inventory walk ([`crate::ui_items::find_item`]) with the entry already bound, so -/// this stays a pure function of the template and the walk's answers β€” the law is testable -/// without a world. +/// The reference's equip-or-use decision for an item action (`0x4e5fdd`-`0x4e5ff7`): +/// `InventoryType` 0 always uses; otherwise a worn copy (equipment slots 0..18) uses in place and +/// a carried one equips. `find` is the inventory walk with the entry already bound. pub(super) fn item_action_route( template: &benilla_protocol::ItemInfo, find: impl Fn(crate::ui_items::ItemSearch) -> Option<(u8, u8, u64)>, @@ -65,8 +37,7 @@ pub(super) fn item_action_route( live_charges_only, }; if template.inventory_type == 0 { - // The use leg's mode-`0x20` charge filter (`0x4e603a`): only when the TEMPLATE says this - // item carries finite charges does the search skip spent copies. + // The use leg's charge filter (`0x4e603a`): spent copies are skipped for finite charges. return match find(anywhere(template.has_finite_charges())) { Some(pos) => ItemRoute::Use(pos), None => ItemRoute::Nowhere, @@ -84,11 +55,8 @@ pub(super) fn item_action_route( } } -/// ATTACKTARGET through the binding table (0997; default T β€” 1.12's `AttackTarget()`): exactly -/// the action-bar attack arm below, without the action slot β€” the Phase A actor refusal first -/// ([`attack_actor_refusal`], the full `0x612df0` gate set), then the with-target attack-start -/// (auto-draw + swing) or the no-target nearest-acquire. One law, two doors, the reference's -/// own shape (`AttackTarget` and `UseAction`'s SPELL_ATTACK both land in `0x612df0`). +/// The ATTACKTARGET binding (default T): the action bar's attack arm without a slot, as in the +/// reference, where `AttackTarget` and `UseAction`'s Attack both land in `0x612df0`. pub(super) fn attack_target_binding( binds: Res, targeting: cast_target::CastTargeting, @@ -115,8 +83,7 @@ pub(super) fn attack_target_binding( "bindings: ATTACKTARGET {} at {guid:#x}", if engaged { "toggled off" } else { "swing" } ); - // The same `0x6131a0` this binding's doc says it shares with the action button β€” so - // it takes the same seam, toggle and all, instead of a second copy that drifts. + // The action button's own toggle (`0x6131a0`). crate::creature_anim::toggle_attack_local( e, guid, @@ -135,24 +102,15 @@ pub(super) fn attack_target_binding( } } -/// **The world right-click's GameObject opener**, run through the one cast path (decision 2199) β€” -/// the seam [`crate::ui_action::GoOpenerCasts`] exists for. -/// -/// The reference reaches TryCast from the GameObject strategy's use-sender exactly as it does from -/// a button press (`0x5f35c0 β†’ 0x6e5a90 β†’ 0x6e4b60`), so an opener -/// takes **every rung** β€” in-flight, cooldown/GCD, power, crowd control, mounted, water, moving, -/// form, reagents β€” and not the two the click used to check on its own. The rung that matters for -/// the report this closes is the in-flight one: the second right-click on a chest whose Opening -/// cast is still running is `6e4d43`'s **silent** same-spell bail, so no duplicate ever reaches the -/// wire and the running bar is never red-faded by the server's answer to a packet we should not -/// have sent. +/// Sends the queued GameObject openers through the cast ladder. The reference reaches `TryCast` +/// from a right-click as from a button (`0x5f35c0`, `0x6e5a90`, `0x6e4b60`), so every rung +/// applies: a second click on a chest mid-cast is `0x6e4d43`'s silent same-spell bail. pub(super) fn drain_go_openers( mut queue: ResMut, script: Option>, targeting: cast_target::CastTargeting, mut ladder: CastLadder, - // The by-key local-refusal sink, passed explicitly for the same reason `drain_action_uses` - // does: a resource reachable twice from one system is a `B0002` panic (decision 1903). + // Not a `CastLadder` field: Bevy panics on a resource reachable twice from one system. mut ui_errors: ResMut, mut gate: crate::ui_bind_confirm::BindGate, ) { @@ -167,8 +125,7 @@ pub(super) fn drain_go_openers( debug!("ui_action: gameobject opener casts {spell_id} at {go_guid:#x}"); ladder.send_at_object(spell_id, &ctx, go_guid); } - // The key's own `CGItem::Use` fork β€” one function for every use surface (decision - // 0664), so the key reaches the wire through the same route a bag click does. + // The key goes through the shared `CGItem::Use` fork, as a bag click does. super::GoOpener::Key(it) => { let Some(script) = script.as_deref_mut() else { continue; @@ -191,22 +148,10 @@ pub(super) fn drain_go_openers( } } -/// The **self-cast modifier**, applied to a cast's targeting inputs: `UseAction`'s third argument -/// (1.12's `SELFACTIONBUTTON1`-`12`, `ALT-1`…`ALT-=`, through `ActionButtonUp(id, 1)`). -/// -/// It substitutes the caster for the selection as the bind candidate β€” both halves of the -/// substitution, the guid *and* the store the relation checks read, which is exactly the pair -/// `resolve_cast_target`'s own autoSelfCast fallback swaps at `0x6e53d7`. So a self-cast runs the -/// ordinary binder against the ordinary candidate and needs no leg of its own; a spell that -/// cannot target a friendly unit refuses for the reason it always would, and one that needs no -/// target at all is still `SelfImplicit`. -/// -/// **The behaviour is not in doubt; the byte attribution is INFERRED.** The reference's -/// `UseAction 0x4f1460` was not traced for this β€” the modelling here is that `onSelf` fills -/// `ArmCast 0x6e5250`'s explicit-guid argument (`6e5393`), the slot every benilla caster passes -/// zero in, because that is the one input that already means "bind this unit instead of the -/// selection". A forced-self leg of its own would be observationally identical, so nothing -/// downstream rests on which it is. +/// Applies the self-cast modifier, `UseAction`'s `onSelf` (`SELFACTIONBUTTON1`-`12`): the caster +/// replaces the selection as the bind candidate, guid and store both, the pair the autoSelfCast +/// fallback swaps (`0x6e53d7`). Where `UseAction 0x4e5ee0` applies `onSelf` is untraced; this +/// fills `ArmCast 0x6e5250`'s explicit guid (`0x6e5393`); a forced-self leg would look the same. fn self_bound<'a>( mut ctx: cast_target::CastContext<'a>, press: benilla_ui::script::ActionUse, @@ -223,11 +168,8 @@ pub(super) fn drain_action_uses( actions: Res, targeting: cast_target::CastTargeting, mut acquire: MessageWriter, - // The by-key local error line β€” the only sink here that is not the ladder's own - // (`ladder.cast_errors` is the reason-coded one, `ladder.ground` the targeting mode). It is - // deliberately NOT a `CastLadder` field: a resource reachable twice from one system is a - // `B0002` panic on the first live frame, which compiles and passes every unit test - // (decision 1903). + // The by-key error line. Not a `CastLadder` field: Bevy panics, at runtime and not in unit + // tests, on a resource reachable twice from one system. mut ui_errors: ResMut, mut ladder: CastLadder, mut gate: crate::ui_bind_confirm::BindGate, @@ -244,12 +186,8 @@ pub(super) fn drain_action_uses( }; match actions.buttons.get(&slot) { Some(b) if b.kind == ACTION_KIND_SPELL && b.action == SPELL_ATTACK => { - // The attack-start validator's Phase A ([`attack_actor_refusal`]) β€” for a melee - // swing the actor is US. It refuses BEFORE the with-target swing and before the - // nearest-enemy scan (every Phase A gate precedes `0x6130b5`), so both arms gate - // here. Widened from mounted-only to the rest of `0x612df0`: - // stunned, pacified, fleeing, confused, charmed by somebody else, and dead now - // refuse the swing too, each with its own red line. + // The attack validator's actor gates (`0x612df0`) precede both the swing and the + // nearest-enemy scan (`0x6130b5`), so both arms gate here. if attack_actor_refusal( targeting.self_store.iter().next(), targeting.context().self_guid, @@ -259,17 +197,10 @@ pub(super) fn drain_action_uses( } match selection.guid { Some(guid) => { - // **The Attack button is a TOGGLE** β€” `0x6131a0`, which the base Attack - // pseudo-spell reaches through `TryCast`'s effect-0x4e short-circuit - // (`0x6e4c7a`), forks on `0x60ecb0`: already attacking β†’ - // `0x6131d9 call 0x5ecac0` **StopAttack**, else `0x6131ee call 0x5ecb70` - // **StartAttack**. - // - // Both halves were wrong here. There was no toggle-off at all, and the - // press cancelled a running auto-repeat *unconditionally* β€” but only the - // START arm reaches `0x5ecd8c`, so in the reference toggling melee OFF - // leaves Auto Shot running. The seams carry the sheath snap and the cancel - // now, so neither is spelled out twice. + // A toggle (`0x6131a0`, via `TryCast`'s effect-0x4e short-circuit + // `0x6e4c7a`): attacking (`0x60ecb0`) stops (`0x5ecac0`), else starts + // (`0x5ecb70`). Only the start cancels auto-repeat (`0x5ecd8c`), so + // stopping melee leaves Auto Shot running. let Ok((e, engaged)) = ladder.self_player.single() else { continue; }; @@ -288,8 +219,7 @@ pub(super) fn drain_action_uses( &ladder.commands, ); } - // No target: the client's attack resolver runs the nearest-enemy core and - // swings at the winner (`0x612df0` @ `6130b5`) β€” `target::scan` answers. + // No target: the reference swings at the nearest enemy (`0x6130b5`). None => { debug!("ui_action: attack with no target β€” acquiring nearest"); acquire.write(crate::target::AttackNearestRequest); @@ -297,11 +227,9 @@ pub(super) fn drain_action_uses( } } Some(b) if b.kind == ACTION_KIND_SPELL => { - // UseAction's toggle-cancel (`0x4e5ee0`: `GetTargetingSpellId 0x6e48e0` + - // `StopTargeting 0x6e4900`, decision 0792): re-pressing the spell whose - // targeting cursor is up cancels the targeting instead of re-arming it β€” - // press-again-to-cancel, before TryCast ever runs. (A spellbook re-press stays - // the ref's abort-and-re-enter β€” it never passes through UseAction.) + // In `UseAction 0x4e5ee0` only: re-pressing the spell whose targeting cursor is + // up cancels it before `TryCast` (`GetTargetingSpellId 0x6e48e0`, + // `StopTargeting 0x6e4900`). A spellbook re-press aborts and re-enters. if ladder.ground.spell() == Some(b.action) { debug!( "ui_action: cast {} re-pressed β€” targeting toggles off", @@ -310,11 +238,8 @@ pub(super) fn drain_action_uses( ladder.ground.clear(); continue; } - // The active-action toggle (`0x4e55f0` β†’ the `0x4e60c1` cancel): a live - // ActiveIconID spell re-pressed on its button cancels its own aura β€” Ghost Wolf, - // the druid forms, Stealth. The - // form-match toggle is deliberately NOT here (the ref's `UseAction` has no such - // leg β€” the `CastSpell` dispatcher alone carries it; keep the asymmetry). + // A live `ActiveIconID` spell re-pressed cancels its aura (`0x4e55f0`, cancel + // `0x4e60c1`). The form-match toggle is `CastSpell`'s alone, not `UseAction`'s. if let Some(d) = ladder.spells.as_ref().and_then(|s| s.catalog.get(b.action)) { if let Some(store) = targeting.self_store.iter().next() { if super::toggle::active_action_toggle(b.action, d, store) { @@ -339,11 +264,8 @@ pub(super) fn drain_action_uses( CastCommit::Spell, ); } - // An item action names an item ENTRY, not a position, so the click has to find a copy - // β€” [`item_action_route`] is that law. A miss (the copy left the bags between the - // click and this drain, or a stale action from a previous session) is a - // debug-log-and-skip, NOT the red error line: nothing was attempted against the - // server, so "Item is not ready" would be a lie. + // An item action names an entry, so the click finds a copy. A miss only logs, with no + // red error line: nothing was attempted. Some(b) if b.kind == ACTION_KIND_ITEM => { let Some(store) = targeting.self_store.iter().next() else { continue; @@ -352,8 +274,7 @@ pub(super) fn drain_action_uses( .items .template(b.action, 0, &ladder.commands) .cloned(); - // The reference reads the template first and bails on a null record; ours is all - // but always cached by click time (the icon resolve needed it). + // The reference bails on a null template too; the icon resolve usually cached it. let Some(template) = template else { debug!( "ui_action: item action {action} (entry {}) has no template yet β€” skipped", @@ -376,15 +297,10 @@ pub(super) fn drain_action_uses( } }; if equip { - // Deliberately WITHOUT the bag click's quest guard: the bar's own engine tests - // only `[rec+0x2c]` (inventoryType) before the equip route (`0x4e5fdd`), where - // `Script::UseContainerItem` also tests `StartQuest` (`0x4fa3c4`) β€” so an - // equippable quest-starter on the bar equips, exactly as the reference does - // (decision 0664). + // No quest guard: the bar tests only `InventoryType` (`0x4e5fdd`), unlike + // the bag click (`StartQuest`, `0x4fa3c4`), so a quest-starter equips. debug!("ui_action: item action {action} auto-equip (wire {bag_index}/{slot0})"); - // Through the one auto-equip sender (decision 1750), which carries the ammo - // fork and the soulbind deferral: a BoE dragged to the bar and pressed asks - // before it binds, exactly as the same item right-clicked in the bag does. + // The shared auto-equip sender, so a BoE asks before binding, as from a bag. crate::ui_items::send_auto_equip( &mut script, &mut gate, @@ -397,14 +313,9 @@ pub(super) fn drain_action_uses( false, ); } else { - // …then the shared use fork (`CGItem::Use` β€” the bar's engine calls the very - // same function at `0x4e607b`), so a quest-starter on the bar offers its quest - // instead of a `CMSG_USE_ITEM` the server can only refuse (decision 0664). The - // wire's third byte is the spell BLOCK ordinal, not a flag (decision 0666). - // The fork runs the WHOLE cast ladder β€” an item use IS a cast through the same - // `TryCast` (decisions 0908/0914; [`crate::ui_items::send_item_use`] is the - // law), so the cooldown/GCD/in-flight/mounted/moving/form rungs and the local - // "Item is not ready yet." live there now, for the bag and doll clicks too. + // The shared `CGItem::Use` fork (called at `0x4e607b`): a quest-starter offers + // its quest, and the use runs the whole cast ladder. The wire's third byte is + // the spell block ordinal, not a flag. let spell_index = template.use_spell_index().unwrap_or(0); debug!( "ui_action: item action {action} use (wire {bag_index}/{slot0}, spell #{spell_index})" @@ -432,10 +343,8 @@ pub(super) fn drain_action_uses( ); } } - // The MACRO arm (`0x4e5ee0`'s `and ecx,0xbfffffff; call 0x4f1460` fork): run the - // macro's body. Every line goes onto the chat-input queue β€” the door a typed line - // comes through β€” so `/cast`, `/target`, `/script`, the chat types and the 225 emotes - // all work in a macro by construction (`crate::ui_macro::run`'s module doc). + // The macro arm (`0x4e5ee0` calls `0x4f1460`): each body line goes through the chat + // input, as if typed. Some(b) if b.kind == ACTION_KIND_MACRO => { if !crate::ui_macro::run_macro(&mut script, b.action) { debug!( @@ -455,14 +364,8 @@ pub(super) fn drain_action_uses( } } -/// Drain the `(lua action id, packed)` pairs the cursor seam's `PickupAction`/`PlaceAction` -/// queued (decision 0216 Β§7) β€” the engine's own local mutation already agrees with what lands -/// here (it wrote the same value into its optimistic `model.actions` mirror before queuing this). -/// Each entry: write `PlayerActions.buttons` (`packed == 0` removes the slot, else inserts), -/// mark `dirty` so [`super::feed::feed_actions`] re-resolves + re-pushes + fires -/// `ACTIONBAR_SLOT_CHANGED` (the existing diff machinery β€” no bespoke event here), and send ONE -/// `CMSG_SET_ACTION_BUTTON` (0218 Β§4: client-authoritative, no answer packet, a drag-swap is two -/// independent sends). +/// Applies the queued `PickupAction`/`PlaceAction` writes (packed 0 clears) to the store, which +/// the VM already mirrors, marks it dirty for the feed and sends each as `CMSG_SET_ACTION_BUTTON`. pub(super) fn drain_action_sets( script: Option>, mut actions: ResMut, diff --git a/crates/benilla-app/src/ui_action/drain_tests.rs b/crates/benilla-app/src/ui_action/drain_tests.rs index c3043d33..5e4366e9 100644 --- a/crates/benilla-app/src/ui_action/drain_tests.rs +++ b/crates/benilla-app/src/ui_action/drain_tests.rs @@ -1,17 +1,13 @@ -//! [`super::drain::item_action_route`] β€” the reference's two-stage equip-vs-use law for an ITEM -//! action (decision 0666). The inventory walk is stubbed, so these pin the *decision*, not the -//! search (`ui_items::find_item` has its own tests for the walk order). +//! The item action's equip-or-use decision, with the inventory walk stubbed. use super::drain::{item_action_route, ItemRoute}; use crate::items::test_template; use crate::ui_items::ItemSearch; -// `(bag_index, slot, instance guid)` β€” the triple the walk returns (the guid feeds the -// shared use fork, decision 0664). +// `(bag_index, slot, instance guid)`, as the walk returns it. const WORN_AT: (u8, u8, u64) = (255, 13, 0xE1); // the trinket-1 doll slot const IN_BAG: (u8, u8, u64) = (255, 23, 0xB1); // the first backpack slot -/// A consumable (`InventoryType == 0`) is never equipped, wherever it is. #[test] fn a_consumable_always_uses() { let food = test_template("Tough Jerky"); // inventory_type 0 @@ -22,9 +18,6 @@ fn a_consumable_always_uses() { assert_eq!(item_action_route(&food, |_| None), ItemRoute::Nowhere); } -/// **The bug this closes.** An equippable item that IS worn uses in place β€” it does not -/// re-equip. A one-stage `equippable β†’ equip` fork answers `Equip` here forever, and the old -/// bags-only walk answered `Nowhere`, which is what made the button inert. #[test] fn an_equipped_trinket_uses_in_place() { let mut trinket = test_template("Trinket"); @@ -36,8 +29,6 @@ fn an_equipped_trinket_uses_in_place() { assert_eq!(route, ItemRoute::Use(WORN_AT)); } -/// The same trinket sitting in a bag equips instead β€” and the *same button* then uses it, -/// which is exactly the two-stage behaviour the reference has. #[test] fn an_unworn_trinket_equips() { let mut trinket = test_template("Trinket"); @@ -48,8 +39,7 @@ fn an_unworn_trinket_equips() { assert_eq!(route, ItemRoute::Equip(IN_BAG)); } -/// The charge filter is asked for **only** when the template says the item has finite -/// charges, and only on the use leg (`template+0x144 != 0 && != -1`). +/// Finite charges are `template+0x144` neither 0 nor -1; only the use leg filters on them. #[test] fn the_charge_filter_rides_only_a_charged_use() { let plain = test_template("Potion"); diff --git a/crates/benilla-app/src/ui_action/drop_item.rs b/crates/benilla-app/src/ui_action/drop_item.rs index f6315860..cffb9b8c 100644 --- a/crates/benilla-app/src/ui_action/drop_item.rs +++ b/crates/benilla-app/src/ui_action/drop_item.rs @@ -1,29 +1,9 @@ -//! **`DropItemOnUnit 0x48d960`** β€” the cursor's held item dropped onto a unit, which in 1.12 is -//! how you feed your pet (decision 1055). -//! -//! The reference's binding forks on which unit it was given: -//! -//! - **PET** β€” the unit guid equals `[0xb714a0]/[0xb714a4]`, the charm-else-summon pet global β†’ -//! `0x6ea1e0`, the validator whose three gates are transcribed in [`feedable_pet`] β†’ `CastSpell` -//! with the learned Feed Pet spell and **`castItem = NULL`**, then `BindTarget(the food)`. -//! - **PLAYER** β€” a trade (`CMSG_INITIATE_TRADE 0x116` via `0x5d3fb0`, else a trade-slot -//! placement). Not modelled here: the trade window owns it, and no node does yet. A drop on a -//! player is silently refused, which is the same shape every other refusal on this path takes. -//! -//! **The wire is `CMSG_CAST_SPELL (0x12E)`, not `CMSG_USE_ITEM`,** and that falls out of the null -//! cast item: the sender's discriminator (`0x6e5513..0x6e55a7`) takes its no-item arm -//! (`je 0x6e5808`), skipping the `push 0xab` block outright. The block that goes out is -//! `{ spellId, flags = TARGET_FLAG_ITEM (0x0010), the FOOD's guid }` β€” **the pet's guid never -//! reaches the wire at all**, which is why this is [`TargetedBind::Item`] and not a unit bind. -//! -//! Two behaviours worth stating because they are easy to get backwards: -//! -//! - **`ClearCursor 0x495190(1,1)` runs on SUCCESS only.** Every refusal is silent and leaves the -//! payload on the cursor β€” no error line, no drop. So a mis-drop costs nothing and you are still -//! holding the food. -//! - **There is no range test on this path.** Neither function contains a floating-point -//! instruction, and the one reachable range helper sits behind a `test [0xceac5c],0x8202` mask -//! disjoint from `0x0010`. The server judges distance. +//! `DropItemOnUnit 0x48d960`: a held item dropped on a unit, which in 1.12 feeds the pet. On the +//! pet (the guid global `[0xb714a0]`) the validator `0x6ea1e0` casts the learned Feed Pet with no +//! cast item, so the wire is `CMSG_CAST_SPELL` with `TARGET_FLAG_ITEM` and the food's guid, never +//! the pet's (`0x6e5513`-`0x6e55a7`). On a player the reference starts a trade (`0x5d3fb0`) or +//! fills a trade slot; that leg is not built and refuses. Every refusal is silent and keeps the +//! payload (`ClearCursor 0x495190` runs on success only), and the server alone judges range. use bevy::prelude::*; @@ -31,39 +11,24 @@ use benilla_ui::script::{CursorPayload, UiScript}; use crate::spell::{CastCommit, TargetedBind}; -/// `0x6ea1e0` β€” is this unit a pet *I* can feed? All three gates, in the reference's order. Any -/// miss is a silent refusal (see the module docs), so this returns a plain bool rather than a -/// reason. -/// -/// The gates are about **ownership and provenance**, not about the food: what may be fed to a pet -/// is the server's call (vmangos checks the item's food type against the pet's diet and answers -/// `SPELL_FAILED_WRONG_PET_FOOD`), and the client never looks at the item here at all. +/// `0x6ea1e0`'s three gates, in order: is this a pet I can feed? The food is not checked here; +/// the server judges the diet (`Spell.cpp:5912`, `SPELL_FAILED_WRONG_PET_FOOD`). fn feedable_pet( pet: &benilla_protocol::ObjectFields, self_guid: u64, feed_pet_known: bool, ) -> bool { - // 1 Β· `UNIT_CREATED_BY_SPELL` non-zero β€” something summoned it. + // 1: `UNIT_CREATED_BY_SPELL` is set, so something summoned it. pet.unit_created_by_spell().is_some() - // 2 Β· `UNIT_FIELD_CREATEDBY == my guid` β€” and that something was me. + // 2: `UNIT_FIELD_CREATEDBY` is my guid. && pet.unit_created_by() == Some(self_guid) - // 3 Β· the learn-time latch `[0xcecad8]` β€” I actually know Feed Pet. A warlock's imp is a - // summon owned by its master and passes both field gates; this is the leg that stops - // it, because only a hunter ever learns a `SPELL_EFFECT_FEED_PET` spell. + // 3: the Feed Pet latch `[0xcecad8]`: an imp passes 1 and 2, but only hunters learn it. && feed_pet_known } -/// Drain the feed asks and run the pet leg β€” **both** of the reference's entries into `0x6ea1e0`: -/// -/// - the **frame**: `PetFrame_OnClick`'s `DropItemOnUnit("pet")`, queued by the VM; -/// - the **world**: a left click on the pet's own model with an item held. `0x6ea1e0`'s second -/// caller is `0x4927e8`, inside the world left-click worker `0x4925d0`'s object leg, so dropping -/// food on the body feeds it identically. The generic object-leg rule still stands around it -/// (`cursor::world_drop_click`: an `Object` pick drops nothing and keeps the payload, and the -/// click still selects) β€” the feed is an extra arm inside that leg, not a replacement for it. -/// -/// One law, one place: whichever entry it came from, the ask runs the same three gates and the same -/// commit. +/// Runs the pet leg for both of the reference's entries into `0x6ea1e0`: `PetFrame_OnClick`'s +/// `DropItemOnUnit("pet")`, and a left click on the pet's model with an item held (`0x4927e8`, +/// in the world click's object leg `0x4925d0`, where the click still selects). pub(crate) fn drop_item_on_unit( script: Option>, self_q: Query<(&crate::net::Guid, &crate::net::ObjectStore), With>, @@ -81,9 +46,8 @@ pub(crate) fn drop_item_on_unit( }; let hovered = press.hovered; let mut tokens = script.take_drop_item_on_unit(); - // The world entry: a left click whose pick IS the pet. The click's own press latch is the same - // nearest-object pick it selects from, so cursor and click agree by construction (1122: the - // *live* hover no longer does, since a click can end a gesture that orbited). + // The world entry: a click whose press-time pick is the pet. That is the pick the click + // selects from; the live hover can differ after a drag. let clicked_pet = clicks.read().count() > 0 && pet_bar.spells.pet_guid != 0 && hovered.guid == Some(pet_bar.spells.pet_guid); @@ -97,9 +61,7 @@ pub(crate) fn drop_item_on_unit( return; }; for token in tokens { - // The reference compares the resolved unit's guid against the pet global; our `"pet"` token - // resolves off the same cached guid the pet frame and `UNIT_PET` read (decision 0990), so - // any other token simply is not the pet and takes the unmodelled trade leg. + // Only the "pet" token is the pet; any other unit takes the unbuilt trade leg. if token != "pet" { debug!("DropItemOnUnit({token}) β€” only the pet leg is modelled; refused, payload kept"); continue; @@ -109,7 +71,7 @@ pub(crate) fn drop_item_on_unit( .flatten() .and_then(|&e| stores.get(e).ok()) else { - continue; // no pet, or it hasn't streamed in β€” silent, payload kept + continue; // no pet, or not streamed in: silent, payload kept }; let Some(spell_id) = learned.feed_pet else { continue; // gate 3 fails outright: we never learned Feed Pet @@ -118,9 +80,7 @@ pub(crate) fn drop_item_on_unit( debug!("DropItemOnUnit(\"pet\") β€” not a pet I summoned; refused, payload kept"); continue; } - // The food itself: the cursor's held item, resolved to the live guid the same way the bag - // click seam resolves a clicked slot. A Spell/Action/Macro payload is not an item and - // cannot be dropped on a unit. + // The food: the held item's live guid. Only an item payload can be dropped on a unit. let Some(CursorPayload::Item(held)) = script.cursor_payload() else { continue; }; @@ -128,11 +88,11 @@ pub(crate) fn drop_item_on_unit( let Some(item_guid) = crate::ui_items::slot_guid(&self_store.0, held.bag, slot0, &ladder.objects) else { - continue; // the slot emptied under us β€” silent, as every refusal here is + continue; // the slot emptied: silent, as every refusal here is }; debug!("DropItemOnUnit(\"pet\") β€” Feed Pet {spell_id} at item {item_guid:#x}"); ladder.commit_targeted(spell_id, CastCommit::Spell, TargetedBind::Item(item_guid)); - // `ClearCursor(1,1)` β€” success only. + // `ClearCursor(1,1)`, on success only. script.clear_cursor_payload(); } } @@ -141,8 +101,7 @@ pub(crate) fn drop_item_on_unit( mod tests { use super::*; - /// `0x6ea1e0`'s three gates, each failed alone (decision 1055). The warlock's-imp row is the - /// one that earns gate 3: it passes both field gates and must still be refused. + /// `0x6ea1e0`'s three gates, each failed alone. #[test] fn the_feed_gates_are_ownership_and_provenance() { use benilla_protocol::messages::ObjectFields; @@ -162,12 +121,11 @@ mod tests { let mine = pet(Some(883), Some(ME)); assert!(feedable_pet(&mine, ME, true), "my own summoned pet feeds"); - // 1 Β· nothing summoned it β€” a plain creature that happens to name me. + // 1: not summoned, though it names me. assert!(!feedable_pet(&pet(None, Some(ME)), ME, true)); - // 2 Β· summoned, but by somebody else. + // 2: summoned by someone else. assert!(!feedable_pet(&pet(Some(883), Some(1)), ME, true)); - // 3 Β· a warlock's imp: BOTH field gates pass, and it is still not feedable, because only a - // hunter ever learns a SPELL_EFFECT_FEED_PET spell. + // 3: a warlock's imp passes both field gates, but its owner never learns Feed Pet. assert!(!feedable_pet(&mine, ME, false)); } } diff --git a/crates/benilla-app/src/ui_action/errors.rs b/crates/benilla-app/src/ui_action/errors.rs index 9ea6bbc2..8784af01 100644 --- a/crates/benilla-app/src/ui_action/errors.rs +++ b/crates/benilla-app/src/ui_action/errors.rs @@ -1,26 +1,5 @@ -//! The red error line's queues and resolvers β€” every route into `UIErrorsFrame`'s top line -//! except the cast-fail two-layer display (its own module, [`super::cast_fail`]): -//! -//! - [`CastErrors`] β€” the wire [`CastFail`]s (`SMSG_CAST_RESULT` + local cast refusals), resolved -//! by [`super::cast_fail`] at the drain. -//! - [`MountErrors`] β€” the `SMSG_MOUNTRESULT`/`SMSG_DISMOUNTRESULT` code pairs, resolved by -//! [`mount_result_key`] (decision 0441 P2). -//! - [`UiErrorKeys`] β€” client-LOCAL refusals straight by GlobalStrings key, the -//! `CGGameUI::DisplayError` route for errors with no wire code and no spell record: -//! `ERR_ATTACK_MOUNTED` (decision 0481) and the GameObject lock-refusal toasts -//! ("Requires Herbalism", decision 0545) and the guild lines (decision 2054) β€” the latter -//! carry [`UiError`]'s ordered argText list, resolved by [`ui_error_text`]. -//! -//! - [`UiErrorTexts`] β€” the same route for lines that arrive already resolved (the server's own -//! `SMSG_NOTIFICATION` / `SMSG_AREA_TRIGGER_MESSAGE` text, the death durability notice), so -//! there is no key to look up β€” only the arm to pick. -//! -//! All four drain in `super::feed_actions` into the one sink [`show_messages`], which puts each -//! resolved line on the surface its message record names β€” read from the catalog -//! ([`benilla_ui::messages`], decision 1770) rather than carried to the call site by hand. -//! Every string comes from the VM's own loaded `GlobalStrings.lua`, never hardcoded, so -//! localization rides for free. An absent key shows **nothing**, which is a deliberate -//! divergence and not the reference's behaviour β€” see [`ui_error_text`]. +//! The UI error line's queues and resolvers, which `super::feed_actions` drains into +//! [`show_messages`]; cast failures resolve in [`super::cast_fail`]. use benilla_ui::messages::MessageRecord; use benilla_ui::script::{ScriptValue, UiScript}; @@ -31,74 +10,42 @@ use crate::sound::MessageSounds; use crate::ui_chat::{ChatEvent, ChatEventKind, ChatLog}; use crate::ui_items::{count_of, InventoryScope}; -/// Cast failures queued for the UI error line β€” the wire triple from `SMSG_CAST_RESULT` and the -/// local refusals alike. The spell id rides along because the display layer keys several messages -/// on the failing spell's record ([`super::cast_fail`]: NO_POWER's power family, the 0x28/0x3c -/// cooldown families). +/// Cast failures for the UI error line; the spell id lets the display key on the spell's record. #[derive(Resource, Default)] pub(crate) struct CastErrors(pub Vec); -/// One queued cast failure: the failing spell, the wire reason, and the reason-specific argument -/// word that fills the message's `%s` (the arm table at `0x6e1d8e` β€” a `SpellFocusObject.dbc` id -/// for 0x5e, an `AreaTable.dbc` id for 0x5d). -/// -/// `arg` is `None` for **almost** every client-local refusal, and that is faithful rather than a -/// gap: the local refusals the reference raises itself go through `DisplayError` with no argText, -/// and the two client-generated argument messages it *does* build (the lock toasts, MIN_SKILL) are -/// [`UiError`]'s tenants, not this queue's. -/// -/// The **one** exception is the crowd-control ladder's `0x8d` (decision 1948). That refusal is -/// local β€” `0x6094f0` bails before any packet β€” yet it carries an argument all the same, because -/// its own exemption scan produced one: the blocking aura's `SpellMechanic.dbc` id. It is the -/// only place a locally-generated word reaches this queue, which is why `push_local` still cannot -/// set one and [`CastErrors::push_local_arg`] exists beside it. +/// One queued cast failure. `arg` fills the message's `%s` (the arm table `0x6e1d8e`); the +/// reference raises its local refusals with none, except the crowd-control `0x8d` (`0x6094f0`), +/// which names the blocking aura's `SpellMechanic.dbc` id. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) struct CastFail { pub spell_id: u32, pub reason: u8, pub arg: Option, - /// **Whose** refusal this is β€” which decides which of the reference's two message tables - /// resolves it. See [`Caster`]. + /// Whose refusal this is, which picks the reference's message table ([`Caster`]). pub caster: Caster, - /// **This entry is a REDISPLAY** β€” it was queued once already, declined for want of an item - /// template, and is being resolved now that the template landed (the `0x78`/`0x5c`/`0x84` - /// cache-miss path, decisions 0545 + 0552). - /// - /// It exists because the reference's two attempts do not raise the same things (decision - /// 2285). The first pass bails at `0x6e1eab`/`0x6e1efc` to the epilogue `0x6e224f`, which is - /// past the red line AND past the combat-log formatter; the retry is the DBCACHECALLBACK - /// `0x6e29b0`, which calls `CGGameUI::DisplayError 0x496720` **directly** and never reaches - /// `0x62c360`. So a reagent refusal that had to wait for its item name shows the toast and is - /// never written to the log β€” where one that resolved first try is written to both. + /// A retry after the item template landed (the `0x78`/`0x5c` cache miss). The reference's + /// first pass exits past both the line and the log (`0x6e1eab`, `0x6e1efc`); its retry, the + /// cache callback `0x6e29b0`, calls `DisplayError` directly and never `0x62c360`, so a + /// redisplay is not logged. pub redisplay: bool, } -/// Who failed to cast β€” the one input that picks between the reference's **two** cast-failure -/// message tables (decision 2033). -/// -/// It is not a flag on one handler: `SMSG_CAST_FAILED` and `SMSG_PET_CAST_FAILED` land in two -/// separate functions, `0x6e1a00` and `0x6e8eb0`, each with its own reason -> errorId map. Ten of -/// the pet's reasons resolve to something the player's never says, six of them to the -/// `ERR_PET_SPELL_*` catalog rows that exist for no other purpose. Everything else about the two -/// β€” the first-layer `SPELL_FAILED_*` vocabulary, the argument arms, the strip fallback β€” is -/// literally the same code, which is why this rides the shared queue instead of forking it. -/// -/// [`super::cast_fail::cast_fail_text`] is the reader; nothing downstream of it asks. +/// Who failed to cast, which picks the reference's handler: `SMSG_CAST_RESULT` lands in +/// `0x6e1a00` and `SMSG_PET_CAST_FAILED` in `0x6e8eb0`, each with its own reason-to-errorId map; +/// the rest of [`super::cast_fail::cast_fail_text`] is shared. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub(crate) enum Caster { - /// Ours β€” `SMSG_CAST_FAILED` and every client-local refusal we raise for ourselves. + /// Ours: `SMSG_CAST_RESULT` and every local refusal. #[default] Player, - /// The pet's or the charm's β€” `SMSG_PET_CAST_FAILED` only. + /// The pet's or the charm's: `SMSG_PET_CAST_FAILED` only. Pet, } impl CastFail { - /// A **client-local** refusal β€” no wire argument (see [`Self::arg`]). - /// - /// Always the player's: every local refusal benilla raises is raised on its own cast, and the - /// reference has no client-side path that refuses on the pet's behalf β€” a pet's refusals are - /// all decided server-side and arrive as `SMSG_PET_CAST_FAILED`. + /// A client-local refusal, with no argument word; always the player's, since the server + /// decides every pet refusal. pub(crate) const fn local(spell_id: u32, reason: u8) -> Self { Self { spell_id, @@ -109,8 +56,7 @@ impl CastFail { } } - /// The same entry, re-queued for the frame its item template lands on β€” see - /// [`Self::redisplay`]. + /// The same entry, marked as a [`Self::redisplay`]. pub(crate) const fn requeued(self) -> Self { Self { redisplay: true, @@ -125,8 +71,7 @@ impl CastErrors { self.0.push(CastFail::local(spell_id, reason)); } - /// Queue a client-local refusal that carries its own argument word β€” see [`CastFail::arg`] - /// for why there is exactly one such tenant. + /// Queue a client-local refusal with an argument word, the crowd-control `0x8d`. pub(crate) fn push_local_arg(&mut self, spell_id: u32, reason: u8, arg: u32) { self.0.push(CastFail { spell_id, @@ -137,11 +82,8 @@ impl CastErrors { }); } - /// Queue the **pet's** wire refusal (`SMSG_PET_CAST_FAILED`). Same queue, same drain, same - /// sink β€” [`Caster::Pet`] is the whole difference, and it is read once, at the table pick. - /// - /// No argument word: `PetCastFailed::AppendBodyTo` writes none, so the reference's own - /// argument arms on this path have nothing to read. + /// Queue the pet's `SMSG_PET_CAST_FAILED` refusal, which carries no argument word + /// (vmangos `Packets/Pet.cpp:127-132`). pub(crate) fn push_pet(&mut self, spell_id: u32, reason: u8) { self.0.push(CastFail { spell_id, @@ -153,60 +95,33 @@ impl CastErrors { } } -/// (Dis)mount refusals queued for the UI error line, as the wire pair `(mount, code)` from -/// `SMSG_MOUNTRESULT`/`SMSG_DISMOUNTRESULT` (decision 0441 P2) β€” resolved to text through the -/// VM's own GlobalStrings by key ([`mount_result_key`]), the [`CastErrors`] shape exactly. +/// (Dis)mount refusals from `SMSG_MOUNTRESULT`/`SMSG_DISMOUNTRESULT` as `(mount, code)`, resolved +/// by [`mount_result_key`]. #[derive(Resource, Default)] pub(crate) struct MountErrors(pub Vec<(bool, u32)>); -/// Taming refusals queued for the UI error line, as the raw `SMSG_PET_TAME_FAILURE` reason byte -/// (decision 2039) β€” [`MountErrors`]' shape, and here for the same reason: it is a **wire code -/// family**, resolved by a code table, not a client-local key. -/// -/// It cannot ride [`UiErrorKeys`] because its message is a *nested* lookup, which is exactly what -/// the reference does: `0x6e6a20` resolves the reason's `PETTAME_*` key through the script VM -/// (`0x703bf0`) and then passes that **string** as `DisplayError(0xee)`'s argText, filling -/// `ERR_TAME_FAILED`'s lone `%s`. [`UiError`]'s arguments are text the raise site already has; -/// here the raise site has only a byte, and the VM is only reachable at the drain. +/// Taming refusals as the raw `SMSG_PET_TAME_FAILURE` reason. The reference (`0x6e6a20`) resolves +/// the reason's `PETTAME_*` string through the VM (`0x703bf0`) and passes it as `ERR_TAME_FAILED`'s +/// `%s` (`DisplayError(0xee)`), so both lookups wait for the VM at the drain. #[derive(Resource, Default)] pub(crate) struct PetTameFailures(pub Vec); -/// Where a client message is SHOWN is **not a decision this crate makes** β€” it is the `kind` field -/// (`+0x04`) of the reference's message record, and it now arrives from the catalog -/// ([`benilla_ui::messages`], decision 1770) instead of being hand-carried to every call site -/// beside the key. [`MsgKind`] is that field; [`UiError::kind`] is the whole lookup. -/// -/// It reads as a property of the MESSAGE rather than of the window that raised it because that is -/// exactly what it is: the quest refusals (0669), the auction house (1523) and the party -/// quest-share (1733) all ask the same table the same question. +/// Where a message shows: the `kind` field (`+0x04`) of the reference's message record, read from +/// the catalog ([`benilla_ui::messages`]). pub(crate) use benilla_ui::messages::MsgKind; -/// One argument in a message's **argText list**. -/// -/// Heterogeneous and ordered because the template's specifiers are: the lock-refusal toast -/// `ERR_USE_LOCKED_WITH_SPELL_KNOWN_SI` is String-then-Integer (its arm `0x5f34a9`, -/// decision 0545), and a list of strings beside a separate number cannot express that. +/// One argText argument; the list is ordered and mixed because a template's specifiers are: +/// `ERR_USE_LOCKED_WITH_SPELL_KNOWN_SI` takes a string then an integer (`0x5f34a9`). #[derive(Debug, PartialEq, Eq)] pub(crate) enum FillArg { S(String), D(i64), } -/// One `DisplayError` message: a GlobalStrings key plus the **ordered argText list** the -/// reference pushes with it. Not red-line-only: this is the payload of the reference's ONE -/// `CGGameUI::DisplayError` (`0x496720`) whatever surface the message's record names β€” the quest -/// refusals carry their chat lines in it too (decision 0669). -/// -/// **`0x496720` is variadic** β€” cdecl, the catalog id first and the argText after it, `add esp,4` -/// at a bare call site and `add esp,8` at a one-string one (22 of 22 in the guild command-result -/// handler `0x5e7520`). Three strings is the most any call site pushes: `SMSG_GUILD_EVENT`'s -/// shared emitter tail `0x5e745f` passes **1, 2 or 3** of them off the packet's `strCount`, feeding -/// `ERR_GUILD_PROMOTE_SSS`. This field was a single `fill_s` until decision 2054, which is why -/// the guild lines could not use this route at all and composed their own English instead. -/// -/// A key whose string carries fewer specifiers than there are arguments ignores the rest; one -/// that carries more shows the starved specifier verbatim, which is `SStrPrintf`'s own behaviour -/// and the reason a short `strCount` is passed short rather than padded with empties. +/// One `DisplayError` (`0x496720`) message, whatever its surface: a GlobalStrings key and the +/// argText the reference pushes after the id, at most three strings (the guild event tail +/// `0x5e745f`). Extra arguments are ignored and a starved specifier shows verbatim, as +/// `SStrPrintf` does, so a short list is passed short. #[derive(Debug, PartialEq, Eq)] pub(crate) struct UiError { pub key: &'static str, @@ -214,8 +129,7 @@ pub(crate) struct UiError { } impl UiError { - /// A fill-less message β€” the plain-key tenants (`ERR_ATTACK_MOUNTED`, the flag-locked - /// strategy defaults). The reference's `push ; call 0x496720; add esp,4`. + /// A message with no arguments. pub(crate) fn key(key: &'static str) -> Self { Self { key, @@ -223,7 +137,7 @@ impl UiError { } } - /// One string β€” the `_S` family, and the reference's `add esp,8`. + /// One string, the `_S` family. pub(crate) fn s(key: &'static str, s: impl Into) -> Self { Self { key, @@ -231,7 +145,7 @@ impl UiError { } } - /// Several strings, in the order they are pushed β€” the `_SS`/`_SSS` family. + /// Several strings in push order, the `_SS`/`_SSS` family. pub(crate) fn strings(key: &'static str, args: &[&str]) -> Self { Self { key, @@ -244,7 +158,7 @@ impl UiError { Self { key, args } } - /// The first string argument β€” what a test asking "was the name filled in?" means. + /// The first string argument. #[cfg(test)] pub(crate) fn arg_s(&self) -> Option<&str> { self.args.iter().find_map(|a| match a { @@ -263,26 +177,15 @@ impl UiError { } } -/// Client-LOCAL refusals queued for the UI error line straight by GlobalStrings key β€” the -/// `CGGameUI::DisplayError` route for errors that carry no wire code and no spell record -/// (`ERR_ATTACK_MOUNTED` was the first tenant; the GameObject lock-refusal toasts of -/// decision 0545 are the formatted ones). The [`MountErrors`] shape without the code table. +/// Client-local refusals queued by GlobalStrings key: the `DisplayError` route for errors with no +/// wire code and no spell record. #[derive(Resource, Default)] pub(crate) struct UiErrorKeys(pub Vec); -/// [`UiErrorKeys`]' twin for lines that arrive **already resolved** β€” text with no GlobalStrings -/// key to look up, because the server (or a fixed literal) already wrote it. Same frame, same -/// sink, same drain. -/// -/// This is the one route that does **not** consult the catalog, and faithfully so: its tenants do -/// not go through `DisplayError` at all, so there is no record and no id to read a kind from. The -/// wire tenants are the reference's own: `SMSG_NOTIFICATION` (`0x1cb`, handler `0x401800` β€” -/// `mov edx,1; call 0x4945b0` β†’ `UI_ERROR_MESSAGE`) and `SMSG_AREA_TRIGGER_MESSAGE` (`0x2b8`, the -/// shared handler `0x48f690`'s arm at `0x48f8ff` β€” `xor edx,edx; call 0x4945b0` β†’ -/// `UI_INFO_MESSAGE`). `0x4945b0(text, flag)` is the whole sink: null/empty guard, then -/// `neg edx; sbb edx,edx; add edx,0xe1` = event `0xe1` when the flag is 0 and `0xe0` when it is 1. -/// The handler's own choice of flag is what the [`MsgKind`] here carries β€” the same two surfaces -/// the catalog names, reached by a different road. +/// Lines that arrive already resolved, with no key and no catalog record: they reach the +/// reference's sink `0x4945b0(text, flag)` without `DisplayError`, flag 1 firing +/// `UI_ERROR_MESSAGE` and 0 `UI_INFO_MESSAGE`. `SMSG_NOTIFICATION` (`0x401800`) passes 1, +/// `SMSG_AREA_TRIGGER_MESSAGE` (`0x48f8ff`) passes 0. #[derive(Resource, Default)] pub(crate) struct UiErrorTexts(pub Vec<(String, MsgKind)>); @@ -298,31 +201,11 @@ impl UiErrorTexts { } } -/// Resolve one [`UiError`] to its displayed text β€” `GetText(key)` + the `%s`/`%d` argText -/// substitution ("Requires %s" + "Herbalism" β†’ "Requires Herbalism"). -/// `None` (the key resolves to nothing, or the filled text is empty) = show nothing β€” a -/// **NAMED DIVERGENCE**, corrected from a false citation this doc carried until 2246. -/// -/// The claim was that this is the reference's own guard at `0x4967bd`/`0x4967c5`. It is not. -/// Those two test the **catalog row's key field** β€” `0x4967b6 mov ecx,[edx*4 + 0xb4b498]`, the -/// row's `+0x00`, null then first-byte-empty β€” i.e. whether the *record names a key at all*. In -/// benilla that field is a `&'static str` literal in a generated table, so the guard they model -/// can never fire, and the guard actually implemented here is one the reference does not have: -/// between the resolve (`0x4967d7 call 0x703bf0`) and the dispatch (`0x496842`) there is **no -/// test of the resolved string**. A key the player's `GlobalStrings.lua` does not define resolves -/// to the empty string and the reference emits an **empty** line β€” `0x49a870`'s own entry test -/// (`0x49a881 test esi,esi`) is on the buffer *pointer*, not its contents. -/// -/// Kept because it only bites on a chain missing a stock key, where silence beats a blank line in -/// the log β€” but kept **named**, because the shape of the old comment (bytes cited for behaviour -/// those bytes do not justify) is the same shape as the `removed_spell` error 2246 corrects. -/// [`keyed_line`] and [`keyed_line_s`] carry the same divergence. +/// Resolve a [`UiError`] to its text, the template filled in push order. Deviation: an absent or +/// empty result shows nothing, where the reference shows an empty line (no test of the string +/// between `0x4967d7` and `0x496842`), because a blank line is worse than none; only a chain +/// missing a stock key reaches it. pub(crate) fn ui_error_text(e: &UiError, get: &dyn Fn(&str) -> Option) -> Option { - // Through the one shared filler (2045). This used `str::replace`, which fills EVERY `%s` - // with the same argument β€” latent only because no message on this queue carried two yet. - // The list's order is the reference's own push order, and a specifier with no argument left - // is copied through rather than blanked, which is `SStrPrintf`'s behaviour and the whole - // reason a short guild `strCount` is passed short (2054). let args: Vec> = e .args .iter() @@ -335,13 +218,8 @@ pub(crate) fn ui_error_text(e: &UiError, get: &dyn Fn(&str) -> Option) - (!text.is_empty()).then_some(text) } -/// One resolved line on its way to the screen β€” the text, where it goes, and the **catalog row it -/// came from** when it has one. -/// -/// The record rides along because a message's *sound* is one of its fields (`+0x08`/`+0x0c`) just -/// as its surface is (`+0x04`), and the reference reads all three in the same breath inside -/// `CGGameUI::DisplayError`. Carrying only the [`MsgKind`], as this did before 1815, threw the -/// other two away at the call site and left the catalog's sound columns unreadable by anything. +/// One resolved line: its text, its surface, and its catalog record, whose sound fields +/// (`+0x08`/`+0x0c`) `DisplayError` reads with the kind (`+0x04`). pub(crate) struct Shown { record: Option<&'static MessageRecord>, kind: MsgKind, @@ -349,28 +227,15 @@ pub(crate) struct Shown { } impl Shown { - /// The resolved text β€” what the surface will show. #[cfg(test)] pub(crate) fn text(&self) -> &str { &self.text } - /// A **catalog** message, named by its GlobalStrings key β€” the reference's `DisplayError(id)`, - /// which is nearly every line benilla shows. One lookup answers both "where does it go" and - /// "what does it sound like". - /// - /// A key with no row falls back to [`MsgKind::Error`] and no sound. That cannot arise in the - /// reference (a message is an *index*, so an unknown key is not expressible), so it only ever - /// means benilla named a key the client does not have. - /// - /// `every_error_key_in_the_source_is_a_catalog_row` was supposed to keep that unreachable and - /// **could not**: all 465 catalog keys begin `ERR_`, so that walk collects `"ERR_…"` literals - /// β€” which makes it blind to exactly the mistake it is guarding against, a raise site that - /// named something else. `PET_SPELL_NOPATH` and `SPELL_FAILED_OUT_OF_RANGE` sat on the pet's - /// feedback line for months, took this fallback every time, and no gate could see them - /// (decision 2033). Widening the walk is not possible from the text β€” `SPELL_FAILED_*` keys - /// are legitimate GlobalStrings lookups elsewhere in this very module β€” so the tripwire moves - /// to the funnel: this is where a non-row key becomes observable, so it says so. + /// A catalog message by GlobalStrings key, the reference's `DisplayError(id)`: its record gives + /// the surface and the sound. A key with no row cannot occur in the reference, where a message + /// is an index, so it warns and falls back to a silent [`MsgKind::Error`]; the warning is the + /// only check on keys the source walk for `ERR_` literals cannot see. pub(crate) fn keyed(key: &str, text: String) -> Self { let record = benilla_ui::messages::by_key(key); if record.is_none() { @@ -383,9 +248,8 @@ impl Shown { } } - /// A line with **no catalog row** β€” the wire's own already-resolved text ([`UiErrorTexts`]), - /// which reaches the reference's sink `0x4945b0` directly and never passes a record. Silent, - /// faithfully: there is no `+0x08`/`+0x0c` to read. + /// A line with no catalog row, the wire's own text ([`UiErrorTexts`]), which reaches the sink + /// `0x4945b0` without a record and so makes no sound. pub(crate) fn unkeyed(kind: MsgKind, text: String) -> Self { Self { record: None, @@ -395,21 +259,14 @@ impl Shown { } } -/// Resolve a message key against the VM's own `GlobalStrings.lua` into a [`Shown`] β€” or `None` -/// when the key has no string there. Carrying a *key* rather than text at every raise site is the -/// reference's shape; suppressing on an **absent** one is [`ui_error_text`]'s named divergence, -/// not the `0x4967bd`/`0x4967c5` guard this line used to cite. -/// -/// Lives here rather than in each window because every keyed raise site needs exactly this and -/// four of them had grown their own copy (decision 1821). +/// Resolve a message key against the VM's `GlobalStrings.lua` into a [`Shown`]; an absent or empty +/// string is `None`, [`ui_error_text`]'s deviation. pub(crate) fn keyed_line(script: &UiScript, key: &'static str) -> Option { let text = script.lua().globals().get::(key).ok()?; (!text.is_empty()).then(|| Shown::keyed(key, text)) } -/// [`keyed_line`] for a `%s`-shaped string: each argument fills the next `%s`, in order β€” the -/// reference's `format`-with-pushed-arguments face of the same catalog rows (the battleground -/// join verdict's map name, the joined-or-left player's name, 1974). +/// [`keyed_line`] for a `%s` template, each argument filling the next `%s`. pub(crate) fn keyed_line_s(script: &UiScript, key: &'static str, args: &[&str]) -> Option { let template = script.lua().globals().get::(key).ok()?; let args: Vec> = args @@ -421,26 +278,11 @@ pub(crate) fn keyed_line_s(script: &UiScript, key: &'static str, args: &[&str]) (!text.is_empty()).then(|| Shown::keyed(key, text)) } -/// **The one sink** for a resolved message line: put it on the surface its [`MsgKind`] names, and -/// queue the sound its catalog row names. -/// -/// Every route into the client's message display ends here β€” the window queues that resolve a -/// [`UiError`] (the questgiver refusals 0669, the auction house 1523, the party quest-share 1733), -/// the client-local [`UiErrorKeys`], and the already-resolved [`UiErrorTexts`] off the wire. They -/// differ only in how they *build* a line and where the kind comes from; what happens to a built -/// one is this, once, so the three cannot drift apart. -/// -/// The sound is **queued, not played** β€” this runs deep inside the UI feeds, which have no audio -/// resources and should not grow them; `crate::sound::message` drains the queue after the input -/// pass, on the same frame (decision 1815). -/// -/// `who` is the caller's module tag for the debug line β€” which exists because the chat path keeps -/// no log of its own, so without it a live probe can count lines but never read one (0669's -/// in-app leg). -/// -/// [`MsgKind::Chat`] lines go out as `CHAT_MSG_SYSTEM`. Three catalog rows ask for -/// `CHAT_MSG_SKILL` instead (the skill-up trio, [`benilla_ui::messages::MessageRecord::chat_type`]) -/// and benilla raises none of them yet; when it does, this is the line that has to read the field. +/// The one sink for a resolved line: show it on its [`MsgKind`]'s surface and queue its catalog +/// row's sound, which `crate::sound::message` plays after the input pass, the same frame. `who` +/// tags the debug line. [`MsgKind::Chat`] lines go out as `CHAT_MSG_SYSTEM`; the three rows that +/// ask for `CHAT_MSG_SKILL` ([`benilla_ui::messages::MessageRecord::chat_type`]) are not raised +/// yet. pub(crate) fn show_messages( script: &mut UiScript, sink: &mut MessageSink, @@ -449,8 +291,7 @@ pub(crate) fn show_messages( ) { for Shown { record, kind, text } in lines { debug!("{who}: message ({kind:?}) {text:?}"); - // The reference sounds a message on the way past its text, not instead of it β€” so this - // sits with the display and inherits its every guard (an empty line never gets here). + // The reference sounds a message beside its text, so the sound shares the display's guards. if let Some(record) = record { sink.sounds.push(record); } @@ -469,24 +310,17 @@ pub(crate) fn show_messages( } } -/// Everywhere a displayed message lands, besides the script VM: the chat window and the sound -/// queue. One [`SystemParam`] rather than two, because [`show_messages`] writes both on every -/// line and no caller wants one without the other β€” and because the feeds that raise messages are -/// already at Bevy's parameter ceiling. +/// Where a shown message lands besides the VM: the chat log and the sound queue, one parameter +/// because the feeds that raise messages are at Bevy's parameter limit. #[derive(bevy::ecs::system::SystemParam)] pub(crate) struct MessageSink<'w> { pub(crate) chat: ResMut<'w, ChatLog>, pub(crate) sounds: ResMut<'w, MessageSounds>, } -/// `UNIT_FIELD_FLAGS` bits the attack-start validator refuses on, paired with the message each -/// raises β€” read at `0x612eec`+ in the binary's own test order. All four are crowd control: the -/// actor is not refusing, it is unable. -/// -/// Each has a second face in the reference β€” errorId `0xa9` -/// `ERR_ATTACK_PREVENTED_BY_MECHANIC_S`, which substitutes a resolved mechanic name β€” chosen by -/// five `0x6e9…` resolvers whose bodies are **not yet decoded**. We raise the plain form -/// only; the fill is a strictly better message for the same refusal, never a different one. +/// The `UNIT_FIELD_FLAGS` crowd-control bits the attack-start validator refuses on, in its test +/// order (`0x612eec`). The reference can raise `ERR_ATTACK_PREVENTED_BY_MECHANIC_S` (`0xa9`) with a +/// mechanic name instead, by a choice not yet traced; only the plain form is raised here. const ATTACK_FLAG_REFUSALS: [(u32, &str); 4] = [ (0x0004_0000, "ERR_ATTACK_STUNNED"), (0x0002_0000, "ERR_ATTACK_PACIFIED"), @@ -494,28 +328,11 @@ const ATTACK_FLAG_REFUSALS: [(u32, &str); 4] = [ (0x0040_0000, "ERR_ATTACK_CONFUSED"), ]; -/// Phase A of the shared attack-start validator `0x612df0` β€” **the actor's own eligibility** -/// (it supersedes the mounted-only fragment decision 0481 built from the one gate that was known -/// then). -/// -/// `0x612df0(ecx = actor, &outGuid)` is ONE function with three call sites, and **the actor is -/// whoever the caller passes in `ecx`** β€” the player for the melee attack-start router `0x6131aa`, -/// and **the pet** for the pet bar's ATTACK arm (`0x4bd40d` passes `edi`, the pet object). Every -/// gate below reads the actor's own descriptor, so one function answers both; that is why this -/// takes an actor rather than reaching for the self store the way the fragment did. -/// -/// A veto is total: `EAX = 0`, one of the ten consecutive `ERR_ATTACK_*` registry ids `0xa0`–`0xa9` -/// through `CGGameUI::DisplayError`, and **no packet at all** β€” on the pet arm the refusal's -/// `0x4bd414 je 0x4bd4c6` lands on the function epilogue, not on the shared send. -/// -/// Order is the binary's, and it is observable: a dead **and** mounted actor says "Can't attack -/// while dead." -/// -/// `dead` is `0x605f30(actor)`, which for any non-player actor β€” a pet never carries the player -/// typemask bit β€” degenerates to exactly `health <= 0`. Its further leg for a *player* actor -/// (`[[obj+0xe68]+8]` bit 4, reached only when health is positive, so plainly the ghost state) is -/// byte-read but its name is unconfirmed, so it is left to the caller: pass `dead` yourself when -/// you know more than the health field does. +/// Phase A of the attack-start validator `0x612df0`, the actor's own eligibility: the first +/// failing gate raises its `ERR_ATTACK_*` line and returns `true`, and no packet is sent. The +/// actor is the player for Attack (`0x6131aa`) and the pet for the pet bar (`0x4bd40d`). The +/// reference's dead test `0x605f30` also refuses a player with `PLAYER_FLAGS_GHOST` +/// (`[[obj+0xe68]+8]` bit 4); this tests health only. pub(crate) fn attack_actor_refusal( actor: Option<&ObjectStore>, self_guid: Option, @@ -529,28 +346,15 @@ pub(crate) fn attack_actor_refusal( true } -/// The same ladder **without the message** β€” which condition blocks the swing, or `None`. -/// -/// It exists because `0x612df0` is not on every attack-start path, and decision 1851 pinned which: -/// its three callers image-wide are the pet-attack command (`0x4bd40d`), the Attack -/// action/keybind (`0x6131aa`) and TryCast (`0x6e4efb`) β€” and **not** the world right-click. -/// That click runs `0x60bea0` β†’ `0x60c247 call 0x5ecb70`, whose whole extent -/// `[0x5ecb70, 0x5ecda3)` contains no `call 0x496720` and no `push` of `0xa0`–`0xa9`: it has its -/// own short, silent conjunct set (`0x5ecc06` is its mounted bail) and every failure lands on -/// `0x5ecc37`, which bails or stops attacking. **A right-click on a hostile shows no error text -/// under any condition** β€” not mounted, dead, charmed, stunned, pacified, fleeing or confused. -/// -/// So the click asks this, and the bar asks [`attack_actor_refusal`]. The *predicate* is still -/// `0x612df0`'s rather than `0x5ecb70`'s own β€” the two sets overlap but are not identical, and -/// transcribing `0x5ecb70`'s is its own slice β€” but the **silence** is now the verified law, and -/// the swing is still suppressed, which is the half that keeps us off the wire. +/// The same ladder without the message, in the reference's gate order. The world right-click +/// asks this: its path (`0x60bea0` to `0x5ecb70`) shows no error text and never reaches +/// `0x612df0`, whose callers are the pet bar, Attack and TryCast (`0x6e4efb`). That path's own +/// gate set overlaps this one but is not transcribed. pub(crate) fn attack_actor_blocked( actor: Option<&ObjectStore>, self_guid: Option, ) -> Option<&'static str> { - // No descriptor is no refusal. The reference resolves the actor object first and skips the - // whole chain when it cannot (`0x4bd403`: no pet β‡’ send unmodified) β€” an un-streamed unit is - // not an ineligible one. + // An unresolved actor skips the chain, as in the reference (`0x4bd403`). let fields = &actor?.0; let key = if fields.unit_health().is_some_and(|h| h == 0) { "ERR_ATTACK_DEAD" @@ -558,8 +362,7 @@ pub(crate) fn attack_actor_blocked( .unit_charmed_by() .is_some_and(|g| Some(g) != self_guid) { - // Charmed by somebody who is not us. Charmed BY US is not a refusal β€” a mind-controlled - // unit is one you are allowed to swing with. + // Charmed by anyone but us; a unit we control may swing. "ERR_ATTACK_CHARMED" } else if let Some((_, key)) = ATTACK_FLAG_REFUSALS .iter() @@ -574,17 +377,11 @@ pub(crate) fn attack_actor_blocked( Some(key) } -/// The ref's pre-send totem/reagent possession check β€” `CheckReagentsAndTotems 0x6e4000` -/// (decision 0552): TryCast runs it -/// for EVERY cast path (action bar, Lua, the GameObject-use opener) **before any packet is -/// built**. Totems first (2 slots, a bag **presence** test β€” the Mining Pick / Skinning Knife / -/// Thieves' Tools tools), then reagents (8 slots, a bag **count** test). The first failing slot -/// refuses the cast LOCALLY β€” reason `0x78`/`0x5c` into [`CastErrors`] (whose drain fills -/// "Requires " / "Missing reagent: ") and **no send** β€” which is the only way -/// "Requires Mining Pick" can ever appear: vmangos answers a sent pickless cast with -/// `ITEM_GONE` ("Item is gone"), and its own source marks the totem reason "client-side only". -/// A missing self store skips the check, like the ref's `IsActivePlayer` gate (the client can -/// only see its own bags). Returns `true` when the cast must be refused. +/// The reference's pre-send check `0x6e4000`, run on every cast path before any packet: the first +/// missing totem (a presence test), then the first short reagent (a count), refuses locally as +/// `0x78` or `0x5c` and returns `true`. It is the only source of "Requires Mining Pick": vmangos +/// answers a pickless cast with `ITEM_GONE` (`Spell.cpp:7301`). A missing self store skips the +/// check, as the reference's active-player gate does. pub(crate) fn reagent_totem_refusal( spell_id: u32, def: Option<&benilla_formats::SpellDisplay>, @@ -595,7 +392,7 @@ pub(crate) fn reagent_totem_refusal( let (Some(d), Some(store)) = (def, self_store) else { return false; }; - // Totems before reagents β€” the ref's in-function loop order. + // Totems before reagents, the reference's order. let reason = if first_missing_totem(d, store, objects).is_some() { 0x78 } else if first_short_reagent(d, store, objects).is_some() { @@ -608,8 +405,7 @@ pub(crate) fn reagent_totem_refusal( true } -/// The first totem (tool) slot whose item is absent from our bags β€” the `0x6e4000` totem loop's -/// failing slot, re-derived (a presence test: any owned count satisfies). +/// The first totem item absent from our bags: `0x6e4000`'s failing slot. pub(super) fn first_missing_totem( d: &benilla_formats::SpellDisplay, store: &ObjectStore, @@ -622,8 +418,7 @@ pub(super) fn first_missing_totem( .find(|&t| count_of(&store.0, objects, t, InventoryScope::CARRIED) == 0) } -/// The first reagent slot whose owned count falls short β€” the `0x6e4000` reagent loop's failing -/// slot, re-derived. +/// The first reagent we hold too few of: `0x6e4000`'s failing slot. pub(super) fn first_short_reagent( d: &benilla_formats::SpellDisplay, store: &ObjectStore, @@ -637,11 +432,9 @@ pub(super) fn first_short_reagent( .map(|(id, _)| id) } -/// The (dis)mount result code β†’ its `ERR_MOUNT_*`/`ERR_DISMOUNT_*` GlobalStrings key. The code -/// tables are vmangos `UnitDefines.h` (`UnitMountResult`/`UnitDismountResult`); every key was -/// verified present in the shipped 1.12 `GlobalStrings.lua` (patch-2.MPQ, extracted 2026-07-17) -/// β€” including the deliberately-shipped `ERR_MOUNT_OTHER` = "UNKNOWN MOUNT ERROR" and the -/// INTERNAL-ERROR dismount strings. The success codes (10 mounting / 3 dismounting) are silent. +/// A (dis)mount result code to its GlobalStrings key, by vmangos `UnitMountResult` and +/// `UnitDismountResult` (`UnitDefines.h:842-863`); every key ships in 1.12, `ERR_MOUNT_OTHER`'s +/// "UNKNOWN MOUNT ERROR" included. The success codes (10, 3) show nothing. pub(super) fn mount_result_key(mount: bool, code: u32) -> Option<&'static str> { if mount { match code { @@ -655,14 +448,14 @@ pub(super) fn mount_result_key(mount: bool, code: u32) -> Option<&'static str> { 7 => Some("ERR_MOUNT_RACECANTMOUNT"), 8 => Some("ERR_MOUNT_SHAPESHIFTED"), 9 => Some("ERR_MOUNT_FORCEDDISMOUNT"), - _ => None, // 10 = OK; anything past the table stays the debug log's business + _ => None, // 10 is OK; an unknown code shows nothing } } else { match code { 0 => Some("ERR_DISMOUNT_NOPET"), 1 => Some("ERR_DISMOUNT_NOTMOUNTED"), 2 => Some("ERR_DISMOUNT_NOTYOURPET"), - _ => None, // 3 = OK + _ => None, // 3 is OK } } } @@ -673,11 +466,8 @@ mod shown_tests { use benilla_ui::script::UiScript; use bevy::prelude::*; - /// The producer half of the message-sound path (decision 1815), driven through the real - /// [`super::show_messages`] rather than around it: a **keyed** line queues its catalog row for - /// `crate::sound::message` to sound, an **unkeyed** one queues nothing (the wire's own text has - /// no record), and a key the client does not have queues nothing either β€” the same fallback - /// that makes it a red line. + /// Through [`super::show_messages`]: a keyed line queues its catalog row; an unkeyed line and + /// an unknown key queue nothing. #[test] fn a_keyed_line_queues_its_row_and_an_unkeyed_one_queues_nothing() { let mut world = World::new(); @@ -693,8 +483,7 @@ mod shown_tests { &mut sink, "test", [ - // A voiced refusal, a cue row, a chat row that still speaks, and one with - // neither β€” every arm of the record's two sound columns. + // A voiced refusal, a named cue, a chat row and a silent row. Shown::keyed("ERR_OUT_OF_MANA", "Not enough mana".into()), Shown::keyed("ERR_NEWTAXIPATH", "New flight path discovered!".into()), Shown::keyed("ERR_ALREADY_IN_GROUP_S", "Bob is already in a group".into()), @@ -718,7 +507,7 @@ mod shown_tests { ], "the unkeyed line and the unknown key have no row to sound" ); - // …and what each of them asks the drain for. + // What each asks the drain for. assert_eq!(queued[0].type_tag, 0x0f, "the voice line"); assert_eq!(queued[1].sound, Some("TaxiNodeDiscovered"), "the named cue"); assert_eq!(queued[2].kind, MsgKind::Chat, "a chat row sounds too"); @@ -741,14 +530,11 @@ mod mount_error_tests { assert_eq!(mount_result_key(true, 2), Some("ERR_MOUNT_ALREADYMOUNTED")); assert_eq!(mount_result_key(true, 8), Some("ERR_MOUNT_SHAPESHIFTED")); assert_eq!(mount_result_key(false, 1), Some("ERR_DISMOUNT_NOTMOUNTED")); - // Off-table codes stay the debug log's business β€” no red line. + // Off-table codes show nothing. assert_eq!(mount_result_key(true, 11), None); assert_eq!(mount_result_key(false, 4), None); } - /// The RUNTIME leg on the real data (the `cast_fail` pattern): every key this table can - /// emit resolves to a non-empty string in the shipped 1.12 `GlobalStrings.lua` β€” the guard - /// against a typo'd key silently swallowing the red line. Skips without client data. #[test] fn every_mount_key_resolves_in_the_real_global_strings() { let data = benilla_formats::wow_data_or_skip!(); @@ -786,8 +572,7 @@ mod ui_error_tests { UiError::args(key, args) } - /// The DisplayError argText substitution against a fake getter: `%s` then `%d`, key-absent - /// and key-empty both silent (the GlobalStrings data-suppression face). + /// `%s` then `%d`; an absent or empty key shows nothing. #[test] fn fills_substitute_and_absent_keys_are_silent() { let get = |key: &str| match key { @@ -814,11 +599,7 @@ mod ui_error_tests { assert_eq!(t(&UiError::key("ABSENT")), None); } - /// The RUNTIME leg on the real data (the `cast_fail`/mount pattern): every GlobalStrings - /// key the lock-refusal toasts (decision 0545, the USE sender `0x5f33e0`) and the totem - /// fill can emit resolves in the shipped 1.12 `GlobalStrings.lua`, with the exact ref-quoted - /// formats β€” the guard against a typo'd key silently swallowing the red line. Skips without - /// client data. + /// The lock-refusal keys (the use sender `0x5f33e0`) against the shipped file. #[test] fn every_lock_refusal_key_resolves_in_the_real_global_strings() { let data = benilla_formats::wow_data_or_skip!(); @@ -843,17 +624,15 @@ mod ui_error_tests { "That has already been used." ); assert_eq!(g("ERR_USE_CANT_OPEN").unwrap(), "You can't open that."); - // The ENGINE's own by-key refusal (`benilla_ui`'s `place_action`, errorId `0x9e`). The - // key is the load-bearing half: an unresolvable one makes `ui_error_text` answer None and - // the refusal quietly goes back to being the divergence 0666 named. + // The engine's own refusal (`place_action`, errorId `0x9e`). assert_eq!( g("ERR_PASSIVE_ABILITY").unwrap(), "You can't put a passive ability in the action bar." ); - // The wire-side totem fill's template (feed_actions' 0x78 arm): "Requires Mining Pick". + // The drain's `0x78` template. assert_eq!(g("SPELL_FAILED_TOTEMS").unwrap(), "Requires %s"); - // End to end through the formatter β€” the two gathering lines the director will see. + // Through the formatter: the herb and vein lines. let herb = filled("ERR_USE_LOCKED_WITH_SPELL_S", Some("Herbalism"), None); assert_eq!( ui_error_text(&herb, &g).as_deref(), @@ -922,13 +701,11 @@ mod attack_actor_tests { "unit flag {bit:#x}" ); } - // An unrelated flag bit is not a refusal β€” the mask is four specific bits, not "any flag". + // An unrelated flag bit is no refusal. assert_eq!(refusal(Some(&actor(&[(FLAGS, 0x1000)])), Some(7)), None); } - /// `0x612e33`'s charm test compares against the ACTIVE PLAYER's guid, so being charmed **by - /// us** is not a refusal β€” that is the whole point of mind control, and reading the field as a - /// plain "is charmed" boolean would make every controlled unit unable to swing. + /// `0x612e33` compares the charmer with the active player's guid. #[test] fn charmed_by_us_still_swings_and_charmed_away_does_not() { let mine = actor(&[(CHARMEDBY, 7), (CHARMEDBY + 1, 0)]); @@ -936,24 +713,20 @@ mod attack_actor_tests { let theirs = actor(&[(CHARMEDBY, 9), (CHARMEDBY + 1, 0)]); assert_eq!(refusal(Some(&theirs), Some(7)), Some("ERR_ATTACK_CHARMED")); - // Not knowing our own guid yet cannot make somebody else's charm look like ours. + // An unknown own guid never matches another's charm. assert_eq!(refusal(Some(&theirs), None), Some("ERR_ATTACK_CHARMED")); } - /// The order is the binary's, and it is observable: the FIRST gate to fail names the message, - /// so a dead-and-mounted actor says "Can't attack while dead." + /// The reference's order: dead before the flags, the flags before mounted. #[test] fn the_first_failing_gate_names_the_message() { let both = actor(&[(HEALTH, 0), (MOUNT, 1147), (FLAGS, 0x0004_0000)]); assert_eq!(refusal(Some(&both), Some(7)), Some("ERR_ATTACK_DEAD")); - // …and with the death removed, the flag block still precedes the mount check. let cc = actor(&[(MOUNT, 1147), (FLAGS, 0x0004_0000)]); assert_eq!(refusal(Some(&cc), Some(7)), Some("ERR_ATTACK_STUNNED")); } - /// An un-streamed actor is not an ineligible one: the reference skips the whole chain when it - /// cannot resolve the object (`0x4bd403` β€” no pet β‡’ send unmodified). A descriptor that simply - /// has not sent a health field yet must not read as a corpse. + /// A descriptor with no health field yet is not a corpse. #[test] fn an_unresolved_actor_is_never_a_refusal() { assert_eq!(refusal(None, Some(7)), None); @@ -963,10 +736,6 @@ mod attack_actor_tests { ); } - /// The RUNTIME leg (the mount/lock-refusal pattern): all ten of the consecutive `ERR_ATTACK_*` - /// registry ids `0xa0`–`0xa9` resolve to non-empty strings in the shipped 1.12 - /// `GlobalStrings.lua`. This is the guard against a typo'd key turning a refusal into a silent - /// one β€” a veto that shows nothing is indistinguishable from a click that did nothing. #[test] fn every_attack_error_key_resolves_in_the_real_global_strings() { let data = benilla_formats::wow_data_or_skip!(); @@ -992,7 +761,6 @@ mod attack_actor_tests { ] { assert!(!g(key).unwrap_or_default().is_empty(), "{key} missing"); } - // One key's text quoted from the file, as a spot check that the ids line up with the keys. assert_eq!(g("ERR_ATTACK_DEAD").unwrap(), "Can't attack while dead."); } } @@ -1004,12 +772,11 @@ mod totem_reagent_tests { use benilla_protocol::ObjectFields; fn store() -> ObjectStore { - // Empty bags: no pack fields streamed β†’ every count reads 0, everything is "missing". + // Empty bags: every count reads 0. ObjectStore(ObjectFields::default()) } - /// The object index the count walks read β€” nothing streamed, which is the empty-bags pole - /// every case here is graded at (2334). + /// The object index the count walks read, with nothing streamed. fn objects() -> crate::ui_items::TestObjects { crate::ui_items::TestObjects::new() } @@ -1022,10 +789,8 @@ mod totem_reagent_tests { } } - /// The pre-send check's routing (`0x6e4000`, decision 0552) against empty bags: a totem - /// spell refuses 0x78, a reagent spell 0x5c, totems win when both lack (the ref's loop - /// order), a materials-free spell passes, and absent def/store skip the check (the ref's - /// `IsActivePlayer` gate) β€” the cast then goes out for the server to judge. + /// Against empty bags: a totem spell refuses `0x78`, a reagent spell `0x5c`, totems first; a + /// spell with neither passes, and no spell or no store skips the check. #[test] fn missing_materials_refuse_with_the_refs_reasons() { let mut objs = objects(); @@ -1091,8 +856,7 @@ mod totem_reagent_tests { assert!(errors.0.is_empty()); } - /// The failing-slot selection the fill re-derives: the first MISSING totem / first SHORT - /// reagent (against empty bags, the first nonzero of each). + /// Against empty bags, the first nonzero totem and reagent. #[test] fn first_failing_slot_is_named() { let mut objs = objects(); diff --git a/crates/benilla-app/src/ui_action/feed.rs b/crates/benilla-app/src/ui_action/feed.rs index 64564b79..830e98c2 100644 --- a/crates/benilla-app/src/ui_action/feed.rs +++ b/crates/benilla-app/src/ui_action/feed.rs @@ -1,28 +1,7 @@ -//! **Inward** β€” the action bar's *identity* feed: what each of the 120 slots shows. -//! -//! [`feed_actions`] resolves each occupied slot's icon (spell: Spell.dbc Γ— SpellIcon.dbc; item: the -//! item template chain, the same ask-once store the bags use) and count (item: the bag walk, -//! `ui_items::count_of`), diffs against what the VM already holds, pushes the changed slots and -//! fires `ACTIONBAR_SLOT_CHANGED` per transition. -//! -//! **What is gated and what is not** is the whole design here. The identity resolve runs on its two -//! inputs having changed β€” `PlayerActions::dirty` (a real, if occasional, event: login or a local -//! pickup/place) OR a landed item template ([`Items::template_epoch`], decision 0660: an ITEM -//! icon's template is fetched ask-once, so the *first* resolve of a cold entry is the one that -//! ISSUES the query and reads back nothing). Two things drift independently of BOTH and so refresh -//! every frame instead: an ITEM slot's bag **count** (eating a stack down never touches -//! `SMSG_ACTION_BUTTONS`) and a weapon-substituting **icon** ([`super::weapon_icon`], decisions -//! 0230/0231 β€” a swap changes it without touching the action table). Gating those on the same flag -//! is what leaves a stale Count fontstring or a stale Attack face. -//! -//! The feed also pumps the UIErrorsFrame queues (cast fails, mount refusals, the app's by-key -//! local refusals, the ENGINE's own β€” `benilla_ui` is engine-free and cannot reach -//! [`UiErrorKeys`], so a refusal raised inside the script crate queues its GlobalStrings key here -//! instead β€” and [`UiErrorTexts`], the lines that arrive already resolved) into -//! `UI_ERROR_MESSAGE`, or `UI_INFO_MESSAGE` for the yellow arm; and the stance page -//! (`GetBonusBarOffset`) β€” our descriptor's -//! shapeshift-form byte indexed into `SpellShapeshiftForm.dbc`'s BonusActionBar column -//! (`0x4e4fc0`), firing `UPDATE_BONUS_ACTIONBAR` on change. +//! The action bar's identity feed: each of the 120 slots' icon and count, pushed to the VM with +//! `ACTIONBAR_SLOT_CHANGED` per changed slot. [`feed_actions`] also drains the UI error queues +//! and feeds the stance page (`GetBonusBarOffset`: the form's `SpellShapeshiftForm.dbc` +//! BonusActionBar column, `0x4e4fc0`). use crate::ui_items::{count_of, InventoryScope}; use std::collections::{HashMap, HashSet}; @@ -43,11 +22,8 @@ use super::{ PlayerActions, Shown, Spells, UiError, UiErrorKeys, UiErrorTexts, }; -/// What an ITEM action shows when its icon cannot be resolved β€” the reference's own hardcoded -/// literal at `0x847fe4`, returned by the item-icon resolver's failure block `0x5d8927` (exactly -/// two engine sites binary-wide, zero Lua). Reached two ways: -/// the template hasn't answered yet (displayId 0 β€” the login window decision 0660 closes), or the -/// displayId has no `ItemDisplayInfo` row at all. +/// An item action's icon when its template has not answered or its display id has no +/// `ItemDisplayInfo` row: the reference's literal `0x847fe4`, from the resolver's `0x5d8927`. pub(super) const MISSING_ITEM_ICON: &str = "Interface\\Icons\\INV_Misc_QuestionMark"; /// The feed's memory of what it last pushed, for per-slot change events. @@ -55,40 +31,25 @@ pub(super) const MISSING_ITEM_ICON: &str = "Interface\\Icons\\INV_Misc_QuestionM pub(super) struct FeedMemory { pushed: HashMap, bonus_offset: u8, - /// **Has the identity resolve run against the VM this memory is about?** - /// - /// Wrapping the memory in `VmMemo` (1290) makes a login's fresh VM reset `pushed` β€” but the - /// diff that reads `pushed` lives *inside* the gate below, and every other input to that gate - /// is host-side and survives the VM. `dirty` is false, and both `template_epoch` and - /// `macro_generation` can legitimately match the reset memory's zeros. The bar would then be - /// fed nothing at all, for the whole session. - /// - /// So the gate takes the memory's own freshness as an input. It is not a fourth *reason* to - /// re-resolve; it is the statement that a resolve is only valid for the VM it ran against. + /// Whether the identity resolve has run against this VM: a new VM resets this memory, while + /// every other input to the gate is host-side and may still match. resolved: bool, - /// The [`Items::template_epoch`] the last identity resolve ran at β€” the feed's half of the - /// landed-template redisplay (decision 0660). An advance re-resolves, exactly like a bar edit. + /// The [`Items::template_epoch`] of the last resolve; an advance re-resolves. template_epoch: u64, - /// The macro-table generation the last identity resolve ran at (decision 0983) β€” the THIRD - /// input, exactly like `template_epoch` above: editing a macro changes its bar icon while + /// The macro-table generation of the last resolve: a macro edit changes a bar icon while /// touching neither the action table nor any item template. macro_generation: u64, } -/// The DBC name tables the cast-fail **argument arms** read (`FailArgs`), as one parameter β€” three -/// `Option>` that are one concept and were pushing this system past Bevy's parameter arity -/// (decision 1948). Each is `Option` for the same reason `FailArgs`' fields are: a client with no -/// game data has none, and the arm then declines and the template strips. -/// -/// The fourth table, `SpellShapeshiftForm.dbc` (`0x56`), is not here: it already rides -/// [`Spells`], which this system takes anyway, so [`Self::args`] takes that and joins them. +/// The DBC name tables the cast-fail argument arms read, as one parameter to stay within Bevy's +/// parameter limit; each is absent without game data. The shapeshift forms ride [`Spells`]. #[derive(bevy::ecs::system::SystemParam)] pub(super) struct FailNameTables<'w> { - /// `SpellFocusObject.dbc` β€” the crafting book's catalog, for `0x5e`. + /// `SpellFocusObject.dbc`, for `0x5e`. focus: Option>, - /// `AreaTable.dbc` β€” the map arc's, for `0x5d`. + /// `AreaTable.dbc`, for `0x5d`. areas: Option>, - /// `SpellMechanic.dbc` β€” for `0x8d`, the one arm whose word is produced locally. + /// `SpellMechanic.dbc`, for `0x8d`. mechanics: Option>, } @@ -110,14 +71,10 @@ pub(super) fn feed_actions( mut cast_errors: ResMut, mut mount_errors: ResMut, mut pet_tame_failures: ResMut, - // The two client-local refusal queues as one param (the 16-SystemParam ceiling this - // signature already sits at): the by-KEY route (`DisplayError`) and the already-resolved - // TEXT route, both drained onto the same red line below. + // The by-key and resolved-text queues as one parameter, at Bevy's 16-parameter limit. ui_errors: (ResMut, ResMut), spells: Option>, self_q: Query<&ObjectStore, With>, - // The inventory read (2334): every count, reagent and totem question below walks the bags - // through it, and the weapon-icon arms resolve the equipped instance here. objects: crate::net::Objects, items: Res, icons: Option>, @@ -125,8 +82,6 @@ pub(super) fn feed_actions( name_tables: FailNameTables, commands: Res, mut memory: Local>, - // Where a displayed message lands: the chat window (the combat log's own record of a failed - // cast, 1703, rides the same resource) and the catalog's sound queue (1815). mut sink: MessageSink, ) { let Some(mut script) = script else { @@ -135,24 +90,14 @@ pub(super) fn feed_actions( let (mut ui_error_keys, mut ui_error_texts) = ui_errors; let memory = memory.get(&script); - // Rejected casts surface as the client's red error line (UI_ERROR_MESSAGE β†’ the errors - // frame), resolved through the byte-verified two-layer display ([`cast_fail`]) against the - // VM's own GlobalStrings β€” resolve first (immutable script), then fire (mutable). - // 0x78 TOTEMS / 0x5c REAGENTS are the argument-formatted reasons whose `%s` fill benilla - // models (decisions 0545 + 0552, the ref's shared fill arm `0x6e1e7f`): "Requires %s" / - // "Missing reagent: %s" + the FAILING slot's item name β€” re-derived here exactly as the - // check derived it (first missing totem / first short reagent against our bags). On an - // item-cache miss the ref queries and shows nothing that frame, then its DBCACHECALLBACK - // `0x6e29b0` REDISPLAYS when the answer lands β€” modeled by keeping the entry queued: the - // ask-once query is away, and the frame the template answers, the fill succeeds and fires. - // The DBC-only arms (0x5d REQUIRES_AREA, 0x5e REQUIRES_SPELL_FOCUS) need no round trip and - // are filled inside [`cast_fail`] itself, off the wire's argument word. + // Cast failures become error lines through [`cast_fail`], all resolved before any event + // fires. The drain owns the arms that need our bags or the item cache: `0x78` and `0x5c` + // (`0x6e1e7f`) name the failing slot's item, found as the pre-send check finds it. On a cache + // miss the entry stays queued, as the reference's callback `0x6e29b0` redisplays on the answer. let self_store = self_q.iter().next(); let mut await_template: Vec = Vec::new(); - // The same failures, worded for the combat log. Collected beside the red line rather than - // instead of it: `0x6e1a00` calls `0x62c360` AND `DisplayError`, which frame the one string - // two ways β€” "Not enough mana." on the screen, "You fail to cast Frostbolt: Not enough mana." - // in the log. + // The same failures for the combat log: `0x6e1a00` calls both `DisplayError` and the log + // formatter `0x62c360`. let mut fail_lines: Vec = Vec::new(); let fail_args = name_tables.args(spells.as_deref()); let texts: Vec = cast_errors @@ -168,16 +113,12 @@ pub(super) fn feed_actions( let pet = caster == crate::ui_action::Caster::Pet; let d = spells.as_ref().and_then(|s| s.catalog.get(spell_id)); let get = |key: &str| script.lua().globals().get::(key).ok(); - // The displayed line first, the log line off it β€” the reference's own order, and the - // reason the two can no longer drift apart (see the combat-log twin below). + // The displayed line first and the log line from it, the reference's order. let line = (|| -> Option { - // 0x19/0x1a/0x1b EQUIPPED_ITEM_CLASS* β€” the other argument-formatted family whose - // `%s` benilla models (`0x6e1db7`, the arm that resolves an item class/subclass name - // through `0x6e2380`): "Must have a **Wand** equipped", the SINGULAR DisplayName, - // where the spell tooltip's own requirement line takes the verbose plural. Purely a - // DBC read, so no query/redisplay round trip. A multi-bit mask resolves too β€” through - // ItemSubClassMask.dbc's group name, else the FIRST matching subclass (the - // tooltip's twin at `0x52eea7` joins instead). + // `0x19`-`0x1b` (`0x6e1db7`, through `0x6e2380`): the singular subclass name, + // "Must have a Wand equipped"; a multi-bit mask names its `ItemSubClassMask.dbc` + // group, else the first subclass, where the tooltip (`0x52eea7`) uses the plural + // and joins. The pet's table fills it too (`0x6e904d`). if let (0x19..=0x1b, Some(d), Some(subs)) = (reason, d, sub_classes.as_deref()) { if let Some(name) = (d.equipped_item_class >= 0) .then(|| { @@ -194,26 +135,10 @@ pub(super) fn feed_actions( .map(|t| cast_fail::CastFailLine::passthrough(t.replace("%s", &name))); } } - // `0x31` NEED_EXOTIC_AMMO (`0x6e1e54`) β€” the same `0x6e2380` helper as the arm - // above, one class over: `requirement_display_name(6 /* Projectile */, 1 << arg)` - // reads the singular DisplayName at `row + 0x28 + locale*4`, so "Requires exotic - // ammo: %s" names the ammo type. The shift is masked to five bits because the - // reference's is β€” `0x6e1e5c: shl edx,cl` takes x86's own `cl & 31` β€” and a - // subclass naming no row declines to the strip fallback exactly as - // `0x6e1e6a: je 0x6e21d8` does. - // - // **The player's arm only.** The pet handler's index table (`0x6e93d0`) is gated - // `cmp reason,0x8d; ja default` and carries no `0x31` entry, so a pet's exotic-ammo - // refusal takes its generic arm `0x6e936a` and shows the bare template β€” unlike - // `0x19`–`0x1b` just above, which the pet's own table *does* fill (`0x6e904d`). - // Same shape as `0x78`/`0x5c` below, and the same trap decision 2033 named. - // - // **It declines on a vmangos server, every time**, and that is the server's shape - // rather than a gap here: `Spell::SendCastResult` fills `failureArg1` for - // NOT_READY, REQUIRES_SPELL_FOCUS, REQUIRES_AREA and the EQUIPPED_ITEM_CLASS - // family and for nothing else, so `0x31` arrives with no word and the line reads - // "Requires exotic ammo:". Modeled anyway because the arm is the mechanism and the - // word is the server's to supply (decision 2292). + // `0x31` (`0x6e1e54`): the same helper for class 6 (Projectile), mask `1 << arg`, + // the shift masked to five bits as x86 `shl` masks it (`0x6e1e5c`). The player's + // arm only: the pet's table (`0x6e93d0`) has no `0x31` entry. vmangos never sends + // the word (`Spell.cpp:4419-4443`), so against it this declines to the stem. if let (false, 0x31, Some(arg), Some(subs)) = (pet, reason, fail.arg, sub_classes.as_deref()) { @@ -228,16 +153,14 @@ pub(super) fn feed_actions( .map(|t| cast_fail::CastFailLine::passthrough(t.replace("%s", &name))); } } - // `0x78` TOTEMS / `0x5c` REAGENTS β€” **the player's arms only**. The pet handler's - // index table (`0x6e93d0`) sends both to its generic arm, so a pet's refusal takes the - // shared passthrough below and never runs the bag walk or the item query (decision - // 2033). + // `0x78` and `0x5c`, the player's arms only: the pet's table sends both to its + // generic arm. if !pet && (reason == 0x78 || reason == 0x5c) { let d = d?; let failing = if reason == 0x78 { self_store .and_then(|s| first_missing_totem(d, s, &objects)) - // No store to test against (a race): name the first tool at all. + // No self store yet: name the first tool. .or_else(|| d.totems.iter().copied().find(|&t| t != 0)) } else { self_store @@ -249,12 +172,10 @@ pub(super) fn feed_actions( .map(|i| i.name.clone()); let name = match cached { Some(name) => name, - // Answered-unknown β†’ the ref's callback fallback literal (`0x838044`); - // still pending β†’ keep the entry queued for the redisplay. + // Answered unknown: the reference's callback literal (`0x838044`). None if items.template_answered_unknown(failing) => "UNKNOWN".to_string(), None => { - // The cache-miss re-queue β€” and it comes back MARKED, because the - // reference's retry is a different raise (see `CastFail::redisplay`). + // Pending: requeued as a redisplay, which is not logged. await_template.push(fail.requeued()); return None; } @@ -279,57 +200,23 @@ pub(super) fn feed_actions( &get, ) })(); - // The retest instrument for this whole bug class (decision 1313). A red-line defect is - // reported as *seen* β€” B255 arrived as a screenshot of the word "Requires" β€” and until - // this line the only way to read what the client resolved was to look at the screen. - // Logging the reason, its wire argument and the resolved line makes an argument arm - // that silently declined (a missing word, an unnamed id) legible from a probe run. - // The line's `Debug` carries BOTH buffers, so a probe also reads the toast string - // beside the log string β€” the one reading that makes 2285's class of drift visible - // without opening the chat window. + // The reason, its wire word and both resolved buffers, so a declined arm is readable + // from a probe run. debug!( "ui_action: cast fail β€” {caster:?} spell {spell_id} reason {reason:#04x} \ arg {:?} β†’ {:?}", fail.arg, line ); - // **The combat-log twin β€” the reference's OTHER buffer, not the displayed text** - // (decision 2285, correcting 2280). It runs after the resolution because the - // reference's does: `0x6e1a00` calls `DisplayError 0x496720` at `0x6e21dd` and the - // log formatter `0x62c360` at `0x6e21fc`. But what it hands the formatter is `edi` - // (`0x6e21e2`), the **argText** buffer β€” which is the first-layer - // `SPELL_FAILED_` string for every reason no argument arm claims, *including* - // the eighteen the second layer re-words on screen. A cooldown refusal toasts "Spell - // is not ready yet." and logs "Not yet recovered"; `0x09` toasts "You have no target." - // and logs "No target". [`CastFailLine::logged`] is that one-byte test. - // - // The trap this corrects is that deriving the log's `%s` a *second* time, from the - // key table, gets six of the seven divergent rows right by accident and the filled - // arms wrong β€” which is how a raw "Must be in %s" sat in the log while the red line - // read "Must be in Cat Form". One resolution, two buffers, is the shape that holds. - // - // A refusal that resolved to nothing logs nothing, which is the same control flow: - // `0x17` DONT_REPORT, the keys 5875 leaves out of `GlobalStrings.lua`, and `0x56`'s - // no-forms exit all jump past `0x62c360` as well as past `DisplayError`. - // - // **A redisplay is not a raise.** The item-cache retry (`0x6e29b0`) calls - // `DisplayError` directly and never reaches the log formatter, so an entry that had - // to wait for its item name shows the toast alone β€” `CastFail::redisplay`. - // - // **Only the `…SELF` half is reachable here**: `SMSG_CAST_FAILED` is addressed to the - // caster alone, so benilla never learns that somebody *else's* cast failed β€” the - // `…OTHER` keys exist and stay unproduced, exactly as the reference leaves its own - // two unreachable `…SELFSTART` keys. - // - // **The pet's refusal is not logged.** `0x6e1a00` calls the log formatter `0x62c360` - // beside its `DisplayError`; `Spell_C::HandlePetCastFailed 0x6e8eb0` calls neither it - // nor the error sound β€” its entire call set is the two packet readers, `0x496720` and - // the string plumbing. Before decision 2033 a pet's refused Growl printed "You fail to - // cast Growl: ..." in the combat log, attributed to the player. + // The combat-log line, after the display as in the reference (`DisplayError` at + // `0x6e21dd`, the log formatter `0x62c360` at `0x6e21fc`), from the argText buffer + // ([`CastFailLine::logged`]). Nothing is logged for a line that shows nothing, for a + // redisplay (`0x6e29b0` skips the log) or for the pet (`0x6e8eb0` never calls + // `0x62c360`). `SMSG_CAST_RESULT` goes to the caster alone, so the `OTHER` keys stay + // unused. if let (Some(shown), Some(display), false, false) = (line.as_ref(), d, pet, fail.redisplay) { - // `0x62aff0`: `Attributes` bit 4 marks an ABILITY, which "performs" rather than - // "casts". + // `0x62aff0`: `Attributes` bit 4 marks an ability, which "performs", not "casts". const ATTR_IS_ABILITY: u32 = 0x10; let family = if display.attributes & ATTR_IS_ABILITY != 0 { crate::ui_chat::combat::SPELLFAILPERFORM @@ -367,8 +254,7 @@ pub(super) fn feed_actions( texts.into_iter().map(|l| Shown::keyed(l.key, l.text)), ); - // (Dis)mount refusals ride the same route, keyed straight into GlobalStrings - // ([`mount_result_key`] β€” no format arguments in any of these strings). + // (Dis)mount refusals by key ([`mount_result_key`]); none of these strings takes arguments. let mount_texts: Vec<(&'static str, String)> = mount_errors .0 .drain(..) @@ -386,11 +272,8 @@ pub(super) fn feed_actions( .map(|(key, text)| Shown::keyed(key, text)), ); - // Taming refusals ([`PetTameFailures`]) β€” the one message whose argText is itself a - // GlobalStrings lookup. The reference resolves the reason's `PETTAME_*` key first - // (`0x6e6a20`'s `0x703bf0` call) and passes the resulting STRING as `DisplayError(0xee)`'s - // argument, so `ERR_TAME_FAILED` ("%s.") renders "Creature is too high level for you to - // tame." β€” two lookups, in this order, and neither can be folded into the other. + // Taming refusals ([`PetTameFailures`]): the reason's `PETTAME_*` string fills + // `ERR_TAME_FAILED` ("%s."), two lookups in that order. let tame_texts: Vec = pet_tame_failures .0 .drain(..) @@ -402,10 +285,7 @@ pub(super) fn feed_actions( .collect(); show_messages(&mut script, &mut sink, "ui_action", tame_texts); - // Client-local by-key refusals (the `DisplayError` route β€” [`UiErrorKeys`]); the key IS the - // GlobalStrings lookup, no code table between, and the key is also what names the surface: - // [`UiError::kind`] reads the message record straight out of the catalog instead of the queue - // carrying a hand-set flag alongside every push (decision 1770). + // Client-local refusals by key ([`UiErrorKeys`]); the key also names the catalog record. let key_lines: Vec = ui_error_keys .0 .drain(..) @@ -416,8 +296,7 @@ pub(super) fn feed_actions( .collect(); show_messages(&mut script, &mut sink, "ui_action", key_lines); - // Already-resolved lines ([`UiErrorTexts`]) β€” the wire's own text, no key to look up and no - // record behind it; the queued kind IS the reference's `0x4945b0` flag. + // Already-resolved lines ([`UiErrorTexts`]); the queued kind is the `0x4945b0` flag. let resolved: Vec = ui_error_texts .0 .drain(..) @@ -425,12 +304,8 @@ pub(super) fn feed_actions( .collect(); show_messages(&mut script, &mut sink, "ui_action", resolved); - // The ENGINE's own by-key refusals ride the very same line. `benilla_ui` is engine-free and - // cannot reach [`UiErrorKeys`], so a refusal raised inside the script crate (today: dropping a - // passive spell on the bar, `ERR_PASSIVE_ABILITY`) queues its key and we resolve it here β€” - // standing in for the reference's inline `push ; call CGGameUI::DisplayError`. One - // frame late by construction (the refusal happens during the input pass this feed precedes), - // which is invisible on a toast. + // The engine's own by-key refusals (`ERR_PASSIVE_ABILITY`): `benilla_ui` cannot reach + // [`UiErrorKeys`], so it queues keys that show here, one frame late. let engine_keys = script.take_ui_errors(); let engine_lines: Vec = engine_keys .into_iter() @@ -444,8 +319,7 @@ pub(super) fn feed_actions( let store = self_q.iter().next(); - // Stance page: our own descriptor's form byte, pushed on change (UPDATE_BONUS_ACTIONBAR is - // the client's event for exactly this transition β€” the bar re-picks its page on it). + // Stance page: our form's bonus bar offset, with `UPDATE_BONUS_ACTIONBAR` on change. let form = store.map(|s| s.0.unit_shapeshift_form()).unwrap_or(0); let offset = spells .as_ref() @@ -459,15 +333,9 @@ pub(super) fn feed_actions( script.fire_event("UPDATE_BONUS_ACTIONBAR", vec![]); } - // The identity resolve has TWO inputs, not one. `dirty` covers the action table; the item - // TEMPLATE cache is the other, and it fills asynchronously β€” an ITEM slot's icon needs a - // template that `Items::template` fetches **ask-once**, so the very first resolve of a cold - // entry is the call that ISSUES the query and it necessarily reads back `None`. Gating on - // `dirty` alone left that slot on the fallback question mark until some unrelated bar edit - // happened to re-dirty it β€” the login race that put a question mark on every fresh - // character's food/water button (decision 0660; verified live 2026-07-26: the Tough Jerky - // ask and the one and only feed landed 0.5 ms apart, in that order, and nothing re-fed). - // The epoch is the second input, so a landed answer redisplays like the ref's DBCACHECALLBACK. + // The identity resolve reruns on a new VM, a bar edit, a macro edit or a landed item + // template: a template is fetched ask-once, so a cold slot's first resolve only issues the + // query, and the epoch advance redisplays it, like the reference's cache callback. let template_epoch = items.template_epoch(); let macro_generation = script.macros_generation(); let macros_moved = macro_generation != memory.macro_generation; @@ -477,14 +345,10 @@ pub(super) fn feed_actions( memory.resolved = true; memory.template_epoch = template_epoch; memory.macro_generation = macro_generation; - // Cloned once per re-resolve, never per frame: the gate above is a `u64` compare. let macros = script.macros(); - // Resolve every occupied wire slot to its display, diff against what the VM holds, push + - // fire ACTIONBAR_SLOT_CHANGED (arg1 = the Lua action id) per transition. Item icons/counts - // resolve via the same ask-once template chain + bag walk the bags use - // (`ui_items::count_of`) β€” an in-flight template shows the fallback (no texture), and the - // epoch gate above re-runs this whole resolve the frame the answer lands. + // Each occupied slot's display, diffed against what the VM holds; a changed slot is + // pushed and fires `ACTIONBAR_SLOT_CHANGED` with its Lua action id. let mut fresh: HashMap = HashMap::new(); for (slot, button) in &actions.buttons { let (texture, count, consumable) = match button.kind { @@ -505,14 +369,8 @@ pub(super) fn feed_actions( (icon, 0, false) } ACTION_KIND_ITEM => { - // The question mark belongs HERE, not in the Lua (decision 0666, correcting - // 0660's modeling note): the reference's resolver never returns nil for a - // populated ITEM slot β€” an un-cached template (displayId 0) or a displayId - // with no row both fall into `0x5d88b0`'s failure block `0x5d8927`, which - // returns the hardcoded `INV_Misc_QuestionMark` at `0x847fe4`. Since ref - // FrameXML *hides* the icon on a nil texture, feeding nil here and letting a - // Lua `or` paint the fallback would show a BLANK button on faithful - // FrameXML β€” the placeholder is the engine's, at two sites binary-wide. + // Never nil: the reference's resolver `0x5d88b0` returns the question mark, + // and the stock `ActionButton.lua:158` hides the icon on a nil texture. let template = items.template(button.action, 0, &commands).cloned(); let texture = template .as_ref() @@ -521,22 +379,13 @@ pub(super) fn feed_actions( let count = store .map(|s| count_of(&s.0, &objects, button.action, InventoryScope::CARRIED)) .unwrap_or(0); - // The Count fontstring's gate β€” `IsConsumableAction 0x4e5250`: ammo/thrown by - // InventoryType, or an ON_USE block with NEGATIVE charges - // ([`ItemInfo::is_consumable`], byte-cited there; decision 0926 Β§3). It comes - // from the SAME ask-once template the icon does, so it belongs on the same - // push: fed from the per-frame state map instead, it answered the Lua one - // frame late for ever and left a fresh character's food with no stack number - // (decision 1301 β€” the count's half of 0660's login race). + // The Count gate (`0x4e5250`, [`ItemInfo::is_consumable`]) reads the icon's + // template, so it rides the same push. let consumable = template.as_ref().is_some_and(|t| t.is_consumable()); (Some(texture), count, consumable) } - // A MACRO slot serves **the macro's own icon, never its bound spell's** β€” the one - // asymmetry in the icon resolver: `0x4e6a50`'s macro arm (`0x4e6bf9`) validates the - // slot and calls `0x4f0fd0(idx, buf, 0x104)`, the macro record's own icon-path - // builder, without ever touching `[rec+0x564]`. Its dynamic state DOES go - // through the bound spell β€” that split is the whole design (`state`'s macro - // arm, decision 0983). + // A macro slot shows the macro's own icon (`0x4e6bf9` calls `0x4f0fd0`), never its + // bound spell's, though its state follows the bound spell. ACTION_KIND_MACRO => ( macros .get(button.action as usize) @@ -557,10 +406,8 @@ pub(super) fn feed_actions( }, ); } - // A MACRO slot's observable is wider than its `ActionSlot`: the name line under the icon - // reads the macro table through `GetActionText` at repaint (decision 1636), so a rename β€” - // which moves the table and nothing in the slot value β€” must re-fire the slot exactly as - // a re-icon does, or the bar keeps the old name until an unrelated edit repaints it. + // A macro rename changes the name line (`GetActionText`) but not the slot value, so a + // macro edit re-fires every macro slot. let changed: Vec = fresh .keys() .chain(memory.pushed.keys()) @@ -589,13 +436,8 @@ pub(super) fn feed_actions( } } - // Two things drift independently of `dirty` (decision 0216 Β§7's module-doc note) and so must - // refresh every frame, not just on an action-table edit: an ITEM slot's COUNT (eating down a - // stack never touches SMSG_ACTION_BUTTONS) and the auto-attack's ICON (it tracks the equipped - // main-hand weapon, which a weapon swap changes without touching the action table β€” decision - // 0230). Gating either on `dirty` leaves it stale until the next unrelated action-bar edit. - // Bounded to the already-pushed slots (normally a handful) β€” the same per-frame bag walk / - // template lookup `count_of` already pays for the quest log's item objectives. + // An item slot's count and a live icon change without an action-table edit (eating a stack + // sends no `SMSG_ACTION_BUTTONS`), so the pushed slots refresh every frame. if let Some(store) = store { for (&action, slot) in memory.pushed.iter_mut() { let changed = match slot.kind { @@ -607,12 +449,8 @@ pub(super) fn feed_actions( } changed } - // Two icon families track live character state, not the action table, so they - // refresh every frame: a weapon-substituting icon follows the equipped weapon - // AND the current form (Attack's `0x4e6870` β€” decisions 0230/0231 + the form - // face), and a toggle spell with a nonzero ActiveIconID swaps faces with its own - // aura (`0x4e6a50`'s `0x4e6bbd` predicate β€” a shift in/out never touches - // SMSG_ACTION_BUTTONS). A plain spell's icon is stable, so it's skipped. + // Live icons: a weapon face follows the equipped weapon and form (Attack's + // `0x4e6870`), and a toggle's `ActiveIconID` follows its own aura (`0x4e6bbd`). ACTION_KIND_SPELL => { let d = spells .as_ref() @@ -656,21 +494,11 @@ pub(super) fn feed_actions( } } -/// The whole SPELL-slot icon rule β€” the reference's `GetActionTexture` resolver `0x4e6a50`, arms -/// in its execution order: -/// -/// 1. The **pre-emptive arms** ([`auto_attack_icon`]): Attack serves the current form's face / -/// the main-hand weapon (`0x4e6870`), an auto-repeat shot the ranged weapon (`0x4e6990`) β€” -/// before the spell's own icon fields are ever read. -/// 2. The **active-toggle swap** (`0x4e6bbd β†’ 0x4e6bc6`): `ActiveIconID` while the button's OWN -/// spell id sits live-and-cancelable in the player's aura slots β€” the literal `0x4e55f0` -/// predicate `UseAction`'s cancel fork rides ([`super::toggle::active_action_toggle`]; the -/// same function in the binary, by call-target address). Ghost Wolf's swirl while shifted. -/// 3. The spell's own `SpellIconID` face. -/// -/// The spellbook's `GetSpellTexture` (`0x4b3f50`) deliberately runs ONLY arms 1 and 3 β€” it never -/// serves `ActiveIconID` (proof by exhaustion of its return paths). `ui_spellbook` keeps that -/// asymmetry; do not "fix" it to match the bar. +/// A spell slot's icon, by the reference's `GetActionTexture` resolver `0x4e6a50` in order: the +/// auto-attack faces ([`auto_attack_icon`], `0x4e6870`, `0x4e6990`), then `ActiveIconID` while +/// the spell's own aura is live and cancelable (`0x4e6bbd`, the `0x4e55f0` predicate of +/// [`super::toggle::active_action_toggle`]), then the spell's icon. The spellbook's +/// `GetSpellTexture` (`0x4b3f50`) never serves `ActiveIconID`. fn spell_action_icon( spell_id: u32, d: &benilla_formats::SpellDisplay, diff --git a/crates/benilla-app/src/ui_action/feed_tests.rs b/crates/benilla-app/src/ui_action/feed_tests.rs index 282a0788..d1201f5f 100644 --- a/crates/benilla-app/src/ui_action/feed_tests.rs +++ b/crates/benilla-app/src/ui_action/feed_tests.rs @@ -1,13 +1,5 @@ -//! [`super::feed::feed_actions`] as a real Bevy system through a real Lua VM β€” the seam where the -//! action bar's slot identity is resolved and pushed. -//! -//! What these pin is the **landed-template redisplay** (decision 0660): an ITEM slot's icon needs -//! an item template that arrives asynchronously, so the resolve that first touches a cold entry is -//! the one that ISSUES the ask-once query and necessarily reads back nothing. The regression these -//! guard is a question mark that never goes away β€” the fresh character's food button. -//! -//! `run_system_once` is deliberately NOT used: the feed's memory is a `Local`, which that helper -//! rebuilds per call, so a two-frame test has to run a registered system across two `app.update()`s. +//! [`super::feed::feed_actions`] as a registered system through a real Lua VM. Not +//! `run_system_once`: the feed's memory is a `Local`, which that helper rebuilds per call. use std::collections::HashMap; @@ -22,17 +14,16 @@ use crate::entities::ItemDisplays; use crate::items::{test_template, Items}; use crate::net::{ClientCommand, NetCommands}; -/// The fresh human warrior's default bar (vmangos `playercreateinfo_action`): Tough Jerky, item -/// **117**, on wire slot **83** (the Battle Stance bonus page) β€” Lua action id 84. +/// A new human warrior's food button (vmangos `playercreateinfo_action`): Tough Jerky on wire +/// slot 83, the Battle Stance page. const JERKY: u32 = 117; const JERKY_SLOT: u8 = 83; const JERKY_ACTION: u32 = JERKY_SLOT as u32 + 1; -/// Tough Jerky's real display id and the icon that display carries (`ItemDisplayInfo.dbc`). +/// Tough Jerky's display id and its `ItemDisplayInfo.dbc` icon. const JERKY_DISPLAY: u32 = 2473; const JERKY_ICON: &str = "Interface\\Icons\\INV_Misc_Food_16"; -/// An app with the feed registered and the fresh character's food button on the bar β€” but a cold -/// template cache, exactly as at login. +/// The feed with the food button on the bar and a cold template cache, as at login. fn app_with_food_on_the_bar() -> (App, crossbeam_channel::Receiver) { let (tx, rx) = crossbeam_channel::unbounded(); let mut app = App::new(); @@ -45,7 +36,7 @@ fn app_with_food_on_the_bar() -> (App, crossbeam_channel::Receiver (App, crossbeam_channel::Receiver() .init_resource::() .init_resource::() - // The cast-failure combat-log line (1703) rides the same drain. + // The cast-failure combat-log line rides the same drain. .init_resource::() .init_resource::() .insert_resource(ItemDisplays::icons_for_tests( @@ -76,8 +67,8 @@ fn app_with_food_on_the_bar() -> (App, crossbeam_channel::Receiver Option { app.world_mut() .non_send_resource::() @@ -85,7 +76,8 @@ fn fed_texture(app: &mut App) -> Option { .unwrap() } -/// What the VM answers `ActionBar.xml`'s Count gate β€” the ref's 1/nil, so `None` is "no". +/// The food button's `IsConsumableAction`, the count gate in `ActionButton_UpdateCount` +/// (`ActionButton.lua:287`): 1 or nil. fn fed_consumable(app: &mut App) -> Option { app.world_mut() .non_send_resource::() @@ -93,10 +85,7 @@ fn fed_consumable(app: &mut App) -> Option { .unwrap() } -/// The whole bug in one test: the first resolve issues the query and can only show the -/// placeholder; the frame the answer lands, the slot re-resolves and the real icon arrives. -/// Before decision 0660 the second half never happened β€” the button kept the question mark for the -/// whole session, until some unrelated bar edit re-dirtied the feed. +/// The first resolve asks for the template and shows the placeholder; the answer re-resolves. #[test] fn a_landed_item_template_redisplays_the_action_slot() { let (mut app, rx) = app_with_food_on_the_bar(); @@ -114,7 +103,6 @@ fn a_landed_item_template_redisplays_the_action_slot() { "…and it must have asked the server for the template" ); - // Nothing else changes: no bar edit, no new spell, no local pickup β€” only the answer arriving. let mut info = test_template("Tough Jerky"); info.display_info_id = JERKY_DISPLAY; app.world_mut() @@ -129,15 +117,8 @@ fn a_landed_item_template_redisplays_the_action_slot() { ); } -/// The Count fontstring's **gate** rides the same landed template as the icon (decision 1301). -/// -/// `IsConsumableAction 0x4e5250` reads nothing but the slot's item template, so it moves exactly -/// when the icon does. Fed from the per-frame *state* map it could not: that feed runs `.after` -/// this one and fires no event of its own for the flag, so the `ACTIONBAR_SLOT_CHANGED` that -/// repaints the button always carried the previous frame's answer β€” and at login the previous -/// frame had no template at all. The button kept its icon and lost its stack number for the whole -/// session (the director's report, 2026-08-14). This is 0660's race again, one field over, which -/// is why the guard belongs beside it: the two are one push or they are two bugs. +/// `IsConsumableAction 0x4e5250` reads only the item template, so it rides the icon's push: the +/// `ACTIONBAR_SLOT_CHANGED` repaint reads both. #[test] fn a_landed_item_template_also_lands_the_consumable_gate() { let (mut app, _rx) = app_with_food_on_the_bar(); @@ -149,8 +130,7 @@ fn a_landed_item_template_also_lands_the_consumable_gate() { "a cold template cannot answer the gate β€” the ask is still in flight" ); - // Tough Jerky's real row (vmangos `item_template` 117, read 2026-08-14): one ON_USE block, - // spell 433 Food, SpellCharges **-1** β€” the destroy-on-use sign `is_consumable` tests. + // Tough Jerky's row (vmangos `item_template` 117): one ON_USE block, spell 433, charges -1. let mut info = test_template("Tough Jerky"); info.display_info_id = JERKY_DISPLAY; info.spells = vec![ItemSpellEntry { @@ -179,10 +159,7 @@ fn a_landed_item_template_also_lands_the_consumable_gate() { ); } -/// `IsConsumableAction 0x4e5250` β€” the gate's own law -/// ([`benilla_protocol::ItemInfo::is_consumable`], fed into [`benilla_ui::script::ActionSlot`]). -/// The director's B201 is the mount row: an on-use item with no charges wore a stack number under -/// it because we tested `Class == 0` instead of the reference's two clauses (decision 0926 Β§3). +/// `IsConsumableAction 0x4e5250`'s two clauses; the item class is neither. #[test] fn is_consumable_is_ammo_thrown_or_a_negative_charge_use_block() { let block = |trigger: u32, charges: i32| ItemSpellEntry { @@ -195,24 +172,22 @@ fn is_consumable_is_ammo_thrown_or_a_negative_charge_use_block() { category_cooldown_ms: -1, }; - // The report: a mount. Class 15 Miscellaneous, InventoryType 0, one ON_USE block whose - // SpellCharges is 0 β€” the item is not destroyed by using it. + // A mount: class 15, InventoryType 0, one ON_USE block with charges 0 (not used up). let mut mount = test_template("Red Skeletal Horse"); mount.class = 15; mount.spells = vec![block(0, 0)]; assert!(!mount.is_consumable(), "a mount has no stack to show"); - // A potion: Class 0, but that is not what decides it β€” the ON_USE block's -1 charges is. + // A potion: its ON_USE block's -1 charges decide, not its class 0. let mut potion = test_template("Minor Healing Potion"); potion.spells = vec![block(0, -1)]; assert!(potion.is_consumable()); - // …and Class 0 alone (a conjured-water-shaped template with no on-use block at all) is - // NOT enough, which is exactly what the old `class == 0` read got wrong in reverse. + // Class 0 with no ON_USE block is not consumable. let classless = test_template("Trade Good"); assert!(!classless.is_consumable()); - // The InventoryType clause, both members β€” ammo and thrown always count, charges or not. + // Ammo (24) and thrown (25) always count, charges or not. for inv in [24u32, 25] { let mut ammo = test_template("Rough Arrow"); ammo.inventory_type = inv; @@ -228,9 +203,6 @@ fn is_consumable_is_ammo_thrown_or_a_negative_charge_use_block() { assert!(!proc_item.is_consumable()); } -/// The epoch is a *change* gate, not a per-frame re-resolve: once the answer has landed and been -/// fed, an idle frame re-resolves nothing. (Guards the obvious over-correction β€” turning the feed -/// into an every-frame rebuild of all 120 slots.) #[test] fn a_quiet_frame_after_the_answer_re_resolves_nothing() { let (mut app, _rx) = app_with_food_on_the_bar(); @@ -256,9 +228,7 @@ fn a_quiet_frame_after_the_answer_re_resolves_nothing() { ); } -/// A NEGATIVE answer (the server does not know the entry) also advances the epoch β€” it is a real -/// transition for anything that waits on the ask β€” but it resolves to no icon, so the slot keeps -/// the fallback. Pins that the gate cannot spin: one re-resolve, then quiet. +/// A negative answer advances the epoch once and keeps the placeholder. #[test] fn an_unknown_entry_answers_once_and_settles() { let (mut app, _rx) = app_with_food_on_the_bar(); @@ -281,15 +251,8 @@ fn an_unknown_entry_answers_once_and_settles() { ); } -/// A MACRO slot serves **the macro's own icon**, and follows an EDIT of that macro without any -/// bar-table change at all (decision 0983). -/// -/// Two things are pinned. The icon rule: `GetActionTexture`'s macro arm (`0x4e6bf9`) builds the -/// macro record's own icon path and never touches the bound spell. And the *trigger* is the -/// macro-table generation β€” the third -/// input beside `dirty` and the item-template epoch β€” because renaming or re-iconing a macro moves -/// neither of those, and gating on them alone leaves a stale icon on the bar until some unrelated -/// edit happens to re-dirty the feed (exactly decision 0660's bug, one seam over). +/// `GetActionTexture`'s macro arm (`0x4e6bf9`) shows the macro's own icon, never the bound +/// spell's; the macro-table generation, beside `dirty` and the template epoch, re-resolves it. #[test] fn a_macro_slot_shows_the_macros_own_icon_and_follows_an_edit() { use benilla_protocol::messages::ACTION_KIND_MACRO; @@ -317,7 +280,7 @@ fn a_macro_slot_shows_the_macros_own_icon_and_follows_an_edit() { .init_resource::() .init_resource::() .init_resource::() - // The cast-failure combat-log line (1703) rides the same drain. + // The cast-failure combat-log line rides the same drain. .init_resource::() .init_resource::() .insert_resource(NetCommands(tx)); @@ -326,7 +289,7 @@ fn a_macro_slot_shows_the_macros_own_icon_and_follows_an_edit() { account: vec![MacroView { name: "Ambush".into(), texture: Some("Interface\\Icons\\Ability_Ambush".into()), - // The bound spell is deliberately NOT what the icon shows. + // The bound spell is not what the icon shows. body: "/cast Ambush".into(), local_only: false, }], @@ -348,7 +311,6 @@ fn a_macro_slot_shows_the_macros_own_icon_and_follows_an_edit() { "the MACRO's own icon" ); - // Re-icon the macro. Nothing touches `PlayerActions` β€” only the macro table moves. app.world_mut() .non_send_resource_mut::() .run(r#"EditMacro(1, nil, "Interface\\Icons\\Spell_Fire_FlameBolt")"#) @@ -365,9 +327,8 @@ fn a_macro_slot_shows_the_macros_own_icon_and_follows_an_edit() { "the generation gate re-resolved the slot" ); - // RENAME the macro (decision 1636). The slot's value β€” texture, kind, id β€” is byte-identical - // after this, so a value diff alone would fire nothing and the bar would keep drawing the old - // name line until some unrelated edit repainted it. The feed must re-fire the slot anyway. + // A rename leaves the slot's texture, kind and id unchanged, so a value diff alone would + // fire nothing; the feed must re-fire the slot anyway. let events = |app: &mut App| { app.world_mut() .non_send_resource::() @@ -402,19 +363,13 @@ fn a_macro_slot_shows_the_macros_own_icon_and_follows_an_edit() { Some("Shadowstep"), "and the repaint reads the new name" ); - // A frame with nothing moved fires nothing more β€” the re-fire is gated on the table moving. + // A frame with nothing moved fires nothing more. app.update(); assert_eq!(events(&mut app), 1); } -/// **The pre-resolved lines reach the frame, on the arm they asked for** β€” `UiErrorTexts` end to -/// end: the queue the net drain writes, through this feed, into the shipped `UIErrorsFrame`'s own -/// drawn quads. -/// -/// This is the seam the GM-mode double line lived in. `SMSG_NOTIFICATION` used to be pushed into -/// the chat feed (a stand-in from before benilla had an errors frame), so vmangos answering -/// `.gm on` with both a `SendSysMessage` and a `SendNotification` printed the words twice. The -/// notice belongs here, red β€” and its `SMSG_AREA_TRIGGER_MESSAGE` sibling here, yellow. +/// `UiErrorTexts` end to end into the stock `UIErrorsFrame`: an `SMSG_NOTIFICATION` draws red, +/// an `SMSG_AREA_TRIGGER_MESSAGE` yellow. #[test] fn pre_resolved_lines_land_on_the_errors_frame_in_the_arms_colour() { benilla_formats::wow_data_or_skip!(); @@ -422,8 +377,7 @@ fn pre_resolved_lines_land_on_the_errors_frame_in_the_arms_colour() { { let mut script = app.world_mut().non_send_resource_mut::(); script.set_screen_size(1024.0, 768.0); - // The errors frame is the reference's own file since 1751 window 14, so this reads both - // stores through the one loader that speaks them. + // Stock files, read from the install. for file in [ "Interface\\FrameXML\\Fonts.xml", "Interface\\FrameXML\\UIErrorsFrame.xml", @@ -432,7 +386,7 @@ fn pre_resolved_lines_land_on_the_errors_frame_in_the_arms_colour() { } } - // What the net drain queues for one `.gm on` toggle, plus a refused portal. + // What the net drain queues for one `.gm on`, plus a refused portal. let mut texts = app.world_mut().resource_mut::(); texts.error("GM mode is ON".to_string()); texts.info("You must be at least level 58 to enter.".to_string()); @@ -466,8 +420,7 @@ fn pre_resolved_lines_land_on_the_errors_frame_in_the_arms_colour() { assert_eq!( drawn, [ - // `AddMessage` byte-quantizes every channel (`ftol(v*255 + 0.5)`), so 0.1 draws as - // 26/255 β€” the same arithmetic `ui_script::errors_tests` pins. + // `AddMessage` quantizes each channel to a byte (`ftol(v*255 + 0.5)`): 0.1 is 26/255. ( "GM mode is ON".to_string(), [1.0, 26.0 / 255.0, 26.0 / 255.0, 1.0] @@ -481,17 +434,9 @@ fn pre_resolved_lines_land_on_the_errors_frame_in_the_arms_colour() { ); } -/// **A pet's refused cast is not written to the combat log** (decision 2033). -/// -/// `HandleCastFailed 0x6e1a00` calls the log formatter `0x62c360` beside its `DisplayError`; -/// `HandlePetCastFailed 0x6e8eb0` calls neither it nor the error sound β€” its whole call set is the -/// two packet readers, `0x496720`, and the string plumbing behind it. Until this guard the drain -/// treated both queue entries alike, so a pet's refused Growl printed "You fail to cast Growl: -/// ..." in the log, worded as the player's own failure. -/// -/// Driven through the real system with the two entries **side by side and the same spell**, so the -/// assert cannot pass by the line failing to build for some unrelated reason: one goes in, one -/// comes out. +/// `HandleCastFailed 0x6e1a00` logs through `0x62c360`; `HandlePetCastFailed 0x6e8eb0` calls only +/// the packet readers and `0x496720`, with no log line and no error sound. Both entries name the +/// same spell, so only the caster differs. #[test] fn a_pets_refused_cast_writes_no_combat_log_line() { use crate::ui_action::{CastFail, Caster, Spells}; @@ -502,8 +447,7 @@ fn a_pets_refused_cast_writes_no_combat_log_line() { let (tx, _rx) = crossbeam_channel::unbounded(); let mut app = App::new(); let mut errors = CastErrors::default(); - // 0x5f ROOTED: an override on BOTH tables, so neither entry can be dropped for want of a - // string, and the red line still gets two lines β€” it is only the log that differs. + // 0x5f ROOTED has a string on both tables, so neither entry drops for want of one. errors.0.push(CastFail { spell_id: GROWL, reason: 0x5F, @@ -535,8 +479,7 @@ fn a_pets_refused_cast_writes_no_combat_log_line() { .init_resource::() .insert_resource(NetCommands(tx)); let script = UiScript::new().unwrap(); - // Only the two strings the log line needs; the red line's own text is `cast_fail`'s business - // and has its own tests. + // Only what the log line needs; the red line's text has its own tests. script .run(r#"SPELL_FAILED_ROOTED = "You are unable to move";"#) .unwrap(); @@ -553,21 +496,14 @@ fn a_pets_refused_cast_writes_no_combat_log_line() { ); } -/// **The taming refusal is TWO GlobalStrings lookups, in order** (decision 2039) β€” the one -/// message benilla shows whose argText is itself a key. -/// -/// The reference's `0x6e6a20` resolves `PETTAME_` through the script VM first and hands -/// the resulting *string* to `DisplayError(0xee)`, so `ERR_TAME_FAILED` ("%s.") renders the -/// reason's sentence with a period after it. Getting the order wrong is not a compile error and -/// not a silent no-op β€” it would print the literal key on the red line β€” so it is pinned here at -/// both ends of the reason table's bounds check. +/// `0x6e6a20` resolves `PETTAME_` first and passes the string to `DisplayError(0xee)`, +/// so `ERR_TAME_FAILED` ("%s.") prints the sentence, not the key. #[test] fn a_tame_failure_composes_the_reason_string_into_err_tame_failed() { let (mut app, _rx) = app_with_food_on_the_bar(); { let script = app.world_mut().non_send_resource_mut::(); - // The two strings verbatim from 1.12's `GlobalStrings.lua`, plus a recorder for the - // event the sink fires β€” this test is about the composition, not the frame. + // The strings verbatim from 1.12's `GlobalStrings.lua`, and a recorder for the event. script .run( r#" @@ -591,8 +527,8 @@ fn a_tame_failure_composes_the_reason_string_into_err_tame_failed() { .unwrap() }; - // 9 = PETTAME_TOOHIGHLEVEL, the middle of the jump table; 12 is vmangos's own out-of-range - // twelfth value, which the reference's `reason - 1 > 0xa` bound sends to the default arm. + // 9 is `PETTAME_TOOHIGHLEVEL`; 12, a vmangos value past the reference's `reason - 1 > 0xa` + // bound, takes the default arm. app.world_mut().resource_mut::().0.push(9); app.world_mut().resource_mut::().0.push(12); app.update(); @@ -612,8 +548,7 @@ fn a_tame_failure_composes_the_reason_string_into_err_tame_failed() { "out of range takes the default arm rather than showing nothing" ); - // The reference's data-suppression face: a reason whose PETTAME string is not loaded shows - // NOTHING rather than a bare "." β€” the inner lookup fails, so the whole line is suppressed. + // A reason whose `PETTAME_` string is not loaded shows nothing, not a bare ".". app.world_mut().resource_mut::().0.push(1); app.update(); assert_eq!( diff --git a/crates/benilla-app/src/ui_action/mod.rs b/crates/benilla-app/src/ui_action/mod.rs index 97ca9c49..5efe070d 100644 --- a/crates/benilla-app/src/ui_action/mod.rs +++ b/crates/benilla-app/src/ui_action/mod.rs @@ -1,27 +1,7 @@ -//! The app-side **action seam** (decision 0068 slice 1, extended by decision 0216 Β§7/slice 4) β€” -//! the three directions around the engine-free bindings ([`benilla_ui::script`]'s `action`/ -//! `cursor::bar` modules). This module owns the seam's *shared state* β€” the action table, the -//! spell catalogs, the plugin wiring β€” and each direction lives in its own file: -//! -//! - **Inward β€” identity** ([`feed`]): the net bridge fills [`PlayerActions`] from -//! `SMSG_INITIAL_SPELLS` + `SMSG_ACTION_BUTTONS` (and [`drain::drain_action_sets`] writes it -//! directly, client-side β€” the bar is client-authoritative, decision 0218 Β§4); the feed resolves -//! each occupied slot's icon and count, pushes the 120-slot snapshot into the VM, and fires -//! `ACTIONBAR_SLOT_CHANGED` per changed slot. What is gated on what is the design there. -//! - **Inward β€” dynamic state** ([`state`]): cooldown swirl, usability tint, range colour, -//! checked/flash β€” the per-frame half, fed after the identity half so a fresh slot's first state -//! push lands the same frame. -//! - **Outward β€” use** ([`drain::drain_action_uses`]): a queued `UseAction(n)` becomes wire β€” a -//! spell through the one cast-send path ([`crate::spell::CastLadder`]), the auto-attack through -//! `CMSG_ATTACKSWING`, an item through the two-stage equip-vs-use law ([`drain::item_action_route`], -//! decision 0666). -//! - **Outward β€” set** ([`drain::drain_action_sets`]): the cursor seam's `PickupAction`/ -//! `PlaceAction` mutations become `CMSG_SET_ACTION_BUTTON` sends, one per queued entry. -//! -//! The supporting law sits alongside: [`cast_fail`] + [`errors`] (the red error line's two -//! layers) and [`weapon_icon`] (the auto-attack's borrowed weapon icon). The cast ladder itself β€” -//! the target bind, the validator's rungs, the usable walk, the targeting cursor β€” is the spell's -//! ([`crate::spell`], decision 2330); this module is one of its callers. +//! The app side of the action bar: the action table, the spell catalogs and the plugin. [`feed`] +//! pushes each slot's identity into the VM and [`state`] its per-frame cooldown, usability, range +//! and checked state; [`drain`] sends `UseAction` as a cast, swing, item use or macro, and +//! `PickupAction`/`PlaceAction` as `CMSG_SET_ACTION_BUTTON`. use std::collections::{BTreeSet, HashMap}; @@ -49,13 +29,9 @@ mod state; pub(crate) mod toggle; mod weapon_icon; -/// The cooldown-event cut: [`state::feed_action_state`] fires the store-change flush trio -/// (`ACTIONBAR_UPDATE_COOLDOWN`/`SPELL_UPDATE_COOLDOWN`/`BAG_UPDATE_COOLDOWN`) **synchronously** -/// (`UiScript::fire_event` walks the handlers inline), so every feed that pushes cooldown -/// triples the handlers re-read (the container feed's slot cooldowns, the spellbook feed's, the -/// stance feed's β€” decision 2009) must run `.before(CooldownEvents)` β€” or a handler reads last -/// frame's triples and the pie stays missing until the next store change. The action states -/// themselves are safe by construction (pushed by the same system, before it fires). +/// Every feed that pushes cooldown triples runs `.before` this set: [`state::feed_action_state`] +/// fires the cooldown events synchronously, so a triple pushed later goes unread until the next +/// cooldown change. #[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)] pub(crate) struct CooldownEvents; @@ -64,113 +40,66 @@ pub(crate) use errors::{ show_messages, ui_error_text, CastErrors, CastFail, Caster, FillArg, MessageSink, MountErrors, PetTameFailures, Shown, UiError, UiErrorKeys, UiErrorTexts, }; -// `pub(crate)`: the spellbook shows the same borrowed weapon icon its bar buttons do, pre-resolved -// once per page (decisions 0230/0231). pub(crate) use weapon_icon::{melee_auto_attack_icon, ranged_weapon_icon}; -/// The auto-attack pseudo-spell (`Attack`, every character's slot-1 default): not a cast β€” it -/// toggles melee via the attack-swing pair. The USE path (`CMSG_ATTACKSWING`) keys on this id; the -/// ICON substitution keys on the effect type instead ([`benilla_formats::SpellDisplay::is_melee_auto_attack`], -/// decision 0231 β€” 6603 is simply the only spell carrying `SPELL_EFFECT_ATTACK`). +/// `Attack`, the auto-attack pseudo-spell: it toggles melee rather than casting. The use path +/// keys on this id; the icon keys on `SPELL_EFFECT_ATTACK`, which only 6603 carries. pub(crate) const SPELL_ATTACK: u32 = 6603; -/// The player's action store: the occupied wire slots (0..119) + the known-spell set. Written by -/// the net bridge (`SMSG_INITIAL_SPELLS`/`SMSG_ACTION_BUTTONS`) AND, since decision 0216 Β§7, -/// directly by [`drain::drain_action_sets`] (the bar is client-authoritative β€” a local -/// pickup/place is never echoed back by the server, vmangos `MasterPlayer::addActionButton`/ -/// `removeActionButton` send nothing). Read by [`feed::feed_actions`]/[`drain::drain_action_uses`]. +/// The action slots (wire 0..119) and the known spells. The bar is client-authoritative: +/// [`drain::drain_action_sets`] writes it directly, and vmangos stores a slot without a reply +/// (`MasterPlayer::addActionButton`). #[derive(Resource, Default)] pub(crate) struct PlayerActions { - /// Wire slot (0-based) β†’ the slot's packed action. Lua action id = slot + 1. + /// Wire slot (0-based) to its packed action; the Lua action id is slot + 1. pub buttons: HashMap, - /// The spell book (`SMSG_INITIAL_SPELLS`). - /// - /// **Ordered, and that is load-bearing** (decision 1312). The reference keeps its known spells - /// in an ARRAY and the scans that hunt it β€” the GameObject lock resolver `0x5f83d0` above all β€” - /// stop at the first hit, so the visit order picks *which* of several equally-qualified spells - /// wins. A `HashSet` made that pick nondeterministic: every character knows both 6478 - /// "Opening" and 22810 "Opening - No Text" (both `LockType 13`, both trivially sufficient), and - /// whichever the hash happened to reach first went on the cast bar (B247). Ascending spell id - /// is the array's own order after login β€” the server sends the initial batch out of a - /// `std::map`, so the wire arrives sorted. + /// The spell book (`SMSG_INITIAL_SPELLS`), ordered on purpose: the reference's first-hit + /// scans (the lock resolver `0x5f83d0`) pick between equal spells, 6478 and 22810 "Opening", + /// by array order, which is ascending id: the server sends the book from a `std::map`. pub spells: BTreeSet, - /// Set on every book/bar arrival AND every local `action_sets` drain; cleared by the feed - /// after re-resolving each slot's identity (icon/kind/action) and pushing. It is only ONE of - /// the identity resolve's two triggers β€” a landed item template is the other (decision 0660, - /// [`Items::template_epoch`]) β€” and it gates ONLY that resolve: an ITEM slot's bag COUNT is - /// refreshed unconditionally every frame instead (see [`feed`]'s module doc), since it drifts - /// independently of both. - /// - /// [`Items::template_epoch`]: crate::items::Items::template_epoch + /// Set by every book or bar change, cleared once the feed re-resolves slot identity, which a + /// landed item template ([`crate::items::Items::template_epoch`]) also triggers. Item counts + /// refresh every frame regardless. pub dirty: bool, } -/// **The world right-click's GameObject-opener queue** β€” the lock chain's resolved action, carried -/// one frame to the one cast path (decision 2199). -/// -/// It exists because the right-click system ([`crate::target::click::act_on_right_click`]) and -/// [`crate::spell::CastLadder`] want the same half-dozen -/// resources, so the click cannot call the ladder in place β€” a resource reachable twice from one -/// system is a `B0002` panic on the first live frame. A one-frame queue is the seam, and it keeps -/// the rule that **nothing sends a cast except the ladder**. -/// -/// Before it, the opener was the last cast in the tree that sent its own packet. That is not a -/// tidiness point: the ladder is where the in-flight rung lives (`6e4d97` β€” the reference's -/// already-casting refusal, whose same-spell leg `6e4d43` is *silent*), so spamming right-click on -/// a chest shipped a `CMSG_CAST_SPELL` per click, vmangos answered every duplicate -/// `SPELL_FAILED_SPELL_IN_PROGRESS`, and that failure β€” naming the **same** spell as the running -/// cast β€” red-faded the running bar while the cast completed anyway. Character for character the -/// B200 report decision 0908 fixed for items; this is the same bug at the arm 0914 named as still -/// open. +/// The world right-click's GameObject openers, queued one frame for the cast ladder: the click +/// system cannot also hold the ladder's resources, and only the ladder sends a cast, so a chest +/// right-clicked mid-cast takes the reference's silent same-spell bail (`0x6e4d43`). #[derive(Resource, Default)] pub(crate) struct GoOpenerCasts(pub(crate) Vec); -/// One queued opener β€” what the lock chain resolved the right-click to -/// ([`crate::target::click::resolve_go_action`]). +/// A right-click's resolved opener ([`crate::target::click::resolve_go_action`]). #[derive(Clone, Copy, Debug)] pub(crate) enum GoOpener { - /// A known `OPEN_LOCK` spell the player satisfies, cast **at the object** β€” `CMSG_CAST_SPELL` - /// carrying the GameObject target block (decision 0239). + /// A known `OPEN_LOCK` spell, cast with a GameObject target block. Spell { spell_id: u32, go_guid: u64 }, - /// A key slot we carry: `CGItem::Use` with the lock's guid, which the commit turns into - /// `CMSG_USE_ITEM` + `TARGET_FLAG_GAMEOBJECT` (decision 0769). The item carries the bound guid - /// on its own [`crate::ui_items::ItemUse::on_object`]. + /// A carried key: `CMSG_USE_ITEM` with `TARGET_FLAG_GAMEOBJECT`, the object's guid in + /// [`crate::ui_items::ItemUse::on_object`]. Key(crate::ui_items::ItemUse), } -/// The spell display catalog + the shapeshift bonus-bar map (absent when the client data isn't β€” -/// every consumer tolerates that). `pub(crate)`: the cast-visual router -/// (`crate::creature_anim::spell_visual`) resolves spell β†’ visual through the same catalog β€” one -/// `Spell.dbc` load serves both faces (decision 0107). +/// The spell DBC catalogs, absent without client data; the cast-visual router shares the catalog. #[derive(Resource)] pub(crate) struct Spells { pub(crate) catalog: SpellCatalog, - /// Form id β†’ the `SpellShapeshiftForm.dbc` row: **BonusActionBar** (the client's own paging - /// map: `GetBonusBarOffset 0x4e7620` reads a cached copy of exactly this - /// lookup) + **flags1** (the form gate's stance bit, [`state`]'s usable walk; the - /// toggle-cancel block bit, `crate::ui_shapeshift`'s drain). + /// Form id to its `SpellShapeshiftForm.dbc` row: `BonusActionBar` pages the bar + /// (`GetBonusBarOffset 0x4e7620`), `flags1` holds the stance and cancel bits. pub(crate) forms: std::collections::HashMap, - /// `SpellRange.dbc` β€” the byte-verified `GetMinMaxRange 0x6e3480` inputs the range indicator - /// reads ([`state`], decision 0137 phase 4). Empty when the DBC failed (range reads `None`). + /// `SpellRange.dbc`, the `GetMinMaxRange 0x6e3480` inputs; empty on a failed load. pub(crate) ranges: benilla_formats::SpellRangeCatalog, - /// `SpellCastTimes.dbc` β€” the tooltip's cast-time cell (byte-verified `GetCastTime 0x6e3340` - /// reads `CastingTimeIndex` against it; decision 0274 P2). Empty on a failed load. + /// `SpellCastTimes.dbc`, read through `CastingTimeIndex` (`GetCastTime 0x6e3340`). pub(crate) cast_times: benilla_formats::SpellCastTimeCatalog, - /// `SpellDuration.dbc` β€” the `$d`/`$o` tokens' source (`GetDuration 0x6ea000`). + /// `SpellDuration.dbc`: the `$d`/`$o` tokens' source (`GetDuration 0x6ea000`). pub(crate) durations: benilla_formats::SpellDurationCatalog, - /// `SpellRadius.dbc` β€” the `$a` token's yards. + /// `SpellRadius.dbc`: the `$a` token's yards. pub(crate) radii: benilla_formats::SpellRadiusCatalog, } impl Spells { - /// The resolved cast time, ms β€” `GetCastTime 0x6e3340`'s level-scaled walk: `CastingTimeIndex` - /// resolves the [`Self::cast_times`] row, `base + perLevelΒ·(casterLevel βˆ’ baseLevel)` floors to - /// the row's minimum (row 1, the all-zero instant sentinel, resolves 0). The level term keys on - /// the `SpellRec+0x70` column ([`SpellDisplay::base_level`]). Spell-mod op `0xa` - /// (SPELLMOD_CASTING_TIME) is still unread here β€” the tables themselves are live - /// (`crate::spell::mods`), only this consumer is not wired to them, so a talent-shortened - /// cast still shows its untalented length. - /// A missing row reads 0 (instant), like a failed catalog load everywhere else. + /// The cast time in ms (`GetCastTime 0x6e3340`), level-scaled from the `CastingTimeIndex` + /// row; a missing row reads 0. Spell-mod op `0xa` is not applied, so a talent-shortened cast + /// shows its base length. pub(crate) fn cast_time_ms( &self, def: &benilla_formats::SpellDisplay, @@ -184,7 +113,6 @@ impl Spells { #[cfg(test)] impl Spells { - /// An empty catalog set β€” the usable walk's unit tests need only the `forms` map. pub(crate) fn empty_for_tests() -> Self { Spells { catalog: SpellCatalog::from_displays(HashMap::new()), @@ -197,46 +125,27 @@ impl Spells { } } -/// The client's **learned-ability latches** β€” `[0xb700e4]` and `[0xb700e8]`, mirrored (decision -/// 0752). The reference does not scan the spell book to answer "can this player skin?": it caches -/// the answer at *learn* time. `0x4b25e0`, right after setting the known-spell bit, tests the -/// freshly-learned spell's `Effect[0]` and stores the spell id into a dedicated global β€” -/// `0x5f` (`SPELL_EFFECT_SKINNING`) β†’ `[0xb700e4]`, `0x74` (`SPELL_EFFECT_SKIN_PLAYER_CORPSE`) β†’ -/// `[0xb700e8]` β€” and the unlearn path `0x4b2c50` zeroes whichever global named that spell. -/// -/// The world cursor's skin leg then reads `[0xb700e4 + 4Γ—isPlayerTarget]` as a hard precondition -/// (`0x482589`): **a corpse flagged `UNIT_FLAG_SKINNABLE` shows no skin cursor at all to a player -/// who never learned Skinning.** Without it the ladder offers the knife to everyone. +/// The reference's learned-ability latches: at learn time `0x4b25e0` stores a spell's id by its +/// `Effect[0]`, and the unlearn path `0x4b2c50` clears it. The skin cursor requires the latch +/// (`0x482589`), so a player who never learned Skinning gets no skin cursor. #[derive(Resource, Default)] pub(crate) struct LearnedAbilities { - /// `[0xb700e4]` β€” our known `SPELL_EFFECT_SKINNING` spell (creature skinning), `None` if we - /// never learned one. It is also the spell the skin click casts, so there is one lookup here, - /// not a second scan at the click. + /// `[0xb700e4]`: the known `SPELL_EFFECT_SKINNING` spell, which the skin click also casts. pub(crate) skinning: Option, - /// `[0xb700e8]` β€” our known `SPELL_EFFECT_SKIN_PLAYER_CORPSE` spell (the PvP insignia). Kept - /// for symmetry with the reference's pair; the insignia arm of the cursor isn't modelled yet. + /// `[0xb700e8]`: the known `SPELL_EFFECT_SKIN_PLAYER_CORPSE` spell; no cursor reads it yet. pub(crate) skin_player_corpse: Option, - /// `[0xcecad8]` β€” our known `SPELL_EFFECT_FEED_PET` spell (Feed Pet 6991), `None` if we never - /// learned one. The reference latches it the same way, at learn time: `0x6ea1d0` (← - /// `0x5e9e49`) stores the spell whose `Effect[0] == 0x65`. It is the **third gate** on - /// [`targeting::drop_item_on_unit`]'s pet leg, and the spell that leg casts β€” so, like - /// `skinning`, one lookup here rather than a second scan at the drop. + /// `[0xcecad8]`: the known `SPELL_EFFECT_FEED_PET` spell, latched by `0x6ea1d0` (from + /// `0x5e9e49`); gate 3 of [`drop_item::drop_item_on_unit`] and the spell it casts. pub(crate) feed_pet: Option, } -/// `SpellEffects` value `0x74` β€” `SPELL_EFFECT_SKIN_PLAYER_CORPSE` (the "Remove Insignia" family), -/// the second of the two effects `0x4b25e0` latches. +/// The "Remove Insignia" effect, the second one `0x4b25e0` latches. const SPELL_EFFECT_SKIN_PLAYER_CORPSE: u32 = 0x74; -/// `SpellEffects` value `0x65` (101) β€” `SPELL_EFFECT_FEED_PET`, the effect the reference tests at -/// learn time to latch `[0xcecad8]` (`0x5e9e42`). -/// Feed Pet 6991 is the only shipped row carrying it. +/// The effect latched into `[0xcecad8]` at learn time (`0x5e9e42`); only Feed Pet 6991 has it. const SPELL_EFFECT_FEED_PET: u32 = 0x65; -/// Re-derive [`LearnedAbilities`] whenever the spell book changes β€” our stand-in for the -/// reference's learn/unlearn write sites. Change-detected, so it is a no-op on almost every frame; -/// rescanning the book beats threading a hook through every spell-arrival path, and it cannot -/// drift from the book the way an incrementally-maintained latch could. +/// Re-derives [`LearnedAbilities`] whenever the book changes, standing in for learn-time writes. fn track_learned_abilities( actions: Res, spells: Option>, @@ -246,11 +155,8 @@ fn track_learned_abilities( if !actions.is_changed() && !spells.is_changed() { return; } - // The **last** matching spell in ascending-id order, not the first: the reference latches at - // learn time and each new rank overwrites the global, so what it holds is the newest rank - // learned β€” and a rank chain is ascending by id (Skinning 8613 β†’ 8617 β†’ 8618 β†’ 10768). Which - // end we take is only visible when a chain's ranks are known together, but "arbitrary" was - // never an answer: the set was a `HashSet` until 1312 and this picked at the hash's whim. + // The last match in id order: each learned rank overwrites the reference's latch, and a rank + // chain ascends by id (Skinning 8613, 8617, 8618, 10768). let last_with = |effect: u32| { actions.spells.iter().copied().rfind(|&id| { spells @@ -279,9 +185,7 @@ fn track_learned_abilities( } } -/// `SpellMechanic.dbc` β€” the vocabulary that fills `SPELL_FAILED_PREVENTED_BY_MECHANIC`'s `%s` -/// ([`benilla_formats::SpellMechanicCatalog`], decision 1948). Its one reader is the cast-failure -/// resolver's `0x8d` arm. +/// `SpellMechanic.dbc`: the name in `SPELL_FAILED_PREVENTED_BY_MECHANIC`'s `%s` (reason `0x8d`). #[derive(Resource)] pub(crate) struct SpellMechanics { pub(crate) catalog: benilla_formats::SpellMechanicCatalog, @@ -298,8 +202,6 @@ fn load_spell_mechanics(mut commands: Commands, assets: Option warn!( "ui_action: SpellMechanic.dbc failed to load β€” the crowd-control refusal drops the \ mechanic name: {e:#}" @@ -325,16 +227,10 @@ impl Plugin for UiActionPlugin { .add_systems( Update, ( - // Feed rides with the unit feed, before the VM ticks; both drains run after - // the input pass so a click's UseAction/PickupAction/PlaceAction goes out the - // same frame. The two queues are disjoint per gesture (a checkCursor place - // routes entirely to `action_sets`, never also queuing a use), so the drains' - // relative order doesn't matter. The dynamic-state feed follows the identity - // feed so a fresh slot's first state push lands the same frame. - // The rank pass runs on the same `dirty` flag the identity feed consumes, - // and strictly before it: a slot corrected here is resolved and pushed with - // its right rank the same frame, so a stale rank never reaches a pixel - // (decision 0883). + // The feeds ride with the unit feed, before the VM ticks: the rank pass before + // the identity feed that consumes its `dirty`, the state feed after it, so a + // slot's rank fix and first state land the same frame. The drains follow the + // input pass, in any order: a gesture fills only one of their queues. ranks::normalize_action_ranks .in_set(UnitFeed) .before(feed::feed_actions), @@ -345,17 +241,11 @@ impl Plugin for UiActionPlugin { .after(feed::feed_actions), drain::drain_action_sets.after(UiInput), drain::drain_action_uses.after(UiInput), - // The T binding (0997): the attack arm's twin door, after the dispatch wrote - // this frame's fires. + // The ATTACKTARGET binding, after the dispatch wrote this frame's key fires. drain::attack_target_binding.after(UiInput), - // The world right-click's opener (2199), beside the chain cast and for the - // same reason: the click resolved it, the ladder sends it. After the input - // pass like the other drains β€” the queue is filled by the target chain, - // which runs earlier in the frame. + // The target chain queues the openers earlier in the frame. drain::drain_go_openers.after(UiInput), - // The learned-ability latches must be current before the target chain's - // cursor classifier reads them; the book feed runs in `UnitFeed`, so sitting - // right after it is enough. + // The latches must be current before the target chain's cursor reads them. track_learned_abilities .in_set(UnitFeed) .after(feed::feed_actions), diff --git a/crates/benilla-app/src/ui_action/net.rs b/crates/benilla-app/src/ui_action/net.rs index 24b1e6c1..c7f62288 100644 --- a/crates/benilla-app/src/ui_action/net.rs +++ b/crates/benilla-app/src/ui_action/net.rs @@ -1,6 +1,4 @@ -//! The action bar's packet handler (in the net handler table since 2322, moved out of the drain's -//! mount arm file) β€” the (dis)mount attempt's result code (decision 0441 P1) onto the mount error -//! line. The flourish half of that arc is [`crate::creature_anim::net`]'s. +//! The mount and dismount result packets, onto the red error line. use benilla_protocol::{SessionEvent, SessionEventKind}; use bevy::prelude::*; @@ -8,7 +6,6 @@ use bevy::prelude::*; use super::MountErrors; use crate::net::NetHandlerApp; -/// Register the handler β€” called from [`super::UiActionPlugin`]. pub(super) fn register(app: &mut App) { app.net_handler(SessionEventKind::MountResult, on_mount_result); } @@ -19,9 +16,8 @@ fn on_mount_result(In(ev): In, mut errors: ResMut) { } } -/// `SMSG_MOUNTRESULT`/`SMSG_DISMOUNTRESULT` β€” OK is silent in the reference (10 mounting, -/// 3 dismounting); a failure queues the red error line (`ui_action::mount_result_key` β€” resolved -/// against the VM's GlobalStrings at drain). +/// `SMSG_MOUNTRESULT`/`SMSG_DISMOUNTRESULT`: OK (`MOUNTRESULT_OK` 10, `DISMOUNTRESULT_OK` 3) is +/// silent in the reference; a failure queues the red error line. fn mount_result(mount: bool, code: u32, errors: &mut MountErrors) { let ok = if mount { code == 10 } else { code == 3 }; if !ok { diff --git a/crates/benilla-app/src/ui_action/ranks.rs b/crates/benilla-app/src/ui_action/ranks.rs index 706999d5..ba3e157f 100644 --- a/crates/benilla-app/src/ui_action/ranks.rs +++ b/crates/benilla-app/src/ui_action/ranks.rs @@ -1,33 +1,11 @@ -//! **A bar slot never holds a rank the character has outgrown** (decision 0883) β€” the one place -//! that keeps the action bar's spell ids in step with the spell book's ranks. -//! -//! The bar is client-authoritative (decision 0218 Β§4): the server stores 120 packed words and -//! hands them back at login, and nothing on the server side ever rewrites them when a rank is -//! learned (VERIFIED vmangos β€” `Player::AddSpell`'s supersede path touches only the spell store; -//! the only `ConvertSpell` that walks `character_action` is the race-change tool). So the stored -//! bar drifts, and it drifts *silently*: a superseded rank is dropped from the book -//! (`Player::SendInitialSpells` skips `!active` spells), so the button comes back pointing at a -//! spell the character no longer knows β€” dead, with no way to tell from the icon, which is the -//! same art on every rank. That is the reported bug: a level-60 rogue's saved slot 1 still held -//! Sinister Strike **rank 1** (`character_action.action = 1752`) while the book held only rank 8 -//! (11294), so the button did nothing. -//! -//! The chain lives in `SkillLineAbility.dbc`'s `forward_spellid` β€” 406 of the build's 4753 spells -//! carry it, and they are exactly the abilities the server supersedes (every -//! `SMSG_SUPERCEDED_SPELL` vmangos sends is gated on `GetSpellBookSuccessorSpellId`, which reads -//! that column). Warrior/rogue physical abilities and the profession tier openers are chained; -//! caster nukes and heals are not β€” their lower ranks stay known and castable, which is what -//! makes vanilla down-ranking work. Normalization therefore cannot touch a deliberate down-rank: -//! it only ever moves within a chain, and a chain is precisely a family where only one rank is -//! ever known at a time. -//! -//! This runs on the `dirty` flag β€” every book arrival, rank-up and local slot write β€” so it is -//! ONE mechanism covering all of them, not a login special case. Hence -//! [`super::spells::superceded_spell`] no longer re-points buttons itself: the server's -//! `SMSG_SUPERCEDED_SPELL` moves the *book*, and the bar follows from here. It has to be that way -//! round, because the case that bit us got no supersede packet at all (vmangos suppresses them -//! while the character is loading, so ranks gained offline β€” a `.levelup`, a boost β€” arrive as a -//! book that silently disagrees with the bar). +//! Keeps each spell slot on the highest rank of its chain the book holds, and sends each move. A +//! chain is `SkillLineAbility.dbc`'s `forward_spellid`, the column the server's supersede reads; +//! caster ranks are unchained, so a deliberate down-rank is never moved. Deviation: this runs on +//! every book or bar change, where the reference re-points slots in memory only on +//! `SMSG_SUPERCEDED_SPELL` (`0x4e5e60`), because vmangos never rewrites stored slots on a rank-up +//! (`Player::AddSpell`), drops superseded ranks from the book (`Player.cpp:3463`) and sends no +//! supersede while loading (`Player.cpp:3704`), so a stored slot can name a spell the character +//! no longer knows. use bevy::prelude::*; @@ -37,13 +15,8 @@ use super::PlayerActions; use crate::net::{ClientCommand, NetCommands}; use crate::ui_spellbook::SkillLines; -/// Re-point every spell button at the highest rank of its ability the book actually holds, and -/// persist each move (decision 0216 Β§7: a local slot mutation IS a `CMSG_SET_ACTION_BUTTON` β€” the -/// bar has no other writer, so a fix we keep to ourselves would be re-applied every single login -/// while the server's copy stayed wrong). -/// -/// Gated on `dirty` and run before [`super::feed::feed_actions`] consumes it, so the corrected id -/// is what the feed resolves and pushes β€” the stale rank never reaches a frame. +/// Re-points each spell slot at the highest known rank of its chain and sends the move, so the +/// server's stored copy is fixed too. Runs on `dirty`, before the feed consumes it. pub(super) fn normalize_action_ranks( mut actions: ResMut, skill_lines: Option>, @@ -55,8 +28,7 @@ pub(super) fn normalize_action_ranks( if !actions.dirty { return; } - // Resolve first (the walk reads `buttons` and `spells` together), then write: an empty book - // yields no fixes at all, which is what keeps a bar-before-book packet order harmless. + // Resolve, then write. An empty book yields no fixes, so a bar arriving first is harmless. let fixes: Vec<(u8, u32)> = actions .buttons .values() @@ -90,12 +62,10 @@ mod tests { use super::*; - /// Sinister Strike's real chain, ranks 1..8 (pinned against the DBC by `benilla_formats`' - /// own `real_rank_chains_resolve_the_highest_known_rank`). + /// Sinister Strike's chain, ranks 1 to 8. const SS: [u32; 8] = [1752, 1757, 1758, 1759, 1760, 8621, 11293, 11294]; - /// These run the walk against the **real** `SkillLineAbility.dbc` β€” the chain is the thing - /// under test, and a hand-built fake chain would only test the walk against itself. + /// These run against the real `SkillLineAbility.dbc`: the chain is what is under test. fn client_data() -> Option { let data = benilla_formats::wow_data_or_skip!(None); Some(data) @@ -109,8 +79,7 @@ mod tests { } } - /// Run the system once over `buttons` + a book of `spells`, on the real catalog. Returns the - /// resulting store and the `(button, packed)` pairs that went out on the wire. + /// Runs the pass once; returns the store and the `(button, packed)` pairs sent. fn normalize( data: &std::path::Path, buttons: &[ActionButton], @@ -142,9 +111,6 @@ mod tests { (store, sent) } - /// **The reported bug.** A level-60 rogue's saved slot 1 holds Sinister Strike rank 1 while - /// the book holds only rank 8: the slot moves to rank 8 AND the move goes out on the wire, so - /// the server's stored copy stops being wrong. #[test] fn a_stale_rank_1_slot_moves_to_the_known_rank_and_persists() { let Some(data) = client_data() else { return }; @@ -154,9 +120,6 @@ mod tests { assert_eq!(sent, vec![(0, 11294)]); } - /// Every rank below the known one lands on the same answer β€” including from *above* it, the - /// down-rank direction a forward-only walk can't reach. The already-correct slot doesn't move - /// and doesn't send. #[test] fn every_wrong_rank_converges_and_the_right_one_stays_put() { let Some(data) = client_data() else { return }; @@ -165,7 +128,7 @@ mod tests { .enumerate() .map(|(i, &id)| spell_button(i as u8, id)) .collect(); - // Rank 4 (1759) known: ranks 1-3 move UP to it, ranks 5-8 move DOWN to it. + // Rank 4 (spell 1759) known: ranks 1-3 move up to it, ranks 5-8 down. let (store, sent) = normalize(&data, &buttons, &[1759]); for slot in 0..8u8 { assert_eq!(store.buttons[&slot].action, 1759, "slot {slot}"); @@ -174,8 +137,7 @@ mod tests { assert!(!sent.iter().any(|(b, _)| *b == 3)); } - /// A caster's down-rank is NOT a mistake: Fireball's ranks carry no `forward_spellid`, so a - /// slot holding rank 1 stays on rank 1 even with rank 2 known and castable. + /// Fireball's ranks 1 and 2 (133, 143) carry no `forward_spellid`. #[test] fn an_unchained_caster_rank_is_left_alone() { let Some(data) = client_data() else { return }; @@ -184,8 +146,6 @@ mod tests { assert!(sent.is_empty(), "nothing to persist"); } - /// With no rank of the chain known β€” the book hasn't arrived yet, or the ability was never - /// learned β€” the slot is left exactly as it is rather than pointed somewhere arbitrary. #[test] fn an_unknown_chain_leaves_the_slot_untouched() { let Some(data) = client_data() else { return }; @@ -194,8 +154,6 @@ mod tests { assert!(sent.is_empty()); } - /// Macro and item slots carry ids from other namespaces β€” walking them as spell ranks would - /// be nonsense, so the kind byte gates it. #[test] fn macro_and_item_slots_are_never_walked() { let Some(data) = client_data() else { return }; @@ -217,8 +175,6 @@ mod tests { assert!(sent.is_empty()); } - /// A correct bar is a no-op β€” nothing moves, nothing is sent. That is what lets this ride the - /// `dirty` flag (set by every drag-and-drop) without adding traffic. #[test] fn a_correct_bar_sends_nothing() { let Some(data) = client_data() else { return }; diff --git a/crates/benilla-app/src/ui_action/state.rs b/crates/benilla-app/src/ui_action/state.rs index 7d7e0e42..283703d1 100644 --- a/crates/benilla-app/src/ui_action/state.rs +++ b/crates/benilla-app/src/ui_action/state.rs @@ -1,25 +1,10 @@ -//! The per-action **dynamic-state feed** (decision 0137 phase 4) β€” the app-side computation -//! behind the engine's `IsUsableAction`/`IsActionInRange`/`IsCurrentAction`/`GetActionCooldown` -//! family: each occupied action slot's [`ActionState`], recomputed per frame, diff-pushed into -//! the VM, with the reference client's own event edges fired on these transitions: -//! -//! - a cooldown-store change β†’ `ACTIONBAR_UPDATE_COOLDOWN` + `SPELL_UPDATE_COOLDOWN` + -//! `BAG_UPDATE_COOLDOWN` (the `0x4b31b0`/`0x4f93d0` flush pair the SMSG handlers call); -//! - a usable/oom change on any slot β†’ `ACTIONBAR_UPDATE_USABLE` + `SPELL_UPDATE_USABLE` -//! (`0x4b31c0`; the client fires only on a cache CHANGE β€” `0x4e5c00` β€” hence the diff edge); -//! - a current/auto-repeat change β†’ `ACTIONBAR_UPDATE_STATE` + `CURRENT_SPELL_CAST_CHANGED` -//! (`0x4b3250`); -//! - our own melee engage/disengage β†’ `PLAYER_ENTER_COMBAT`/`PLAYER_LEAVE_COMBAT` -//! (`0x6256ff`/`0x625778` β€” the attack-start/stop handlers); -//! - the live autorepeat key's edges β†’ `START_AUTOREPEAT_SPELL` (`0x6e5952`, at cast-send) / -//! `STOP_AUTOREPEAT_SPELL` (`0x6ea170`). -//! -//! The per-flag semantics are the reference's: `notEnoughMana` is strictly the power-cost verdict, -//! `IsCurrentAction` (`0x4e53a0`) keys on the engaged attack GUID / the in-flight cast id, -//! `IsAutoRepeatAction` on the `0xceac30` key, and the range test is squared distance against the -//! `GetMinMaxRange 0x6e3480` (its constants transcribed below). The usable pair itself is the full -//! `IsSpellUsableNow 0x6e3d60` gate walk β€” [`crate::spell::usable`]: reagents, forms, stealth, -//! aura states (the Execute-family target dependence), the works. +//! Each action slot's per-frame state behind `IsUsableAction`, `IsActionInRange`, +//! `IsCurrentAction` and `GetActionCooldown`, diff-pushed into the VM with the reference's events +//! on each change: the cooldown trio (`0x4b31b0`, `0x4f93d0`), the usable pair (`0x4b31c0`, on a +//! cache change only, `0x4e5c00`), the state pair (`0x4b3250`), combat enter and leave +//! (`0x6256ff`, `0x625778`), and autorepeat start and stop (`0x6e5952`, `0x6ea170`). Usability is +//! `IsSpellUsableNow 0x6e3d60` ([`crate::spell::usable`]), auto-repeat the `0xceac30` key, and +//! range the squared distance against `GetMinMaxRange 0x6e3480`. use crate::ui_items::carried_counts; use std::collections::HashMap; @@ -47,41 +32,25 @@ pub(super) struct StateMemory { last_generation: Option, engaged: bool, auto_repeat: Option, - /// Last `benilla_assets::trace` "cd tick" stamp β€” the once-per-second gate (trace runs only). last_cd_trace: Option, } -/// What a slot *is* once the MACRO indirection is applied β€” the reference's slotβ†’spell resolver -/// `0x4e5a50` plus the leg of the usable compute `0x4e5050` that reads its zero (decision 1636). +/// A slot after the macro indirection (`0x4e5a50`), as the usable compute `0x4e5050` reads it. #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum SlotResolve { - /// The slot IS this `(kind, id)` from here down: a SPELL or ITEM slot, or a macro whose - /// bound spell is live (`[rec+0x564] > 0`). + /// A spell or item slot, or a macro whose bound spell is live (`[rec+0x564] > 0`). Action(u8, u32), - /// A macro that exists but casts nothing (`[rec+0x564] == 0`): `0x4e5050`'s spell-less leg - /// (`0x4e50f4`–`0x4e516f`) answers **usable=1** off `0x4e5030` β€” "the slot's macro id is in - /// the macro table" β€” and computes nothing else: no cooldown, no range, no checked ring. + /// A macro that casts nothing (`[rec+0x564] == 0`): usable while it exists (`0x4e5030`), + /// with no cooldown, range or check (`0x4e50f4`-`0x4e516f`). BareMacro, - /// Not usable and nothing to report: a `/cast` whose name did not resolve (`-1`, which the - /// spell path refuses at `0x4e518b: jl`), or a slot whose macro no longer exists (`0x4e5030` - /// is 0 and `IsActionActive 0x4e55f0` has no spell to find). + /// Grey with nothing to report: a `/cast` that did not resolve (`-1`, refused at + /// `0x4e518b`), or a macro that no longer exists. Dead, } -/// Resolve a slot **through** a macro before any state is computed β€” the reference's own shape, -/// and the reason a macro button on the bar wears its spell's cooldown swirl, usability tint, -/// range colour and checked ring while showing its own icon. -/// -/// Every `Is*Action`/`GetActionCooldown` binding routes through the one slotβ†’spell resolver -/// `0x4e5a50`, whose MACRO arm resolves the macro record and returns `[rec+0x564]` as the slot's -/// spell id. So from here down, a macro that casts Fireball simply *is* the Fireball slot. -/// `GetActionTexture` is the deliberate exception β€” its macro arm keeps the macro's own icon -/// (`super::feed`). -/// -/// The zero is NOT "nothing to report" (0983's reading β€” B340's grey `.spawn` macro): the field -/// is three-valued and the usable compute reads each value differently, which [`SlotResolve`] -/// carries. Only the SPELL indirection is modelled: 1.12 has no `/use ` slash command, so -/// no 1.12 macro body can name an item and the resolver's item leg is unreachable from one. +/// Resolves a slot through its macro: every `Is*Action`/`GetActionCooldown` binding goes through +/// `0x4e5a50`, so a macro that casts Fireball is the Fireball slot, its own icon aside. Spells +/// only: 1.12 has no `/use`, so no macro names an item. fn resolve_through_macro( kind: u8, action: u32, @@ -98,7 +67,7 @@ fn resolve_through_macro( } } -/// Compute + diff-push every occupied slot's dynamic state, and fire the reference event edges. +/// Computes and diff-pushes every occupied slot's state, and fires the reference's events. #[allow(clippy::type_complexity)] // a Bevy system's full input set pub(super) fn feed_action_state( script: Option>, @@ -107,11 +76,7 @@ pub(super) fn feed_action_state( mut cooldowns: ResMut, clock: Res, auto_repeat: Res, - // One tuple param (Bevy's 16-SystemParam ceiling): our own cast tracking β€” the in-flight - // guard, the queued on-next-swing strike, the running channel, and the awaiting-click - // ground targeting β€” plus the macroβ†’spell binding the MACRO arm resolves through - // (decision 0983) and the talent spell-modifier tables that leg 12's cost reads through, - // both of which ride here for the same ceiling reason. + // One tuple for Bevy's 16-param ceiling: cast tracking, macro bindings and spell mods. cast_state: ( Res, Res, @@ -122,9 +87,7 @@ pub(super) fn feed_action_state( ), self_q: Query<(&ObjectStore, &Transform, Has, Option<&Casting>), With>, selection: Res, - // The object lookup (2334) β€” the guid index this system already resolved its selection - // through, plus the item-store read the ITEM arms need. One param, not two: this signature - // sits at Bevy's 16-SystemParam ceiling. + // One param for the guid index and the item store, for the same ceiling. objects: crate::net::Objects, units: Query<(&ObjectStore, &Transform), Without>, factions: Option>, @@ -138,9 +101,8 @@ pub(super) fn feed_action_state( }; let memory = memory.get(&script); let now = Instant::now(); - // The frame's atomic clock pair β€” `ui_triple`'s conversion base: every cooldown is pushed as - // its absolute start on the GetTime clock, derived through the ONE lawful pair - // ([`crate::ui_script::UiClock`]) so a running cooldown re-derives the same start every frame. + // Every cooldown pushes its absolute start on the `GetTime` clock, derived from this one + // pair, so a running cooldown derives the same start every frame. let (anchor, ui_now) = (clock.anchor, clock.ui_now); cooldowns.prune(now); let gen_changed = memory.last_generation != Some(cooldowns.generation); @@ -156,9 +118,7 @@ pub(super) fn feed_action_state( let (pending, queued_melee, channel, targeting, bound, spell_mods) = &cast_state; let me = self_q.iter().next(); - // The bags, walked ONCE for the frame: every reagent, totem and item-count question below - // reads this table. It used to be one whole walk per question β€” per reagent per spell slot, - // per item slot β€” for the same bags each time (1697 item 13). + // The bags, walked once per frame for every reagent, totem and item count below. let carried = me .map(|(s, _, _, _)| carried_counts(&s.0, &objects)) .unwrap_or_default(); @@ -170,7 +130,7 @@ pub(super) fn feed_action_state( let current_cast = pending.current(now).or(casting_spell); let self_reach = me.map_or(1.5, |(s, _, _, _)| s.0.unit_combat_reach()); let self_pos = me.map(|(_, t, _, _)| t.translation); - // The current target's reach + squared distance (the client tests dxΒ²+dyΒ²+dzΒ² β€” 0x6e47b0). + // The target's reach and squared distance (the reference tests dxΒ²+dyΒ²+dzΒ², `0x6e47b0`). let target = selection .guid .and_then(|g| objects.entity(g)) @@ -188,12 +148,9 @@ pub(super) fn feed_action_state( let (kind, id) = match resolve_through_macro(button.kind, button.action, bound) { SlotResolve::Action(kind, id) => (kind, id), SlotResolve::BareMacro => { - // The spell-less leg of `0x4e5050`: the macro exists, so the slot is usable β€” - // full colour on the bar β€” and there is no other state to compute (1636). The - // leg's one gate benilla does not model is `[0xb4b3e4]`, the player-control - // flag (1 from boot, `0x48f626`; 0 only across a control loss β€” taxi/fear/charm β€” - // through `0x4958e0`); for that span the reference greys every spell-less - // macro and item. + // `0x4e5050`'s spell-less leg: usable. It also greys every spell-less macro and + // item while the player-control flag `[0xb4b3e4]` is clear (taxi, fear, charm, + // `0x4958e0`; set at boot, `0x48f626`); that gate is not built. st.usable = true; fresh.insert(action, st); continue; @@ -216,32 +173,22 @@ pub(super) fn feed_action_state( continue; }; st.is_attack = d.is_melee_auto_attack(); - // The Attack action is "current" while auto-attack is engaged; a castable - // spell while it is our in-flight cast OR our queued on-next-swing strike OR our - // running channel (the ref reads one inflight id `0xceca88` β€” which a queued - // Heroic Strike *occupies* until the swing fires it β€” plus the channel id - // `0xceac58`; our model splits the queue into its own slot, same observable) β€” - // OR the shapeshift arm (`IsCurrentAction`'s predicate `0x4e53a0` @ `0x4e5556`): - // a MOD_SHAPESHIFT spell whose form == the player's form byte reads checked. - // Deliberately NOT the icon's aura-scan predicate β€” the two are different - // functions in the binary and the asymmetry is load-bearing (a form granted by a - // different spell lights the check without swapping the icon). + // `IsCurrentAction 0x4e53a0`: Attack while engaged; a spell while in flight + // (`0xceca88`, which a queued on-swing strike holds), channelled (`0xceac58`), or + // when its form is ours (`0x4e5556`). The form arm is not the icon's aura scan: + // a form granted by another spell lights the check but not the icon. st.current = if st.is_attack { engaged } else { current_cast == Some(button.action) || queued_melee.current() == Some(button.action) || channel.current(now) == Some(button.action) - // The awaiting-target arm (`0x4e53a0` @ `0x4e54d0`: the `0x6e48e0` - // targeting-spell read) β€” checked while the ground click is pending. + // Checked while its ground click is pending (`0x4e54d0`, `0x6e48e0`). || targeting.spell() == Some(button.action) || (form_byte != 0 && d.shapeshift_form == Some(u32::from(form_byte))) }; st.auto_repeat = auto_repeat.0 == Some(button.action); - // The full usable walk (`0x6e3d60` β€” [`super::usable`]): reagents, combo - // points, forms, stealth, aura states, the bit-25 cooldown fold, and the power - // gate (the sole notEnoughMana writer). Target-dependent for the Execute family - // only. `spells` is necessarily Some here β€” `d` came out of it. + // The usable walk (`0x6e3d60`); only its power gate sets `notEnoughMana`. if let (Some((store, _, _, _)), Some(sp)) = (me, spells.as_deref()) { let ctx = usable::UsableCtx { store, @@ -266,7 +213,7 @@ pub(super) fn feed_action_state( } else { st.usable = true; } - // The range verdict vs the current target (`0x4e56f0`); nil without one. + // The range verdict against the target (`0x4e56f0`); nil without one. let row = spells.as_ref().and_then(|s| s.ranges.get(d.range_index)); let resolved = benilla_formats::min_max_range(d, row, self_reach, target_reach); st.has_range = resolved @@ -280,8 +227,7 @@ pub(super) fn feed_action_state( let info = cooldowns.info(button.action, 0, Some(d), now); st.cooldown = info.ui_triple(anchor, ui_now); if st.cooldown.is_some() && trace_cd { - // The store (Instant clock) vs the widget (GetTime clock) β€” the sink - // stamps the wall time, so drift between the two clocks reads directly. + // The store's clock against the widget's; the sink stamps wall time. benilla_assets::trace::line( "cd", &format!( @@ -292,16 +238,14 @@ pub(super) fn feed_action_state( } } ACTION_KIND_ITEM => { - // Only the on-use spell leaves the template (`ItemUseSpell` is `Copy`) β€” not a - // clone of the whole `ItemInfo` (its Strings and Vecs) per item slot per frame. + // Only the `Copy` on-use spell, not a clone of the template per slot per frame. let use_spell = items .template(button.action, 0, &commands) .and_then(|t| t.use_spell); - // `IsConsumableAction` is NOT fed from here. It reads nothing but this template - // (`0x4e5250`), so it is slot IDENTITY, and it rides the identity feed's push - // beside the count it gates β€” `super::feed`'s ITEM arm, decision 1301. + // `IsConsumableAction` (`0x4e5250`) reads only the template, so the identity + // feed pushes it with the count it gates, not this one. let count = carried.get(&button.action).copied().unwrap_or(0); - // Worn on any equipment slot (0..18) β€” the green border's IsEquippedAction. + // `IsEquippedAction`, the green border: worn in any equipment slot (0..18). st.equipped = me.is_some_and(|(s, _, _, _)| { (0..19).any(|i| { s.0.player_inv_slot(i) @@ -310,11 +254,9 @@ pub(super) fn feed_action_state( == Some(button.action) }) }); - // The rest of `0x4e5050`'s ITEM arm β€” the count gate, `IsItemOnCooldown`, and - // the item's on-use spell run through the SAME `0x6e3d60` walk a spell slot - // takes ([`super::usable::item_usable`]). Food greys in combat from leg 8 there. - // No active player and the reference answers (0,0) before resolving anything - // (`0x4e5080`) β€” which is `ActionState::default()`'s `usable`. + // `0x4e5050`'s item arm runs the count, `IsItemOnCooldown` and the on-use spell + // through the same `0x6e3d60` walk. With no player the reference answers (0, 0) + // (`0x4e5080`), the default state. if let Some((store, _, _, _)) = me { let ctx = usable::UsableCtx { store, @@ -346,15 +288,11 @@ pub(super) fn feed_action_state( } _ => {} } - // No between-generation carry: the triple holds the ABSOLUTE start, so one running - // cooldown re-derives the same value every frame (no diff churn) and a re-arm derives a - // new one (the sweep restarts). The old `(remaining, duration)` carry-the-stale-triple - // scheme aliased a fail-clear+re-arm inside one inter-feed gap into "unchanged" β€” the - // vanished-GCD-pie-on-spam bug. + // The triple holds the absolute start, so a running cooldown diffs equal every frame and + // a re-arm, even within one frame gap, restarts the sweep. fresh.insert(action, st); } - // Diff-push + collect which event families changed. let mut usable_changed = false; let mut state_changed = false; let keys: Vec = fresh @@ -391,7 +329,7 @@ pub(super) fn feed_action_state( } memory.pushed = fresh; - // The event edges, in the client's own flush order (the ACTIONBAR_* sibling first). + // The events in the reference's flush order, the `ACTIONBAR_*` one first. if gen_changed { script.fire_event("ACTIONBAR_UPDATE_COOLDOWN", vec![]); script.fire_event("SPELL_UPDATE_COOLDOWN", vec![]); @@ -434,11 +372,7 @@ mod tests { use super::*; use benilla_formats::SpellDisplay; - /// A MACRO slot resolves through its bound spell for EVERY dynamic read (decision 0983) β€” - /// the `0x4e5a50` law β€” and the three values of `[rec+0x564]` split three ways at the usable - /// compute (decision 1636): a live spell IS that spell; a macro that casts nothing is a bare, - /// usable button (B340's `.spawn` macro); an unresolved `/cast` β€” or a slot whose macro is - /// gone β€” is grey. + /// `[rec+0x564]`'s three values (`0x4e5a50`): a live spell, a bare macro, and grey. #[test] fn a_macro_slot_resolves_through_its_bound_spell() { use crate::ui_macro::BoundSpell; @@ -469,7 +403,6 @@ mod tests { SlotResolve::Dead, "a slot whose macro no longer exists: grey" ); - // Spell and item slots pass through untouched. assert_eq!( resolve_through_macro(ACTION_KIND_SPELL, 133, &bound), SlotResolve::Action(ACTION_KIND_SPELL, 133) @@ -480,11 +413,6 @@ mod tests { ); } - /// The feed end to end, at the symptom (B340): a MACRO slot whose macro casts nothing is - /// pushed **usable** β€” `IsUsableAction` answers true in the VM, the full-colour icon β€” while - /// a `/cast` of an unknown spell, and a slot whose macro is gone, are pushed grey. The - /// pre-1636 feed pushed `ActionState::default()` for all three, whose `usable` is false: every - /// GM `.spawn` macro on the bar was grey. #[test] fn the_feed_pushes_a_bare_macro_as_usable() { use crate::ui_macro::{BoundSpell, MacroBoundSpells}; @@ -503,8 +431,7 @@ mod tests { }, ); } - // Macro 1 is `.spawn 16032`, macro 2 is `/cast Pyroblast` with Pyroblast unknown, and - // macro 3 does not exist. + // Macro 1 is `.spawn 16032`, 2 an unknown `/cast Pyroblast`, and 3 does not exist. let mut bound = MacroBoundSpells::default(); bound.0.insert(1, BoundSpell::None); bound.0.insert(2, BoundSpell::Unresolved); @@ -549,24 +476,17 @@ mod tests { ); } - /// **Food on the bar greys in combat** β€” the feed end to end, at the symptom. An ITEM slot's - /// usable verdict is the reference's `0x4e5050` ITEM arm, which resolves the item's on-use - /// spell (`0x4e5a50`) and walks it through `Spell_C::IsSpellUsableNow 0x6e3d60`; every - /// Food/Drink spell in the shipped `Spell.dbc` carries `Attributes` bit 28 - /// (`ATTR_NOT_IN_COMBAT`, the walk's leg 8), so a stack of food is grey while - /// `UNIT_FLAG_IN_COMBAT` is up and full-colour the moment it drops. Before this test the - /// ITEM arm answered `count > 0 || equipped` and nothing else, and food stayed lit. + /// An item slot's on-use spell takes the `0x6e3d60` walk (`0x4e5050`), and every shipped + /// Food/Drink spell has `Attributes` bit 28, `ATTR_NOT_IN_COMBAT` (the walk's leg 8). #[test] fn food_on_the_bar_greys_while_the_player_is_in_combat() { use benilla_protocol::messages::{ActionButton, ItemUseSpell}; use benilla_protocol::ObjectFields; - // `Conjured Muffin`-shaped: one ON_USE block casting spell 433 "Food", which the shipped - // DBC gives `Attributes = 0x18000100` β€” bit 28 among them. + // One ON_USE block casting 433 "Food", whose shipped `Attributes` are `0x18000100`. const FOOD_ITEM: u32 = 1487; const FOOD_SPELL: u32 = 433; - // Descriptor indices, raw (the codebase's test idiom): `ITEM_FIELD_STACK_COUNT` and - // `PLAYER_FIELD_PACK_SLOT_1` β€” the backpack's first slot, the walker's CARRIED section. + // Raw indices: `ITEM_FIELD_STACK_COUNT`, and `PLAYER_FIELD_PACK_SLOT_1`, backpack slot 1. const STACK: u16 = 14; const PACK_SLOT_1: u16 = 532; @@ -624,7 +544,6 @@ mod tests { .init_resource::() .insert_resource(items) .insert_resource(NetCommands(tx)); - // The ten muffins, as the one index's own item entity (2334). crate::items::test_spawn_item( app.world_mut(), 0xF0, diff --git a/crates/benilla-app/src/ui_action/toggle.rs b/crates/benilla-app/src/ui_action/toggle.rs index ed63e664..e6c7776b 100644 --- a/crates/benilla-app/src/ui_action/toggle.rs +++ b/crates/benilla-app/src/ui_action/toggle.rs @@ -1,31 +1,17 @@ -//! The plain-path **press-again-to-cancel** toggles. The reference client cancels an active -//! toggle spell in its DISPATCHERS, above `TryCast 0x6e4b60` (which itself has no cancel branch), -//! and the two plain paths are deliberately asymmetric: -//! -//! - **Active-action toggle** ([`active_action_toggle`], predicate `0x4e55f0` and its byte-twin -//! `0x4b36f0`) β€” on BOTH `UseAction 0x4e5ee0` (cancel `0x4e60c1`) and the `CastSpell`/ -//! `CastSpellByName` dispatcher `0x4b3300` (cancel `0x4b3466`): the spell's raw -//! **ActiveIconID β‰  0** and its own id sits in a live `UNIT_FIELD_AURA` slot with the -//! AURAFLAGS **cancelable bit** (nibble bit 0). No `SpellShapeshiftForm.dbc` guard β€” the -//! data is the guard (a warrior stance has ActiveIconID 0, so the predicate never fires). -//! Ghost Wolf, the druid forms, Stealth and Shadowform all carry a nonzero column. -//! - **Form-match toggle** ([`form_recast_disposition`], `0x4b348b`–`0x4b35e5`) β€” ONLY inside -//! `0x4b3300`, never `UseAction`: the spell's MOD_SHAPESHIFT form equals the caster's form -//! byte β†’ cancel (`0x4b35da`), guarded by `SpellShapeshiftForm.flags1 & 0x2` (`0x4b35cf`, the -//! separately-compiled twin of the stance bar's `0x4b4963`): guard set = **silent no-op** -//! (bare epilogue, not a fall-through to cast). -//! -//! Both cancels are `Spell_C::CancelAura 0x6e7040` β†’ `CMSG_CANCEL_AURA` β€” wire-identical to a -//! buff right-click. Its own internal guard (`AttributesEx & 0x2000` set and `& 0x4` clear) -//! never fires for the shipped toggle spells, and stays unmodeled. +//! Press-again-to-cancel. The reference cancels in its dispatchers, above `TryCast 0x6e4b60`, +//! and the two toggles differ: the active-action one runs in both `UseAction 0x4e5ee0` (cancel +//! `0x4e60c1`) and the `CastSpell` dispatcher `0x4b3300` (cancel `0x4b3466`), the form-match one +//! (`0x4b348b`-`0x4b35e5`, cancel `0x4b35da`) in `CastSpell` only. Both cancel through +//! `CancelAura 0x6e7040`, the buff right-click's `CMSG_CANCEL_AURA`; its own guard +//! (`AttributesEx & 0x2000` set, `& 0x4` clear) never fires for a shipped toggle and is not built. use benilla_formats::{ShapeshiftForm, SpellDisplay}; use crate::net::ObjectStore; -/// The generic active-action toggle predicate (`0x4e55f0`/`0x4b36f0`): a nonzero raw -/// ActiveIconID, and the spell's OWN id live in the player's aura slots with the cancelable -/// flag. True = the press cancels the aura instead of casting. +/// The active-action predicate (`0x4e55f0`, twin `0x4b36f0`): a nonzero raw `ActiveIconID` and +/// the spell's own aura live with the cancelable bit, so the press cancels instead of casting. +/// No form guard: a warrior stance has `ActiveIconID` 0. pub(crate) fn active_action_toggle(spell_id: u32, d: &SpellDisplay, store: &ObjectStore) -> bool { d.active_icon_id != 0 && store @@ -34,10 +20,9 @@ pub(crate) fn active_action_toggle(spell_id: u32, d: &SpellDisplay, store: &Obje .any(|a| a.spell_id == spell_id && a.is_cancelable()) } -/// The shapeshift-specific recast fork (`0x4b348b`–`0x4b35e5`, and the stance bar's own -/// MOD_SHAPESHIFT case β€” the same twin guard): `None` = not the active form, fall through to -/// the cast; `Some(true)` = cancel the form aura; `Some(false)` = the non-cancelable guard -/// (`SpellShapeshiftForm.flags1 & 0x2`) β€” a silent no-op. +/// The form-match recast: `None` casts (not the active form), `Some(true)` cancels the form, +/// `Some(false)` is a silent no-op when `SpellShapeshiftForm.flags1 & 0x2` is set (`0x4b35cf`, +/// twin of the stance bar's `0x4b4963`). pub(crate) fn form_recast_disposition( d: &SpellDisplay, form_byte: u8, @@ -54,8 +39,7 @@ mod tests { use super::*; use benilla_protocol::ObjectFields; - /// Aura field indices mirrored from the protocol crate's own consts (private there): - /// UNIT_FIELD_AURA 47, UNIT_FIELD_AURAFLAGS 95 (nibble-packed, 8 slots per u32). + /// Raw indices: `UNIT_FIELD_AURA` 47, `UNIT_FIELD_AURAFLAGS` 95 (a nibble per slot). fn store_with_aura(spell_id: u32, flags_nibble: u32) -> ObjectStore { ObjectStore(ObjectFields::from_pairs(&[ (47u16, spell_id), @@ -63,7 +47,6 @@ mod tests { ])) } - /// Mechanism 1: fires only on ActiveIconID β‰  0 AND own live cancelable aura. #[test] fn active_action_toggle_needs_the_icon_and_the_live_cancelable_aura() { let ghost_wolf = SpellDisplay { @@ -71,23 +54,18 @@ mod tests { ..Default::default() }; let plain = SpellDisplay::default(); - // Occupied (eff-index bit) + cancelable bit 0 β†’ 0xb covers both. + // 0xb: occupied (effect-index bits) and cancelable (bit 0). let shifted = store_with_aura(2645, 0xb); assert!(active_action_toggle(2645, &ghost_wolf, &shifted)); - // No active icon: never a toggle, whatever the aura says. assert!(!active_action_toggle(2645, &plain, &shifted)); - // Aura present but NOT cancelable (eff-index bits only): no toggle. + // 0x2: occupied, not cancelable. let uncancelable = store_with_aura(2645, 0x2); assert!(!active_action_toggle(2645, &ghost_wolf, &uncancelable)); - // A different spell's aura: no toggle. assert!(!active_action_toggle(768, &ghost_wolf, &shifted)); - // No aura at all: no toggle. let bare = ObjectStore(ObjectFields::from_pairs(&[])); assert!(!active_action_toggle(2645, &ghost_wolf, &bare)); } - /// Mechanism 2: active-form match forks cancel / silent no-op on the flags1 & 0x2 guard; - /// any other form falls through. #[test] fn form_recast_forks_on_the_active_form_and_the_cancelable_guard() { let ghost_wolf = SpellDisplay { diff --git a/crates/benilla-app/src/ui_action/weapon_icon.rs b/crates/benilla-app/src/ui_action/weapon_icon.rs index a51e4d36..801c49aa 100644 --- a/crates/benilla-app/src/ui_action/weapon_icon.rs +++ b/crates/benilla-app/src/ui_action/weapon_icon.rs @@ -1,12 +1,6 @@ -//! **Weapon-icon substitution** β€” the handful of spells that show an *equipped weapon's* icon -//! instead of their own (decisions 0230 + 0231; `0x4e6870` melee, `0x4e6990` ranged). -//! -//! Two spells' worth of law, but it is character-level rather than spell-level: the melee -//! auto-attack borrows the main hand's icon (or `Spell-Reset` when unarmed), a ranged auto-repeat -//! shot borrows the ranged slot's (unless it is thrown, which keeps the spell's own face). Both -//! track the *equipped item*, which a weapon swap changes without ever touching the action table β€” -//! which is why [`super::feed`] refreshes these every frame rather than on the identity gate, and -//! why `ui_spellbook` pre-resolves them once per page. +//! The spells that show an equipped weapon's icon: the melee auto-attack (`0x4e6870`) and the +//! ranged auto-repeat shots (`0x4e6990`). A weapon swap changes the icon without touching the +//! action table, so the feed refreshes these every frame. use benilla_formats::SpellDisplay; @@ -15,32 +9,21 @@ use crate::entities::ItemDisplays; use crate::items::Items; use crate::net::{NetCommands, ObjectStore, Objects}; -/// Equipment slot 15 = `EQUIPMENT_SLOT_MAINHAND` (vmangos `EquipmentSlots`). const EQUIPMENT_SLOT_MAINHAND: u8 = 15; -/// Equipment slot 17 = `EQUIPMENT_SLOT_RANGED` β€” the ranged helper `0x4e6990`'s read -/// (`[ecx+0x88]`, `0x88 = 17Γ—8`). +/// The ranged helper's read, `[ecx+0x88]` in `0x4e6990` (17 Γ— 8). const EQUIPMENT_SLOT_RANGED: u8 = 17; -/// Weapon subclass 16 = thrown β€” the ranged icon helper's skip (`0x4e6990`'s `0x5d9f90 == 0x10` -/// test): a thrown weapon never substitutes its icon, so Throw keeps the spell's own face. +/// A thrown weapon keeps the spell's own icon (`0x4e6990` tests `0x5d9f90 == 0x10`). const ITEM_SUBCLASS_THROWN: u32 = 16; -/// The client's unarmed/disarmed auto-attack icon (the hardcoded string at `0x84bf58`) β€” what the -/// melee auto-attack shows when there is no main-hand weapon to borrow from, instead of spell -/// 6603's `Temp` placeholder (decision 0231). +/// The unarmed auto-attack icon, hardcoded at `0x84bf58`, in place of Attack's own `Temp`. const SPELL_RESET_ICON: &str = "Interface\\Buttons\\Spell-Reset"; -/// `ItemClass` 2 β€” **WEAPON**: what the disarmed guard tests on the hand it just fetched -/// (`0x4e68df`'s `GetWeapon(0, 1)` result byte `== 2`). Disarm takes weapons, so a main hand -/// holding anything else keeps showing that item's own icon. +/// The disarmed guard's class test (`0x4e68df`): a main hand holding a non-weapon keeps its icon. const ITEM_CLASS_WEAPON: u32 = 2; -/// The equipped main-hand item's `(item class, its inventory icon)` β€” slot 15 β†’ the item's -/// `ItemDisplayInfo` icon, the chain the bags/paper doll use. `None` for an empty hand or an item -/// that hasn't streamed yet; the icon half alone is `None` when the display row or its texture -/// hasn't. Both halves come off ONE walk because the disarmed guard needs the class of the very -/// item whose icon would otherwise be shown. +/// The main-hand item's `(class, icon)`; the icon is `None` until its display row loads. fn main_hand_item( store: &ObjectStore, objects: &Objects, @@ -58,19 +41,9 @@ fn main_hand_item( Some((class, icon)) } -/// The character's melee auto-attack icon (decision 0231; the client's melee helper `0x4e6870`). -/// The helper's four steps, in order: -/// -/// 1. the **current shapeshift form's own attack face** when its `SpellShapeshiftForm` row carries -/// one (the `+0x34` AttackIconID read, `0x4e68af`–`0x4e68da` β€” a cat's paw, a bear's swipe; -/// closing decision 0231's deferred form case); -/// 2. the **disarmed guard** (`0x4e68df`) β†’ [`SPELL_RESET_ICON`], weapon equipped or not -/// (decision 1863, closing 0231's other deferred case); -/// 3. the equipped main-hand weapon's icon; -/// 4. no main-hand item β†’ [`SPELL_RESET_ICON`]. -/// -/// Character-level β€” independent of WHICH auto-attack spell (they all show this), so the -/// spellbook can pre-resolve it once for its whole page. +/// The melee auto-attack icon (`0x4e6870`), first match wins: the form's `AttackIconID` +/// (`+0x34`, `0x4e68af`-`0x4e68da`), `Spell-Reset` while disarmed (`0x4e68df`), the main-hand +/// icon, then `Spell-Reset` for an empty hand. pub(crate) fn melee_auto_attack_icon( store: &ObjectStore, forms: &std::collections::HashMap, @@ -89,11 +62,8 @@ pub(crate) fn melee_auto_attack_icon( } } let main = main_hand_item(store, objects, items, icons, commands); - // Precedence step 2 β€” the **disarmed guard** (`0x4e68df`: `test dword ptr [ecx+0xa0], - // 0x200000`, then `GetWeapon(0, 1)` and a `== 2` on the returned class byte): while the - // character is disarmed, a weapon in the main hand shows `Spell-Reset` exactly as an empty - // hand does β€” the weapon is equipped and on screen, but the button reads unarmed, because - // the swing it fires is (decision 1863, closing 0231's deferred case). + // The disarmed guard (`0x4e68df`): a disarmed weapon hand shows `Spell-Reset`, as its swing + // is unarmed. if store.0.unit_flags() & UNIT_FLAG_DISARMED != 0 && main .as_ref() @@ -105,11 +75,8 @@ pub(crate) fn melee_auto_attack_icon( .unwrap_or_else(|| SPELL_RESET_ICON.to_string()) } -/// The equipped ranged weapon's inventory icon (slot 17 β†’ `ItemDisplayInfo`), for the ranged -/// icon substitution (`0x4e6990`, decision 0231's deferred case): a **thrown** weapon is skipped -/// (the helper's `0x5d9f90 == 0x10` test), and `None` β€” missing weapon, thrown, or an unstreamed -/// item β€” falls back to the spell's OWN icon at the caller, never `Spell-Reset` (the helper's -/// `0x4e6a44` null return hands over to the normal SpellIconID path). +/// The ranged weapon's icon (`0x4e6990`). `None` (no weapon, thrown, unstreamed) shows the +/// spell's own icon, never `Spell-Reset` (`0x4e6a44`). pub(crate) fn ranged_weapon_icon( store: &ObjectStore, objects: &Objects, @@ -127,20 +94,12 @@ pub(crate) fn ranged_weapon_icon( icons?.catalog.get(display)?.icon.clone() } -/// Whether `spell` substitutes an equipped weapon's icon at all β€” the two resolvers' shared -/// pre-test (melee: the effect trigger; ranged: the paired attribute bits). The per-frame icon -/// refresh keys on this, so a ranged-weapon swap re-feeds Auto Shot like a main-hand swap -/// re-feeds Attack. +/// Whether `spell` shows a weapon's icon; the per-frame icon refresh keys on it. pub(super) fn substitutes_weapon_icon(spell: &SpellDisplay) -> bool { spell.is_melee_auto_attack() || spell.ranged_icon_substitution() } -/// The icon `spell` shows on the action bar when it substitutes an equipped weapon's -/// ([`substitutes_weapon_icon`]): the melee auto-attack shows [`melee_auto_attack_icon`] -/// (weapon or `Spell-Reset`); a ranged auto-repeat shot ([`SpellDisplay::ranged_icon_substitution`]) -/// shows [`ranged_weapon_icon`]. `None` for any other spell, for a ranged shot with no -/// substitutable weapon, or when there is no character to read the weapon from β€” the caller uses -/// the spell's own icon. +/// The weapon icon `spell` shows on the bar; `None` shows the spell's own. pub(super) fn auto_attack_icon( spell: &SpellDisplay, store: Option<&ObjectStore>, @@ -172,9 +131,8 @@ mod tests { use super::*; use crate::items::TestDeps; - /// `PLAYER_FIELD_INV_SLOT_HEAD + 2Γ—15` (the main hand's private item guid), `UNIT_FIELD_FLAGS` - /// and `UNIT_FIELD_BYTES_1` (the form byte lives in its third byte) β€” raw wire indices, the - /// constants being crate-private to benilla-protocol. + /// Raw indices: `PLAYER_FIELD_INV_SLOT_HEAD + 2Γ—15` (the main hand), `UNIT_FIELD_FLAGS`, and + /// `UNIT_FIELD_BYTES_1`, whose third byte is the form. const INV_SLOT_MAINHAND: u16 = 486 + 2 * 15; const UNIT_FLAGS: u16 = 46; const UNIT_BYTES_1: u16 = 138; @@ -183,10 +141,8 @@ mod tests { const SWORD_ICON: &str = "Interface\\Icons\\INV_Sword_04"; const BEAR_ICON: &str = "Interface\\Icons\\Ability_Racial_BearForm"; - /// One `melee_auto_attack_icon` resolve. `hand` is the item CLASS held in the main hand - /// (`None` = empty), `flags` the descriptor's `UNIT_FIELD_FLAGS`, `form` the shapeshift byte. - /// The item is always the same sword display, so any change in the answer is the law moving, - /// not the fixture. + /// One resolve. `hand` is the main-hand item's class (`None` for empty), always with the + /// sword's display, so only the law moves the answer. fn icon(flags: u32, hand: Option, form: u8) -> String { let mut deps = TestDeps::new(); let mut pairs = vec![(UNIT_FLAGS, flags), (UNIT_BYTES_1, u32::from(form) << 16)]; @@ -225,32 +181,24 @@ mod tests { }) } - /// **The disarmed guard on the Attack button** (`0x4e68df`, decision 1863 closing 0231's - /// deferred case): a disarmed character's auto-attack shows `Spell-Reset` with the weapon - /// still equipped β€” the same face an empty hand shows, because the swing it fires is the - /// same bare-handed one. + /// The disarmed guard (`0x4e68df`). #[test] fn a_disarmed_character_shows_spell_reset_though_armed() { - // CONTROL β€” armed, and the button wears the sword. assert_eq!(icon(0, Some(2), 0), SWORD_ICON); - // The guard. assert_eq!(icon(DISARMED, Some(2), 0), SPELL_RESET_ICON); - // Its `== 2` half (the recursed `GetWeapon(0, 1)` class test): Disarm takes weapons, so a - // main hand holding anything else keeps showing that item. + // The class half: a disarmed non-weapon (class 4) keeps its own icon. assert_eq!(icon(DISARMED, Some(4), 0), SWORD_ICON); - // The step-4 fallback is the same string, and is NOT what the guard is being read from: - // an empty hand shows `Spell-Reset` whether the flag is up or down. + // An empty hand shows `Spell-Reset` with or without the flag. assert_eq!(icon(0, None, 0), SPELL_RESET_ICON); assert_eq!(icon(DISARMED, None, 0), SPELL_RESET_ICON); } - /// The precedence the helper `0x4e6870` reads in: the **form** override is step 1 and the - /// disarmed guard step 2, so a disarmed bear still swipes with its own paw. + /// `0x4e6870` tests the form before the disarmed guard. #[test] fn the_form_icon_outranks_the_disarmed_guard() { assert_eq!(icon(DISARMED, Some(2), 1), BEAR_ICON); assert_eq!(icon(0, Some(2), 1), BEAR_ICON, "and outranks the weapon"); - // A form with no attack icon of its own falls through to the rest of the ladder. + // A form with no attack icon falls through. assert_eq!(icon(DISARMED, Some(2), 2), SPELL_RESET_ICON); assert_eq!(icon(0, Some(2), 2), SWORD_ICON); } diff --git a/crates/benilla-app/src/ui_auction/mod.rs b/crates/benilla-app/src/ui_auction/mod.rs index aaa7c52e..388e2609 100644 --- a/crates/benilla-app/src/ui_auction/mod.rs +++ b/crates/benilla-app/src/ui_auction/mod.rs @@ -1,47 +1,11 @@ -//! The app-side **auction feed** (decision 1511 P1) β€” the inward half of the auction seam around -//! [`benilla_ui::script`]'s `auction` module, the auctioneer twin of [`crate::ui_merchant`]'s -//! vendor seam and [`crate::ui_mail`]'s mailbox one. +//! The auction window's app side: [`net`] fills [`AuctionOpen`] from the wire, [`feed_auction`] +//! pushes it into the VM and fires its events, and [`drain_auction`] sends the Lua intents. //! -//! **The window is opened by the server, not by the click.** A right-click on an auctioneer -//! ([`crate::target`]) sends `MSG_AUCTION_HELLO` and nothing else happens; the *reply* β€” the same -//! opcode coming back with the auctioneer guid and an `AuctionHouse.dbc` house id β€” is what opens -//! the session here (the window's opener `0x4cd570` calls `SetInteractNPC` and fires -//! `AUCTION_HOUSE_SHOW` from inside the hello handler). That house id is load-bearing rather than -//! decorative: it keys the deposit rate the sell pane displays, and the six faction houses charge -//! 5% where the neutral goblin house charges 25%. -//! -//! **Three lists, one session β€” and three separate events.** Browse (`"list"`), Bids (`"bidder"`) -//! and Auctions (`"owner"`) are three independent server queries into one window. Each holds at -//! most one 50-row page β€” the server's own cap β€” plus the pre-cap match count the pager needs, -//! plus its own sort stack, plus its own update event. The reference keeps them just as far apart -//! (three arrays allocated by `0x4cc0f0`, three counts, three sort stacks, and every -//! `AUCTION_*_LIST_UPDATE` fired by whatever just touched *its* array). -//! So a list-update event here is a statement about **one** list; firing the other two runs the -//! stock addon's other tabs over state they were never given, and the Auctions tab answers that -//! by crashing (decision 2308). -//! -//! **Sorting is ours and paging is the server's**, which is the split that shapes this module: a -//! header click re-orders rows we already hold and sends nothing ([`sort`]), while a page turn -//! re-sends the whole query. Nothing about the sort ever reaches the wire. -//! -//! **The browse throttle is real and it is silent.** The client refuses a browse query inside 5 s -//! of the last one and *drops it with no failure event*, which is why the Search button polls -//! [`benilla_ui::script::UiScript::set_auction_can_query`] every frame instead of reacting to a -//! refusal. The window opening clears the gate, so the first search is always allowed. (INTERIM, -//! decision 1511.) -//! -//! The packet handlers ([`net`], in the net handler table β€” decision 2305) fill [`AuctionOpen`] -//! from the wire. Each frame -//! [`feed_auction`] resolves each [`AuctionListEntry`] to a Lua-facing row (name/quality/icon via -//! the ask-once item-template cache + `ItemDisplayInfo.dbc`, seller via the ask-once name cache, -//! the time-left bucket from the wire's milliseconds), applies the sort, pushes the snapshot, and -//! fires the events the reference Lua drives. [`drain_auction`] pulls the Lua intents back out -//! into the auction `CMSG`s. The standardized NPC-session range guard ([`crate::ui_session`]) -//! client-side-closes the window when the player walks away from the auctioneer. That radius is -//! the auction window's own rather than borrowed: its interaction cell `[0xb72410]` -//! holds `30.864194869995117` β€” a radius of `5.5555553` yd β€” which is exactly the service gate -//! the cursor already greys at, so the guard's existing constant is the right one. The close -//! sends no packet, also byte-confirmed. +//! The hello reply opens the window, not the click (`0x4cd570` fires `AUCTION_HOUSE_SHOW` inside +//! the hello handler). Browse, Bids and Auctions are independent lists, as in the reference's +//! three arrays (`0x4cc0f0`), each one 50-row page with its own match count, sort stack and update +//! event. Walking out of the 5.56 yd service range closes the window with no packet (`[0xb72410]` +//! holds 30.864, that radius squared). use bevy::prelude::*; @@ -65,48 +29,23 @@ use sort::SortStack; mod net; -/// The browse query rate limit, in seconds (`QueryAuctionItems 0x4ce980`): the reference arms the -/// gate with `tick + 0x1388` *after* the packet goes out, re-checks it inside the query itself, -/// and its refusal path fires **nothing at all** β€” no event, no error. That silence is why the -/// Search button polls the gate every frame instead of waiting to be told no. +/// The browse rate limit: `0x4ce980` arms `tick + 0x1388` once sent; a refused query fires nothing. const QUERY_THROTTLE_SECS: f64 = 5.0; -/// The time-left buckets, in milliseconds β€” the thresholds the reference's four -/// `AUCTION_TIME_LEFT` strings key off (the table at `0x8072a8`). +/// The upper bounds, in ms, of time-left buckets 1 to 3 (the reference's table at `0x8072a8`). const TIME_LEFT_SHORT_MS: u32 = 30 * 60 * 1000; const TIME_LEFT_MEDIUM_MS: u32 = 2 * 60 * 60 * 1000; const TIME_LEFT_LONG_MS: u32 = 8 * 60 * 60 * 1000; -/// Past this, the wire's `time_left_ms` is not a duration β€” it is an underflow. -/// -/// The server writes `(expireTime - now) * 1000` with **no clamp** and only sweeps expiry on a -/// 60 s timer, so an auction that ran out less than a minute ago is still listed with a negative -/// remaining time that arrives as a `u32` near 4.29 billion. The longest auction anyone can create -/// is 24 h, so anything beyond a few days is that wrap and not a long auction β€” it reads as -/// **expired**, never as "Very Long" (decision 1511's verified section). +/// Past this, `time_left_ms` is an underflow and reads as expired: vmangos writes it unclamped +/// (`AuctionHouseMgr.cpp:838`) and sweeps expiry every 60 s, and no auction runs past 24 h. const TIME_LEFT_IMPLAUSIBLE_MS: u32 = 7 * 24 * 60 * 60 * 1000; -/// `ItemSubClass.dbc` `DisplayFlags` bit 1 β€” this subclass is **not offered** in the auction -/// house's category filter. -/// -/// Read directly off the shipped table when the polarity was still ambiguous, and since -/// **independently confirmed at the bytes** (`0x4cf9c0`). Guessing it backwards would have -/// emptied the filter instead of trimming it: every row carrying the bit is an obsolete or unused subclass β€” -/// Spear, Buckler(OBSOLETE), the OBSOLETE quivers, bolts and wands, Engineering Bag β€” and every -/// subclass a player can actually buy (Cloth, Leather, Mail, Plate, Shield, Arrow, Bullet, the -/// weapon families, the nine Recipe professions) has it clear. +/// `ItemSubClass.dbc` `DisplayFlags` bit 1: left out of the auction category filter (`0x4cf9c0`). const SUBCLASS_HIDDEN_FROM_AUCTIONS: u32 = 0x2; -/// The ten item classes the auction house offers, in the reference's own menu order. -/// -/// The set and the order are a structural fact of the window, not text: the four `(OBSOLETE)` -/// classes plus Quest and Key are simply not auctionable, and every id here was checked to be a -/// real `ItemClass.dbc` row. **Every string the filter displays comes from the player's own DBC**, -/// which is what keeps this feature clear of decisions 1234/1260 β€” we ship the structure, the -/// install supplies the words. -/// -/// Each entry is the reference's `0x807060` row `{itemClassId, hasSubclassFilter}`: a class whose -/// flag is 0 offers no subclass rows and no inventory-slot rows. +/// The reference's `0x807060` rows `{itemClassId, hasSubclassFilter}` in menu order; a class with +/// the flag clear offers no subclass or slot rows. The names come from `ItemClass.dbc`. const AUCTION_CLASSES: [(u32, bool); 10] = [ (2, true), // Weapon (4, true), // Armor @@ -120,48 +59,42 @@ const AUCTION_CLASSES: [(u32, bool); 10] = [ (15, false), // Miscellaneous ]; -/// `ItemSubClass.Flags` bit `0x200`: the subclass offers the fourteen inventory-slot rows beneath -/// it (`GetAuctionInvTypes`, `0x4cfb63 test ah,2`). In the shipped file it is set on exactly Armor -/// 0..4 (Miscellaneous, Cloth, Leather, Mail, Plate) β€” not on Shield, Libram, Idol or Totem. +/// `ItemSubClass.dbc` `Flags` bit `0x200`: the subclass offers the inventory-slot rows beneath it +/// (`GetAuctionInvTypes`, `0x4cfb63`). Set on Armor 0 to 4 only, not Shield, Libram, Idol or Totem. const SUBCLASS_OFFERS_INV_TYPES: u32 = 0x200; -/// The `AuctionHouse.dbc` catalog, loaded with the other item DBCs ([`crate::ui_items`]). Optional -/// resource β€” absent, the sell pane shows no deposit rather than inventing one. +/// The `AuctionHouse.dbc` catalog; without it the sell pane shows no deposit. #[derive(Resource)] pub(crate) struct AuctionHouses(pub(crate) benilla_formats::AuctionHouseCatalog); -/// A wire row resolved for display. Its 1-based position in [`AuctionListSlot::rows`] after the -/// sort is the index the Lua API addresses it by, which is why the sort lives on this side: the -/// index the player clicks has to mean the same thing as the index the drain maps back to -/// [`Self::auction_id`]. +/// A wire row resolved for display. Its 1-based position after the sort is the index the Lua API +/// uses, so the drain maps a click back to [`Self::auction_id`] through the same sort. #[derive(Debug, Clone, Default, PartialEq)] pub(crate) struct AuctionRow { pub(crate) auction_id: u32, pub(crate) item_entry: u32, - /// The listed item's random-suffix roll β€” the suffix in [`Self::name`], the enchant lines the - /// row hover shows, and the link's third field (decision 1547). + /// The random-suffix roll behind the name's suffix, the hover's enchant lines and the link. pub(crate) random_property_id: u32, pub(crate) count: u32, pub(crate) name: Option, pub(crate) texture: Option, pub(crate) quality: Option, - /// The item's `RequiredLevel` β€” `0` until its template lands. + /// The item's `RequiredLevel`, 0 until its template lands. pub(crate) level: u32, pub(crate) start_bid: u32, pub(crate) min_increment: u32, pub(crate) buyout: u32, pub(crate) current_bid: u32, - /// `1..=4`, or `0` for a row whose remaining time we could not read. + /// The time-left bucket, `1..=4`. pub(crate) time_left: u32, - /// Whether *the player* holds the high bid. + /// Whether the player holds the high bid. pub(crate) high_bidder: bool, pub(crate) owner: Option, pub(crate) link: Option, } impl AuctionRow { - /// What the row shows in its "current bid" column: the seller's opening price until somebody - /// has actually bid. A `0` current bid is "no bids", not "free". + /// The "current bid" column: the opening price until someone bids, since 0 means no bids. pub(crate) fn displayed_bid(&self) -> u32 { if self.current_bid == 0 { self.start_bid @@ -174,134 +107,72 @@ impl AuctionRow { /// One of the three lists. #[derive(Debug, Default)] pub(crate) struct AuctionListSlot { - /// The current page exactly as the wire delivered it. + /// The current page in wire order. pub(crate) entries: Vec, - /// `totalCount` β€” the pre-cap match count, which is what tells the pager there is a page 2. + /// `totalCount`, the match count before the page cap; the pager reads it. pub(crate) total: u32, - /// A result for **this** list has landed since the window opened β€” the interface asked for it - /// and the server answered. `false` is "we hold no such list", which is *not* "the list is - /// empty": an empty page still arrives, and still counts. It is what the server-driven - /// refreshes gate on (decision 2308). + /// A result for this list landed, an empty page included; the refreshes gate on it. received: bool, sort: SortStack, } -/// What the live end-to-end probe (`crate::capture::ProbeAuctionPlugin`) reads to tell an -/// **empty** answer apart from **no** answer. -/// -/// Three of this arc's outcomes are, by design, invisible in the window's own state β€” and each is -/// a different failure a probe has to be able to name: -/// -/// - a list result carrying **zero rows** changes nothing [`feed_auction`] can diff, so "the -/// auction house is empty" and "the query never came back" look identical from the snapshot; -/// - a *successful* `SMSG_AUCTION_COMMAND_RESULT` is consumed straight into a re-query -/// (`crate::ui_auction::net`) and leaves no record β€” only a *failed* one surfaces, and only -/// as an error line; -/// - a browse query the throttle refuses is dropped with **no event at all** (the silence this -/// module's header describes), so "refused" and "sent" differ only in what went out on the -/// wire. -/// -/// Nothing in the client needs any of it: the window reacts to the *effects*. It therefore lives -/// in one clearly-named block that the client never reads, rather than being smeared through the -/// session's own fields β€” and it is a monotonic tally, deliberately **not** reset by -/// [`AuctionOpen::clear`], so a count means the same thing across a session close. +/// What the live probe (`crate::capture::ProbeAuctionPlugin`) reads to tell an empty answer from +/// none; the client never reads it, and [`AuctionOpen::clear`] does not reset it. #[derive(Default)] pub(crate) struct AuctionWireLog { - /// How many list results have landed, per list, in [`LIST`]/[`BIDDER`]/[`OWNER`] order. + /// List results landed, per list, in [`LIST`]/[`BIDDER`]/[`OWNER`] order. pub(crate) list_results: [u32; 3], - /// How many browse queries actually went out β€” the count the throttle refuses to raise. + /// Browse queries that went out; a throttled one does not count. pub(crate) browse_sent: u32, - /// The most recent `SMSG_AUCTION_COMMAND_RESULT`, as `(auction_id, action, error)`. + /// The latest `SMSG_AUCTION_COMMAND_RESULT` as `(auction_id, action, error)`. pub(crate) last_command: Option<(u32, u32, u32)>, } -/// The open auctioneer session, filled by the net bridge ([`crate::ui_auction::net`]) and read -/// by [`feed_auction`]. Cleared on a client-side close, on walking away, and on disconnect. +/// The open auctioneer session; cleared on a client-side close, on walking away and on disconnect. #[derive(Resource, Default)] pub(crate) struct AuctionOpen { - /// The auctioneer whose window is open; `None` = no session. pub(crate) auctioneer: Option, - /// The `AuctionHouse.dbc` row this auctioneer serves (1..=7) β€” keys the deposit rate. + /// The `AuctionHouse.dbc` row (1..=7), which keys the deposit rate. pub(crate) house_id: u32, pub(crate) lists: [AuctionListSlot; 3], - /// Fire `AUCTION_HOUSE_SHOW` next feed β€” set when the hello reply lands. + /// Fire `AUCTION_HOUSE_SHOW` next feed: a hello reply landed. show_requested: bool, - /// Fire `NEW_AUCTION_UPDATE` next feed β€” the sell slot changed. + /// Fire `NEW_AUCTION_UPDATE` next feed: the sell slot changed. sell_slot_dirty: bool, - /// A list result landed **for that list**, so *its* event is owed whether or not the snapshot - /// changed. Indexed [`LIST`]/[`BIDDER`]/[`OWNER`]. - /// - /// Diffing the snapshot is not enough and the live probe is what proved it: an empty auction - /// house, or re-running the same search, produces a result identical to what we already hold. - /// The window's Browse pane clears its "Searching…" state only on `AUCTION_ITEM_LIST_UPDATE`, - /// so on an empty server a search animated its dots forever and never reported a result. - /// - /// **Per list β€” that is the whole point** (decision 2308). This was one flag driving all three - /// fires, on 1511's reading that "one routine invalidates all three lists". The reference has - /// no such routine: three arrays, three counts, three sort stacks, **three separate events**, - /// each fired by whatever just touched *its* array. Firing the other two crashed the stock - /// `Blizzard_AuctionUI` outright β€” `AuctionFrameAuctions_Update` multiplies by - /// `AuctionFrameAuctions.page`, which the addon initialises only in that tab's `OnShow`, so a - /// browse result arriving while the player had never opened the Auctions tab raised - /// `attempt to perform arithmetic on field 'page' (a nil value)`. + /// Per list, a result landed, so its event is owed even for an identical page: the Browse + /// pane leaves "Searching" only on it (`Blizzard_AuctionUI.lua:187`). Only that list's: the + /// Auctions tab's update needs the `page` its `OnShow` sets (`Blizzard_AuctionUI.lua:817`). list_result_landed: [bool; 3], - /// Empty the sell slot next feed β€” a listing was accepted, so the staged item is *gone*. - /// - /// Also from the probe: without this the pane kept painting a phantom stack after a successful - /// `StartAuction`, `Create Auction` stayed enabled, and a second click re-resolved the - /// remembered `(bag, slot)` at send time β€” which by then addresses whatever else has landed in - /// that slot. That is not a cosmetic bug: it auctions an item the player never chose. + /// Empty the sell slot next feed: a listing was accepted, and the stale `(bag, slot)` would + /// auction whatever lands there next. sell_slot_taken: bool, - /// `Time::elapsed_secs_f64` before which a browse query is refused; `None` = allowed now. - /// Cleared when the window opens, so the first search never waits. + /// The `Time::elapsed_secs_f64` before which a browse query is refused; opening clears it. query_gate: Option, - /// Client messages the net apply queued for [`feed_auction`] to resolve and show β€” the - /// reference's `DisplayError(msgId)` split into (surface, GlobalStrings key, fill), so the line - /// comes from the VM's own strings and lands where that message's catalog row says (decisions - /// 0669 / 1523). - /// - /// **Not cleared by [`Self::clear`].** These outlive the window on purpose: "your auction sold" - /// arrives wherever the player is standing, and closing an auctioneer's window must not swallow - /// a notice that has not been printed yet. + /// Messages queued for [`feed_auction`]; [`Self::clear`] keeps them, since a sale notice can + /// land after a close. pub(crate) messages: Vec, - /// Set when the server tells us a list we hold is now wrong (our auction sold, we were - /// outbid, a cancel went through). The drain re-asks rather than patching the local page: the - /// server is the only thing that knows what the page looks like now, and the notification - /// carries an auction id, not a row. + /// A list we hold went stale (a sale, an outbid, a cancel): the drain re-asks its page 0. pending_owner_refresh: bool, pending_bidder_refresh: bool, - /// The live probe's observation window onto the wire. Written by the net bridge and the - /// drain, read by nothing in the client β€” see [`AuctionWireLog`]. pub(crate) wire: AuctionWireLog, } -/// One queued auction message: which of the reference's twenty catalog rows, and the item name that -/// fills its `%s` when it has one. -/// -/// The surface is the row's own (decision 1523): the twelve **precondition failures** (`0x16c`-`0x177` -/// β€” "you cannot auction a soulbound item", "your bid is too low") are kind 2 and land on the red -/// `UIErrorsFrame`; the eight **outcomes** (`0x178`-`0x17f` β€” created, cancelled, outbid, won, sold, -/// expired, removed, bid accepted) are kind 0 and land in the **chat window** as `CHAT_MSG_SYSTEM`. -/// That split is the whole reason this is a queue of records rather than a queue of red strings. +/// One of the reference's twenty auction message rows: the twelve refusals (`0x16c`-`0x177`) are +/// kind 2, on the red `UIErrorsFrame`; the eight outcomes (`0x178`-`0x17f`) are kind 0, in chat. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct AuctionMessage { pub(crate) key: &'static str, - /// The item entry whose name fills the `%s`; `None` for the six fill-less lines. - /// - /// The reference composes a **plain** name here (`0x5d8b00`), never a coloured `|Hitem:` link β€” - /// so "Your auction of Linen Cloth sold." is unlinked in the real client too, and matching that - /// is deliberate. It also carries the random-property roll for a suffixed name; benilla's item - /// names are unsuffixed today, so only the entry is kept. + /// The entry whose name fills the `%s`. The reference fills a plain name, never a link + /// (`0x5d8b00`), with its random suffix; this carries no roll, so the name is unsuffixed. pub(crate) item: Option, } impl AuctionMessage { - /// A line with no fill (`ERR_AUCTION_STARTED` and friends). pub(crate) fn chat(key: &'static str) -> Self { Self { key, item: None } } - /// A line about one item β€” the five `_S` outcomes. + /// A line naming one item: the five `_S` outcomes. pub(crate) fn chat_item(key: &'static str, item: u32) -> Self { Self { key, @@ -309,41 +180,36 @@ impl AuctionMessage { } } - /// A refusal β€” the twelve precondition failures, none of which take a fill. + /// A refusal; none of the twelve takes a fill. pub(crate) fn error(key: &'static str) -> Self { Self { key, item: None } } } impl AuctionOpen { - /// The hello reply β€” open (or re-open) the session. A *different* auctioneer resets everything - /// including the sort stacks; the same one keeps them and just re-shows, since the reference - /// re-fires its show on every greeting. + /// The hello reply: a new auctioneer resets the lists and sort stacks; the same one keeps them + /// and re-shows, as the reference re-fires its show on every greeting. pub(crate) fn open(&mut self, auctioneer: u64, house_id: u32) { if self.auctioneer != Some(auctioneer) { self.auctioneer = Some(auctioneer); self.lists = Default::default(); } self.house_id = house_id; - // The window opening clears the throttle: the reference zeroes its gate in the same - // handler that stores the auctioneer, so the first search after walking up is free. + // The reference zeroes the browse gate in the handler that stores the auctioneer. self.query_gate = None; self.show_requested = true; } - /// Replace one list's page. Always owes **that list's** event, even for an identical page. + /// Replace one list's page; that list's event is owed even for an identical page. pub(crate) fn set_list(&mut self, which: usize, entries: Vec, total: u32) { self.list_result_landed[which] = true; let slot = &mut self.lists[which]; slot.received = true; slot.entries = entries; - // A server that reports fewer total matches than it just sent us is not a case worth - // trusting over our own eyes; the pager reads the larger of the two. slot.total = total.max(slot.entries.len() as u32); } - /// The wire auction id at a 1-based **display** row β€” what the auction `CMSG`s address. Takes - /// the sorted view rather than the list index, because that is the order the player clicked in. + /// The auction id at a 1-based row of the sorted view, the order the player clicked in. fn auction_id_at(index_1based: u32, rows: &[AuctionRow]) -> Option { index_1based .checked_sub(1) @@ -351,29 +217,22 @@ impl AuctionOpen { .map(|r| r.auction_id) } - /// A listing was accepted: the staged item has left the bag, so the slot must let go of it. pub(crate) fn sell_slot_taken(&mut self) { self.sell_slot_taken = true; } - /// Mark our own listings stale β€” the drain re-queries next frame. - /// - /// **Only a list we hold can be stale** (decision 2308). A notification says "the page you are - /// showing is now wrong"; if the interface has never asked for the owned list, there is no - /// such page, and re-asking would *introduce* one β€” landing an `AUCTION_OWNED_LIST_UPDATE` on - /// a tab whose `page` field the stock addon has not initialised yet. The reference cannot - /// reach that state at all: its notification handlers patch the cached row in place, so an - /// array it never fetched has nothing to patch and announces nothing. + /// Re-query our listings next frame, only if we hold them: the reference patches its cached + /// rows, so a list it never fetched announces nothing. pub(crate) fn refresh_owner(&mut self) { self.pending_owner_refresh |= self.lists[OWNER].received; } - /// Mark the bids we hold stale β€” under the same rule as [`Self::refresh_owner`]. + /// Re-query our bids next frame, under [`Self::refresh_owner`]'s rule. pub(crate) fn refresh_bidder(&mut self) { self.pending_bidder_refresh |= self.lists[BIDDER].received; } - /// Close the window (a client-side close β€” vanilla sends nothing). + /// Close the window client-side; the reference sends nothing. pub(crate) fn clear(&mut self) { self.auctioneer = None; self.house_id = 0; @@ -387,14 +246,11 @@ impl AuctionOpen { self.sell_slot_taken = false; } - /// Disconnect: drop the open window (mirrors every other session clear). pub(crate) fn clear_session(&mut self) { self.clear(); } } -/// The auction window is an NPC session like any other: the standardized range guard closes it -/// when the player leaves the auctioneer's service range. impl NpcSession for AuctionOpen { fn npc(&self) -> Option { self.auctioneer @@ -413,18 +269,12 @@ impl Plugin for UiAuctionPlugin { app.init_resource::().add_systems( Update, ( - // The merchant/mail ordering exactly: range-close before the feed so the clear - // turns into AUCTION_HOUSE_CLOSED the same frame; feed before the input pass so an - // open/close is on screen the same frame; drain after it so a click's intent goes - // out the same frame. After the UnitFeed set so a row's tooltip reads a landed - // item-template store. + // Range-close before the feed so the close fires the same frame; the feed before + // the input pass, and after `UnitFeed` so a row's tooltip reads a landed template; + // the drain after the input pass so a click goes out the same frame. close_npc_session_out_of_range::.before(feed_auction), - // Gated on the interface being up (decision 2279): `AuctionOpen::messages` is - // filled by the server's unprompted sold/outbid/expired notices, and one landing - // in the same drain as the login burst would be resolved and shown on the boot - // VM β€” lost β€” if this ran in 2214's one-frame window. The queue is bounded by - // what the server sends while no interface is up, and the refresh flags the same - // packets set survive as wire re-asks either way. + // Only with the interface up: a sale notice landing in the login burst would + // otherwise be shown on the boot VM and lost. feed_auction .after(crate::ui_unit::UnitFeed) .in_set(UiFeed) @@ -435,11 +285,8 @@ impl Plugin for UiAuctionPlugin { } } -/// The wire's unclamped millisecond remainder β†’ the reference's four buckets. +/// The wire's unclamped milliseconds left, as the reference's bucket 1 to 4. fn time_left_bucket(ms: u32) -> u32 { - // An expired-but-still-listed auction arrives as an underflowed u32. Caught first and on its - // own, because the alternative reading β€” a number larger than every threshold, so bucket 4 β€” - // would show an auction that ended a minute ago as the longest one on the page. if ms >= TIME_LEFT_IMPLAUSIBLE_MS { return 1; } @@ -454,11 +301,8 @@ fn time_left_bucket(ms: u32) -> u32 { } } -/// Resolve one wire entry into a display row: name/quality/icon/required-level through the -/// ask-once item-template cache, seller through the ask-once name cache, the link built with the -/// row's full enchant/property/suffix tail (an auction row carries all three, so it gets the -/// complete link rather than the zeroed one a bag slot settles for). `None`s stay `None` while a -/// query is in flight β€” the row shows a placeholder and fills in when the answer lands. +/// One wire entry as a display row, through the ask-once template and name caches; a `None` +/// fills in when its answer lands. The link carries the row's enchant, property and suffix. fn resolve_row( entry: &AuctionListEntry, self_guid: Option, @@ -470,8 +314,6 @@ fn resolve_row( ) -> AuctionRow { let template = items.template(entry.item_entry, 0, commands); let roll = entry.random_property_id as u32; - // The rolled name β€” one formatter for every display of an item's name (1547), so the row, the - // link's bracket text and the hover's plate agree. let name = template.map(|t| rolls.name(&t.name, roll)); let quality = template.map(|t| t.quality); let level = template.map_or(0, |t| t.required_level); @@ -501,7 +343,6 @@ fn resolve_row( buyout: entry.buyout, current_bid: entry.current_bid, time_left: time_left_bucket(entry.time_left_ms), - // "High bidder" is about US, not about whether anyone has bid at all. high_bidder: self_guid.is_some_and(|g| g == entry.bidder_guid), owner: names .resolve(entry.owner_guid, commands) @@ -510,9 +351,7 @@ fn resolve_row( } } -/// Build the Browse tab's category tree from the player's own DBCs (decision 1511 Β§5). -/// `pub(crate)`: the addon-corpus survey seats the same tree off the player's chain (2167), and a -/// second copy of the class set would be a second thing to keep right. +/// The Browse tab's category tree from the player's own DBCs; the addon-corpus survey reuses it. pub(crate) fn categories( classes: Option<&crate::ui_items::ItemClasses>, subclasses: Option<&crate::ui_items::ItemSubClasses>, @@ -551,7 +390,6 @@ pub(crate) fn categories( .collect() } -/// Resolve + sort one list into its display rows. fn rows_for( slot: &AuctionListSlot, self_guid: Option, @@ -570,7 +408,6 @@ fn rows_for( rows } -/// One display row β†’ the Lua-facing row. fn to_script_row(r: &AuctionRow) -> AuctionItemRow { AuctionItemRow { auction_id: r.auction_id, @@ -592,10 +429,8 @@ fn to_script_row(r: &AuctionRow) -> AuctionItemRow { } } -/// The DBC catalogs a row reads, as ONE system param β€” [`feed_auction`] is at the -/// 16-SystemParam ceiling, and these four belong together anyway: the two class tables the -/// category tree is built from, and the random-suffix pair a rolled listing needs -/// (`ItemRandomProperties` for the name, `SpellItemEnchantment` for its lines β€” decision 1547). +/// The DBC catalogs as one system param, since [`feed_auction`] is at Bevy's 16-param limit: the +/// class tables for the category tree and the random-suffix pair for a rolled listing. type AuctionCatalogs<'w> = ( Option>, Option>, @@ -603,8 +438,7 @@ type AuctionCatalogs<'w> = ( Option>, ); -/// Push the current auction house into the VM and fire the show/close/list-update events on a -/// transition or content change. Diffed against a `VmMemo`, exactly like the merchant/mail feeds. +/// Push the auction house into the VM and fire its events on a change, diffed against `VmMemo`s. fn feed_auction( script: Option>, mut auction: ResMut, @@ -631,10 +465,8 @@ fn feed_auction( let last_can_query = last_can_query.get(&script); let last_sell = last_sell.get(&script); - // The Browse tab's class tree is login-scoped: the stock `AuctionFrameBrowse_OnLoad` reads - // `GetAuctionItemClasses()` at the addon's LOAD β€” the first auctioneer, before the session's - // snapshot lands β€” and the client answers off `ItemClass.dbc` with no session at all (1971). - // Pushed once the two catalogs exist and whenever they change (a diff, like the inbox's). + // The class tree needs no session: the stock `AuctionFrameBrowse_OnLoad` reads + // `GetAuctionItemClasses()` when the addon loads (`Blizzard_AuctionUI.lua:174`). let classes_now = categories(catalogs.0.as_deref(), catalogs.1.as_deref()); let last_classes = last_classes.get(&script); if *last_classes != classes_now { @@ -642,13 +474,8 @@ fn feed_auction( script.set_auction_item_classes(classes_now); } - // The queued client messages (decision 1523). Resolved against the VM's own GlobalStrings and - // shown on the surface the message's catalog row names β€” the `ui_quest` shape (0669). - // - // A `_S` line whose item template has not landed is **kept queued, not dropped**: the reference - // defers exactly this case through `0x4cd190` and prints the line when the item arrives. Ours - // falls out of the same ask-once cache the rows use, so the deferral is just "leave it in the - // queue and try again next frame" β€” and the `items.template` call below is what asks. + // A `_S` line whose item is not cached yet stays queued, as the reference defers it through + // `0x4cd190`; the `items.template` call below asks for it. { let mut deferred = Vec::new(); let mut lines = Vec::new(); @@ -657,7 +484,6 @@ fn feed_auction( None => None, Some(entry) => match items.template(entry, 0, &commands) { Some(t) => Some(t.name.clone()), - // Not named yet β€” hold it and ask again next frame. None => { deferred.push(msg); continue; @@ -702,7 +528,6 @@ fn feed_auction( }); AuctionState { lists, - // The rate this house charges, keyed by the id the hello reply carried. deposit_percent: houses .as_deref() .and_then(|h| h.0.deposit_percent(auction.house_id)) @@ -715,11 +540,7 @@ fn feed_auction( let show_requested = std::mem::take(&mut auction.show_requested); let landed = std::mem::take(&mut auction.list_result_landed); let changed = fresh != *last; - // **Which events are owed, per list.** A landed result owes its own list's event outright; a - // changed list owes it because an async name or template just filled one of its rows in. The - // diff is per list rather than over the whole snapshot for the same reason the fires are - // (2308): an event names one list, and firing the other two runs the stock Lua's other tabs - // over state they were never given. + // Owed per list: a result landed, or an async name or template filled one of its rows. let owed: [bool; 3] = std::array::from_fn(|i| { landed[i] || fresh.as_ref().map(|s| &s.lists[i]) != last.as_ref().map(|s| &s.lists[i]) }); @@ -735,11 +556,7 @@ fn feed_auction( if show_requested { script.fire_event("AUCTION_HOUSE_SHOW", vec![]); } - // Three literal fires, not a loop over names: the producer tripwire - // (`reference_ui::every_event_a_chain_file_registers_has_a_producer`) reads fire sites by - // their literal, and the stock addon registers all three (1971). Browse first, then Bids, - // then Auctions β€” the array's own order; the reference never fires two in one pass either, - // so nothing rests on it. + // Literal names, not a loop: `reference_ui`'s producer test finds fire sites by literal. if owed[LIST] { script.fire_event("AUCTION_ITEM_LIST_UPDATE", vec![]); } @@ -753,20 +570,18 @@ fn feed_auction( *last = fresh; *last_open = auction.auctioneer; - // A listing was accepted: drop the staged item before anything reads the slot again. + // A listing was accepted: drop the staged item before anything reads the slot. if std::mem::take(&mut auction.sell_slot_taken) { script.clear_auction_sell_item(); } - // The sell slot's own event: the create pane re-reads the item, re-suggests a price and - // re-computes the deposit off this. let sell = script.auction_sell_item(); if sell != *last_sell || std::mem::take(&mut auction.sell_slot_dirty) { *last_sell = sell; script.fire_event("NEW_AUCTION_UPDATE", vec![]); } - // The browse throttle, pushed every frame it changes β€” the Search button polls it. + // The browse gate, pushed when it changes; the Search button polls it every frame. let now = time.elapsed_secs_f64(); let can_query = auction.query_gate.is_none_or(|gate| now >= gate); if can_query != *last_can_query { @@ -786,8 +601,6 @@ fn drain_auction( icons: Option>, names: Res, self_q: Query<(&ObjectStore, &crate::net::Guid), With>, - // The clickβ†’auction-id map re-derives the same sorted rows the feed pushed, so it resolves - // them the same way, roll included (1547). props: Option>, enchants: Option>, ) { @@ -799,7 +612,7 @@ fn drain_auction( return; }; let Some(auctioneer) = auction.auctioneer else { - // No session: honor a stray close and drop everything else on the floor. + // No session: honor a stray close and drop every other intent. if script.take_auction_close() { auction.clear(); } @@ -813,7 +626,7 @@ fn drain_auction( return; }; - // The browse query, behind the same gate the Search button is reading. + // The browse query, behind the gate the Search button reads. if let Some(q) = script.take_auction_query() { let now = time.elapsed_secs_f64(); if auction.query_gate.is_none_or(|gate| now >= gate) { @@ -826,7 +639,7 @@ fn drain_auction( level_min: u8::try_from(q.min_level).unwrap_or(u8::MAX), level_max: u8::try_from(q.max_level).unwrap_or(u8::MAX), slot_id: q.inv_type.unwrap_or(auction_filter::ANY), - // Already ids β€” the binding maps menu positions the way `0x4ce980` does. + // Already ids: the binding maps menu positions as `0x4ce980` does. main_category: q.class.unwrap_or(auction_filter::ANY), sub_category: q.sub_class.unwrap_or(auction_filter::ANY), quality: q.quality.unwrap_or(auction_filter::ANY), @@ -835,8 +648,6 @@ fn drain_auction( } } - // Server-driven refreshes: something we hold went stale (a sale, an outbid, a cancel). These - // are page-0 re-asks, deliberately separate from the Lua's own paging. if std::mem::take(&mut auction.pending_owner_refresh) { let _ = commands.0.send(ClientCommand::AuctionListOwnerItems { auctioneer, @@ -858,9 +669,8 @@ fn drain_auction( }); } if let Some(page) = script.take_auction_bidder_query() { - // The refresh set is empty: it exists so a client can re-ask about auctions it was outbid - // on, and we do not track that memory yet (the reference keeps eight). A plain page still - // returns every auction we currently hold the bid on, which is the whole tab. + // An empty refresh set: the reference sends the ids it was outbid on (it keeps eight), + // which this does not track, so only the auctions we lead are listed. let _ = commands.0.send(ClientCommand::AuctionListBidderItems { auctioneer, list_from: page.saturating_mul(50), @@ -868,8 +678,7 @@ fn drain_auction( }); } - // Bids and cancels address the wire auction id behind the row the player clicked, which means - // re-deriving the same sorted view the feed pushed. + // Bids and cancels map the clicked row through the sorted view the feed pushed, roll included. let self_guid = self_q.iter().next().map(|(_, g)| g.0); let bids = script.take_auction_bids(); let cancels = script.take_auction_cancels(); @@ -911,8 +720,7 @@ fn drain_auction( } } - // Creating an auction: the sell slot's (bag, slot) resolves to the wire item guid HERE rather - // than at attach time, because the reference re-reads the slot when the create fires. + // The `(bag, slot)` resolves at send time, as the reference re-reads the slot on create. if let Some(req) = script.take_auction_start() { let item_guid = script .auction_sell_item() @@ -933,9 +741,8 @@ fn drain_auction( } } - // Header clicks land LAST, and that ordering is load-bearing: a row pick above was made - // against the order the feed pushed last frame, so re-sorting before resolving it would point - // the same index at a different auction. Nothing here goes on the wire. + // Header clicks last: a row pick above indexes last frame's order, so sorting first would + // point it at another auction. A sort sends nothing. for (which, key) in script.take_auction_sorts() { if let Some(slot) = auction.lists.get_mut(which) { slot.sort.click(&key); @@ -951,9 +758,8 @@ fn drain_auction( mod tests { use super::*; - /// The tree off the player's own DBCs carries the reference's two gates: the `0x807060` class - /// flag, and `ItemSubClass.Flags & 0x200` (`0x4cfb63`) for the inventory-slot rows β€” which - /// Shield does NOT carry, though it is Armor. + /// The reference's gates: the `0x807060` class flag, and `Flags & 0x200` (`0x4cfb63`), which + /// Shield lacks though it is Armor. #[test] fn the_tree_carries_the_reference_gates() { let data = benilla_formats::wow_data_or_skip!(); @@ -999,8 +805,6 @@ mod tests { assert!(!tree[3].subclasses.is_empty()); } - /// The four buckets, and the one that matters: an expired auction the server has not swept yet - /// arrives as an underflowed u32 and must read as Short, never as "Very Long". #[test] fn the_time_left_buckets_read_an_underflow_as_expired() { assert_eq!(time_left_bucket(0), 1, "already gone"); @@ -1016,11 +820,6 @@ mod tests { ); } - /// An identical list result still owes its event. Diffing the snapshot was the bug: an empty - /// auction house and a repeated search both produce a page identical to the one we hold, and - /// the Browse pane clears its "Searching…" state only when the event arrives β€” so on an empty - /// server a search animated forever and never reported a result. Found by the live probe, - /// which is the only place an empty house is the normal case. #[test] fn an_identical_list_result_still_owes_its_events() { let mut open = AuctionOpen::default(); @@ -1032,7 +831,6 @@ mod tests { // The feed consumes the flag when it fires. open.list_result_landed = [false; 3]; - // The very same empty page again β€” nothing to diff, and still owed. open.set_list(LIST, Vec::new(), 0); assert!( open.list_result_landed[LIST], @@ -1040,14 +838,6 @@ mod tests { ); } - /// **A result owes its OWN list's event and no other** (decision 2308). - /// - /// The regression this guards is a crash, not a repaint: the stock `Blizzard_AuctionUI`'s - /// `AuctionFrameAuctions_Update` multiplies by `AuctionFrameAuctions.page`, and that field is - /// initialised only in the Auctions tab's `OnShow`. A browse result that also fired - /// `AUCTION_OWNED_LIST_UPDATE` therefore ran that tab's repaint before it had ever been - /// shown β€” `attempt to perform arithmetic on field 'page' (a nil value)`, on screen, on the - /// first search of the session. `auction_frame.rs` holds the Lua half of this. #[test] fn a_result_owes_only_its_own_lists_event() { let mut open = AuctionOpen::default(); @@ -1069,14 +859,6 @@ mod tests { assert_eq!(open.list_result_landed, [false, true, false]); } - /// **A refresh can only re-ask for a list we hold** (decision 2308) β€” the other half of the - /// same crash. - /// - /// "Your auction sold" arrives whenever the server feels like it, including while the player - /// is standing on the Browse tab having never opened the Auctions one. Turning that into a - /// fresh owner query would land an `AUCTION_OWNED_LIST_UPDATE` on that never-shown tab β€” the - /// nil `page` again, by a slower road. The reference cannot get there: its notification - /// handlers patch the cached row, and an array it never fetched has no row to patch. #[test] fn a_refresh_never_introduces_a_list_the_interface_never_asked_for() { let mut open = AuctionOpen::default(); @@ -1089,8 +871,7 @@ mod tests { "we hold neither list, so neither can have gone stale" ); - // The Auctions tab has now been shown once and the server answered β€” even with nothing in - // it. From here a sale really does invalidate what the player is looking at. + // The Auctions tab was shown and answered, even empty: now a sale makes it stale. open.set_list(OWNER, Vec::new(), 0); open.refresh_owner(); assert!(open.pending_owner_refresh, "a list we hold can go stale"); @@ -1099,15 +880,12 @@ mod tests { "and the other one still cannot" ); - // Closing forgets all of it β€” a notice landing after the window is gone re-asks nothing. + // Closing forgets it: a notice after the close re-asks nothing. open.clear(); open.refresh_owner(); assert!(!open.pending_owner_refresh); } - /// An accepted listing releases the sell slot. Without it the pane kept painting a phantom - /// stack, Create Auction stayed enabled, and a second click re-resolved the remembered - /// (bag, slot) at send time β€” auctioning whatever had since landed in that slot. #[test] fn an_accepted_listing_releases_the_sell_slot() { let mut open = AuctionOpen::default(); @@ -1117,13 +895,11 @@ mod tests { open.sell_slot_taken(); assert!(open.sell_slot_taken, "the feed owes the slot a clear"); - // And a closed session forgets it rather than clearing a slot it no longer owns. + // A closed session forgets it rather than clearing a slot it does not own. open.clear(); assert!(!open.sell_slot_taken); } - /// The displayed bid falls back to the opening price until somebody has bid β€” a zero current - /// bid means "no bids", never "free". #[test] fn an_unbid_row_shows_its_opening_price() { let unbid = AuctionRow { diff --git a/crates/benilla-app/src/ui_auction/net.rs b/crates/benilla-app/src/ui_auction/net.rs index dc99cc3a..d5983b6c 100644 --- a/crates/benilla-app/src/ui_auction/net.rs +++ b/crates/benilla-app/src/ui_auction/net.rs @@ -1,7 +1,5 @@ -//! The auction house's packet handlers (decision 1511 P1; in the net handler table since 2305 β€” -//! the first family out of the drain's dispatch match) β€” every `SMSG_AUCTION_*` lands here and -//! becomes state on [`AuctionOpen`], which [`super::feed_auction`] turns into the window's events -//! on the next frame. Nothing here touches the VM: the feed owns the script. +//! The auction house's packet handlers: every `SMSG_AUCTION_*` becomes state on [`AuctionOpen`], +//! which [`super::feed_auction`] turns into the window's events; nothing here touches the VM. use benilla_protocol::messages::{ auction_action, auction_error, AuctionBidderNotification, AuctionCommandTail, AuctionListEntry, @@ -14,8 +12,7 @@ use bevy::prelude::*; use super::{AuctionMessage, AuctionOpen}; use crate::net::NetHandlerApp; -/// Register the house's handlers β€” called from [`super::UiAuctionPlugin`]. One per `SMSG_AUCTION_*` -/// kind, plus the session-end listener. +/// One handler per `SMSG_AUCTION_*` kind, plus the session-end listener. pub(super) fn register(app: &mut App) { use SessionEventKind as K; app.net_handler(K::AuctionHello, on_hello) @@ -99,32 +96,27 @@ fn on_removed_notification(In(ev): In, mut auction: ResMut, mut auction: ResMut) { auction.clear_session(); } -/// `MSG_AUCTION_HELLO`'s reply β€” **this**, not our send, is what opens the window (the window's -/// opener runs inside the hello handler `0x4cc420`). The house id keys the deposit rate. +/// The `MSG_AUCTION_HELLO` reply opens the window, as in the reference's hello handler `0x4cc420`. fn auction_hello(auctioneer: u64, house_id: u32, auction: &mut AuctionOpen) { auction.open(auctioneer, house_id); } -/// One of the three list results. They share a frame and a record; only the tab differs. +/// Any of the three list results, which share a record and differ only in their tab. fn auction_list( which: usize, auctions: Vec, total_count: u32, auction: &mut AuctionOpen, ) { - // Tallied before the drop below, because "it arrived and there was nowhere to put it" and "it - // never arrived" are different answers (`AuctionWireLog`). + // Counted before the drop below, so a dropped result still shows as arrived. auction.wire.list_results[which] += 1; - // A list can arrive for a window that just closed (we walked away while the page was in - // flight); dropping it is the same thing the reference does by having nowhere to put it. + // A page in flight when the window closed is dropped: the reference has nowhere to put it. if auction.auctioneer.is_none() { return; } @@ -147,10 +139,8 @@ fn auction_owner_list_result( auction_list(OWNER, auctions, total_count, auction); } -/// The bidder page. The server emits the explicitly-refreshed ids first and then every auction we -/// currently hold the bid on, so **one auction can appear twice in a page** β€” deduped here by -/// auction id, keeping the first occurrence, because a duplicated row would be two rows the player -/// can click that address the same auction. +/// The bidder page, deduped by auction id: the server lists the refreshed ids, then every auction +/// we lead, so one auction can appear twice. fn auction_bidder_list_result( auctions: Vec, total_count: u32, @@ -164,12 +154,8 @@ fn auction_bidder_list_result( auction_list(BIDDER, deduped, total_count, auction); } -/// `SMSG_AUCTION_COMMAND_RESULT` β€” the verdict on a sell, a cancel or a bid. -/// -/// Two shapes of failure the UI has to survive: `auction_id` is **`0`** on most failure paths (the -/// server writes `auc ? auc->Id : 0`), so it is not a correlation handle on an error; and several -/// refusals send **no packet at all** (a bid the player cannot afford, a cancel whose cut they -/// cannot pay), which is why nothing in this arc blocks its UI waiting for an ack. +/// `SMSG_AUCTION_COMMAND_RESULT`, the verdict on a sell, a cancel or a bid. `auction_id` is 0 on +/// most failures, and some refusals send nothing at all, so nothing waits on this. fn auction_command_result( auction_id: u32, action: u32, @@ -178,40 +164,15 @@ fn auction_command_result( auction: &mut AuctionOpen, ) { let _ = tail; - // The verdict itself, kept for the live probe only (`AuctionWireLog`): a SUCCESSFUL result - // turns into a re-query below and otherwise leaves no trace, so without this a probe cannot - // tell "the server said STARTED/OK" from "the server said nothing at all". + // For the live probe: a success otherwise leaves no trace but a re-query. auction.wire.last_command = Some((auction_id, action, error)); if error == auction_error::OK { - // A successful sell/cancel/bid changes a list we are showing, so we re-ask: the server is - // the only thing that knows what the page looks like now, and the result carries an - // auction id, not a row. - // - // **The reference re-asks on two of these three, and neither ask is page 0** (both list - // senders have exactly two callers each and zero address-takes; decision 2308): - // - // - `STARTED` β€” `[0xb7263c] = 1`, then `0x4cc4e0 call 0x4cd680` = `CMSG 0x259` at the - // **saved page offset** `[0xb72650]`. No row patch, no event of its own. - // - `BID_PLACED` β€” drops the id from the outbid list, `[0xb72640] = 1`, then - // `0x4cc528 call 0x4cd720` = `CMSG 0x264` at `[0xb72654]` β€” and *then* patches the - // browse row optimistically (bid ← the amount we sent, high bidder ← our own guid, - // deadline ← `max(deadline, now + 90 s)`) and fires 424. - // - `REMOVED` β€” **no query at all**: `0x4cc658 call 0x4cdfe0` deletes the id from all - // three arrays locally, each list firing only if it actually lost a row. - // - // We re-ask on all three, always at page 0, and patch nothing. Two live differences fall - // out: a player on page 2 of their own auctions is thrown back to page 1 by a sale, and - // their own bid does not appear on the row until the next result lands. Both belong to the - // freshness-model slice 2308 leaves open, not to this arm. What 2308 *does* enforce here - // is that a re-ask may never **introduce** a list the interface never asked for β€” the - // reference cannot, because its notification handlers patch instead of asking - // (`AuctionOpen::refresh_owner`). - // - // Each success also says so, in **chat** β€” the success arm `0x4cc4be` shows - // `0x178`/`0x179`/`0x17f` keyed on the action field, with zero varargs. + // A success re-asks page 0 and patches nothing. The reference re-asks at the saved page + // (`[0xb72650]`, `[0xb72654]`) on `STARTED` (`0x4cc4e0`) and `BID_PLACED` (`0x4cc528`, + // then patches the browse row), deletes the row locally on `REMOVED` (`0x4cdfe0`), and + // prints each success in chat with no fill (`0x4cc4be`). match action { auction_action::STARTED => { - // The item is gone from the bag; the sell slot must stop claiming to hold it. auction.sell_slot_taken(); auction.refresh_owner(); auction @@ -234,10 +195,8 @@ fn auction_command_result( } return; } - // **HIGHER_BID says nothing here.** Its arm (`0x4cc672`) reads two extra fields and takes the - // live *outbid update* path β€” it patches the row rather than raising a message, and the line - // the player actually sees is `ERR_AUCTION_OUTBID_S`, off the bidder notification. Printing a - // refusal here would double it. + // `HIGHER_BID` prints nothing: its arm (`0x4cc672`) patches the row, and the player's line is + // `ERR_AUCTION_OUTBID_S` from the bidder notification. if error == auction_error::HIGHER_BID { auction.refresh_bidder(); return; @@ -247,31 +206,21 @@ fn auction_command_result( } } -/// The failed command's GlobalStrings key β€” resolved to text in the feed against the player's own -/// table, never carried as English here (decisions 0669 / 1190). -/// -/// **INTERIM on three arms.** In the reference's dispatch (`0x4cc460`), code 1 computes its id from -/// the packet's own second field through the *inventory*-result formatter (`0x622630`) β€” a -/// different message family, whose keys are not yet decoded; code 4 raises `0x17`; code 13 raises -/// `0x1be`. Those three ids are outside the `ERR_AUCTION_*` block and their GlobalStrings names are -/// not recorded yet, so they fall to the catch-all here rather than being invented. Everything else -/// is the dispatch table verbatim, including that `2, 6, 8, 9, 11, 12` and anything above 13 are -/// the reference's own `ja` default. +/// The failed command's GlobalStrings key, per the reference's dispatch (`0x4cc460`), whose +/// default arm takes 2, 6, 8, 9, 11, 12 and anything past 13. Codes 1, 4 and 13 fall to that +/// default here; the reference formats 1 through the inventory results (`0x622630`) and raises +/// `ERR_ITEM_NOT_FOUND` (`0x17`) for 4 and `ERR_RESTRICTED_ACCOUNT` (`0x1be`) for 13. fn command_error_key(error: u32) -> Option<&'static str> { Some(match error { auction_error::NOT_ENOUGH_MONEY => "ERR_NOT_ENOUGH_MONEY", // `0x25`, the shared id auction_error::BID_INCREMENT => "ERR_AUCTION_BID_INCREMENT", // `0x174` auction_error::BID_OWN => "ERR_AUCTION_BID_OWN", // `0x173` - // `0x172` β€” the server's catch-all, and the reference's default arm for every code - // without one of its own. + // `0x172`, the server's catch-all and the reference's default arm. _ => "ERR_AUCTION_DATABASE_ERROR", }) } -/// `SMSG_AUCTION_BIDDER_NOTIFICATION` β€” we won, or we were outbid. -/// -/// **`bid_or_zero == 0` means WON**, not "no bid" β€” the server overloads the field, and reading it -/// the obvious way turns every win into an outbid notice. +/// `SMSG_AUCTION_BIDDER_NOTIFICATION`: a zero `bid_or_zero` means we won, else we were outbid. fn auction_bidder_notification(notice: &AuctionBidderNotification, auction: &mut AuctionOpen) { let won = notice.bid_or_zero == 0; auction.messages.push(AuctionMessage::chat_item( @@ -285,13 +234,10 @@ fn auction_bidder_notification(notice: &AuctionBidderNotification, auction: &mut auction.refresh_bidder(); } -/// `SMSG_AUCTION_OWNER_NOTIFICATION` β€” one of ours sold, or took a bid. An all-zero bidder guid is -/// the "sold" signal (the server zeroes it on a sale). +/// `SMSG_AUCTION_OWNER_NOTIFICATION`: one of ours took a bid, sold or expired. fn auction_owner_notification(notice: &AuctionOwnerNotification, auction: &mut AuctionOpen) { - // **Two stages, and the first one decides whether anything is said at all** (`0x4cd1f0`). - // A NON-zero bidder guid is "somebody bid on your auction": the row updates and the client says - // nothing β€” `[0x4cd25f, 0x4cd3bd)` holds no display call. Only a zeroed guid reaches the message - // path, and there the *bid* picks the line: non-zero sold, zero expired. + // A bidder guid means a new bid, which the reference does not announce (no display call in + // `[0x4cd25f, 0x4cd3bd)`); a zero guid is a close, sold if the bid is non-zero (`0x4cd1f0`). if notice.bidder_guid == 0 { auction.messages.push(AuctionMessage::chat_item( if notice.bid != 0 { @@ -305,10 +251,8 @@ fn auction_owner_notification(notice: &AuctionOwnerNotification, auction: &mut A auction.refresh_owner(); } -/// `SMSG_AUCTION_REMOVED_NOTIFICATION` β€” an auction we had bid on was cancelled by its seller. -/// -/// One id, unconditionally (`0x4cd480` has exactly one `call 0x496720` and no branch selecting -/// an id). +/// `SMSG_AUCTION_REMOVED_NOTIFICATION`: the seller cancelled an auction we bid on; the reference +/// always prints `ERR_AUCTION_REMOVED_S` (`0x4cd480`). fn auction_removed_notification(item_entry: u32, auction: &mut AuctionOpen) { auction.messages.push(AuctionMessage::chat_item( "ERR_AUCTION_REMOVED_S", @@ -322,7 +266,6 @@ mod tests { use super::*; use benilla_ui::messages::MsgKind; - /// The registration, end to end: a hello and a session end through the real table. #[test] fn the_table_routes_the_hello_and_the_session_end_to_the_house() { let mut app = App::new(); @@ -378,10 +321,6 @@ mod tests { } } - /// The owner notification's **two-stage** discrimination (`0x4cd1f0`), and the first stage is - /// the one a re-implementation gets wrong: a NON-zero bidder guid means "somebody bid on your - /// auction", and the client says **nothing at all** β€” the row updates and that is the whole - /// response. Only a zeroed guid reaches the message path, and there the *bid* picks the line. #[test] fn a_bid_on_your_auction_updates_the_row_and_says_nothing() { let mut a = AuctionOpen::default(); @@ -409,7 +348,6 @@ mod tests { ); } - /// The bidder side's discriminator is the zero bid field and nothing else. #[test] fn a_zero_bid_field_means_you_won() { let mut a = AuctionOpen::default(); @@ -427,9 +365,6 @@ mod tests { ); } - /// Every one of the eight OUTCOMES goes to CHAT, which is the director's report of 2026-08-22 - /// ("I just saw a red center screen message that one of my auc sold, pretty sure that the wrong - /// place"). Catalog rows `0x178`-`0x17f` are kind 0; the twelve refusals below are kind 2. #[test] fn the_outcomes_are_chat_and_the_refusals_are_the_error_frame() { let tail = AuctionCommandTail::Empty; @@ -462,8 +397,7 @@ mod tests { vec![("ERR_AUCTION_BID_OWN", MsgKind::Error, None)] ); - // HIGHER_BID is the live outbid UPDATE path, not a message β€” the line the player sees is - // ERR_AUCTION_OUTBID_S off the bidder notification, and printing here would double it. + // `HIGHER_BID` patches the row; the outbid line comes from the bidder notification. let mut a = AuctionOpen::default(); auction_command_result(7, auction_action::BID_PLACED, 5, &tail, &mut a); assert!( @@ -472,8 +406,6 @@ mod tests { ); } - /// A notice outlives the window. "Your auction sold" arrives wherever the player is standing, - /// and closing an auctioneer's window between the packet and the next feed must not swallow it. #[test] fn closing_the_window_does_not_swallow_a_pending_notice() { let mut a = AuctionOpen::default(); diff --git a/crates/benilla-app/src/ui_auction/sort.rs b/crates/benilla-app/src/ui_auction/sort.rs index 8050d153..c6651699 100644 --- a/crates/benilla-app/src/ui_auction/sort.rs +++ b/crates/benilla-app/src/ui_auction/sort.rs @@ -1,29 +1,14 @@ -//! The auction list sort (decision 1511 Β§6) β€” client-side, because the wire carries no sort field -//! at all: `CMSG_AUCTION_LIST_ITEMS` is ten fields and none of them is a column. -//! -//! **It is not an asc β†’ desc β†’ none cycle**, which is the thing everyone assumes and gets wrong. -//! Each list owns a *most-recently-clicked stack* of up to eight `(key, reversed)` pairs. Clicking -//! the column that is already primary toggles its direction; clicking any other column promotes it -//! to primary **keeping the direction it already remembers**. Two consequences fall out, and both -//! are visible in the window: a column that is not currently sorting anything can still draw a -//! flipped arrow, and the sort is *stable across keys* β€” dropping "quality" to second place still -//! breaks ties by it. -//! -//! `0x4cd940` *is* the auction comparator, the sort *is* an 8-key MRU cascade, and each list owns -//! its own `{col,dir}` stack. The per-key directions below are the reference's, not our guess β€” -//! three of them were wrong when this file first landed, and all three were ones a shopping UI -//! makes the "obvious" way round. +//! The auction list sort, client-side since `CMSG_AUCTION_LIST_ITEMS` carries no sort field: each +//! list keeps the reference's most-recently-clicked stack of `(key, reversed)` pairs (`0x4cd940`), +//! and every key below the primary breaks ties. use std::cmp::Ordering; use super::AuctionRow; -// The eight keys live in `benilla_ui::script::SORT_KEYS` β€” the Lua binding is what refuses an -// unknown one, with the reference's own `Usage:` error, so this side never re-lists them. +// The keys are `benilla_ui::script::SORT_KEYS`; the Lua binding refuses any other. -/// The reference keeps eight slots per list. Past that the oldest key is simply forgotten, which -/// is invisible in a window with at most six columns β€” but the bound is the reference's, so it is -/// the bound here. +/// The reference's eight slots per list; past that the oldest key is forgotten. const DEPTH: usize = 8; /// One list's sort state: the most-recently-clicked key first. @@ -33,8 +18,7 @@ pub(crate) struct SortStack { } impl SortStack { - /// A header click. Already primary β†’ flip its direction. Anywhere else (or brand new) β†’ it - /// becomes primary, **carrying the direction it had**, and everything above it shifts down. + /// A header click: the primary flips; any other key becomes primary with the direction it had. pub(crate) fn click(&mut self, key: &str) { if let Some(pos) = self.entries.iter().position(|(k, _)| k == key) { if pos == 0 { @@ -49,20 +33,17 @@ impl SortStack { } } - /// The stack as the engine pushes it to Lua (`IsAuctionSortReversed` reads it back). + /// The stack as pushed to Lua, where `IsAuctionSortReversed` reads it. pub(crate) fn pairs(&self) -> Vec<(String, bool)> { self.entries.clone() } - /// Order `rows` by the whole stack: primary key first, each remaining key breaking the ties - /// above it. An empty stack leaves the server's own order alone β€” which for a browse page is - /// buyout ascending, and is a perfectly good default. + /// Order `rows` by the whole stack, primary key first; an empty stack keeps the server's order. pub(crate) fn apply(&self, rows: &mut [AuctionRow]) { if self.entries.is_empty() { return; } - // `sort_by` is stable, so equal-under-every-key rows keep the order the server sent β€” - // which keeps a repaint from shuffling rows the player is looking at. + // A stable sort: rows equal under every key keep the server's order across repaints. rows.sort_by(|a, b| { for (key, reversed) in &self.entries { let ord = compare_by(key, a, b); @@ -76,30 +57,18 @@ impl SortStack { } } -/// One key's comparison, in the direction a *first* click on that column produces (`0x4cd940`'s -/// arms, read as "which row appears first"). -/// -/// Three of these run the opposite way to the intuition, and each is intuitive once you know what -/// the column is for: **quality** opens with the epics, **buyout** opens with the most expensive -/// item rather than the cheapest, and **status** opens with the auctions you are already in. Only -/// level, bid, duration and the two name columns run the "small/soon/A-first" way round. +/// One key's order on a first click, per `0x4cd940`'s arms: quality, buyout and status open +/// highest first; level, bid, duration, name and seller open lowest first. fn compare_by(key: &str, a: &AuctionRow, b: &AuctionRow) -> Ordering { match key { "level" => a.level.cmp(&b.level), - // Highest quality first β€” the epics, not the greys. "quality" => b.quality.cmp(&a.quality), - // What the row SHOWS as the current bid: the opening price until somebody has bid. "bid" => a.displayed_bid().cmp(&b.displayed_bid()), - // The bucket, not the raw milliseconds β€” two rows in the same bucket read as equal to the - // player, so ordering them by a millisecond difference they cannot see would make the sort - // look unstable. + // The bucket; the reference compares the exact deadline. "duration" => a.time_left.cmp(&b.time_left), - // Highest buyout first β€” which also settles what a *missing* buyout does without a - // special case: `0` is "no buyout", and descending drops it to the bottom on its own, - // exactly where it belongs. The ascending version this file shipped with needed a - // hand-written arm to avoid opening the column with every buyout-less auction. + // Highest first, which also sinks a 0 (no buyout) to the bottom. "buyout" => b.buyout.cmp(&a.buyout), - // The auctions you are already in, first β€” the Bid tab's High Bidder / Outbid column. + // Our own bids first; the reference also ranks another bidder's above no bid. "status" => b.high_bidder.cmp(&a.high_bidder), "name" => name_of(a).cmp(name_of(b)), "seller" => owner_of(a).cmp(owner_of(b)), @@ -107,8 +76,7 @@ fn compare_by(key: &str, a: &AuctionRow, b: &AuctionRow) -> Ordering { } } -/// A row whose template has not landed yet sorts as the empty string rather than jumping position -/// when the name arrives β€” the answer is at least stable while the async fill happens. +/// A row whose name has not landed sorts as the empty string. fn name_of(r: &AuctionRow) -> &str { r.name.as_deref().unwrap_or("") } @@ -131,7 +99,6 @@ mod tests { } } - /// The promote-and-toggle law, which is the whole reason this is a stack and not a field. #[test] fn a_click_promotes_but_only_the_primary_toggles() { let mut s = SortStack::default(); @@ -143,16 +110,14 @@ mod tests { s.click("bid"); assert_eq!(s.pairs(), vec![("bid".into(), true)]); - // A new key takes the front; the old primary keeps BOTH its place behind it and its - // reversed flag. + // A new key takes the front; the old primary keeps its place behind it and its flag. s.click("quality"); assert_eq!( s.pairs(), vec![("quality".into(), false), ("bid".into(), true)] ); - // Promoting a remembered key carries its direction back to the front unflipped β€” this is - // the case a plain asc/desc cycle gets wrong. + // Promoting a remembered key keeps its direction. s.click("bid"); assert_eq!( s.pairs(), @@ -165,7 +130,6 @@ mod tests { assert!(!rev("seller"), "never clicked at all"); } - /// Eight slots, oldest forgotten β€” the reference's own bound. #[test] fn the_stack_is_eight_deep() { let mut s = SortStack::default(); @@ -173,15 +137,12 @@ mod tests { s.click(k); } assert_eq!(s.pairs().len(), 8); - // A ninth distinct key would push one out; every key we have IS one of the eight, so - // re-clicking only reorders. + // Every key is one of the eight, so a re-click only reorders. s.click("level"); assert_eq!(s.pairs().len(), 8, "re-click reorders, never grows"); assert_eq!(s.pairs()[0].0, "level"); } - /// Keys below the primary break its ties β€” the reason the whole stack is applied and not just - /// its head. #[test] fn lower_keys_break_the_primary_s_ties() { let mut rows = vec![ @@ -197,8 +158,7 @@ mod tests { assert_eq!(got, vec!["Charlie", "Alpha", "Bravo"], "level, then bid"); } - /// Buyout opens HIGHEST-first (`0x4cdaf2`) β€” and a buyout of `0` means "none", not - /// "free", so descending puts it last with no special case. + /// The comparator's buyout arm is `0x4cdaf2`. #[test] fn buyout_opens_highest_first_and_no_buyout_sinks() { let mut rows = vec![ @@ -213,8 +173,7 @@ mod tests { assert_eq!(got, vec!["Pricey", "Cheap", "NoBuyout"]); } - /// The three columns that run opposite to the intuition, pinned together so a future "fix" - /// has to argue with the comparator `0x4cd940` rather than with a hunch. + /// Both open highest first in the reference's comparator (`0x4cd940`). #[test] fn quality_and_status_open_highest_and_mine_first() { let mut rows = vec![ @@ -246,7 +205,6 @@ mod tests { ); } - /// No sort clicked = the server's own order, untouched. #[test] fn an_empty_stack_leaves_the_servers_order_alone() { let mut rows = vec![row(9, 0, 0, "Zulu"), row(1, 0, 0, "Alpha")]; diff --git a/crates/benilla-app/src/ui_aura.rs b/crates/benilla-app/src/ui_aura.rs index 39d92d65..51515745 100644 --- a/crates/benilla-app/src/ui_aura.rs +++ b/crates/benilla-app/src/ui_aura.rs @@ -1,44 +1,7 @@ -//! The app-side **aura feed** (decisions 0255/0257): turns the live `UNIT_FIELD_AURA` blocks of -//! our own avatar and of the current target into the ordered [`AuraState`] lists the `UnitAura` -//! bindings read, maintains the reference client's insertion-ordered display cache for the player, -//! joins in the self-only durations, and drains the right-click cancels back to the wire. -//! -//! Two things make this more than a snapshot projection: -//! -//! - **Order is state.** The player's buff bar draws a densely packed cache in *insertion* order that -//! repacks on removal (`PlayerAuras_Update`, byte-verified β€” decision 0257). Ascending slot order, -//! which a fresh descriptor read would give, is a *different* order and would shuffle icons into -//! recycled slots. So [`PlayerAuraCache`] is carried across frames: survivors keep their position, -//! a dropped aura closes its gap, a new aura appends at the end. -//! - **Durations arrive out of band, and before the slot is named.** `SMSG_UPDATE_AURA_DURATION` -//! ([`AuraDurations`], filled by the net apply path) is keyed by raw slot and lands *before* the -//! `UNIT_FIELD_AURA` delta that says which spell sits there (verified, decision 0257 B6; measured -//! at one frame, ~50 ms, on a fresh apply β€” decision 0846). So a stamp's slot is **empty at the -//! moment it arrives**, and the only thing that may invalidate it is the aura it would be joined -//! to, never the slot's momentary occupancy: the feed keeps every stamp and gates the *join* on -//! the aura having appeared no earlier than the packet. That gate is what stops a timer left by a -//! since-expired occupant showing on the permanent aura that recycled its slot (the reference -//! avoids this via a DBC "until cancelled" flag we don't parse β€” decision 0257 Β§3). -//! -//! And one thing the bar does **not** get from here: the running countdown. Rust recomputes each -//! aura's `expirationTime` every frame, but the event this feed fires is the reference's -//! discrete-change edge (`PLAYER_AURAS_CHANGED`), which a duration refresh does not trip. The -//! reference resolves that the same way β€” `BuffButton_OnUpdate` re-reads `GetPlayerBuffTimeLeft` -//! **every frame** and caches only the buff *index* on the event β€” and so does `BuffFrame.xml`. -//! A bar that cached the expiry on the event instead is decision 0846's second defect. -//! -//! Scope: the **local player** (decisions 0255/0257) and the **target** (the target frame's aura -//! rows β€” 0255's deferred slice). The target's list is the byte-verified *other-unit* law -//! (decision 0257): `UnitBuff`/`UnitDebuff` on another unit read that unit's own `UNIT_FIELD_AURA` -//! straight, **ascending raw slot within the half** β€” no insertion cache, no durations (the 1.12 -//! wire carries none for anyone but yourself). It **does** carry the display filter, though: an aura -//! whose spell is flagged never-display (`NO_AURA_ICON`/`DO_NOT_DISPLAY` β€” a warrior stance) is -//! hidden on *every* aura display, target rows included, not just the player's own bar (decision -//! 0417, correcting 0268's scope note β€” the director watched the reference hide a -//! target's Battle Stance, which the "other-unit reads straight" reading can't explain: `NO_AURA_ICON` -//! means exactly that, everywhere). A self-target is the one exception in *ordering*: decision 0257 Β§2 -//! resolves the Era API's single `UnitAura` toward the player-bar law under every token, so targeting -//! yourself shows the same insertion-ordered list β€” filtered identically either way. +//! The aura feed: the `UNIT_FIELD_AURA` blocks of the player, the target, the target's target and +//! the pet as the ordered [`AuraState`] lists the aura bindings read, with the player's durations. +//! The player's list keeps the reference cache's insertion order (`0xbc6040`, repacked by +//! `PlayerAuras_Update 0x4e4170`); any other unit's reads its descriptor by ascending slot. use std::collections::HashMap; @@ -57,39 +20,26 @@ use crate::ui_action::Spells; use crate::ui_script::UiInput; use crate::ui_unit::UnitFeed; -/// The self-only aura durations, keyed by raw `UNIT_FIELD_AURA` slot β€” the decoded -/// `SMSG_UPDATE_AURA_DURATION` payload, stamped with the real time it arrived. Written by the net -/// apply path (which owns the event stream), read by [`feed_auras`]. A slot's entry is overwritten -/// by each fresh packet (apply/refresh) and dropped only when the *session* ends (leaving -/// `InWorld`) β€” never per-frame on slot occupancy, which would delete every stamp in the frame -/// before its own aura arrives (decision 0846), and never on the avatar entity's absence, which a -/// cross-map worldport produces mid-session while the auras themselves live on (decision 0900). -/// This is the reference's `0xbc5f68` expiry array: raw-slot-keyed, written only by the duration -/// packet, and a process global that no world change touches. Bounded by construction: one entry -/// per raw slot, ≀ 48. +/// The player's aura durations by raw slot, each `SMSG_UPDATE_AURA_DURATION` stamped with its +/// arrival: the reference's expiry array `0xbc5f68`. Only the session's end clears it, never an +/// empty slot, whose aura may be a frame behind its packet, nor a worldport. At most 48 entries. #[derive(Resource, Default)] pub(crate) struct AuraDurations { by_slot: HashMap, } struct DurationStamp { - /// The span in seconds the last packet reported. On an apply or a refresh this **is** the - /// aura's full duration (vmangos sends `GetAuraDuration()` right after setting it), which is - /// what `UnitBuff`'s Era-shaped `duration` return wants. On a re-send it is only the - /// *remaining* β€” `Map::Add` replays every holder's current duration at world entry, and cast - /// pushback (`DelaySpellAuraHolder`) reports a shortened one. The 1.12 wire carries no maximum - /// at all, for anyone, so there is nothing better to report; the buff bar draws - /// [`Self::expires_at`] and never this. + /// Seconds: the full duration on an apply or refresh, only the remainder on a relog + /// (`Map::ExistingPlayerLogin`) or a cast pushback (`Unit::DelaySpellAuraHolder`). total: f64, - /// The real-clock instant the aura runs out (`received_at + total`). Real, never virtual: see - /// the net apply path's aura tuple β€” a server-sent span counts down in real seconds. + /// The real-clock instant it runs out: a server-sent span counts down in real seconds. expires_at: f64, - /// The real-clock instant the packet arrived β€” the freshness gate against a recycled slot. + /// The real-clock arrival, the freshness gate against a recycled slot. received_at: f64, } impl AuraDurations { - /// Record a `SMSG_UPDATE_AURA_DURATION`. Called from the net apply drain (decision 0257). + /// Records one `SMSG_UPDATE_AURA_DURATION`. pub(crate) fn set(&mut self, slot: u8, remaining_ms: u32, now: f64) { let total = f64::from(remaining_ms) / 1000.0; if trace_period().is_some() { @@ -108,47 +58,36 @@ impl AuraDurations { } } -/// One aura in the player's display cache β€” a benilla mirror of a `0xbc6040` record (decision 0257). -/// Kept across frames so the insertion order survives; the display fields refresh from the -/// descriptor each frame, the position does not. +/// A record of the player's cache (`0xbc6040`): the position persists, the fields refresh. struct CachedAura { slot: u8, spell_id: u32, - /// The real-clock instant this aura first entered the cache β€” the duration freshness gate. + /// When the aura entered the cache, for the duration freshness gate. appeared_at: f64, flags: u8, level: u8, stacks: u8, } -/// The player's insertion-ordered aura cache (decision 0257): buffs and debuffs interleaved in the -/// order the reference client's `PlayerAuras_Update` would hold them. Split into the two filtered -/// lists only at the push, since the bindings filter by sign themselves. +/// The player's aura cache: buffs and debuffs interleaved, in insertion order. #[derive(Resource, Default)] pub(crate) struct PlayerAuraCache { auras: Vec, } impl PlayerAuraCache { - /// The live aura spell ids β€” `ui_tooltip`'s pre-feed reads these at arrival so a buff-bar - /// hover's `SetPlayerBuff` view hits on the FIRST enter (the ask-once path stays as the - /// odd-case fallback). + /// The live aura spell ids, pre-fed to `ui_tooltip` for a buff-bar hover's `SetPlayerBuff`. pub(crate) fn spell_ids(&self) -> impl Iterator + '_ { self.auras.iter().map(|a| a.spell_id) } } -/// The aura-event cut: [`feed_auras`] fires `PLAYER_AURAS_CHANGED` / `UNIT_AURA("player")` -/// **synchronously** (`UiScript::fire_event` walks the handlers inline), so a feed whose pushed -/// state a handler re-reads on them runs `.before(AuraEvents)` β€” the stance feed, whose -/// `isActive` is the form aura's own slot and is pushed silently on that edge (decision 2009) β€” -/// or the handler reads last frame's state and the repaint waits for the next unrelated event. -/// The twin of [`crate::ui_action::CooldownEvents`]. +/// [`feed_auras`] fires `PLAYER_AURAS_CHANGED` and `UNIT_AURA` inline, so a feed whose state their +/// handlers re-read (the stance feed) runs `.before(AuraEvents)` or they read last frame's. #[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)] pub(crate) struct AuraEvents; -/// Adds the aura feed. The `UnitAura` bindings live in `benilla-ui`; this supplies their data and -/// fires `UNIT_AURA`, and drains `CancelUnitBuff` back to the wire. +/// Adds the aura feed and the `CancelUnitBuff` drain. pub(crate) struct UiAuraPlugin; impl Plugin for UiAuraPlugin { @@ -156,20 +95,16 @@ impl Plugin for UiAuraPlugin { app.init_resource::() .init_resource::() .init_resource::() - // Feed before the VM dispatch (like the unit feed), so a frame's OnEvent sees the fresh - // list; drain the cancels after, once the VM has queued them. + // Feed before the VM dispatch, drain after it. .add_systems(Update, feed_auras.in_set(UnitFeed).in_set(AuraEvents)) .add_systems(Update, drain_aura_cancels.after(UiInput)) - // The ONLY teardown of the aura state, and it hangs off the session edge β€” never off - // the avatar entity's existence, which a worldport interrupts mid-session (0900). + // Torn down only at the session's end, not with the avatar, which a worldport drops. .add_systems(OnExit(ClientState::InWorld), end_session_aura_state); } } -/// Reconcile the insertion-ordered cache against the live descriptor: drop entries whose slot no -/// longer holds their spell (expired, or the slot was recycled), then append newly occupied slots in -/// ascending order (so a same-tick multi-apply is deterministic). Survivors keep their position and -/// refresh their mutable fields. Mirrors `PlayerAuras_Update`'s shift-down + append (decision 0257). +/// `PlayerAuras_Update`'s passes: drop a record whose slot lost its spell, closing the gap +/// (`0x4e421b`), then append new slots in ascending order (`0x4e424e`-`0x4e4374`). fn reconcile(cache: &mut Vec, live: &[UnitAuraSlot], now: f64) { cache.retain(|c| { live.iter() @@ -177,7 +112,6 @@ fn reconcile(cache: &mut Vec, live: &[UnitAuraSlot], now: f64) { }); for a in live { if let Some(c) = cache.iter_mut().find(|c| c.slot == a.slot) { - // Same slot, same spell (retain guaranteed it) β€” refresh the volatile fields in place. c.flags = a.flags; c.level = a.level; c.stacks = a.stacks; @@ -200,53 +134,34 @@ fn reconcile(cache: &mut Vec, live: &[UnitAuraSlot], now: f64) { } } -/// One aura's discrete projection (spell id, stacks, cancelable, dispel class β€” the fields a frame -/// repaints on). Excludes the countdown, which drifts every frame and is Lua's `OnUpdate` job, not -/// a `UNIT_AURA` trigger. +/// What a frame repaints on; not the countdown, which the button polls. type AuraProjection = (u32, u8, bool, Option); -/// The feed's edge-detection memory: per token, the projection of the list last pushed (the -/// [`crate::ui_unit::UnitFeedState`] resource shape). -/// -/// The edge keys live behind a [`crate::ui_script::VmMemo`]: what we last told the VM is a claim -/// about THAT VM (1290), so against a `/reload`'s replacement (1291) β€” which swaps the VM while -/// every aura stays live β€” the memo reads fresh and `UNIT_AURA` re-fires for the new frames. That -/// key is also what retired the explicit reset [`end_session_aura_state`] used to run: the memo -/// dies with the VM, while that system keeps its real job, dropping the server-side aura state. +/// The feed's edge memory: per token, the projection last pushed. Behind a +/// [`crate::ui_script::VmMemo`], so a `/reload`'s new VM reads it fresh and `UNIT_AURA` re-fires. #[derive(Resource, Default)] struct AuraFeedMemory { - /// What we last told the VM β€” dies with the VM it was told to. + /// What this VM was last told. vm: crate::ui_script::VmMemo, - /// The Bevy-clock instant [`trace_timers`] last printed (`BENILLA_AURA_TRACE`). Host - /// diagnostics, not VM memory β€” the trace cadence outlives any one VM, deliberately. + /// When [`trace_timers`] last printed: host state, outliving any one VM. traced_at: f64, } -/// The per-VM half of [`AuraFeedMemory`] β€” the `UNIT_AURA` edge keys. +/// The per-VM `UNIT_AURA` edge keys. #[derive(Default)] struct AuraFeedMemo { present: bool, last: Vec, - /// The tracking spell last pushed ([`tracking_state_of`]) β€” part of the player half's change - /// key: switching Find Herbs β†’ Find Minerals changes NO visible list (both are excluded from - /// the bar), only this, and the minimap frame still needs its event. + /// The tracking spell: a tracking switch changes no list, but the minimap needs the event. tracking_last: Option, - /// The target half: the selection guid + projection last pushed under `"target"` (`None` = the - /// token is cleared). The guid joins the key so a target *switch* re-fires `UNIT_AURA` even - /// between two units whose lists happen to project identically. + /// The target's guid and projection: the guid makes a switch between equal lists re-fire. + /// The pet's and the target's target's are keyed the same way. target_last: Option<(u64, Vec)>, - /// The pet half, same shape and same reason (decision 0990): a stable swap between two pets - /// carrying identical auras must still re-fire, so the guid joins the key. pet_last: Option<(u64, Vec)>, - /// The target-of-target half (decision 1576) β€” the ToT frame's four debuff buttons. Same shape - /// and same reason as the two above: the guid joins the key, because your target swinging from - /// one identically-debuffed add to another is still a change the row has to hear. tot_last: Option<(u64, Vec)>, } -/// The `BENILLA_AURA_TRACE` period in seconds, or `None` when the trace is off. A set-but-unparsable -/// value means "on, at the default 1 Hz" rather than off β€” a typo shouldn't silently disarm the -/// instrument you asked for (the `WOW_MOVE_TRACE=1` lesson: a knob that quietly means nothing). +/// The `BENILLA_AURA_TRACE` period in seconds; a set value that is not a positive number means 1 s. fn trace_period() -> Option { static PERIOD: std::sync::OnceLock> = std::sync::OnceLock::new(); *PERIOD.get_or_init(|| { @@ -261,14 +176,9 @@ fn trace_period() -> Option { }) } -/// One trace tick: for every aura the player's bar is drawing, print the app's remaining time next -/// to the remaining time the *button's own Lua* would render from what it last cached. The bar's -/// countdown is a per-frame poll in the reference (`BuffButton_OnUpdate` β†’ `GetPlayerBuffTimeLeft`, -/// extracted 1.12 `BuffFrame.lua` l.128-130), so these two must agree every frame; a row where they -/// don't is a stale button, which is exactly the "the timer sticks / never appears" symptom. -/// -/// The button a given aura lands on is its index *within its own filter* (the bar's ids are -/// 1-based per filter): helpful auras fill `BuffButton0..15`, harmful `BuffButton16..23`. +/// One trace tick: each drawn aura's time left beside its button's text, the button polling +/// `GetPlayerBuffTimeLeft` every frame (`BuffFrame.lua:130`). Buffs fill `BuffButton0..15`, +/// debuffs `BuffButton16..23`. fn trace_timers(script: &UiScript, cache: &[CachedAura], list: &[AuraState], bevy_now: f64) { let script_now = script.now(); info!( @@ -290,9 +200,7 @@ fn trace_timers(script: &UiScript, cache: &[CachedAura], list: &[AuraState], bev } else { "permanent".to_string() }; - // What the bar is actually DRAWING β€” the rendered duration string, not an intermediate. - // The button holds no expiry of its own (it re-reads one every frame), so the text is the - // only readback there is, and it is the right one: it is what the director sees. + // The rendered duration text: the button keeps no expiry, so its text is the readback. let lua = script .eval::(&format!( r#"local b = getglobal("BuffButton{button}") @@ -317,21 +225,9 @@ fn projection_of(list: &[AuraState]) -> Vec { .collect() } -/// **Any unit but yourself**, read straight off its descriptor β€” the verified other-unit aura law -/// (decisions 0257/0268): ascending raw slot within the half, durationless, through the same -/// display filter the player bar uses. -/// -/// One function for every non-self token (`"target"` since 0255, `"pet"` since 0990) because it is -/// one law: the 1.12 wire carries no duration for another unit (byte-verified, 0257 B6), so the -/// only thing any of them can show is what `UNIT_FIELD_AURA` holds. Filtering here rather than in -/// the Lua binding keeps `UnitBuff(token, i)` returning the i-th *shown* aura, matching the -/// reference's own indices. -/// -/// `buffs_visible` is the **unit-level** gate (decision 1035): `UnitBuff 0x519500` decides, once -/// per unit and *before* it walks a single slot, whether this unit's positive half is enumerable at -/// all β€” see [`buffs_visible_on`]. False drops the helpful half entirely, which is exactly what the -/// reference does (a single nil for every index), so `UnitBuff(token, i)` goes nil while -/// `UnitDebuff(token, i)` is untouched: `UnitDebuff 0x5198f0` has no such gate. +/// Another unit's auras as `UnitBuff 0x519500` and `UnitDebuff 0x5198f0` read them: ascending +/// slot within each half, no durations, through the display filter. `buffs_visible` false drops +/// the helpful half, the unit-level gate's nil at every index ([`buffs_visible_on`]). fn other_unit_auras( store: &ObjectStore, catalog: Option<&SpellCatalog>, @@ -353,16 +249,12 @@ fn other_unit_auras( .zip(catalog) .and_then(|(d, cat)| cat.dispel_name(d)) .map(str::to_string), - // No duration for any unit but yourself β€” the 1.12 wire carries none (byte-verified, - // 0257 B6); the reference's target and pet frames show no timers. + // The 1.12 wire carries durations for the player alone. duration: 0.0, expiration_time: 0.0, helpful: a.slot < UNIT_AURA_POSITIVE_SLOTS, cancelable: a.flags & AURA_FLAG_CANCELABLE != 0, - // `untilCancelled` is a field of the PLAYER cache record (`0xbc6040`+0xc) and - // `GetPlayerBuff` is the only reader; no other unit has a cache, and no binding can - // reach this flag through a non-player token. A self-target does not come through - // here at all β€” it mirrors the player list (decision 0257 Β§2). + // `untilCancelled` is the player cache record's alone (`0xbc6040` `+0xc`). until_cancelled: false, channeled: display .is_some_and(|d| d.attributes_ex & SPELL_ATTR_EX_IS_CHANNELED != 0), @@ -371,29 +263,10 @@ fn other_unit_auras( .collect() } -/// **`UnitBuff 0x519500`'s unit-level gate** β€” may this unit's *buffs* be enumerated at all? -/// (Decision 1035.) -/// -/// This sits **before** the per-slot walk and outranks it: when it fails, `UnitBuff` pushes a -/// single nil for **every** index without examining one slot, so the unit shows *no buffs* -/// whatever its aura array holds. `UnitDebuff 0x5198f0` has no counterpart β€” debuffs are always -/// enumerable, which is why a hostile mob shows its debuffs and nothing else. -/// -/// ```text -/// (unit.UNIT_FIELD_FLAGS & UNIT_FLAG_AURAS_VISIBLE) // 0x08000000 -/// || (CanAssist(activePlayer, unit) && activePlayer.UNIT_FIELD_CHARMEDBY == 0) -/// ``` -/// -/// The first clause is **Detect Magic** (spell 2855 β†’ `SPELL_AURA_AURAS_VISIBLE`, vmangos -/// `UnitDefines.h:518` *"Detect Magic. Added by SPELL_AURA_AURAS_VISIBLE"*). That spell exists -/// precisely *because* the second clause denies a hostile unit's buffs β€” which is the independent -/// gameplay corroboration that this gate is real and not an artifact of one screenshot. -/// -/// **It is also why GM mode changes the answer**, and why the same mob read two ways for a while: -/// vmangos ORs the bit into the outgoing `UNIT_FIELD_FLAGS` *for GameMasters only* (`Object.cpp` -/// 765-766, *"Gamemasters should be always able to select units and view auras"*). So the server -/// already tailors this per viewer and we need know nothing about GM state β€” reading the flag off -/// the descriptor as received is both simpler and exactly right in both regimes. +/// `UnitBuff 0x519500`'s unit-level gate, before any slot: a unit's buffs are listed only if it +/// has `UNIT_FLAG_AURAS_VISIBLE` or the uncharmed player can assist it (`0x5195aa`-`0x5195f7`), +/// else every index is nil; `UnitDebuff` has no gate. vmangos sets the flag for Detect Magic +/// (`UnitDefines.h:572`) and for a GM viewer (`Object.cpp:765-766`), so it is read as received. fn buffs_visible_on( store: &ObjectStore, self_store: Option<&ObjectStore>, @@ -404,48 +277,26 @@ fn buffs_visible_on( if store.0.unit_flags() & UNIT_FLAG_AURAS_VISIBLE != 0 { return true; } - // `activePlayer.UNIT_FIELD_CHARMEDBY == 0` β€” while something else is driving you, you assist - // nobody (`0x5195f1`-`0x5195f7`). + // While charmed you assist nobody (`0x5195f1`-`0x5195f7`). if self_store.is_some_and(|s| s.0.unit_charmed_by().is_some()) { return false; } can_assist(Some(store), factions, reputations, self_store, owner_store) } -/// `UNIT_FLAG_AURAS_VISIBLE` β€” see [`buffs_visible_on`]. +/// `UNIT_FIELD_FLAGS` bit 27, `0x08000000` (`0x5195b6`). const UNIT_FLAG_AURAS_VISIBLE: u32 = 0x0800_0000; -/// `Spell.dbc` `AttributesEx & 0x4` β€” `SPELL_ATTR_EX_IS_CHANNELED` (vmangos `SpellDefines.h`), the -/// DBC arm of the cancel gate (`benilla_ui::script::aura`'s `cancel_authorized`, `0x4e4a10`). +/// `AttributesEx & 0x4` (vmangos `SpellDefines.h:834`), the cancel gate's DBC arm (`0x4e4a10`). const SPELL_ATTR_EX_IS_CHANNELED: u32 = 0x4; -/// The cache record's **`untilCancelled`** flag β€” `GetPlayerBuff`'s second return, `+0xc` of a -/// `0xbc6040` record. "This aura has no finite duration to display." -/// -/// Byte-verified against `BuildBuffRecord 0x4e44b0`'s tail (`0x4e452e`-`0x4e45c5`), which is two -/// clauses ORed in sequence: -/// -/// 1. **The DBC duration row.** `SpellRec+0x78` (`DurationIndex`) resolves a `SpellDuration.dbc` row; -/// the flag is set when the row says *infinite* β€” `Duration < 0 && DurationPerLevel <= 0` -/// (`0x4e456e`-`0x4e457a`) β€” or when the index resolves to no row at all (`jl`/`jg`/NULL all fall -/// into the same `mov ecx,1` at `0x4e4580`). -/// 2. **The area-aura override.** Only for a *non*-cancelable aura (`test [edi+0xa],0x1; jne` at -/// `0x4e4588` returns early otherwise): if any of the spell's three effects is one of -/// `{0x23, 0x77, 0x80, 0x81}` β€” the persistent/area-aura effects, which have no per-application -/// timer because they last while you stand in them β€” the flag is **forced** to 1 -/// (`0x4e458e`-`0x4e45be`). -/// -/// This closes the gap this module's header named ("the reference avoids this via a DBC 'until -/// cancelled' flag we don't parse"). It matters because it is answerable from the *spell*, on the -/// frame the aura appears: `ref-BuffFrame.lua:124` returns before ever calling -/// `GetPlayerBuffTimeLeft` when it reads 1, so a permanent aura never flashes a `0 s` countdown -/// while waiting for a duration packet that is never coming. -/// -/// **The one inference.** When the catalog cannot resolve the spell at all, the reference leaves -/// `+0xc` holding whatever the record's previous occupant left there (its early-outs at `0x4e4533`/ -/// `0x4e4550` skip the write) β€” stale memory, not a mechanism. We answer from the only evidence we -/// still have: no expiry stamp joined β‡’ nothing finite to display. A catalog miss already costs the -/// aura its icon and name, so this only decides whether a nameless icon also shows `0 s`. +/// `GetPlayerBuff`'s second return, cache record `+0xc`, set by `BuildBuffRecord 0x4e44b0` when +/// `DurationIndex` names an infinite `SpellDuration.dbc` row, base < 0 and per level <= 0 +/// (`0x4e456e`-`0x4e457a`), or no row (`0x4e4580`); or when the aura is not cancelable and an +/// effect is an area aura, `0x23`, `0x77`, `0x80` or `0x81` (`0x4e4588`-`0x4e45be`). +/// `BuffFrame.lua:124` then shows no countdown. Deviation: on a catalog miss the reference keeps +/// the record's stale value (`0x4e4533`, `0x4e4550` skip the write); this answers from the joined +/// expiry, because a stale value is no mechanism to reproduce. fn until_cancelled( display: Option<&benilla_formats::SpellDisplay>, duration_row: Option<&benilla_formats::SpellDuration>, @@ -457,8 +308,7 @@ fn until_cancelled( }; let permanent = match duration_row { Some(row) => row.base_ms < 0 && row.per_level_ms <= 0, - // No row β€” a `DurationIndex` that is negative, out of range, or resolves to nothing. The - // reference funnels all three into the same `mov ecx,1` at `0x4e4580`. + // A negative, out-of-range or empty `DurationIndex`: all three reach `0x4e4580`. None => true, }; permanent @@ -468,38 +318,23 @@ fn until_cancelled( .any(|e| matches!(e, 0x23 | 0x77 | 0x80 | 0x81))) } -/// The reference's aura display filter (decisions 0268 + 0417): an aura is shown iff its spell is -/// *not* flagged never-display (`SPELL_ATTR_DO_NOT_DISPLAY` / `SPELL_ATTR_EX_NO_AURA_ICON`, via -/// `SpellDisplay::hidden_from_aura_bar`) **and** is not a tracking spell -/// (`SpellDisplay::tracking_aura` β€” the `{0x2c,0x2d,0x97}` effect exclusion both filters carry: -/// the player-cache rebuild `PlayerAuras_Update 0x4e4170` skips a tracking aura *before* the -/// insert, diverting it to the tracking global instead, and `IsAuraDisplayable 0x519860` hides it -/// from other units' rows the same way). This holds on **every** aura display β€” the player's own -/// bar and any other unit's rows alike (the director watched the reference hide a target's Battle -/// Stance; 0417 corrects 0268's player-only scope note). A spell the catalog can't resolve stays -/// visible β€” fail-open, like every other catalog miss in the feed. -/// -/// **That fail-open is ours, not the reference's, on this path** β€” the parenthetical that used to -/// justify it here ("like the reference's own no-SpellRec path, which inserts") was true of the -/// *player cache* and is FALSE of the non-player walk: `UnitBuff`/`UnitDebuff` **skip** a slot -/// whose id has no `Spell.dbc` row (`id > [0xc0d78c]` or a null row; decision 1035). -/// Kept as fail-open deliberately: every id on the wire is a real spell, so the two behaviours are -/// indistinguishable in practice, and failing *closed* would let a catalog load hiccup silently -/// blank every aura on every frame. A knowing divergence, recorded β€” not a comment asserting -/// something untrue. +/// The filter of every aura display, the player's bar and other units' rows alike: never-display +/// and tracking spells are hidden. The player's rebuild tests the attributes +/// (`0x4e42b6`-`0x4e42c8`), then diverts a tracking aura to the tracking global instead of the +/// cache (`0x4e42d6`-`0x4e4308`); `IsAuraDisplayable 0x519860` hides both from other units' rows. +/// Deviation: on other units' rows an id the catalog lacks stays visible, because every wire id is +/// a real spell and failing closed would blank every aura on a catalog failure; the reference +/// skips one there (`[0xc0d78c]`), though its player cache inserts one, as here. fn shown_in_aura_ui(catalog: Option<&SpellCatalog>, spell_id: u32) -> bool { catalog .and_then(|c| c.get(spell_id)) .is_none_or(|d| !d.hidden_from_aura_bar() && !d.tracking_aura()) } -/// The player's active tracking aura β€” the reference's tracking global (`DAT_00bc6378`): the -/// cache rebuild walks the raw slots **ascending** and each -/// visible tracking-effect aura overwrites it, so the LAST one wins. The attribute clauses are -/// tested *first* in the reference (the `goto` skips the effect loop), so an attribute-hidden -/// tracking spell never lands here; a catalog miss can't be identified as tracking and lands in -/// the bar instead (the reference's own no-SpellRec path). Read by `GetTrackingTexture` / -/// `CancelTrackingBuff` / `GameTooltip:SetTrackingSpell` via [`UiScript::set_tracking`]. +/// The player's tracking aura, the reference's global `0xbc6378`: the rebuild's ascending walk +/// overwrites it per tracking aura, so the last wins (`0x4e4302`), after the attribute test; a +/// catalog miss cannot be told apart and goes to the bar. Read by `GetTrackingTexture`, +/// `CancelTrackingBuff` and `GameTooltip:SetTrackingSpell` ([`UiScript::set_tracking`]). fn tracking_state_of( catalog: Option<&SpellCatalog>, occupied: &[UnitAuraSlot], @@ -521,16 +356,12 @@ fn feed_auras( selection: Res, stores: Query<&ObjectStore>, spells: Option>, - // The pet half (decision 0990): the bar owns the pet's identity, the index resolves it. pet: Res, index: Res, - // The `UnitBuff` unit-level gate's inputs ([`buffs_visible_on`]) β€” the same reaction resolve - // the selection ring and the name plates run, so one law answers "is this unit friendly to me" - // everywhere. + // The `UnitBuff` gate's inputs, the same reaction the selection ring and nameplates use. factions: Option>, reputations: Res, - // Read-only over the stamps: the feed joins them, the net apply path writes them, and only the - // session end drops them (decision 0900). Nothing the feed sees may invalidate one. + // Read-only: the net path writes the stamps and only the session's end drops them. durations: Res, mut cache: ResMut, time: Res>, @@ -540,41 +371,18 @@ fn feed_auras( return; }; let Ok((store, self_guid)) = self_q.single() else { - // No avatar entity β€” and that is NOT, on its own, the end of the aura state, so nothing is - // torn down here (that is [`end_session_aura_state`]'s job, on the session edge). A - // cross-map worldport despawns every tracked entity, ours included, and the new map - // re-streams it under a fresh entity with the same guid (`net::apply::tag_self_player`'s - // own note); for the frames of that gap there is no avatar while every aura on us is still - // live, and the server sends nothing to rebuild the timers with β€” vmangos emits - // `SMSG_UPDATE_AURA_DURATION` only on an aura's apply/refresh and at - // `Map::ExistingPlayerLogin` (the already-online relog), never on a worldport. - // - // The reference has nothing to lose across that gap: its buff cache (`0xbc6040`) and expiry - // array (`0xbc5f68`) are process globals, zeroed once at startup (`0x4e40c0`, reached only - // from the one-shot init at `0x48f5a9`, itself called only from `0x401602`) and otherwise - // touched by exactly two sites β€” the duration packet's setter `0x4e43a6` and - // `GetPlayerBuffTimeLeft`'s reader `0x4e4467`. That is the whole xref set: `0xbc5f68` - // appears three times in `WoW.exe`. Nothing clears them on a world change, so a debuff's - // countdown simply carries across the loading screen. Clearing here was the "after a tele - // my disease has no timer" defect (decision 0900): the stamps were dropped, and even - // without that every survivor's `appeared_at` restarted at the re-stream, so the freshness - // gate below would have rejected whatever stamps remained. + // No avatar does not end the aura state: a worldport re-streams the avatar mid-session, and + // vmangos re-sends durations only on apply, refresh and `Map::ExistingPlayerLogin`. The + // reference's `0xbc5f68` is zeroed once, by the startup init (`0x4e40c0` from `0x48f5a9`), + // and touched otherwise only by its setter `0x4e43a6` and reader `0x4e4467`. return; }; let bevy_now = time.elapsed_secs_f64(); let catalog = spells.as_ref().map(|s| &s.catalog); - // `SpellDuration.dbc`, for the cache record's `untilCancelled` flag ([`until_cancelled`]) β€” the - // same catalog the `$d` tooltip token resolves against, so nothing new is loaded for this. let spell_durations = spells.as_ref().map(|s| &s.durations); let occupied: Vec = store.0.unit_auras().collect(); - // The reference's DISPLAY FILTER (byte-verified sites `0x4e42b6`–`0x4e42c8`; decisions - // 0268 + 0385, and 0417 for the target frame): a slot whose spell carries - // `SPELL_ATTR_DO_NOT_DISPLAY` / `SPELL_ATTR_EX_NO_AURA_ICON` (warrior stances) stays live in - // `UNIT_FIELD_AURA` but is never shown β€” `NO_AURA_ICON` means exactly that, on every aura display. - // For the player's bar we filter the cache's *input*, so a hidden aura never takes a cache - // position and insertion order/repacking match the reference's; [`shown_in_aura_ui`] is the same - // predicate the target rows apply below. A catalog miss stays visible β€” fail-open. + // Filtered before the cache, so a hidden aura takes no position (`0x4e42b6`-`0x4e4308`). let live: Vec = occupied .iter() .filter(|a| shown_in_aura_ui(catalog, a.spell_id)) @@ -582,15 +390,7 @@ fn feed_auras( .collect(); reconcile(&mut cache.auras, &live, bevy_now); - // NOTE: there is deliberately no "prune the stamps of empty slots" pass here. It was the whole - // of the "a freshly cast buff shows no timer, ever" defect: the duration packet leads its own - // descriptor delta (decision 0257 B6 β€” measured at one frame, ~50 ms, on a fresh apply), so at - // the instant a stamp arrives its slot is still EMPTY, and an occupancy prune deletes it - // sub-millisecond later, before the aura it belongs to can appear. Staleness belongs to the - // freshness gate below, which compares a stamp against the aura it would be joined to rather - // than against a slot's momentary emptiness β€” the defence 0257 Β§3 built for exactly this. - // The map cannot grow: it is keyed by raw slot (≀ 48), each entry overwritten by the next - // packet for that slot, and cleared with the rest of the aura state when the session ends. + // No stamp is pruned for an empty slot: the packet arrives before its slot fills. let script_now = script.now(); @@ -630,16 +430,10 @@ fn feed_auras( }) .collect(); - // The tracking global's benilla twin: derived from the same walk that excluded tracking spells - // from `live` above, before the memory update so its change joins the edge key. + // Before the memory update, so a tracking change joins the edge key. let tracking = tracking_state_of(catalog, &occupied); let tracking_spell = tracking.as_ref().map(|t| t.spell_id); - // The TIMER trace (`BENILLA_AURA_TRACE=`, default 1 s): the instrument for "the countdown - // is wrong" reports. Every tick it prints, per live aura, the app's own remaining time beside - // what the *bar's Lua* actually holds β€” the two halves that can silently disagree, since the - // app recomputes the expiry every frame while a button only re-reads it when something makes it - // repaint. A row whose `lua left` has drifted from `app left` is the disagreement, named. if let Some(period) = trace_period() { if bevy_now - mem.traced_at >= period { mem.traced_at = bevy_now; @@ -647,25 +441,16 @@ fn feed_auras( } } - // The VM half β€” resolved against THIS VM, so a `/reload`'s replacement (1291) reads a fresh - // memo and every edge below re-fires for it. Taken after the `traced_at` use above: the trace - // cadence is host diagnostics and must not expire with the VM. + // This VM's keys: a `/reload`'s new VM starts empty, so every edge re-fires. let memo = mem.vm.get(&script); - // Edge-trigger UNIT_AURA on a discrete change (the reference's PLAYER_AURAS_CHANGED), never on - // the countdown alone β€” that's a per-frame OnUpdate on the button, not an event. The tracking - // spell joins the key: a tracking *switch* changes no visible list, only the minimap icon. + // Fire on a discrete change, never on the countdown; a tracking switch counts. let projection = projection_of(&list); let changed = !memo.present || projection != memo.last || tracking_spell != memo.tracking_last; memo.last = projection; memo.tracking_last = tracking_spell; memo.present = true; - // Debug affordance (`BENILLA_AURA_DUMP=1`): when the visible set changes, log every slot the - // player's bar will draw β€” raw slot, spell id, resolved name, class, and the flags nibble. The - // fastest answer to "what is actually on my bar, and should any of it be there?" β€” the husk that - // `unit_aura`'s `& 0x0E` gate now hides never reaches here, so anything listed is a live aura - // (decisions 0255/0257). Cheap: the env is only consulted on a change. if changed && std::env::var_os("BENILLA_AURA_DUMP").is_some() { info!("aura dump: {} aura(s) on the player bar", cache.auras.len()); for c in &cache.auras { @@ -688,24 +473,15 @@ fn feed_auras( } } - // The target's list (the target frame's aura rows β€” 0255's deferred slice). A self-target - // mirrors the player list (decision 0257 Β§2: the player-bar law under every token); any other - // unit is its descriptor read straight β€” ascending raw slot, durationless (the verified - // other-unit law, 0257/0268) β€” but through the SAME display filter (`shown_in_aura_ui`): a - // never-display aura (a warrior stance) is hidden here too, exactly as the reference hides it - // (decision 0417 β€” the director's Battle-Stance-on-the-target report; corrects 0268's player-only - // scope note). Filtering here rather than in the Lua binding keeps `UnitBuff("target", i)` - // returning the i-th *shown* aura, matching the reference's own indices. + // The target's rows. A self-target mirrors the player list in cache order, where the + // reference's `UnitBuff` reads by slot. let target_list: Option> = selection.target.zip(selection.guid).and_then(|(e, guid)| { if guid == self_guid.0 { return Some(list.clone()); } - // NOT named `store` β€” that is the SELF player's, bound above and needed here as the - // gate's second argument. + // Not `store`: that is the player's, the gate's second argument. let target_store = stores.get(e).ok()?; - // The gate is resolved per unit, once, exactly where the reference resolves it β€” - // before any slot is read (decision 1035). let buffs = buffs_visible_on( target_store, Some(store), @@ -722,29 +498,19 @@ fn feed_auras( Some(other_unit_auras(target_store, catalog, buffs)) }); - // The pet's list β€” the pet frame's four debuff buttons (decision 0990). Same other-unit law as - // the target above, and deliberately the same function: a pet is not a special case of it. - // The token is the bar's cached pet guid ([`crate::ui_pet::feed_pet_unit`]'s own note on why - // that word and not `UNIT_FIELD_SUMMON`). A self-mirror leg would be dead code here β€” no unit - // is ever its own pet. + // The pet's list: the stock pet frame's four buttons draw its buffs (`PetFrame.lua:37,56`). let pet_guid = pet.spells.pet_guid; let pet_list: Option> = (pet_guid != 0) .then(|| index.0.get(&pet_guid)) .flatten() .and_then(|&e| stores.get(e).ok()) - // **The pet keeps the ungated read, deliberately** (decision 1035). A pet is - // PLAYER_CONTROLLED, which sends `CanAssist` down an arm (`0x60673e`-`0x60679f`, keyed on - // an `[obj+0xe68]` record) not yet named β€” so the gate is not known here, and - // guessing it is the one mistake that could blank a working pet frame. `true` is the - // status quo; the pet frame draws only debuffs today anyway (0990), so nothing observable - // rides on it until that arm is derived. + // Ungated; the reference gates a pet too, but `CanAssist`'s player-controlled arm + // (`0x60673e`-`0x60679f`, the controlling players' duel and FFA-PvP state) passes for + // your own pet, leaving only the charm, selectable and reaction clauses to hide its buffs. .map(|store| other_unit_auras(store, catalog, true)); - // The target's target's list β€” the ToT frame's four debuff buttons (decision 1576). Same - // other-unit law as the target and the pet above, resolved through the same one-hop - // `UNIT_FIELD_TARGET` read the unit feed's `"targettarget"` snapshot uses. The self-mirror leg - // is NOT dead code here the way it would be for a pet: while you tank, your target's target is - // YOU, and 0257 Β§2's player-bar law is what the row must draw then. + // The target's target's list, for the ToT frame's four debuff buttons, through the one-hop + // `UNIT_FIELD_TARGET` read. When that is you, it is the player list, as for a self-target. let tot_guid = selection .target .and_then(|e| stores.get(e).ok()) @@ -782,7 +548,6 @@ fn feed_auras( let target_changed = target_cur.is_some() && target_cur != memo.target_last; memo.target_last = target_cur; - // The BENILLA_AURA_DUMP affordance's target half: what the target rows will draw, on change. if target_changed && std::env::var_os("BENILLA_AURA_DUMP").is_some() { let l = target_list.as_deref().unwrap_or_default(); info!("aura dump: {} aura(s) on the target rows", l.len()); @@ -812,18 +577,15 @@ fn feed_auras( memo.tot_last = tot_cur; script.set_auras("player", Some(list)); - // Clearing the token isn't a UNIT_* event (the frame reacts to PLAYER_TARGET_CHANGED, and the - // pet frame to UNIT_PET) β€” same convention as the unit feed's snapshot clear. + // Clearing a token fires nothing: the frames react to `PLAYER_TARGET_CHANGED` and `UNIT_PET`. script.set_auras("target", target_list); script.set_auras("pet", pet_list); script.set_auras("targettarget", tot_list); script.set_tracking(tracking); if changed { script.fire_event("UNIT_AURA", vec![ScriptValue::Str("player".into())]); - // The reference's own event for the same rebuild β€” PLAYER_AURAS_CHANGED, no args β€” which - // the verbatim-transcribed 1.12 frames register: MiniMapTrackingFrame, the buff bar since - // it came onto the `GetPlayerBuff` family, and every corpus aura addon. Fired beside the - // Era-shaped UNIT_AURA the unit frames listen on: one rebuild, both dialects. + // The cache rebuild's own event, no arguments (`0x4e437f`): the stock buff bar and + // `MiniMapTrackingFrame` register it, the unit frames `UNIT_AURA`. script.fire_event("PLAYER_AURAS_CHANGED", vec![]); } if pet_changed { @@ -837,23 +599,14 @@ fn feed_auras( } } -/// The apply/refresh-to-descriptor slack: a duration packet is accepted for an aura if it arrived no -/// more than this long before the aura appeared. Generous versus the **measured** lead β€” one client -/// frame, ~50 ms, on a fresh apply against the live server (decision 0846) β€” and tight versus the -/// seconds a stale recycled-slot stamp would be off by. +/// How long before its aura a duration packet may arrive and still join it. The measured lead is +/// one frame, about 50 ms, on a fresh apply; a recycled slot's stale stamp is seconds older. const DURATION_SLACK: f64 = 1.0; -/// Join a slot's duration stamp to the aura now sitting in that slot, yielding `UnitBuff`'s -/// `(duration, expirationTime)` pair on the **script** clock (`0.0, 0.0` = no timer, our "until -/// cancelled"). This is the *only* thing that may reject a stamp, and it is the right place for it: -/// the stamp is compared against the aura it would be joined to, never against the slot's momentary -/// occupancy. `SMSG_UPDATE_AURA_DURATION` leads its own descriptor delta (decision 0257 B6), so at -/// the instant a stamp arrives its slot is *empty* β€” a rule phrased on occupancy deletes exactly the -/// packet that matters, which is decision 0846's first defect. -/// -/// A stamp that predates the aura by more than [`DURATION_SLACK`] belonged to a since-expired -/// occupant of a recycled slot, and is dropped (decision 0257 Β§3 β€” our stand-in for the reference's -/// unparsed `SpellDuration.dbc` "until cancelled" flag). +/// Joins a slot's stamp to the aura in that slot as `(duration, expirationTime)` on the script +/// clock, `(0, 0)` for none; only this rejects a stamp. A stamp older than the aura by more than +/// [`DURATION_SLACK`] is dropped as an earlier occupant's, where the reference's reader +/// (`0x4e4450`) has no freshness test. fn join_duration( stamp: Option<&DurationStamp>, appeared_at: f64, @@ -866,19 +619,10 @@ fn join_duration( .unwrap_or((0.0, 0.0)) } -/// The end of the session (`OnExit(ClientState::InWorld)` β€” a confirmed `/logout`, back to the glue -/// layer): drop the whole aura state, lists and duration stamps alike, so nothing reaches the next -/// character's bar. -/// -/// This is the *only* place the state is dropped, and the edge it hangs off is the point. The -/// reference drops nothing, ever β€” `0xbc5f68` is zeroed once at client startup and written only by -/// the duration packet (decision 0900) β€” because at this boundary it was protected by a server that -/// re-sent every aura's duration in its login preamble; mangos still carries the bare -/// `// SMSG_UPDATE_AURA_DURATION` placeholder where that packet went in -/// `Player::SendInitialPacketsBeforeAddToMap`, and vmangos never fills it in. So a stamp we kept -/// across a logout would sit in its raw slot with nothing to overwrite it, and the next character's -/// aura in that slot could inherit a timer that was never theirs. One divergence from the reference, -/// at the one boundary where the reference relied on a server we don't have. +/// The session's end: drops the lists and stamps, so nothing reaches the next character's bar. +/// Deviation: the reference never clears its expiry array (`0xbc5f68`); this does, because vmangos +/// sends no durations at login to overwrite a kept stamp (`SMSG_UPDATE_AURA_DURATION` is an empty +/// placeholder in `Player::SendInitialPacketsBeforeAddToMap`, `Player.cpp:19248`). fn end_session_aura_state( script: Option>, mut durations: ResMut, @@ -892,13 +636,10 @@ fn end_session_aura_state( } cache.auras.clear(); durations.by_slot.clear(); - // No [`AuraFeedMemory`] reset here: its edge keys live behind a `VmMemo` and die with the VM - // this session's pushes went to (1290/1291) β€” only the server-side state above needs a hand. + // No `AuraFeedMemory` reset: its edge keys die with the VM. } -/// Drain the spell ids `CancelUnitBuff` queued this frame and send one `CMSG_CANCEL_AURA` each. The -/// server cancels by spell, not slot (decision 0257 B8); it refuses anything the wire's -/// `AFLAG_CANCELABLE` bit didn't allow, which the binding already gated on. +/// A `CMSG_CANCEL_AURA` per queued `CancelUnitBuff`, naming the spell, not the slot (`0x6e7040`). fn drain_aura_cancels(script: Option>, net: Res) { let Some(mut script) = script else { return; @@ -912,33 +653,23 @@ fn drain_aura_cancels(script: Option>, net: Res App { - // The cancel drain's outbound channel. Nothing sends on it here (its only source is the - // script VM, which this app has none of), so the dropped receiver is inert. let (tx, _) = crossbeam_channel::unbounded(); let mut app = App::new(); app.add_plugins((MinimalPlugins, bevy::state::app::StatesPlugin)) .insert_state(ClientState::InWorld) .init_resource::() - // The pet half's two inputs (decision 0990). Left empty here β€” this app has no pet, so - // the `"pet"` token stays cleared and the feed's pet leg is inert. .init_resource::() .init_resource::() - // The `UnitBuff` gate's reaction inputs (decision 1035). `Factions` is an - // `Option>` (it needs the DBC catalog), but `Reputations` is not β€” the feed - // reads it unconditionally, so a bare app must carry it or the system fails validation. + // A required system parameter, unlike `Factions`. .init_resource::() .insert_resource(NetCommands(tx)) .add_plugins(UiAuraPlugin); app } - /// Seed the app with one live aura carrying a running timer β€” the state a teleport must not - /// disturb and a logout must. + /// One live aura with a running timer: what a teleport must keep and a logout must drop. fn seed_one_timed_aura(app: &mut App) { app.world_mut() .resource_mut::() @@ -947,7 +678,7 @@ mod tests { .resource_mut::() .auras .push(CachedAura { - slot: 32, // the first harmful slot β€” a debuff, like the report's disease + slot: 32, // the first harmful slot, a debuff spell_id: 11976, appeared_at: 10.0, flags: 0x8, @@ -956,23 +687,13 @@ mod tests { }); } - /// **Decision 0900, pinned.** A cross-map worldport despawns every tracked entity β€” our avatar - /// included β€” and the new map re-streams it, so for a stretch of frames mid-session there is no - /// `SelfPlayer` while every aura on us is still live. Nothing may tear the aura state down on - /// that: vmangos re-sends `SMSG_UPDATE_AURA_DURATION` only on apply/refresh (and at an - /// already-online relog), never on a worldport, so a dropped stamp is gone for the aura's whole - /// remaining life β€” the director's "after a tele my disease has no timer". - /// - /// The session edge is the one place it *does* go, and this pins both halves β€” the second one - /// squarely, the first one for what a VM-less app can reach: the Update schedule leaves a - /// seeded, avatar-less aura state alone. The teardown having exactly one site, on that edge, is - /// what makes the first half hold in the real app, and that is structural, not asserted here. + /// A worldport's avatar-less frames keep every aura live, and no duration is re-sent then. #[test] fn the_aura_state_survives_an_avatar_less_frame_and_dies_only_with_the_session() { let mut app = aura_app(); seed_one_timed_aura(&mut app); - // Frames with no avatar entity at all β€” the worldport gap. + // Frames with no avatar: the worldport gap. app.update(); app.update(); assert_eq!( @@ -988,16 +709,13 @@ mod tests { "the duration stamp survives the gap β€” nothing will re-send it" ); - // The session ends: `/logout` back to the glue layer. + // The session ends: `/logout` to the glue screens. app.world_mut() .resource_mut::>() .set(ClientState::CharSelect); app.update(); assert!(app.world().resource::().auras.is_empty()); assert!(app.world().resource::().by_slot.is_empty()); - // The feed's edge memory needs no teardown assertion any more: it sits behind a `VmMemo`, - // so it dies with the VM rather than on this edge β€” the property `ui_script::session`'s - // own tests pin (1290/1291), and one a VM-less app cannot observe. } fn slot(slot: u8, spell_id: u32) -> UnitAuraSlot { @@ -1018,18 +736,14 @@ mod tests { cache.iter().map(|c| (c.slot, c.spell_id)).collect() } - /// The display filter both the player bar and the target rows run through (`shown_in_aura_ui`), - /// exercised against the REAL 5875 `Spell.dbc`: a warrior's Battle Stance carries `NO_AURA_ICON` - /// and is hidden on every frame (decision 0417 β€” the director's "battle stance on the target - /// frame" report), while Battle Shout is a real buff that stays. Skips without client data. + /// Against the real `Spell.dbc`; skips without client data. #[test] fn the_aura_display_filter_hides_a_real_battle_stance_but_keeps_battle_shout() { let data = benilla_formats::wow_data_or_skip!(); let mut chain = benilla_formats::open_chain(&data).expect("open chain"); let catalog = benilla_formats::load_spell_catalog(&mut chain).expect("Spell.dbc"); - // A target carrying Battle Stance (2457) + Battle Shout (6673): the stance is filtered out of - // what the rows draw, exactly as the reference hides it; the shout survives. + // Battle Stance 2457 and Battle Shout 6673. let slots = [slot(0, 2457), slot(1, 6673)]; let shown: Vec = slots .iter() @@ -1043,19 +757,14 @@ mod tests { assert!(shown_in_aura_ui(Some(&catalog), 0xffff_fffe)); } - /// The tracking half of the display filter (Pass 2 of `PlayerAuras_Update 0x4e4170`: the - /// `{0x2c,0x2d,0x97}` effect exclusion + the tracking global), against the REAL 5875 - /// `Spell.dbc`: a tracking aura rides a visible `UNIT_FIELD_AURA` slot but never reaches any - /// bar β€” it is diverted to the tracking state instead, and the ascending walk's LAST tracking - /// aura wins the global. Skips without client data. + /// Against the real `Spell.dbc` (`0x4e42d6`-`0x4e4308`); skips without client data. #[test] fn a_real_tracking_aura_is_diverted_from_the_bar_to_the_tracking_state() { let data = benilla_formats::wow_data_or_skip!(); let mut chain = benilla_formats::open_chain(&data).expect("open chain"); let catalog = benilla_formats::load_spell_catalog(&mut chain).expect("Spell.dbc"); - // Find Herbs 2383 (TRACK_RESOURCES 45), Battle Shout 6673 (an ordinary buff), Track - // Beasts 1494 (TRACK_CREATURES 44): only the shout is shown on any aura display... + // Find Herbs 2383 (aura 45), Battle Shout 6673, Track Beasts 1494 (aura 44). let slots = [slot(0, 2383), slot(1, 6673), slot(2, 1494)]; let shown: Vec = slots .iter() @@ -1068,8 +777,7 @@ mod tests { "both tracking auras are diverted, the shout stays" ); - // ...and the tracking global holds the LAST tracking aura of the ascending walk (each - // match overwrites), with the display fields the icon + tooltip read. + // The last tracking aura in slot order holds the global. let t = tracking_state_of(Some(&catalog), &slots).expect("tracking state set"); assert_eq!( t.spell_id, 1494, @@ -1082,25 +790,18 @@ mod tests { "the synthesized slot carries AFLAG_CANCELABLE" ); - // No tracking aura live β†’ no state (the frame's hide branch); and without a catalog no - // aura can be identified as tracking (the reference's own no-SpellRec path inserts into - // the bar instead β€” fail-open both sides of the diversion). + // No tracking aura, or no catalog to identify one: no tracking state. assert!(tracking_state_of(Some(&catalog), &[slot(0, 6673)]).is_none()); assert!(tracking_state_of(None, &slots).is_none()); } - /// The distinguishing behaviour of decision 0257: a newly-applied aura in a *lower* slot - /// appends at the END of the cache, it is not sorted to the front by slot. A plain - /// `unit_auras()` read (ascending slot) would give the opposite order β€” this is exactly the - /// difference between the descriptor's order and the display order. #[test] fn a_new_low_slot_aura_appends_at_the_end_not_sorted_by_slot() { let mut cache = Vec::new(); // X lands in slot 5 first. reconcile(&mut cache, &[slot(5, 100)], 1.0); assert_eq!(order(&cache), [(5, 100)]); - // Y then lands in slot 2 β€” the descriptor now reads ascending [2, 5], but Y is the newer - // aura, so it goes to the end. + // Y lands in slot 2 later: the descriptor reads [2, 5], but Y is newer. reconcile(&mut cache, &[slot(2, 200), slot(5, 100)], 2.0); assert_eq!( order(&cache), @@ -1109,8 +810,7 @@ mod tests { ); } - /// A dropped aura closes its gap; the survivors keep their relative order and slide along β€” the - /// `PlayerAuras_Update` shift-down. A recycled slot then appends fresh, not into the hole. + /// `PlayerAuras_Update`'s shift-down (`0x4e421b`), then a recycled slot's append. #[test] fn a_dropped_aura_repacks_and_a_recycled_slot_appends_fresh() { let mut cache = Vec::new(); @@ -1121,7 +821,7 @@ mod tests { reconcile(&mut cache, &[slot(0, 10), slot(2, 30)], 2.0); assert_eq!(order(&cache), [(0, 10), (2, 30)], "the gap closes"); - // Slot 1 is recycled by a new spell β€” it appends at the end, not back into the old middle. + // A new spell takes slot 1: it appends, not back into the old middle. reconcile(&mut cache, &[slot(0, 10), slot(1, 99), slot(2, 30)], 3.0); assert_eq!( order(&cache), @@ -1130,7 +830,6 @@ mod tests { ); } - /// A surviving aura refreshes its volatile fields in place (a stack change) without moving. #[test] fn a_surviving_aura_refreshes_in_place() { let mut cache = Vec::new(); @@ -1143,12 +842,9 @@ mod tests { assert_eq!(cache[0].appeared_at, 1.0, "appeared_at is not disturbed"); } - /// The duration freshness gate (decision 0257 Β§3): a stamp older than the aura is ignored β€” the - /// stale-recycled-slot defence β€” while a stamp from around the apply is accepted. #[test] fn a_duration_is_joined_only_when_it_is_no_older_than_the_aura() { - // A stamp received at t=100 is stale for an aura that only appeared at t=200 β€” it belonged - // to the slot's previous, since-expired occupant. + // Received at t=100 for an aura that appeared at t=200: a previous occupant's. let stale = DurationStamp { total: 30.0, expires_at: 130.0, @@ -1159,8 +855,7 @@ mod tests { (0.0, 0.0), "a stamp seconds older than the aura is rejected β€” no timer, not a wrong one" ); - // A stamp received just before the aura appeared (the real applyβ†’descriptor lead) joins, - // and its expiry is rebased from the Bevy clock onto the script clock Lua counts against. + // Received just before the aura: joined, and rebased onto the script clock. let fresh = DurationStamp { total: 30.0, expires_at: 230.0, @@ -1170,28 +865,19 @@ mod tests { join_duration(Some(&fresh), 200.0, 200.0, 5000.0), (30.0, 5030.0) ); - // No stamp at all is a permanent aura: the wire sends no packet for one (decision 0257). + // No stamp: a permanent aura, which gets no packet. assert_eq!(join_duration(None, 200.0, 200.0, 5000.0), (0.0, 0.0)); } - /// **Decision 0846's first defect, pinned.** `SMSG_UPDATE_AURA_DURATION` LEADS the descriptor - /// delta that names its slot (0257 B6; live-measured at one client frame, ~50 ms, on a fresh - /// apply). So at the instant the stamp arrives its slot is **empty** β€” and the feed used to run - /// a per-frame "drop the stamps of unoccupied slots" pass, which deleted it sub-millisecond - /// later, before the aura it belonged to could appear. Every freshly cast buff then showed no - /// timer, forever, while a *refresh* (whose slot is already occupied) worked β€” the "sometimes" - /// in the report. - /// - /// The invariant that replaced it: nothing removes a stamp on occupancy, and the join is what - /// decides. Here the aura arrives a frame *after* its stamp, and must still get its timer. + /// The duration packet arrives about a frame before the delta that fills its slot. #[test] fn a_stamp_that_arrives_one_frame_before_its_aura_still_joins_it() { let mut durations = AuraDurations::default(); - // t = 15.10 β€” the packet lands. Nothing occupies slot 0 yet; the cache is empty. + // t = 15.10: the packet lands while slot 0 is still empty. durations.set(0, 300_000, 15.10); let mut cache = Vec::new(); assert!(cache.is_empty()); - // t = 15.15 β€” the next frame's descriptor delta names slot 0. + // t = 15.15: the next frame's descriptor delta fills slot 0. reconcile(&mut cache, &[slot(0, 1126)], 15.15); let (total, expiry) = join_duration( @@ -1201,42 +887,33 @@ mod tests { 900.0, ); assert_eq!(total, 300.0, "the stamp survived the gap"); - // Script clock 900 + the 300 s that started 50 ms ago: the bar counts down from the - // packet's instant, not from the descriptor delta that named the slot. + // Script clock 900 plus the 300 s that began 50 ms ago, at the packet, not the delta. assert!( (expiry - 1199.95).abs() < 1e-9, "expiry rebased onto the script clock, got {expiry}" ); } - // == Decision 1035 β€” `UnitBuff 0x519500`'s unit-level gate == - // - // The B216 case: a stealthed Ridge Stalker carries TWO auras that both resolve to - // `Ability_Stealth` β€” 5916 "Shadowstalker Stealth" in a POSITIVE slot (creature_addon) and - // 22766 "Sneak" in a NEGATIVE slot (EventAI; vmangos files it harmful because its - // MOD_DECREASE_SPEED effect is negative). We drew both. The reference draws only the debuff, - // because `UnitBuff` never enumerates a hostile creature's positive half at all. + // == `UnitBuff`'s unit-level gate (`0x519500`) == + // A stealthed Ridge Stalker carries 5916 in a positive slot and 22766 in a negative one; the + // reference draws only 22766, as `UnitBuff` lists no hostile creature's buffs. - /// `UNIT_FIELD_FLAGS` index β€” the fixture writes it directly (`from_pairs` is the public - /// fixture constructor; live code only ever decodes the wire). + /// `UNIT_FIELD_FLAGS`'s index. const F_FLAGS: u16 = 46; - /// `UNIT_FIELD_CHARMEDBY`'s low dword β€” a guid pair, so the high half is the next index. + /// `UNIT_FIELD_CHARMEDBY`'s low dword; the high half is the next index. const F_CHARMEDBY: u16 = 10; - /// The B216 mob's two slots: 5916 helpful, 22766 harmful. + /// The Ridge Stalker's two slots: 5916 helpful, 22766 harmful. fn ridge_stalker_slots(flags: u32) -> ObjectStore { ObjectStore(benilla_protocol::ObjectFields::from_pairs(&[ (F_FLAGS, flags), - (47, 5916), // UNIT_FIELD_AURA slot 0 β€” the positive half - (47 + 32, 22766), // slot 32 β€” the negative half + (47, 5916), // UNIT_FIELD_AURA slot 0, the positive half + (47 + 32, 22766), // slot 32, the negative half (95, 0x0000_0008), // AURAFLAGS slot 0 nibble 0x8 (eff0) (95 + 4, 0x0000_0008), // slot 32 nibble 0x8 ])) } - /// **The gate's consequence, both ways.** With buffs denied, `other_unit_auras` returns the - /// harmful half ONLY β€” which is what makes `UnitBuff("target", i)` nil for every i while - /// `UnitDebuff` is untouched, exactly as the reference behaves (a single nil for every buff - /// index, no slot examined). With buffs allowed, both come back. + /// Denied, `UnitBuff` is nil at every index; `UnitDebuff` is untouched. #[test] fn the_unit_gate_drops_the_whole_helpful_half_and_never_the_harmful_one() { let store = ridge_stalker_slots(0); @@ -1257,15 +934,11 @@ mod tests { ); } - /// **Detect Magic / GM mode short-circuits the gate.** `UNIT_FLAG_AURAS_VISIBLE` is clause - /// one, tested before any reaction work β€” and it is why the same mob reads two ways: vmangos - /// ORs the bit in for GameMasters only (`Object.cpp:765-766`), so a GM sees the buffs and an - /// ordinary player does not. Nothing here consults GM state; the flag arrives on the wire. + /// vmangos sets `UNIT_FLAG_AURAS_VISIBLE` for a GM viewer too (`Object.cpp:765-766`). #[test] fn detect_magic_or_gm_mode_opens_the_gate_on_the_flag_alone() { let reputations = Reputations::default(); - // No factions catalog β†’ `ring_reaction` falls through to NEUTRAL (3), which FAILS the - // `>= 4` bar. So the flag is the only thing that can open the gate in this fixture. + // No factions catalog: the reaction is neutral (3), below the `>= 4` bar. let hostile = ridge_stalker_slots(0); assert!( !buffs_visible_on(&hostile, None, None, &reputations, |_| None), @@ -1279,23 +952,19 @@ mod tests { ); } - /// **`NOT_SELECTABLE` and the charm clause.** Both are outside the reaction ladder: the first - /// is `CanAssist`'s own first test, the second is `UnitBuff`'s `activePlayer.CHARMEDBY == 0` - /// (`0x5195f1`-`0x5195f7`) β€” while something else drives you, you assist nobody. + /// `NOT_SELECTABLE` is `CanAssist`'s; the charm clause is `UnitBuff`'s (`0x5195f1`-`0x5195f7`). #[test] fn not_selectable_and_being_charmed_each_close_the_gate_on_their_own() { let reputations = Reputations::default(); - // AURAS_VISIBLE would open it β€” but it is clause one and NOT_SELECTABLE lives inside - // CanAssist, so the flag still wins. Pin that ordering, it is the reference's. + // AURAS_VISIBLE is tested before `CanAssist`, so it wins over NOT_SELECTABLE. let both = ridge_stalker_slots(UNIT_FLAG_AURAS_VISIBLE | (1 << 25)); assert!( buffs_visible_on(&both, None, None, &reputations, |_| None), "AURAS_VISIBLE is tested FIRST and short-circuits CanAssist entirely" ); - // Player-controlled + friendly would otherwise pass the permissive arm; being charmed - // ourselves closes it before CanAssist runs at all. + // Being charmed closes the gate before `CanAssist` runs. let friendly_pc = ridge_stalker_slots(0x8); let charmed_self = ObjectStore(benilla_protocol::ObjectFields::from_pairs(&[ (F_CHARMEDBY, 0x1234), @@ -1313,10 +982,7 @@ mod tests { ); } - /// [`until_cancelled`] β€” `GetPlayerBuff`'s second return, against the two clauses the reference - /// derives it from (`0x4e452e`-`0x4e45c5`). Every case here is one the corpus reads as a - /// **number** compared to `1` (`CT_BuffMod/CT_BuffFrame.lua:175,232` and three more), so a wrong - /// answer shows or hides a countdown rather than erroring. + /// The two clauses of `0x4e452e`-`0x4e45c5`. #[test] fn until_cancelled_follows_the_duration_row_and_the_area_aura_override() { use benilla_formats::{SpellDisplay, SpellDuration}; @@ -1326,10 +992,8 @@ mod tests { per_level_ms, max_ms: base_ms, }; - // The shapes the shipped file actually holds (asserted on the real DBC in - // `benilla_formats::spells::duration`'s tests): row 21 `{-1, 0}` = permanent, row 30 - // `{1_800_000, 0}` = Frost Armor's 30 minutes, row 427 `{-600_000, 60_000}` = the one that - // makes this a TWO-field test, since a negative base alone is not permanence. + // Real `SpellDuration.dbc` rows: 21 `{-1, 0}` permanent, 30 `{1_800_000, 0}` 30 minutes, + // 427 `{-600_000, 60_000}`, whose negative base alone is not permanence. let permanent_row = row(-1, 0); let timed_row = row(1_800_000, 0); let scaling_row = row(-600_000, 60_000); @@ -1339,10 +1003,10 @@ mod tests { ..SpellDisplay::default() }; let ordinary = spell([6, 0, 0]); - // 0x23 = the persistent-area-aura effect, one of the four in the override set. + // 0x23 is `SPELL_EFFECT_APPLY_AREA_AURA_PARTY`, one of the four in the override set. let area = spell([0x23, 0, 0]); - // Clause 1 β€” the DBC duration row decides, cancelable or not. + // Clause 1: the duration row decides, cancelable or not. assert!(!until_cancelled( Some(&ordinary), Some(&timed_row), @@ -1365,20 +1029,17 @@ mod tests { !until_cancelled(Some(&ordinary), Some(&scaling_row), true, 0.0), "a negative base with a positive per-level term is a SCALING duration, not permanence" ); - // No row at all is the reference's `mov ecx,1` branch β€” permanent. + // No row: permanent (`0x4e4580`). assert!(until_cancelled(Some(&ordinary), None, true, 0.0)); - // Clause 2 β€” the area-aura override applies ONLY to a non-cancelable aura (`test - // [edi+0xa],0x1; jne` returns early at 0x4e4588). This asymmetry is the whole clause: the - // same spell answers differently depending on the wire's cancelable nibble. + // Clause 2: the area-aura override applies only to a non-cancelable aura (`0x4e4588`). assert!(until_cancelled(Some(&area), Some(&timed_row), false, 0.0)); assert!( !until_cancelled(Some(&area), Some(&timed_row), true, 0.0), "a CANCELABLE aura skips the effect scan entirely" ); - // The stated inference: the catalog cannot resolve the spell, so answer from the joined - // expiry β€” the only evidence left. + // A catalog miss answers from the joined expiry. assert!(until_cancelled(None, None, false, 0.0)); assert!(!until_cancelled(None, None, false, 1234.0)); } diff --git a/crates/benilla-app/src/ui_bank/mod.rs b/crates/benilla-app/src/ui_bank/mod.rs index 9d6e5103..878df4cf 100644 --- a/crates/benilla-app/src/ui_bank/mod.rs +++ b/crates/benilla-app/src/ui_bank/mod.rs @@ -1,23 +1,6 @@ -//! The app-side **bank feed** (decision 0604 phase 2) β€” the inward half of the bank seam around -//! [`benilla_ui::script`]'s `bank` module, the twin of [`crate::ui_merchant`]'s merchant seam. -//! -//! The net bridge fills [`BankOpen`] from the wire (`SMSG_SHOW_BANK` β†’ the banker guid β€” sent for -//! our own `CMSG_BANKER_ACTIVATE` *and* volunteered for the gossip menu's bank option, so the arm -//! never assumes we asked). The vault's contents never pass through here: the 24 bank slots and -//! the 6 bank bags are player-array descriptor fields streamed at login (decision 0604 β€” the -//! window only reveals them), fed by [`crate::ui_items`] as containers `-1`/`5..=10` beside the -//! backpack. Each frame [`feed_bank`] pushes the purchase-row snapshot -//! ([`benilla_ui::script::BankState`]: the `PLAYER_BYTES_2` byte-2 purchased count + the next -//! slot's `BankBagSlotPrices.dbc` cost), fires `BANKFRAME_OPENED` on open / `BANKFRAME_CLOSED` on -//! clear / `PLAYERBANKBAGSLOTS_CHANGED` when the purchased count moves (a successful buy has **no -//! packet** β€” that descriptor delta is the confirmation), and resolves the banker's name for the -//! title (the merchant feed's ask-once pattern). [`drain_bank`] pulls the Lua intents back out: -//! `PurchaseSlot()` β†’ [`ClientCommand::BuyBankSlot`], `CloseBankFrame()` β†’ a local clear (no -//! close opcode exists β€” decision 0604). The standardized NPC-session range guard -//! ([`crate::ui_session`]) applies the same client-side close out of service range. -//! -//! The right-click auto-move (deposit/withdraw while the bank is open) lives one module over in -//! [`crate::ui_items`]'s drain, the sell affordance's exact pattern. +//! The bank window's app side: the open banker, the purchase row, the bank events and the Lua +//! intents. The vault itself is descriptor fields that [`crate::ui_items`] feeds as containers +//! `-1` and `5..=10`, and moves items in and out of on a right-click. use benilla_protocol::messages::bank_slot_result; use bevy::prelude::*; @@ -28,46 +11,39 @@ use crate::net::{ClientCommand, NetCommands, ObjectStore, SelfPlayer}; use crate::ui_script::{UiFeed, UiInput}; use crate::ui_session::{close_npc_session_out_of_range, npc_switched, NpcSession}; -/// The client-side `BankBagSlotPrices.dbc` table (decision 0604: the purchase ladder is client -/// data β€” 10s/1g/10g/25g/50g/100g, then the 999999999 sentinel). Optional resource β€” absent, the -/// purchase row shows cost 0 and the popup still works (the server re-prices authoritatively). +/// `BankBagSlotPrices.dbc`, the client's own slot prices; absent, the purchase row shows 0 and +/// the server still charges its own price. #[derive(Resource)] pub(crate) struct BankPrices(pub(crate) benilla_formats::BankBagSlotPrices); -/// The open bank session, filled by the net bridge (`SMSG_SHOW_BANK`) and read by [`feed_bank`]. -/// Holds only the banker guid β€” the vault's contents are descriptor fields the container feed -/// already carries. Cleared on a client-side close and on disconnect. +/// The open bank: the banker from `SMSG_SHOW_BANK`, until a close or a disconnect. #[derive(Resource, Default)] pub(crate) struct BankOpen { - /// The banker whose window is open; `None` = no bank open. pub(crate) banker: Option, } impl BankOpen { - /// Open (or re-point) the window at a banker. pub(crate) fn open(&mut self, banker: u64) { self.banker = Some(banker); } - /// Whether the bank window is currently open. pub(crate) fn is_open(&self) -> bool { self.banker.is_some() } - /// Close the open window (a client-side close β€” no packet exists, decision 0604). + /// A client-side close: there is no close packet. pub(crate) fn clear(&mut self) { self.banker = None; } - /// Disconnect: drop the open window (mirrors the merchant/gossip session clears). + /// The disconnect clear. pub(crate) fn clear_session(&mut self) { self.clear(); } } -/// The bank window is an NPC session: the standardized range guard ([`crate::ui_session`]) -/// client-side-closes it β€” the exact `CloseBankFrame` clear β€” when the player walks out of the -/// banker's service range or the banker despawns. +/// The range guard closes the bank, as `CloseBankFrame` does, when the banker is out of range or +/// gone. impl NpcSession for BankOpen { fn npc(&self) -> Option { self.banker @@ -78,8 +54,7 @@ impl NpcSession for BankOpen { } } -/// Bank-slot purchase refusals (`SMSG_BUY_BANK_SLOT_RESULT` β€” vmangos sends it only on failure) -/// queued by the net bridge for the red error line. +/// `SMSG_BUY_BANK_SLOT_RESULT` codes waiting for the error line; vmangos sends one only on failure. #[derive(Resource, Default)] pub(crate) struct BankErrors(pub Vec); @@ -95,8 +70,7 @@ impl Plugin for UiBankPlugin { .add_systems( Update, ( - // Range-close before the feed so the clear turns into BANKFRAME_CLOSED the - // same frame; push before the input pass; drain after it (the merchant shape). + // Range-close first so the clear fires `BANKFRAME_CLOSED` the same frame. close_npc_session_out_of_range::.before(feed_bank), feed_bank.in_set(UiFeed), drain_bank.after(UiInput), @@ -105,14 +79,9 @@ impl Plugin for UiBankPlugin { } } -/// The red error line for a `SMSG_BUY_BANK_SLOT_RESULT` code β€” the reference GlobalStrings' -/// `ERR_BANKSLOT_*` texts verbatim (decision 0604: the codes map 1:1 onto them). -/// -/// **The three ids are the reference's own table, and `None` is the reference's own silence** -/// (decision 1821): its handler at `0x5e3f8d` reads the `u32`, refuses `>= 3` outright -/// (`0x5e3f9c cmp eax,0x3; jae`) and otherwise indexes a 3-entry table at `0x80af14` holding -/// exactly `{0x100, 0x101, 0x102}`. So `OK` (3) is silent by the same bound as an unknown code β€” -/// there is no per-code fallback line to print, and printing one would be our invention. +/// The `ERR_BANKSLOT_*` line for a `SMSG_BUY_BANK_SLOT_RESULT` code. The reference's handler +/// (`0x5e3f8d`) ignores any code `>= 3`, `OK` included (`0x5e3f9c`), and maps the rest through the +/// three-entry table at `0x80af14`, message ids `0x100..=0x102`. fn bank_slot_error_key(result: u32) -> Option<&'static str> { Some(match result { bank_slot_result::FAILED_TOO_MANY => "ERR_BANKSLOT_FAILED_TOO_MANY", // 0x100 @@ -122,14 +91,9 @@ fn bank_slot_error_key(result: u32) -> Option<&'static str> { }) } -/// Push the purchase-row snapshot + the open/close/purchased-count events (module doc). -/// -/// **`BANKFRAME_OPENED` carries no arguments**, which is a change 1751's swap forced and a -/// correction either way: the banker's name used to ride it as `arg1` (the merchant feed's title -/// pattern), and the reference's own `BankFrame_OnEvent` reads no `arg1` at all β€” it titles the -/// window with `UnitName("npc")`, off the interaction token [`crate::ui_session`] already points -/// at the banker. Firing an argument the reference does not fire is a divergence an addon can -/// see, and it bought nothing once the reference's file was the one reading the event. +/// Push the purchase row and fire the bank events. `BANKFRAME_OPENED` has no arguments: the +/// reference fires it bare (`0x4f8522`), and stock `BankFrame.lua:127` titles with +/// `UnitName("npc")`. fn feed_bank( script: Option>, open: Res, @@ -145,9 +109,7 @@ fn feed_bank( }; let last = last.get(&script); let last_banker = last_banker.get(&script); - // Purchase refusals go to the surface β€” and the voice β€” their message record names: the - // insufficient-funds row carries error-speech line `0x16` (decision 1815). A code outside the - // reference's three is silent, exactly as its `jae` makes it ([`bank_slot_error_key`]). + // Refusals show, and speak, as their message rows say: insufficient funds is speech `0x16`. let lines: Vec<_> = errors .0 .drain(..) @@ -165,9 +127,8 @@ fn feed_bank( .and_then(|p| p.0.next_slot_price(purchased)) .unwrap_or(0), }); - // A different banker while the window is already open is a real close+open (decision 0096 / - // [`npc_switched`]) β€” both sounds, and the OnHide-queued close intent is consumed so the - // drain doesn't clear the session we just re-opened. + // A new banker while open is a close then an open; the close intent OnHide queues is + // consumed so the drain keeps the new session. let switched = npc_switched(*last_banker, open.banker); if fresh == *last && !switched { return; @@ -180,8 +141,7 @@ fn feed_bank( } else { match (&*last, &fresh) { (None, Some(_)) => script.fire_event("BANKFRAME_OPENED", vec![]), - // The purchased count moved while open β€” the no-packet buy confirmation (decision - // 0604): the reference event repaints the bag row + purchase frame. + // The bought count moved: a bought slot has no reply packet, so this confirms it. (Some(_), Some(_)) => script.fire_event("PLAYERBANKBAGSLOTS_CHANGED", vec![]), (Some(_), None) => script.fire_event("BANKFRAME_CLOSED", vec![]), (None, None) => {} @@ -191,7 +151,7 @@ fn feed_bank( *last_banker = open.banker; } -/// Drain the Lua intents: `PurchaseSlot()` β†’ the buy wire, `CloseBankFrame()` β†’ the local clear. +/// Drain the Lua intents: `PurchaseSlot()` sends the buy, `CloseBankFrame()` clears locally. fn drain_bank( script: Option>, mut open: ResMut, @@ -216,10 +176,6 @@ fn drain_bank( mod tests { use super::*; - /// The refusal-code β†’ red-line map: the three vmangos failure codes print the reference - /// GlobalStrings verbatim, and everything else β€” `OK` and any unknown code alike β€” is silent, - /// which is the reference's own `cmp eax,0x3; jae` and not a policy of ours (1821). The ids - /// are asserted, not just the keys: those are what `0x80af14` actually holds. #[test] fn bank_slot_error_keys() { assert_eq!( @@ -240,8 +196,7 @@ mod tests { None, "past the reference's own bound" ); - // Every key is a catalog row, at the id `0x80af14` holds for its code; and the - // insufficient-funds one is the spoken one (1815). + // The ids the reference's table at `0x80af14` holds; insufficient funds also speaks. for (key, id) in [ ("ERR_BANKSLOT_FAILED_TOO_MANY", 0x100u16), ("ERR_BANKSLOT_INSUFFICIENT_FUNDS", 0x101), diff --git a/crates/benilla-app/src/ui_bank/net.rs b/crates/benilla-app/src/ui_bank/net.rs index d73cb6e2..cf8fb79f 100644 --- a/crates/benilla-app/src/ui_bank/net.rs +++ b/crates/benilla-app/src/ui_bank/net.rs @@ -1,6 +1,4 @@ -//! The bank's packet handlers (decision 0604; in the net handler table since 2318, moved out of -//! the drain's npc arm file) β€” the [`BankOpen`] session and the [`BankErrors`] line queue the bank -//! feed ([`super`]) reads. +//! The bank's packet handlers: they fill [`BankOpen`] and [`BankErrors`] for the bank feed. use benilla_protocol::{SessionEvent, SessionEventKind}; use bevy::prelude::*; @@ -10,8 +8,7 @@ use crate::net::NetHandlerApp; use crate::ui_gossip::GossipState; use crate::ui_quest::QuestGiver; -/// Register the bank handlers β€” called from [`super::UiBankPlugin`]. One per kind, plus the -/// session-end listener. +/// Register the bank handlers and the session-end listener. pub(super) fn register(app: &mut App) { use SessionEventKind as K; app.net_handler(K::ShowBank, on_show_bank) @@ -36,26 +33,16 @@ fn on_buy_slot_result(In(ev): In, mut errors: ResMut) } } -/// The bank window dies with the socket (decision 0604) β€” a reconnect re-opens via the banker. -/// A listener on the session end (a second handler on the kind, after the bridge's own teardown). +/// The bank window closes with the connection. fn on_session_end(In(_): In, mut bank: ResMut) { bank.clear_session(); } -/// The bank opened (`SMSG_SHOW_BANK`): point the [`BankOpen`] session the bank feed -/// ([`super`]) reads at the banker. Sent for our own `CMSG_BANKER_ACTIVATE` *and* -/// volunteered by the server for the gossip menu's bank option (`GOSSIP_OPTION_BANKER` β†’ -/// `SendShowBank`, decision 0604) β€” so this never assumes we asked. The vault's contents are -/// descriptor fields already streamed; the window renders from local state. -/// -/// Opening the bank ends any open gossip interaction (the `SMSG_GOSSIP_COMPLETE` clear): vmangos -/// sends no `SMSG_GOSSIP_COMPLETE` for the gossip menu's bank option (VERIFIED -/// `Player::OnGossipSelect` β€” BANKER only calls `SendShowBank`), and the panel slots alone can't -/// close the menu β€” the bank's `pushable = 6` sends it to the *center* slot beside a pushable-0 -/// gossip instead of replacing it, so the menu would linger beside the vault -/// (director-observed). The real client ends the old NPC interaction when the new one starts -/// (INFERRED β€” the exact C++ `GOSSIP_CLOSED` fire isn't RE'd; the observable is vanilla's: the -/// menu is gone once the vault is up). +/// `SMSG_SHOW_BANK`, sent for `CMSG_BANKER_ACTIVATE` and unprompted for the gossip bank option. +/// It ends an open gossip interaction: vmangos sends no `SMSG_GOSSIP_COMPLETE` for that option +/// (`Player::OnGossipSelect`), and the bank's `pushable = 6` (`UIParent.lua:28`) would open beside +/// the gossip frame rather than replace it. The reference's gossip menu is gone once the vault +/// shows; its mechanism for that is untraced. fn show_bank(banker: u64, bank: &mut BankOpen, gossip: &mut GossipState, quest: &mut QuestGiver) { debug!("net: bank opened at {banker:#x}"); if gossip.npc.is_some() { @@ -64,8 +51,7 @@ fn show_bank(banker: u64, bank: &mut BankOpen, gossip: &mut GossipState, quest: bank.open(banker); } -/// A bank-slot purchase refusal (`SMSG_BUY_BANK_SLOT_RESULT` β€” vmangos sends it only on failure; -/// success is the PLAYER_BYTES_2 delta): queue it for the bank feed's red error line. +/// `SMSG_BUY_BANK_SLOT_RESULT`, a refusal: vmangos sends it only on failure. fn bank_buy_slot_result(result: u32, errors: &mut BankErrors) { debug!("net: bank slot purchase refused (code {result})"); errors.0.push(result); @@ -75,9 +61,6 @@ fn bank_buy_slot_result(result: u32, errors: &mut BankErrors) { mod tests { use super::*; - /// `SMSG_SHOW_BANK` ends an open gossip interaction (the gossip menu's bank option arrives - /// with no `SMSG_GOSSIP_COMPLETE` β€” `show_bank`'s doc): the gossip/quest sessions clear and - /// the bank session opens. A direct right-click (no gossip open) just opens the bank. #[test] fn show_bank_ends_the_gossip_interaction() { let mut bank = BankOpen::default(); diff --git a/crates/benilla-app/src/ui_battlefield.rs b/crates/benilla-app/src/ui_battlefield.rs index 73611d74..e20f0406 100644 --- a/crates/benilla-app/src/ui_battlefield.rs +++ b/crates/benilla-app/src/ui_battlefield.rs @@ -1,28 +1,10 @@ -//! The battleground **list window**'s app half (decision 1974): what -//! the stock `BattlefieldFrame.lua` and the minimap's queue icon need from the wire, the clock and -//! the Map.dbc rows. The verbs themselves are `benilla_ui::script::battlefield_queue`'s; the -//! queue slots live in [`BattlefieldQueue`]; the scoreboard is [`crate::ui_battlefield_score`]'s. +//! The battleground list window's app half: what the stock `BattlefieldFrame.lua` and the +//! minimap's queue icon need from the wire, the clock and Map.dbc. The queue slots live in +//! [`BattlefieldQueue`]; the scoreboard is [`crate::ui_battlefield_score`]'s. //! -//! - **The list** (`SMSG_BATTLEFIELD_LIST`, `0x4aa6c0`) is the battlemaster's guid, the map, the -//! bracket index and the instance ids. The handler derives the bracket's level pair off the map -//! row (`bracket Β· span + minLevel`, max clamped at 60), fires `BATTLEFIELDS_SHOW`, and only THEN -//! anchors the player's position β€” the leash below measures from it. -//! - **The leash** (`0x4aa090`): a world tick fires `BATTLEFIELDS_CLOSED` once the player is -//! strictly more than 5.5556 yd from where the list was opened, and zeroes the anchor first. The -//! stock window hides itself on the event; there is no other closer. -//! - **The queue view**: every frame the three slots go to the VM with their clock-shaped fields -//! reduced β€” the port deadline, the raw estimated wait, the time waited β€” and the map names -//! resolved (map 0 included: the reference looks a cleared slot's id up like any other). -//! - **The join** (`0x4a9f60`): `JoinBattlefield`'s instance and group flag come back from the VM; -//! the group leg is refused client-side with message 442 when the map's `MaxPlayers` is below -//! either the party count or the raid count, and the opcode is chosen by the cached -//! battlemaster guid β€” `CMSG_BATTLEMASTER_JOIN` with it, `CMSG_BATTLEFIELD_JOIN` without. -//! - **The three speaking handlers** (`0x4aacc0`/`0x4aae10`) print and store nothing: the group -//! verdict (deserters / joined `%s` / failed) and the joined-or-left player lines, the latter -//! once the name cache answers β€” the reference's fallback callback, in the shape this app -//! already uses. -//! - **World enter** (`0x4a9db0`) clears the list scalars and the selection (not the queue slots) -//! and sends the bodyless `CMSG_BATTLEFIELD_STATUS`, which the server answers slot by slot. +//! A list (`SMSG_BATTLEFIELD_LIST`, `0x4aa6c0`) fires `BATTLEFIELDS_SHOW` and anchors the leash +//! (`0x4aa090`), which fires `BATTLEFIELDS_CLOSED`, the window's only engine-side close, once the +//! player is more than 5.5556 yd from the anchor. use std::time::Instant; @@ -40,56 +22,46 @@ use crate::ui_dialog_verbs::BattlefieldQueue; use crate::ui_party::GroupState; use crate::ui_script::{UiFeed, UiInput}; -/// The leash radius, squared: the CRT initialiser's `fld [0x806574]; fmul` of the `.rdata` f32 -/// `5.55555534362793`. +/// The leash radius, the `.rdata` f32 at `0x806574`, which the reference squares at startup. const LEASH_RADIUS_YD: f32 = 5.555_555_3; -/// The leash's degenerate-anchor refusal (`fcomp [0x8029d4]`, `2.384185791015625e-07`): an -/// anchor this close to the origin is "no list open", never a place to measure from. +/// An anchor this close to the origin means no list is open (`fcomp [0x8029d4]`). const DEGENERATE_ANCHOR: f32 = 2.384_185_8e-7; /// `SMSG_GROUP_JOINED_BATTLEGROUND`'s deserters sentinel (`cmp eax,-2` in `0x4aacc0`). const GROUP_JOIN_DESERTERS: u32 = 0xFFFF_FFFE; -/// The list window's state: the last list, the leash anchor, and what the speaking handlers -/// still owe the screen. +/// The list window's state: the last list, the leash anchor, and what the handlers still owe +/// the next feed. #[derive(Resource, Default)] pub(crate) struct Battlefield { - /// The last `SMSG_BATTLEFIELD_LIST` β€” the battlemaster, the map, the bracket, the ids. list: Option, - /// `BATTLEFIELDS_SHOW` owed for a list that just landed. show: bool, - /// The list view needs re-pushing (a new list, or the world-enter reset). dirty: bool, - /// Where the list was opened (`[0xb6e870..78]`), the leash's origin; `None` once it fired. + /// Where the list was opened (`[0xb6e870..78]`); `None` once the leash fired. anchor: Option, - /// `SMSG_GROUP_JOINED_BATTLEGROUND` results awaiting their line. verdicts: Vec, - /// Joined (`true`) / left guids awaiting a name. + /// Joined (`true`) or left guids awaiting a name. players: Vec<(u64, bool)>, } impl Battlefield { - /// `SessionEvent::BattlefieldList` β€” replaces the list; the event and the anchor follow on - /// the next feed, in that order. pub(crate) fn apply_list(&mut self, list: BattlefieldList) { self.list = Some(list); self.show = true; self.dirty = true; } - /// `SessionEvent::GroupJoinedBattleground` β€” one line, no state. + /// `SMSG_GROUP_JOINED_BATTLEGROUND` (`0x4aacc0`): one line, no state. pub(crate) fn apply_verdict(&mut self, result: u32) { self.verdicts.push(result); } - /// `SessionEvent::BattlegroundPlayer` β€” one line once the name resolves, no state. + /// `SMSG_BATTLEGROUND_PLAYER_JOINED`/`_LEFT` (`0x4aae10`): one line once the name resolves. pub(crate) fn apply_player(&mut self, guid: u64, joined: bool) { self.players.push((guid, joined)); } - /// The world-enter reset (`0x4a9db0`): the list scalars and the anchor go; the queue slots do - /// not. fn clear_session(&mut self) { self.list = None; self.show = false; @@ -99,22 +71,19 @@ impl Battlefield { self.players.clear(); } - /// The guid the last `SMSG_BATTLEFIELD_LIST` came from β€” the join's opcode choice reads it, - /// and [`crate::capture::ProbeBgQueuePlugin`] waits on it to know the list actually landed - /// (a body under the bracket floor is refused at the HELLO, so the list never arrives). + /// The last list's battlemaster. A character under the bracket floor gets no list at all + /// (vmangos `BattleGroundHandler.cpp:59`). pub(crate) fn battlemaster(&self) -> Option { self.list.as_ref().map(|l| l.battlemaster) } - /// The listed map β€” `[0xb6eba4]`, which is 0 (a real Map.dbc row) with nothing listed. + /// The listed map (`[0xb6eba4]`); with nothing listed, 0, itself a real Map.dbc row. fn map_id(&self) -> u32 { self.list.as_ref().map_or(0, |l| l.map_id) } } -/// The VM's view of the list: the ids, the bracket pair the handler derived, the map row's -/// `GetBattlefieldInfo` half, and the group-queue flag β€” every one off `[0xb6eba4]`'s row, -/// whatever that id is. +/// The VM's view of the list, every value off `[0xb6eba4]`'s map row, whatever that id is. fn list_view( state: &Battlefield, catalog: Option<&MapCatalog>, @@ -132,8 +101,8 @@ fn list_view( .zip(row) .map(|(name, r)| BattlefieldMapInfo { name: name.to_string(), - // The faction-group index (`0x5efe00`): 0 for the mask-bit-4 side, 1 for bit 2 β€” Horde - // and Alliance in the emulator's naming; the shipped rows carry one text in both. + // The faction-group index (`0x5efe00`): 0 Horde, 1 Alliance; both columns ship + // the same text. description: match faction { Some("Horde") => Some(r.descriptions[0].clone()), Some("Alliance") => Some(r.descriptions[1].clone()), @@ -157,8 +126,8 @@ fn list_view( } } -/// One slot's VM view at `now`: the three clock-shaped getters reduced (`0x4ab620`, `0x4ab790`, -/// `0x4ab820`), the map name resolved, the bracket pair derived (`0x4aa850`). +/// One slot's VM view at `now`: the three clock getters reduced (`0x4ab620`, `0x4ab790`, +/// `0x4ab820`) and the bracket levels derived (`0x4aa850`). fn slot_view( slot: Option<&(BattlefieldStatus, Instant)>, catalog: Option<&MapCatalog>, @@ -193,10 +162,9 @@ fn slot_view( view } -/// The as-group refusal (`0x4a9f60`): the map's `MaxPlayers` must be at least the party count (the -/// populated party slots, 0..4 β€” the members besides us) and the raid roster count. Which -/// members the reference's raid roster counts is INFERRED here as the wire list's members in a -/// raid and none in a party; the party count is VERIFIED. +/// The as-group refusal (`0x4a9f60`): the map's `MaxPlayers` must cover the party count (the +/// members besides us, 0..4) and the raid roster count. The raid count here is the wire list in a +/// raid and 0 in a party; which members the reference's raid roster counts is untraced. fn group_fits(catalog: Option<&MapCatalog>, map_id: u32, group: Option<&GroupState>) -> bool { let max = catalog .and_then(|c| c.battleground(map_id)) @@ -213,9 +181,8 @@ fn group_fits(catalog: Option<&MapCatalog>, map_id: u32, group: Option<&GroupSta max >= party && max >= raid } -/// Every frame, before the dialog feed fires `UPDATE_BATTLEFIELD_STATUS` and the score feed -/// fires `UPDATE_BATTLEFIELD_SCORE`: the list (when it changed), the queue slots (always), the -/// `BATTLEFIELDS_SHOW` event with its anchor, the leash, and the speaking handlers' lines. +/// Runs before the dialog and score feeds fire `UPDATE_BATTLEFIELD_STATUS` and +/// `UPDATE_BATTLEFIELD_SCORE`, so their handlers read this frame's list and slots. fn feed_battlefield( script: Option>, mut state: ResMut, @@ -233,8 +200,7 @@ fn feed_battlefield( let now = Instant::now(); if std::mem::take(&mut state.dirty) { - // The description's side is the local player's faction group (`0x5efe00(player)`) - // β€” the same answer `UnitFactionGroup("player")` gives, read where it already lives. + // The description's side: the player's faction group (`0x5efe00`). let faction = script .eval::>(r#"return (UnitFactionGroup("player"))"#) .ok() @@ -252,12 +218,11 @@ fn feed_battlefield( if std::mem::take(&mut state.show) { script.fire_event("BATTLEFIELDS_SHOW", vec![]); - // `0x4aa6c0` writes the anchor AFTER the event, from the live player position. + // The anchor is written after the event, from the live position (`0x4aa6c0`). state.anchor = Some(player.pos); } - // `0x4aa090`: strictly farther than the leash from where the list opened β†’ the event, anchor - // first. + // The leash (`0x4aa090`): strictly beyond the radius, the anchor clears, then the event. if let Some(anchor) = state.anchor { if anchor.length_squared() > DEGENERATE_ANCHOR && player.pos.distance_squared(anchor) > LEASH_RADIUS_YD * LEASH_RADIUS_YD @@ -296,8 +261,8 @@ fn feed_battlefield( } } -/// After the script tick: `JoinBattlefield`'s sends (with the group refusal and the opcode -/// choice) and `ShowBattlefieldList`'s. +/// `JoinBattlefield`'s sends, `CMSG_BATTLEMASTER_JOIN` with a battlemaster and +/// `CMSG_BATTLEFIELD_JOIN` without (`0x4a9f60`), and `ShowBattlefieldList`'s. fn drain_battlefield( script: Option>, state: Res, @@ -342,8 +307,8 @@ fn drain_battlefield( } } -/// The world-enter reset (`0x4a9db0`): the list scalars, the selection and the anchor cleared, the -/// queue slots kept, and the bodyless status request sent. +/// World enter (`0x4a9db0`): the list, the selection and the anchor clear, the queue slots stay, +/// and the bodyless `CMSG_BATTLEFIELD_STATUS` goes out, answered slot by slot. fn reset_on_world_enter( mut entered: MessageReader, mut state: ResMut, @@ -360,7 +325,7 @@ fn reset_on_world_enter( let _ = commands.0.send(ClientCommand::BattlefieldStatusRequest); } -/// The battlemaster window's packet handlers (in the net handler table since 2313). +/// The battlemaster window's packet handlers. mod net { use benilla_protocol::{SessionEvent, SessionEventKind}; use bevy::prelude::*; @@ -368,7 +333,6 @@ mod net { use super::Battlefield; use crate::net::NetHandlerApp; - /// Register the handlers β€” called from [`super::BattlefieldPlugin`]. pub(super) fn register(app: &mut App) { use SessionEventKind as K; app.net_handler(K::BattlefieldList, on_packet) @@ -376,7 +340,6 @@ mod net { .net_handler(K::BattlegroundPlayer, on_packet); } - /// One handler for the three: each is a one-line fold into the same state. fn on_packet(In(ev): In, mut battlefield: ResMut) { match ev { SessionEvent::BattlefieldList(list) => battlefield.apply_list(list), @@ -427,8 +390,6 @@ mod tests { } } - /// A queued slot's view: the raw estimate, the time waited growing with the clock, the - /// port deadline zero; a confirm slot's deadline counting down and stopping at zero. #[test] fn the_slot_view_reduces_the_clocks() { let at = Instant::now(); @@ -462,8 +423,6 @@ mod tests { ); } - /// The group refusal: the party count and the raid count against `MaxPlayers`; no group - /// always fits. #[test] fn the_group_gate_reads_both_counts() { let data = benilla_formats::wow_data_or_skip!(); @@ -497,8 +456,6 @@ mod tests { ); } - /// The list view with nothing listed reads map 0's row β€” the reference resolves - /// `[0xb6eba4] = 0` like any other id β€” and a listed map's bracket pair off its bracket. #[test] fn the_list_view_resolves_the_listed_map_or_map_zero() { let data = benilla_formats::wow_data_or_skip!(); diff --git a/crates/benilla-app/src/ui_battlefield_positions.rs b/crates/benilla-app/src/ui_battlefield_positions.rs index 115c0332..fe332173 100644 --- a/crates/benilla-app/src/ui_battlefield_positions.rs +++ b/crates/benilla-app/src/ui_battlefield_positions.rs @@ -1,26 +1,11 @@ -//! The battleground **teammate and flag positions** on the world map (decision 1980): -//! `MSG_BATTLEGROUND_PLAYER_POSITIONS` both ways, resolved -//! into the engine's `GetBattlefieldPosition` / `GetBattlefieldFlagPosition` / -//! `GetBattlefieldMapIconScale` backing the stock `WorldMapFrame.lua` and -//! `Blizzard_BattlefieldMinimap.lua` poll every update. +//! Battleground teammate and flag positions on the world map: `MSG_BATTLEGROUND_PLAYER_POSITIONS` +//! resolved into the view behind `GetBattlefieldPosition`, `GetBattlefieldFlagPosition` and +//! `GetBattlefieldMapIconScale`, which `WorldMapFrame.lua` and the battlefield minimap poll. //! -//! **The reference's law, and where each half lives.** The packet carries the teammates outside -//! the requester's own group as raw world floats, plus the friendly flag carrier. The handler -//! (`0x4aad40`) keeps them as sent; the *getter* (`0x4abf90`) does the work per call: it skips -//! the player itself, the four party slots and the raid roster (all of which the map already -//! draws through `GetPlayerMapPosition`), prefers the live object's position when the guid is -//! streamed to us, and normalizes through the world-map projection under the **active queue -//! slot's map** β€” which is why the getter answers `(0, 0)` off that map. Every one of those is an -//! app-side fact (the group, the entity index, the projection, the catalog), so the app resolves -//! the whole list here each frame and the engine holds only the finished view. -//! -//! **The name** is the name cache's answer (a `CMSG_NAME_QUERY` goes out for a guid it has not -//! seen), `nil` until it lands β€” and when it lands the reference fires `WORLD_MAP_NAME_UPDATE`, -//! which `WorldMapFrame_OnEvent` turns into a repaint; that is produced here, once per landing. -//! -//! **The request** (`RequestBattlefieldPositions()`) is the reference's own 5000 ms throttle -//! (`0x4ac0f0`, the `[0xb6ec00]` stamp) and goes out only with an active slot β€” outside a -//! battleground the list is cleared instead, so a stale roster never draws on the next map. +//! The reference's getter (`0x4abf90`) does the work per call: it skips the player, the party and +//! the raid roster, prefers a streamed object's live position, and projects under the active queue +//! slot's map, answering `(0, 0)` off it. The app resolves the list that way each frame. A name +//! landing fires `WORLD_MAP_NAME_UPDATE`, as in the reference. use std::time::{Duration, Instant}; @@ -38,22 +23,21 @@ use crate::ui_party::GroupState; use crate::ui_script::{UiFeed, UiInput}; use crate::ui_world_map::{project_on_displayed, WorldMapUiData}; -/// The reference's request throttle: `RequestBattlefieldPositions` sends at most once per 5000 ms. +/// `RequestBattlefieldPositions` (`0x4aa5c0`) sends at most once per 5000 ms (stamp +/// `[0xb6ebe0]`), and only with an active queue slot; without one it clears the list. pub(crate) const REQUEST_THROTTLE: Duration = Duration::from_millis(5000); /// The last positions packet and the request stamp. #[derive(Resource, Default)] pub(crate) struct BattlefieldPositions { - /// The last `MSG_BATTLEGROUND_PLAYER_POSITIONS`, as sent; `None` outside a battleground. packet: Option, - /// When the last request went out β€” the throttle's stamp. last_request: Option, - /// Guids whose name the cache has not answered yet; a landing fires `WORLD_MAP_NAME_UPDATE`. + /// Guids whose name the cache has not answered yet. pending_names: Vec, } impl BattlefieldPositions { - /// `SessionEvent::BattlefieldPositions` β€” replaces the list. + /// `MSG_BATTLEGROUND_PLAYER_POSITIONS` in: kept as sent, as the handler (`0x4aad40`) does. pub(crate) fn apply(&mut self, packet: PositionsPacket) { self.packet = Some(packet); } @@ -63,7 +47,7 @@ impl BattlefieldPositions { self.pending_names.clear(); } - /// The throttle: `true` (and the stamp moves) when a request may go out now. + /// The throttle: `true`, and the stamp moves, when a request may go out now. fn request_due(&mut self, now: Instant) -> bool { let due = self .last_request @@ -75,8 +59,8 @@ impl BattlefieldPositions { } } -/// The getter's position source (`0x4abf90`): the live object's when the guid is streamed to us, -/// else the packet's floats. Both in wow space `(x, y)`. +/// The getter's position source (`0x4abf90`): a streamed object's live position, else the +/// packet's, as wow `(x, y)`. fn live_or_packet( p: &BattlefieldPosition, guids: &GuidIndex, @@ -91,8 +75,7 @@ fn live_or_packet( } } -/// The flag texture token the local faction selects: the friendly carrier is carrying the OTHER -/// side's flag, so an Alliance viewer sees `HordeFlag` and a Horde viewer `AllianceFlag`. +/// The flag texture token: the friendly carrier holds the other side's flag. fn flag_token(faction: Option<&str>) -> Option<&'static str> { match faction { Some("Alliance") => Some("HordeFlag"), @@ -113,8 +96,7 @@ fn feed_battlefield_positions( unit_pos: Query<&GlobalTransform, With>, names: Res, commands: Res, - // What the empty push last carried (`(has_list, icon_scale bits)`): with no list the engine - // is told once per change, not per frame; with one, every frame β€” the positions move. + // The last empty push: with no list the engine is told on change, with one every frame. mut last_empty: Local>>, ) { let Some(mut script) = script else { @@ -223,7 +205,7 @@ fn reset_on_world_enter( state.last_request = None; } -/// The battlefield map's packet handler (in the net handler table since 2313). +/// The battlefield map's packet handler. mod net { use benilla_protocol::{SessionEvent, SessionEventKind}; use bevy::prelude::*; @@ -231,7 +213,6 @@ mod net { use super::BattlefieldPositions; use crate::net::NetHandlerApp; - /// Register the handler β€” called from [`super::BattlefieldPositionsPlugin`]. pub(super) fn register(app: &mut App) { app.net_handler(SessionEventKind::BattlefieldPositions, on_positions); } diff --git a/crates/benilla-app/src/ui_battlefield_score.rs b/crates/benilla-app/src/ui_battlefield_score.rs index a267a42a..297049ef 100644 --- a/crates/benilla-app/src/ui_battlefield_score.rs +++ b/crates/benilla-app/src/ui_battlefield_score.rs @@ -1,22 +1,9 @@ -//! The battleground **scoreboard** feed (decision 1972): -//! the app's half of the stock `WorldStateFrame.lua` score frame β€” the name-resolution barrier, -//! the team derivation, the column headers, the request throttle and the leave. +//! The battleground scoreboard feed, the app half of the stock `WorldStateFrame.lua` score frame. //! -//! - **The board arrives raw** (`MSG_PVP_LOG_DATA`, GUIDs and numbers, wire order) and the -//! reference does nothing with it until EVERY row's name has resolved (`0x4aa580` β†’ `0x4aa200`: -//! the last name arrival is what rebuilds and fires). Ours asks the name cache for each row every -//! frame it is unresolved and pushes the board the frame the last one lands. -//! - **Team is derived, never wire data** (`0x4aa200`): race β†’ faction; `0` Horde, `1` Alliance, -//! `-1` for a race the tables do not carry. Race and class strings come off the same traits the -//! name query answered with. -//! - **Columns are `WorldStateUI.dbc` rows** (the `0x2D4` status-3 arm, `0x4aa9c3`–`0x4aaa17`): -//! in table order, the first contiguous run of rows whose `MapID` is the battleground's map or -//! `-1` and whose `Type` is 2; the text raw, the icon, the tooltip. -//! - **`UPDATE_BATTLEFIELD_SCORE`** fires here β€” on a pushed board, and on the status-3 arrival -//! the queue flags β€” BEFORE the queue feed fires `UPDATE_BATTLEFIELD_STATUS` (`0x4aaa5a` before -//! `0x4aab05`). -//! - **The request is throttled to 5000 ms** (`0x4aa170`) and the leave carries the active map -//! (`LeaveBattlefield 0x4abe60`). +//! The reference holds a `MSG_PVP_LOG_DATA` board until every row's name has resolved, and the last +//! name arrival rebuilds it (`0x4aa580` β†’ `0x4aa200`); here the board is pushed the frame the last +//! name lands. `UPDATE_BATTLEFIELD_SCORE` fires on a pushed board and on the status-3 arrival, +//! before `UPDATE_BATTLEFIELD_STATUS` (`0x4aaa5a` before `0x4aab05`). use std::time::{Duration, Instant}; @@ -31,11 +18,10 @@ use crate::ui_dialog_verbs::BattlefieldQueue; use crate::ui_script::{UiFeed, UiInput}; use crate::world_state_ui::WorldStateUiRes; -/// `RequestBattlefieldScoreData`'s throttle β€” `0x4aa170`: `now + 0x1388`. +/// `RequestBattlefieldScoreData`'s throttle (`0x4aa170`). const REQUEST_THROTTLE: Duration = Duration::from_millis(5000); -/// The last `MSG_PVP_LOG_DATA`, and the request stamp. Both die with the session -/// (`net::on_session_end`). +/// The last `MSG_PVP_LOG_DATA` and the request stamp, both zeroed when the session ends. #[derive(Resource, Default)] pub(crate) struct BattlefieldScoreboard { log: Option, @@ -43,15 +29,14 @@ pub(crate) struct BattlefieldScoreboard { } impl BattlefieldScoreboard { - /// `SessionEvent::PvpLogData` β€” the whole board, replacing the last. pub(crate) fn apply(&mut self, data: PvpLogData) { self.log = Some(data); } } -/// The scoreboard's column headers for `map`: `WorldStateUI.dbc` in table order, the first -/// contiguous run of `(MapID == map || MapID == -1) && Type == 2` β€” once a run has started, the -/// first non-matching row ends it (`0x4aa9fe`). +/// The column headers for `map`: the first contiguous run of matching `WorldStateUI.dbc` rows in +/// table order, as the status-3 arm builds them (`0x4aa9c3`–`0x4aaa17`; the run ends at +/// `0x4aa9fe`). pub(crate) fn score_columns(catalog: &WorldStateUiRes, map: u32) -> Vec { let mut out = Vec::new(); for (_, row) in catalog.0.rows() { @@ -81,11 +66,8 @@ fn resolve_board( let (race, class) = names .player_traits(r.guid) .map_or((0, 0), |(race, class, _)| (race, class)); - // `0` Horde, `1` Alliance, `-1` neither. `0x4aa200` walks the name-cache record's race - // through `ChrRaces` β†’ `FactionTemplate` β†’ factionGroupMask, which is `0x5efe00`'s walk - // with the null-row guards omitted β€” so it is [`crate::ui_unit::race_pvp_team`] and not a - // second copy of it. (It used to be one, re-derived here out of `race_faction_group`; a - // second copy of this walk is exactly what report B378 was, one surface over.) + // The team comes from race, never the wire (`0x4aa200`, `0x5efe00`'s walk): 0 Horde, + // 1 Alliance, -1 neither. let faction = i32::from(crate::ui_unit::race_pvp_team(race)); let mut stats = [0u32; 8]; for (slot, v) in stats.iter_mut().zip(&r.stats) { @@ -174,7 +156,7 @@ fn drain_battlefield_score( } } -/// The scoreboard's packet handler (in the net handler table since 2313). +/// The scoreboard's packet handlers. mod net { use benilla_protocol::{SessionEvent, SessionEventKind}; use bevy::prelude::*; @@ -182,15 +164,13 @@ mod net { use super::BattlefieldScoreboard; use crate::net::NetHandlerApp; - /// Register the handler β€” called from [`super::BattlefieldScorePlugin`]. pub(super) fn register(app: &mut App) { app.net_handler(SessionEventKind::PvpLogData, on_pvp_log_data) .net_handler(SessionEventKind::Disconnected, on_session_end); } - /// The board and the request stamp are zeroed at every login (module init `0x4a9c40` zeroes - /// the score scalars) β€” a listener on the session end (a second handler on the kind, after the - /// bridge's own teardown). + /// Zeroes the board and the stamp, as the reference's module init (`0x4a9c40`) does at every + /// login. fn on_session_end(In(_): In, mut board: ResMut) { *board = BattlefieldScoreboard::default(); } @@ -210,8 +190,7 @@ impl Plugin for BattlefieldScorePlugin { app.init_resource::().add_systems( Update, ( - // Before the queue feed: the score event precedes the status event on the - // status-3 message, as in the client. + // Before the queue feed: on status 3 the score event precedes the status event. feed_battlefield_score .before(crate::ui_dialog_verbs::feed_dialog_verbs) .in_set(UiFeed), @@ -242,8 +221,6 @@ mod tests { } } - /// The column scan: table order, the map's or the wildcard's `Type == 2` rows, and the first - /// non-matching row after the run has started ends it. #[test] fn the_columns_are_the_first_contiguous_run_of_type_two_rows_for_the_map() { let cat = WorldStateUiRes(WorldStateUiCatalog::from_rows(vec![ @@ -259,7 +236,7 @@ mod tests { .map(|c| c.text) .collect(); assert_eq!(cols, ["Flags Captured", "Flags Returned"]); - // Another map keeps the wildcard row alone β€” the reference's `-1` rows serve every map. + // Another map gets the wildcard row alone: `-1` rows serve every map. let cols: Vec = score_columns(&cat, 30) .into_iter() .map(|c| c.text) @@ -267,10 +244,6 @@ mod tests { assert_eq!(cols, ["Flags Returned"]); } - /// **The board is zeroed at every login** (module - /// init `0x4a9c40`, from `InitializeGame`, zeroes the score scalars) β€” the last session's - /// `MSG_PVP_LOG_DATA` must not come back as the next character's scoreboard, and its request - /// stamp must not throttle that character's first ask. #[test] fn the_session_end_zeroes_the_scoreboard() { let mut app = App::new(); diff --git a/crates/benilla-app/src/ui_bind_confirm.rs b/crates/benilla-app/src/ui_bind_confirm.rs index b03133f3..18c663cd 100644 --- a/crates/benilla-app/src/ui_bind_confirm.rs +++ b/crates/benilla-app/src/ui_bind_confirm.rs @@ -1,57 +1,11 @@ -//! The **equip / auto-equip / use soulbind confirmations** β€” `EQUIP_BIND`, `AUTOEQUIP_BIND` and -//! `USE_BIND` (decision 1750). The loot arm's siblings, and NOT built the same way, because the -//! reference does not build them the same way either. +//! The equip, auto-equip and use soulbind confirmations (`EQUIP_BIND`, `AUTOEQUIP_BIND`, +//! `USE_BIND`). Unlike the loot arm (`bonding == 1` and `quality >= 2`, `0x4c2790`), they read +//! only the template's bonding at `+0x194`, never quality: `SwapItem` (`0x5e0c40`) and +//! `AutoEquipCursorItem` (`0x5e1480`) ask on 2, `CGItem::Use` (`0x5d8d00`) on 3. //! -//! ## The three predicates are three different predicates -//! -//! In the 1.12.1 client, one `item_template + 0x194` compare per arm and **no `+0x1c` (quality) -//! read in any of the three**: -//! -//! | arm | event | fires from | predicate | site | -//! |---|---|---|---|---| -//! | loot (decision 1744) | `LOOT_BIND_CONFIRM` 287 | `0x4c2790` | `bonding == 1` **and** `quality >= 2` | `4c28f2`/`4c28fb` | -//! | equip | `EQUIP_BIND_CONFIRM` 288 | `0x5e0c40` `SwapItem` | `bonding == 2` | `5e0e54` | -//! | auto-equip | `AUTOEQUIP_BIND_CONFIRM` 289 | `0x5e1480` `AutoEquipCursorItem` | `bonding == 2` | `5e163b` | -//! | use | `USE_BIND_CONFIRM` 290 | `0x5d8d00` `CGItem::Use` | `bonding == 3` | `5d91d6` | -//! -//! Carrying the loot arm's `quality >= 2` across would have been wrong twice over: it would have -//! silenced the confirm on a white BoE, and it would have asked about the wrong `bonding` value -//! entirely. (This was benilla's working assumption until the reference refuted it; that is why the -//! table is here and not a sentence.) -//! -//! ## The other conjuncts, and where benilla already had them -//! -//! The equip arms' full set is: container and item resolve Β· the item is **not already soulbound** -//! Β· the call is not itself a suppressed re-issue Β· the item template is **cached** Β· `bonding == 2` -//! Β· the player **can equip it**. Both of the interesting ones were already in this codebase, byte- -//! derived for other reasons and reused here rather than re-written: -//! -//! - *not already soulbound* is [`crate::items::already_bound`] β€” literally `0x5da2c0`, built for -//! the enchant cursor's own bind question (decision 0928) and the tooltip's Soulbound override -//! (B310/1562). The reference calls the same function from all three places. -//! - *can equip it* is `0x5ea930`, [`benilla_ui::script::UiScript::item_usable`] β€” built for the -//! merchant's red rows (decision 0299). -//! -//! ## Accept is a re-issue, not a confirm packet -//! -//! There is no `CMSG_CONFIRM_*` in 1.12. `EquipPendingItem(index)` re-runs the *original action* -//! with the suppress flag set, which is why [`PendingEquip`] stores the action's own coordinates -//! and the accept path calls the very same sender the click did β€” `suppress` threaded through as a -//! real parameter, exactly as `0x5e0c40`/`0x5e1480` take it. `CancelPendingEquip(index)` sends -//! nothing and only drops the record. -//! -//! **A superseded record is CANCELLED, not overwritten** β€” the entries are `exclusive = 1`, so -//! `StaticPopup_Show` hides the standing dialog and runs its `OnHide`, which is -//! `CancelPendingEquip(slot)`. That is why both `OnCancel` and `OnHide` name it, and why this store -//! is an index-addressed array of optional records rather than a single cell: two questions can be -//! live in principle, and the second one's arrival is what retires the first. -//! -//! ## The index space is the reference's own -//! -//! `arg1` is a **0-based index into the pending array**, not a slot (allocated by `0x5e1110`). -//! It is opaque on both sides β€” the event hands it out, `dialog.data` carries it, -//! and the two verbs hand it straight back β€” so unlike the loot arm's row number (1744, translated -//! into benilla's display space) there is nothing to gain by re-basing it, and it is kept as-is. +//! There is no confirm packet: `EquipPendingItem(index)` re-runs the original action with the +//! suppress flag set, and `CancelPendingEquip(index)` sends nothing and drops the record. The +//! dialogs are `exclusive`, so a new question hides the standing one, whose `OnHide` cancels it. use bevy::ecs::system::SystemParam; use bevy::prelude::*; @@ -61,50 +15,38 @@ use benilla_ui::script::{ScriptValue, UiScript}; use crate::items::{Enchants, Items}; use crate::net::Objects; -/// `item_template.bonding` β€” the values the three arms compare against (vmangos -/// `ItemPrototype.h`'s `ItemBondingType`; the client reads the same field at `+0x194`). +/// The equip arms' `item_template.bonding` (vmangos `ItemPrototype.h` `ItemBondingType`). pub(crate) const BIND_WHEN_EQUIPPED: u32 = 2; /// The use arm's value (`0x5d91d6`). pub(crate) const BIND_WHEN_USE: u32 = 3; -/// One deferred action, held until the player answers. Stored as the action's own **Lua-space** -/// coordinates rather than as a built packet, because accept is a re-issue: the same sender runs -/// again with `suppress` set and re-reads the world, so a slot that changed under the open dialog -/// is re-judged instead of being sent stale. +/// One deferred action, stored as its own coordinates rather than a built packet: accept re-runs +/// the same sender with `suppress` set, so a slot that changed under the dialog is re-judged. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum PendingEquip { - /// Arm 288 β€” a placement into an equipment/bag/bank-bag slot (`EQUIP_BIND_CONFIRM`), in - /// **Lua** space. Lua, because the re-issue goes back through the move drain's own sender, - /// which re-derives the wire pair AND takes the pending-op lock β€” one code path for the - /// deferred and the undeferred swap rather than two that must be kept agreeing. + /// `EQUIP_BIND_CONFIRM`, in Lua space so the re-issue goes back through the move drain's own + /// sender, which re-derives the wire pair and takes the pending-op lock. Swap { src_bag: i64, src_slot: u32, dst_bag: i64, dst_slot: u32, }, - /// Arm 289 β€” an auto-equip, in **wire** space plus the item's guid. Wire, because one of the - /// three auto-equip senders is the action bar's, which resolves an item *entry* to a position - /// and so has no Lua pair to re-derive from; the guid, so the re-issue can re-take the ammo - /// fork off the item's own template exactly as the first attempt did. + /// `AUTOEQUIP_BIND_CONFIRM`, in wire space: the action bar's sender resolves an item entry to + /// a position and has no Lua pair. The guid lets the re-issue re-take the ammo fork. AutoEquip { bag_index: u8, slot: u8, guid: u64 }, } -/// The client's pending-equip array (`0xc4c290`-`0xc4c29c`, stride `0x20`), modelled as what it -/// is: an index-addressed store whose free elements are reused. The reference's "free ⇔ the src -/// container guid is 0" is our `None`; its grow-on-demand is our `push`. -/// -/// It is deliberately NOT reset on world enter/leave, matching the reference (whose array is zeroed -/// once at CRT static-init and freed at shutdown) β€” a dialog cannot outlive its session anyway, -/// because the UI is torn down with it. +/// The client's pending-equip array (`0xc4c290`-`0xc4c29c`, stride `0x20`), allocated by +/// `0x5e1110`: the event's `arg1` is a 0-based index into it, passed through Lua untouched. +/// Not reset on world enter or leave: the reference zeroes it once at static init. #[derive(Resource, Default)] pub(crate) struct PendingEquips { slots: Vec>, } impl PendingEquips { - /// File a record into the first free index, growing only when none is free β€” the reference's - /// own allocator shape. Returns the index the event carries out. + /// Files into the first free index, growing only when none is free, as the reference does. pub(crate) fn add(&mut self, rec: PendingEquip) -> u32 { let idx = match self.slots.iter().position(Option::is_none) { Some(i) => { @@ -119,54 +61,44 @@ impl PendingEquips { u32::try_from(idx).unwrap_or(u32::MAX) } - /// Take the record at `index`, freeing the element. `None` for an index nobody filed β€” which is - /// the reference's own answer too (`0x5e1be0` bounds-checks against the live element count and - /// returns), so a stray `EquipPendingItem(99)` from an addon does nothing. + /// Frees the element. An index nobody filed does nothing, as `0x5e1be0` bounds-checks it. pub(crate) fn take(&mut self, index: u32) -> Option { self.slots.get_mut(index as usize)?.take() } - /// How many records are live β€” the step-back check that nothing leaks (a cancelled or accepted - /// question must free its element). + /// Live records: a cancelled or accepted question must free its element. #[cfg(test)] pub(crate) fn live(&self) -> usize { self.slots.iter().flatten().count() } } -/// Arm 290's pending state. The reference does **not** use the array here: it stamps two globals at -/// the fire site (the item guid `0xc4c240`, the target guid `0xc4c1d0`) and `ConfirmBindOnUse()` -/// re-issues `CGItem::Use(&target, suppress = 1)` off them. One cell, no index, and no cancel verb -/// at all β€” declining just drops the question. +/// The use arm's pending state, one cell with no index and no cancel verb: the reference stamps +/// the item guid (`0xc4c240`) and target guid (`0xc4c1d0`) and `ConfirmBindOnUse()` re-issues +/// `CGItem::Use(&target, suppress = 1)` off them. #[derive(Resource, Default)] pub(crate) struct PendingBindOnUse(pub(crate) Option); -/// The deferred use β€” the *original action*, stored verbatim, because that is what the accept -/// re-issues. The reference stamps the item guid and the target guid and re-runs `CGItem::Use` off -/// them; [`crate::ui_items::ItemUse`] is benilla's whole equivalent of that pair of arguments, and -/// it is `Copy`, so the record is literally the call that was about to be made. +/// The deferred use: the [`crate::ui_items::ItemUse`] call about to be made, which carries what +/// the reference's item and target guids do. pub(crate) type PendingUse = crate::ui_items::ItemUse; -/// The three resources the deferral needs, bundled so a drain can take them in one parameter -/// (the shape `crate::spell::CastLadder` established). [`Items`] is deliberately NOT in here: -/// every caller already holds it, and a second `ResMut` in one system is a conflict. +/// The deferral's resources in one system parameter. [`Items`] is not in it: every caller already +/// holds it, and a second `ResMut` in one system conflicts. #[derive(SystemParam)] pub(crate) struct BindGate<'w> { pub(crate) equips: ResMut<'w, PendingEquips>, pub(crate) on_use: ResMut<'w, PendingBindOnUse>, - /// `already_bound`'s enchant catalog β€” an enchant can soulbind the item it lands on, and the - /// reference's `0x5da2c0` walks all seven slots. Absent (no client data) degrades to the - /// instance flag alone, which is the same posture every other consumer takes. + /// An enchant can soulbind its item, and `0x5da2c0` walks all seven enchant slots; without + /// client data only the instance flag counts. pub(crate) enchants: Option>, } impl BindGate<'_> { - /// Does taking this equip action have to ask first? `item_guid` is the item that would BIND β€” - /// for a swap that is the occupant of the non-equip side, for an auto-equip the clicked slot. - /// - /// `false` on every "we cannot tell" (no such item, no template yet): the reference's own - /// cache-**hit** conjunct fails open the same way, and a miss there re-runs the whole decision - /// when the template lands rather than asking about an item it cannot name. + /// Whether this equip must ask first: not already bound (`0x5da2c0`), `bonding == 2` and + /// equippable (`0x5ea930`). `item_guid` is the item that would bind (a swap's non-equip side, + /// an auto-equip's clicked slot); an unknown item or uncached template fails open, as the + /// reference's cache-hit conjunct does. pub(crate) fn equip_binds( &self, script: &UiScript, @@ -190,14 +122,9 @@ impl BindGate<'_> { t.bonding == BIND_WHEN_EQUIPPED && script.item_usable(entry) } - /// Arm 290's predicate, VERIFIED as an **earned census**: `[0x5d91d3, 0x5d91f2)` is 31 - /// contiguous bytes holding exactly three `jcc` β€” `5d91dd` (`bonding == 3`), `5d91e9` - /// (`0x5da2c0` says not already bound) and `5d91f0` (`suppress == 0`) β€” all three jumping to - /// the cast, with no other branch and no other call. Nothing narrower, nothing wider. - /// - /// The `0x5ea930` (can-use) leg is genuinely absent and is not an omission: a `0x5ea930` - /// failure is one of the rungs that **exits** `0x5d8d00` ahead of the bind arm, so by the time - /// the fire site is reached the item has already passed it. + /// The use arm's predicate: `[0x5d91d3, 0x5d91f2)` holds exactly three branches to the cast, + /// `bonding == 3`, not already bound (`0x5da2c0`) and `suppress == 0`. There is no can-use + /// (`0x5ea930`) leg: that failure exits `0x5d8d00` before the bind arm. pub(crate) fn use_binds( &self, objects: &Objects, @@ -219,9 +146,8 @@ impl BindGate<'_> { .is_some_and(|t| t.bonding == BIND_WHEN_USE) } - /// File a deferred equip and raise its dialog. `auto` picks the event, which is the only thing - /// that differs between arms 288 and 289 on this side β€” `UIParent.lua:324-339` shows the two - /// branches are otherwise identical, down to each hiding the other's dialog first. + /// Files a deferred equip and raises its dialog. The two equip arms differ only in the event + /// (`UIParent.lua:324-339`, where each hides the other's dialog first). pub(crate) fn defer_equip(&mut self, script: &mut UiScript, rec: PendingEquip) { let index = self.equips.add(rec); let event = match rec { @@ -229,24 +155,21 @@ impl BindGate<'_> { PendingEquip::AutoEquip { .. } => "AUTOEQUIP_BIND_CONFIRM", }; debug!("ui_bind_confirm: {event} index {index} for {rec:?}"); - // QUEUED, not fired (see `UiScript::queue_event`): the place that triggered this deferral - // has already queued its own `CURSOR_UPDATE`, and `UIParent.lua:356-360` hides both equip - // dialogs on it. Firing immediately would put the question AHEAD of that cursor change and - // the next tick would cancel it β€” proven by a test, not reasoned about. + // Queued, not fired: the triggering action has already queued its `CURSOR_UPDATE`, which + // hides both equip dialogs (`UIParent.lua:357-361`). Fired now, the question would come + // first and be cancelled on the next tick. script.queue_event(event, vec![ScriptValue::Int(i64::from(index))]); } - /// File the deferred use and raise `USE_BIND`. No index and no argument: arm 290's state is one - /// cell, and a second deferred use simply replaces the first (there is no cancel verb to - /// retire it with, which is the reference's own shape). + /// Files the deferred use and raises `USE_BIND`, with no argument: a second deferred use + /// replaces the first. pub(crate) fn defer_use(&mut self, script: &mut UiScript, pending: PendingUse) { debug!( "ui_bind_confirm: USE_BIND_CONFIRM for wire {}/{}", pending.bag_index, pending.slot ); self.on_use.0 = Some(pending); - // Queued for the same reason as the equip arms, though `USE_BIND` is not on the - // CURSOR_UPDATE arm: one rule for the three of them beats one rule and an exception. + // Queued like the equip arms, though `CURSOR_UPDATE` does not hide `USE_BIND`. script.queue_event("USE_BIND_CONFIRM", vec![]); } } diff --git a/crates/benilla-app/src/ui_binder.rs b/crates/benilla-app/src/ui_binder.rs index ee5bd47d..c2edfa66 100644 --- a/crates/benilla-app/src/ui_binder.rs +++ b/crates/benilla-app/src/ui_binder.rs @@ -1,43 +1,22 @@ -//! Setting your hearthstone β€” the innkeeper's bind question, its dialog, and its answer -//! (decision 1331). +//! The innkeeper's bind question. Selecting the gossip line binds nothing: the server closes the +//! gossip and asks (`SMSG_BINDER_CONFIRM`, vmangos `Player::SetBindPoint`), and the bind happens +//! only on `CMSG_BINDER_ACTIVATE`, when the innkeeper casts spell 3286 and `SPELL_EFFECT_BIND` +//! sends `SMSG_BINDPOINTUPDATE` and `SMSG_PLAYERBOUND`. //! -//! **The law this module exists for: selecting the innkeeper's gossip line binds nothing.** It -//! makes the server close the gossip menu and ask (`SMSG_BINDER_CONFIRM`, vmangos -//! `Player::SetBindPoint`); the bind happens only when the client answers `CMSG_BINDER_ACTIVATE`, -//! at which point the innkeeper casts spell 3286 "Bind" and `SPELL_EFFECT_BIND` sends -//! `SMSG_BINDPOINTUPDATE` + `SMSG_PLAYERBOUND`. Before this module the question was parsed as an -//! unknown opcode and dropped, so the click looked like it did nothing at all β€” half of B249. +//! The stock surface (`StaticPopup.lua:1321-1335`, `UIParent.lua:125`, `547-548`) is the +//! `CONFIRM_BINDER` event, whose one argument fills the dialog's `%s`; `ConfirmBinder()`, the +//! Accept that sends the activate; and `CheckBinderDist()`, polled from `OnUpdate`, which hides +//! the dialog the frame it goes false. The reference's handler is `0x5dfdc0`, its +//! `SMSG_BINDER_CONFIRM` arm `0x5e4aa2`. //! -//! The Era surface it drives is the reference's own, and it is small (`StaticPopup.lua:1308-1322`, -//! `UIParent.lua:125`/`547-548`): +//! `CheckBinderDist()` tests `dΒ² <= [0xc4c28c]` (`5.55555534362793Β²`), the same constant and +//! inclusive bound as [`crate::target::SERVICE_RANGE_SQ`], so the question is an [`NpcSession`] +//! closed by [`crate::ui_session::close_npc_session_out_of_range`]; an unstreamed guid reads as +//! gone, so a disconnect needs no teardown. //! -//! - the `CONFIRM_BINDER` event, whose **one argument** is the name that fills -//! `"Do you want to make %s your new home?"`; -//! - `ConfirmBinder()` β€” the dialog's Accept, the only call that sends the activate; -//! - `CheckBinderDist()` β€” polled from the dialog's `OnUpdate`, and the dialog hides itself the -//! frame it goes false. -//! -//! Both halves are now **byte-pinned** against the reference's own handler `0x5dfdc0` and its -//! `SMSG_BINDER_CONFIRM` arm `0x5e4aa2` (folded back by decision 1335, which promotes what -//! decision 1331 had to leave INFERRED): -//! -//! 1. **`arg1` is an AREA name, never the NPC's** β€” the handler never looks a name up. It resolves -//! the player's own **sub-area** through `AreaTable.dbc`, falls back to the **parent zone** when -//! that row is missing, and falls back again to the localized `HOME_INN` GlobalString. It always -//! fires; there is no path on which the question is withheld for want of a name. [`area_name`] -//! is that chain. -//! 2. **`CheckBinderDist()` is the same gate the rest of the NPC surface uses** β€” not a -//! resemblance: the reference's range test is `dΒ² <= [0xc4c28c]`, and `[0xc4c28c]` is -//! `5.55555534362793Β²`, the identical constant behind [`crate::target::SERVICE_RANGE_SQ`] -//! (boundary-inclusive at both sites). So modelling the question as an [`NpcSession`] and -//! letting [`crate::ui_session::close_npc_session_out_of_range`] close it is faithful, not a -//! convenience. It is also why nothing here needs a disconnect teardown: an unstreamed guid -//! reads as gone and closes the question on the next frame. -//! -//! One deviation remains, and it is deliberate: **the reference never clears its latched guid** -//! (`0xc4d7c0`), so a second `ConfirmBinder()` while still in range re-sends the activate. We drop -//! the guid when `SMSG_PLAYERBOUND` says the bind took, because a question that has been answered -//! is not a question β€” the reference's own dialog is gone by then either way. +//! Deviation: the reference never clears its latched guid (`0xc4d7c0`), so a second +//! `ConfirmBinder()` in range re-sends the activate; this drops the guid on `SMSG_PLAYERBOUND`, +//! because an answered question is not a question and the reference's dialog is gone by then. use benilla_ui::script::{ScriptValue, UiScript}; use bevy::prelude::*; @@ -47,25 +26,18 @@ use crate::net::{ClientCommand, NetCommands}; use crate::ui_script::{UiFeed, UiInput}; use crate::ui_session::{close_npc_session_out_of_range, NpcSession}; -/// The innkeeper's pending bind question. Written by the net drain's `BinderConfirm` arm, read by -/// [`feed_binder`] (which fires `CONFIRM_BINDER` and publishes `CheckBinderDist`'s answer) and by -/// [`drain_binder`] (which turns the dialog's Accept into the activate). -/// -/// There is no snapshot beside it: everything the dialog reads arrives as the event's argument β€” -/// [`crate::ui_duel::DuelState`]'s shape, for the same reason. +/// The innkeeper's pending bind question; everything the dialog reads rides the event's argument. #[derive(Resource, Default)] pub(crate) struct BinderState { - /// The innkeeper who asked; `None` = no question pending. This guid is what goes back on the - /// wire, and vmangos resolves it to a live innkeeper in range β€” a stale one binds nothing. + /// The innkeeper who asked, sent back on the wire; vmangos binds only a live one in range. npc: Option, - /// A question the feed still owes the UI. Set per *packet*, not per state edge: asking the - /// same innkeeper twice in a row (decline, then click the line again) is two dialogs, and an - /// edge-diff would swallow the second. + /// A dialog the feed still owes, set per packet: asking again after a decline is a second + /// dialog, which a state diff would swallow. ask: bool, } impl BinderState { - /// `SMSG_BINDER_CONFIRM` β€” park the innkeeper's guid and owe the UI a dialog. + /// `SMSG_BINDER_CONFIRM`: parks the innkeeper's guid and owes the UI a dialog. pub(crate) fn ask(&mut self, npc: u64) { self.npc = Some(npc); self.ask = true; @@ -76,18 +48,15 @@ impl BinderState { self.npc } - /// Retract the question β€” the range guard's close, and `SMSG_PLAYERBOUND`'s (the bind landed, - /// so there is nothing left to ask). Inherent so the net drain can call it without importing - /// [`NpcSession`], exactly as [`crate::ui_gossip::GossipState::clear`] is. + /// Retracts the question: the range guard's close, and `SMSG_PLAYERBOUND`'s. pub(crate) fn clear(&mut self) { self.npc = None; self.ask = false; } } -/// The question is an NPC session: the standardized range guard closes it β€” the same -/// no-packet clear declining does β€” when the player walks out of the innkeeper's service range or -/// the innkeeper despawns. That close is what `CheckBinderDist()` reports (module doc, deviation 2). +/// The range guard closes the question without a packet, as declining does, when the player +/// leaves service range or the innkeeper despawns; that close is what `CheckBinderDist()` reports. impl NpcSession for BinderState { fn npc(&self) -> Option { self.npc @@ -98,11 +67,8 @@ impl NpcSession for BinderState { } } -/// Fire `CONFIRM_BINDER` for a question the UI is still owed, and publish `CheckBinderDist()`'s -/// answer every frame. -/// -/// The fire is **unconditional** once a packet has asked β€” [`area_name`] always yields a string, -/// exactly as the reference's handler always reaches its `SignalEvent2`. +/// Publishes `CheckBinderDist()` every frame and fires `CONFIRM_BINDER` for each packet that +/// asked, unconditionally, as the reference's handler always reaches its `SignalEvent2`. fn feed_binder( script: Option>, mut binder: ResMut, @@ -129,13 +95,8 @@ fn feed_binder( script.fire_event("CONFIRM_BINDER", vec![ScriptValue::Str(name)]); } -/// The name that fills the dialog's `%s` β€” the reference's own three-step chain (`0x5dfe5e`): -/// **sub-area, else parent zone, else `HOME_INN`**. -/// -/// Never `None`, because the reference never withholds the question: a missing catalog or an -/// unresolvable area still fires, carrying the GlobalString. That is the difference between "we -/// don't know where you are" and "there is nothing to ask" β€” only the second should be silent, and -/// only the packet decides it. +/// The dialog's `%s`, an area and never the NPC's name (`0x5dfe5e`): the player's sub-area, else +/// its parent zone, else `HOME_INN`. The reference never withholds the question for want of one. fn area_name( world: &benilla_world::world_point::WorldPoint, areas: Option<&AreaTableRes>, @@ -144,13 +105,8 @@ fn area_name( area_name_of(world.area(), areas, get) } -/// [`area_name`]'s chain over a bare leaf id β€” split out so the three legs are testable against the -/// real `AreaTable` without standing up a world. -/// -/// The tail is the `HOME_INN` GlobalString (`GetBindLocation 0x48dae0` uses the identical -/// fallback), read off the player's own table rather than re-typed -/// (decision 2045); an install that does not carry it yields the empty string, which is the -/// reference's data-suppression face and still fires the question. +/// [`area_name`] over a bare leaf id. The tail is the install's `HOME_INN`, the same fallback as +/// `GetBindLocation` (`0x48dae0`); an install without it gives an empty name and still asks. fn area_name_of( leaf: Option, areas: Option<&AreaTableRes>, @@ -162,8 +118,7 @@ fn area_name_of( .filter(|row| !row.name.is_empty()) .map(|row| row.name.clone()) }; - // The parent zone is the leaf's single-hop `zone_id` (itself when 0) β€” the same walk - // `crate::area`'s zone-text resolve does. + // The parent zone is the leaf's single-hop `zone_id`; 0 means the leaf is itself a zone. let zone = leaf .and_then(|id| areas.and_then(|a| a.0.get(id))) .map(|row| row.zone_id) @@ -174,11 +129,8 @@ fn area_name_of( .unwrap_or_default() } -/// Turn the dialog's Accept into `CMSG_BINDER_ACTIVATE`. -/// -/// Gated on a question still being pending, exactly as the arbiter gate guards -/// [`crate::ui_duel`]'s accept: a `ConfirmBinder()` typed at the console with no innkeeper asking -/// would otherwise send a zero guid the server can only drop. +/// Turns the dialog's Accept into `CMSG_BINDER_ACTIVATE`, only while a question is pending: with +/// no guid latched, the reference's `ConfirmBinder()` finds no unit and sends nothing (`0x5dfdfe`). fn drain_binder( script: Option>, binder: Res, @@ -201,13 +153,11 @@ fn drain_binder( } } -/// `SoundEntries.dbc` id 1141 β€” played on `SMSG_PLAYERBOUND` **unconditionally**, before the area -/// is resolved (`0x5e3d6e`, ahead of the bounds test). So an area id the catalog cannot name is -/// silent but still audible, which is the reference's own ordering rather than an accident of ours. +/// `SoundEntries.dbc` 1141, played on `SMSG_PLAYERBOUND` before the area is resolved (`0x5e3d6e`), +/// so an area the catalog cannot name gets the sound and no line. const SOUND_PLAYERBOUND: u32 = 1141; -/// The innkeeper's packet handlers (in the net handler table since 2312), beside the state they -/// drive. +/// The innkeeper's packet handlers. pub(crate) mod net { use super::*; @@ -218,7 +168,6 @@ pub(crate) mod net { use crate::net::NetHandlerApp; - /// Register the binder's handlers β€” called from [`UiBinderPlugin`]. pub(super) fn register(app: &mut App) { use SessionEventKind as K; app.net_handler(K::BinderConfirm, on_confirm) @@ -250,21 +199,10 @@ pub(crate) mod net { } } - /// `SMSG_PLAYERBOUND` β€” the bind took. Retract the question, play the sound, and queue - /// `DisplayError(0x138)` = `ERR_DEATHBIND_SUCCESS_S` (catalog row 312, `kind 0` β€” a system - /// chat line) with the packet's own area id resolved through `AreaTable` β€” the handler's own - /// order at `0x5e3d3f`: the sound first, then the message. - /// - /// The line rides the by-key queue rather than being composed here (decision 2045): this is - /// the net-apply pass and there is no VM in hand, so the KEY travels to `ui_action`'s drain, - /// which resolves it against the player's own `GlobalStrings.lua` and puts it on the surface - /// the catalog names. The `SoundEntries` cue below is the *handler's* own, separate from the - /// row's `+0x08` (which is `None` for this row). - /// - /// This is the **feedback half of B249** β€” "accepting appears to change nothing" was partly - /// that nothing ever said it had. The hearthstone itself moves on `SMSG_BINDPOINTUPDATE`, the - /// packet beside this one; this arm deliberately does not touch it, exactly as the reference's - /// does not (its `GetBindLocation` reads a different global entirely). + /// `SMSG_PLAYERBOUND`, in the handler's order (`0x5e3d3f`): retract the question, play the + /// handler's own sound (the error row has none), then queue `ERR_DEATHBIND_SUCCESS_S` + /// (`DisplayError(0x138)`, a system chat line) named by the area. The hearthstone moves on + /// `SMSG_BINDPOINTUPDATE`, which this leaves alone, as the reference does. pub(crate) fn bound( area: u32, binder: &mut BinderState, @@ -298,8 +236,7 @@ impl Plugin for UiBinderPlugin { app.init_resource::().add_systems( Update, ( - // Range-close before the feed so walking away takes the dialog down the same - // frame (the gossip window's ordering, for the same reason). + // Range-close before the feed, so walking away hides the dialog the same frame. close_npc_session_out_of_range::.before(feed_binder), feed_binder.in_set(UiFeed), drain_binder.after(UiInput), @@ -312,9 +249,6 @@ impl Plugin for UiBinderPlugin { mod tests { use super::*; - /// A second question from the same innkeeper is a second dialog. The `ask` flag is per packet - /// precisely so a decline-then-click-again (which never passes through `None`) is not - /// swallowed by a state diff. #[test] fn asking_twice_owes_two_dialogs() { let mut binder = BinderState::default(); @@ -329,13 +263,7 @@ mod tests { assert!(binder.ask, "the same innkeeper asking again owes a dialog"); } - /// The `arg1` chain, against the REAL AreaTable and the REAL `GlobalStrings.lua` (`0x5dfe5e`): - /// sub-area, else parent zone, else the `HOME_INN` GlobalString β€” and never nothing, because - /// the reference never withholds the question for want of a name. 186 is Dolanaar, the leaf - /// the bug's own innkeeper stands in. - /// - /// The tail resolves off the player's own table rather than a stub, so a `HOME_INN` that the - /// install words differently is what this reads (decision 2045). Skips without client data. + /// Area 186 is Dolanaar, a leaf area with an innkeeper. #[test] fn the_dialogs_name_falls_back_sub_area_then_zone_then_home_inn() { let data = benilla_formats::wow_data_or_skip!(); @@ -358,21 +286,15 @@ mod tests { let inn = get("HOME_INN").expect("HOME_INN ships"); assert_eq!(area_name_of(Some(186), Some(&areas), &get), "Dolanaar"); - // No row, no catalog, no id at all β€” all three reach the GlobalString rather than "". + // No row, no catalog, no id: all three reach the GlobalString. assert_eq!(area_name_of(Some(0xffff), Some(&areas), &get), inn); assert_eq!(area_name_of(Some(186), None, &get), inn); assert_eq!(area_name_of(None, Some(&areas), &get), inn); - // …and an install with no such key withholds the *name*, never the question. + // An install without the key withholds the name, never the question. assert_eq!(area_name_of(None, None, &|_| None), ""); } - /// The line the bind lands with β€” the feedback half of B249, since "accepting appears to change - /// nothing" was partly that nothing ever said it had. - /// - /// The assertion is the KEY and its one argument, not the sentence (2045): the wording is the - /// install's, and `ui_action::ui_error_text` is what fills it. Resolved here against the real - /// table so a typo'd key β€” which would degrade the line to silence, not to a wrong sentence β€” - /// still fails. Skips without client data. + /// Resolved against the install's table: a mistyped key would silence the line, not garble it. #[test] fn the_bind_queues_the_deathbind_success_line_named_by_the_area() { let data = benilla_formats::wow_data_or_skip!(); @@ -392,8 +314,6 @@ mod tests { ); } - /// Closing (the range guard, or the bind landing) retracts both the guid and any unfired - /// question β€” so `CheckBinderDist()` goes false and a later `ConfirmBinder()` sends nothing. #[test] fn closing_retracts_the_guid_and_the_unfired_dialog() { let mut binder = BinderState::default(); diff --git a/crates/benilla-app/src/ui_cast.rs b/crates/benilla-app/src/ui_cast.rs index ec72b56d..3aa4bccf 100644 --- a/crates/benilla-app/src/ui_cast.rs +++ b/crates/benilla-app/src/ui_cast.rs @@ -1,16 +1,6 @@ -//! The cast-bar feed: the wire's cast edges β†’ FrameXML events (decision 0137 phase 1). -//! -//! The spell's packet handlers and its local cancel (`crate::spell`) queue [`CastBarEdge`]s -//! (self-casts only β€” the producers filter on the self guid; the channel pair is self-only *on -//! the wire*), and the drain fires the reference client's FrameScript events into the script VM -//! β€” `SPELLCAST_START` and family, the exact contract stock -//! `Interface\FrameXML\CastingBarFrame.xml` registers for β€” and pushes the VM's casting flag. -//! The spell name rides the event (resolved here from the `Spell.dbc` catalog β€” the script VM -//! has no spell-catalog binding, deliberately: one lookup face, decision 0107). -//! -//! This is a feed and nothing else. The lifecycle it reads β€” the pending cast, the queued -//! strike, the running channel, the auto-repeat key β€” is the spell's (`crate::spell`, decision -//! 2328). +//! The cast-bar feed: our own cast's wire edges become the `SPELLCAST_*` events stock +//! `CastingBarFrame.lua:6-13` registers for, named from `Spell.dbc`, and the VM's casting flag +//! is pushed each frame. The cast lifecycle it reads is `crate::spell`'s. use std::time::Instant; @@ -24,53 +14,32 @@ use crate::spell::{inflight, PendingCast, QueuedMeleeSpell}; use crate::ui_action::Spells; use crate::ui_unit::UnitFeed; -/// One edge of our own cast's lifecycle, queued by the net bridge for the cast bar. +/// One edge of our own cast, queued by the spell's packet handlers and its local cancel. pub(crate) enum CastBarEdge { - /// `SMSG_SPELL_START` for the self guid with a nonzero timer (an instant shows no bar β€” - /// its GO follows at once; the reference bar would only flicker). + /// `SMSG_SPELL_START` with a nonzero cast time; an instant opens no bar, as in the reference. Start { spell_id: u32, cast_time_ms: u32 }, - /// `SMSG_SPELL_GO` for the self guid β€” the cast completed (the bar fills green and fades). + /// `SMSG_SPELL_GO`: the cast completed. Stop, - /// `SMSG_CAST_RESULT` failure β€” the bar (if shown) turns red "Failed" (the red error line - /// rides the same packet via `CastErrors`, independently). + /// `SMSG_CAST_RESULT` failure: the bar, if shown, reads "Failed". Failed, - /// `SMSG_SPELL_FAILED_OTHER` for the self guid β€” our in-flight cast was interrupted. + /// `SMSG_SPELL_FAILED_OTHER`: our cast was interrupted. Interrupted, - /// `SMSG_SPELL_DELAYED` β€” pushback: our cast took a hit and the server extended it by - /// `delay_ms`. The bar slides its window out (spark jumps back), it does NOT cancel. + /// `SMSG_SPELL_DELAYED`: pushback extends the cast by `delay_ms`; the bar slides, never + /// cancels. Delayed { delay_ms: u32 }, - /// `MSG_CHANNEL_START` (self-only on the wire) β€” a channel opened; the bar counts *down*, - /// and is labelled by [`channel_start_args`]'s law rather than the spell's name. + /// `MSG_CHANNEL_START`, sent by vmangos to the caster only; gated by [`channel_start_args`]. ChannelStart { spell_id: u32, duration_ms: u32 }, - /// `MSG_CHANNEL_UPDATE` (self-only): time left; `0` = the channel is over. + /// `MSG_CHANNEL_UPDATE`, also caster-only: the time left, 0 when the channel is over. ChannelUpdate { remaining_ms: u32 }, } -/// The net bridge's cast-bar queue (the [`crate::ui_action::CastErrors`] pattern). +/// The queue of [`CastBarEdge`]s, drained by [`feed_cast_bar`]. #[derive(Resource, Default)] pub(crate) struct CastBarFeed(pub(crate) Vec); -/// `SPELLCAST_START`'s **name argument** β€” what the reference `CastingBarFrame` puts straight into -/// `CastingBarText:SetText(...)` for a *cast*. At `0x6e7a2d`–`0x6e7a47` the handler tests -/// `AttributesEx3 & 0x4` and pushes **the empty string `0x882748`** when set, `Name[locale]` when -/// clear β€” the event still fires either way, carrying its duration, so the bar is a normal -/// full-length *untitled* bar rather than a hidden one. -/// -/// So two ways to come back empty: -/// -/// - **`AttributesEx3 & 0x4`** ([`SpellDisplay::no_casting_bar_text`], the emulators' -/// `SPELL_ATTR_EX3_NO_CASTING_BAR_TEXT`) β€” the attribute that exists precisely so an internal -/// spell with no player-facing name never puts one on the bar. Three shipped rows carry it, and -/// one is *named after the bit*: **22810 "Opening - No Text"**, the opener the client casts at -/// `LockType 13` ground containers. Printing the name regardless is what put that placeholder -/// over a gathered Hyacinth Mushroom (B247, decision 1312). -/// - **no catalog row** (an unknown id, or no client data) β€” the empty string this always -/// returned in that case. -/// -/// **This is the CAST bar's law and it does not generalise to the channel bar** β€” which is why -/// this is `SPELLCAST_START`'s label and not a shared helper. `0x6e7a2d` is the only -/// `SpellRec+0x24 & 4` test in the whole image (censused twice), and the channel handler -/// `0x6e7550` gates on entirely different bits. That law is [`channel_start_args`]'s, next door. +/// `SPELLCAST_START`'s name argument: empty for an unknown spell, or when `AttributesEx3 & 0x4` +/// is set, where the reference pushes `""` (`0x6e7a2d`) and still fires, so the bar is a +/// full-length untitled one. The channel bar names differently ([`channel_start_args`]). fn cast_bar_label(spells: Option<&crate::ui_action::Spells>, id: u32) -> String { spells .and_then(|s| s.catalog.get(id)) @@ -79,45 +48,14 @@ fn cast_bar_label(spells: Option<&crate::ui_action::Spells>, id: u32) -> String .unwrap_or_default() } -/// The CHANNEL bar's own law β€” `SpellChannelStart 0x6e7550`, whole. Returns the -/// `SPELLCAST_CHANNEL_START` argument list, or `None` where the reference fires **no event at -/// all** and the bar never appears. +/// `SPELLCAST_CHANNEL_START`'s arguments, `(duration, name)` (format `0x8432cc`, "%d%s"), or +/// `None` where `SpellChannelStart` (`0x6e7550`) fires no event: a zero duration, an id with no +/// `Spell.dbc` row, or `AttributesEx3 & 0x2000`. The name is the spell's own only with +/// `AttributesEx` bit 29 set, else the Lua global `CHANNELING` off the player's chain (`""` when +/// missing, and the event still fires). /// -/// The handler is 147 bytes and every one of its gates is here, in its order (decomp -/// `FUN_006e7550`; the disassembly of `0x6e7550`): -/// -/// ```text -/// 6e7571 test eax,eax / jle 6e75d8 ; (1) duration <= 0 -> NO EVENT -/// 6e7579 test eax,eax / jl ; (2) id < 0 -> NO EVENT -/// 6e757d cmp eax,[0xc0d78c] / jg ; (2) id past the table -> NO EVENT -/// 6e758e test eax,eax / je ; (2) no SpellRec at that slot -> NO EVENT -/// 6e7595 test ch,0x20 ; (3) AttributesEx3 & 0x2000 -> NO EVENT -/// 6e759a test [rec+0x1c],0x20000000 ; (4) AttributesEx bit 29 ... -/// 6e75a9 mov eax,[rec+0x1e0+locale*4] ; ... set -> Name[locale] -/// 6e75bc ecx=0x870dd0 call 0x703bf0 ; ... clear -> GetText("CHANNELING") -/// 6e75cb push 0x156 / call 0x703f50 ; fmt 0x8432cc "%d%s" β€” (duration, name) -/// ``` -/// -/// **Bit 29 is the reverse default of the cast bar's naming rule.** A cast bar prints the spell's -/// name unless a bit says not to; a channel bar prints the literal word "Channeling" unless a bit -/// says to name the spell. Only **9** of the 323 channeled rows in the shipped 5875 `Spell.dbc` -/// opt in (the four Fishing ranks, Cannibalize, Dream Vision, Using Control Console, and the two -/// Blood Siphons that gate (3) hides anyway) β€” so on a real server this reads "Channeling" for -/// Blizzard, Arcane Missiles, Mind Flay, Drain Life/Soul/Mana, Rain of Fire, Hurricane, -/// Tranquility, Evocation and First Aid alike. Ours passed the plain `Spell.dbc` name to every -/// one of them until decision 2284. -/// -/// The word itself is read the way the reference reads it β€” `FrameScript_GetText` on the -/// GlobalStrings key, i.e. the Lua global `CHANNELING`, off the player's own chain β€” not from a -/// table of ours, so a localized or patched install gets its own word. A missing global resolves -/// to `""` (`0x882748`, the shared empty string; `GetText` never returns NULL), and the event -/// still fires: an untitled bar, not a hidden one. -/// -/// **What this does NOT gate is [`ActiveChannel`]** (and so the action button's lit ring). The -/// reference's channel mirror is `SPELLCAST+0x10` (`0xceac58`), written at the cast SEND -/// (`0x6e4f88`) and never touched by this handler β€” so a Blood Siphon suppressed here still -/// counts as the current channel there, and ours keeps arming it from the packet for the same -/// reason. +/// This gates the event only: the reference's channel mirror (`0xceac58`) is written at the cast +/// send (`0x6e4f88`), never here, so `ActiveChannel` still arms for a suppressed channel. fn channel_start_args( script: &UiScript, spells: Option<&crate::ui_action::Spells>, @@ -127,8 +65,7 @@ fn channel_start_args( if duration_ms == 0 { return None; } - // Gates (2) and (3): no record at that id, or the record forbids a channel bar outright. The - // `filter` is the `je`/`jne` pair β€” both leave with the event unfired. + // No row at that id, or a row that forbids the channel bar: no event. let display = spells .and_then(|s| s.catalog.get(id)) .filter(|d| !d.no_channel_bar())?; @@ -147,25 +84,14 @@ fn channel_start_args( ]) } -/// Drain the queue into FrameScript events β€” the reference `CastingBarFrame` contract -/// (extracted 1.12 `CastingBarFrame.lua`): `SPELLCAST_START(name, ms)`, -/// `SPELLCAST_CHANNEL_START(ms, name)` β€” arg order reversed, and not a slip β€” -/// `SPELLCAST_CHANNEL_UPDATE(remaining_ms)`, and the argless STOP / FAILED / INTERRUPTED / -/// CHANNEL_STOP. A channel update of `0` fires CHANNEL_STOP β€” the server ends both the natural -/// finish and the interrupt that way. +/// Drain the queue into the stock events: `SPELLCAST_START(name, ms)`, +/// `SPELLCAST_CHANNEL_START(ms, name)` (reversed, as `CastingBarFrame.lua:80-84` reads it), +/// `SPELLCAST_CHANNEL_UPDATE(ms)` and the argless rest. A channel update of 0 is +/// `SPELLCAST_CHANNEL_STOP`: vmangos ends a channel that way, finished or interrupted. /// -/// An edge can resolve to **no event**: [`channel_start_args`] returns `None` for the three -/// conditions under which `0x6e7550` returns without firing, and the bar then never appears. -/// -/// Also pushes the frame's stoppable mirror (running auto-repeat OR the [`Inflight`] slot β€” NOT -/// a channel, which `SpellStopCasting 0x6e6e80` answers nil for) into the -/// VM **after** [`local_self_cancel`] resolved, so the ESC chain's `SpellStopCasting()` reads -/// post-cancel truth when `UiInput` runs later this frame. -/// -/// The mirror is what decides whether the press is EATEN, and that is half of decision 1049: a -/// queued strike makes `IsCasting` true in the reference, so `SpellStopCasting()` returns `1` and -/// `ToggleGameMenu`'s ladder never reaches `ClearTarget()` (`UIParent.lua` l.1489 vs l.1492). -/// Reading only the ordinary-cast half here is what dropped the target on the first Esc. +/// First it pushes the flag `SpellStopCasting` (`0x6e6e80`) answers from: auto-repeat or the +/// in-flight slot, never a channel, taken after the local cancel. A queued strike counts, so the +/// first Esc stops it and `ToggleGameMenu` skips `ClearTarget()` (`UIParent.lua:1489-1492`). pub(crate) fn feed_cast_bar( script: Option>, mut feed: ResMut, @@ -181,9 +107,8 @@ pub(crate) fn feed_cast_bar( return; }; let now = Instant::now(); - // The same slot the cancel resolves, so the mirror and the drain can never disagree about - // whether a press had something to spend itself on β€” plus the auto-repeat key (`0xceac30`) - // the ref reads first. + // The auto-repeat key (`0xceac30`), which the reference reads first, then the in-flight slot + // the cancel resolves, so the flag and the cancel agree. let started = self_guid .0 .as_ref() @@ -217,10 +142,8 @@ pub(crate) fn feed_cast_bar( duration_ms, } => { let args = channel_start_args(&script, spells.as_deref(), spell_id, duration_ms); - // The channel half of the cast trace. The other five edges are traced at RECV - // (`spell::net`); these two are traced HERE because the decision that - // wants watching β€” which of `0x6e7550`'s legs the spell took β€” is made here, - // and the drain runs the same frame the packet lands. + // Traced here, where the `0x6e7550` gate is decided; other edges trace in + // `spell::net`. if *crate::net::CAST_TRACE { match args.as_deref() { Some([_, ScriptValue::Str(label)]) => info!( @@ -236,9 +159,8 @@ pub(crate) fn feed_cast_bar( } args.map(|args| ("SPELLCAST_CHANNEL_START", args)) } - // `0x6e75f0`'s whole shape: nonzero re-times the window, zero ends it. It reads no - // SpellRec, so neither leg has an attribute gate β€” a channel whose START was - // suppressed still sends these, and the stock Lua's `IsShown()` guards absorb them. + // `0x6e75f0`: nonzero re-times the bar, zero ends it, with no attribute gate; for a + // suppressed channel the stock Lua's `IsShown()` guards absorb these. CastBarEdge::ChannelUpdate { remaining_ms } => { let over = remaining_ms == 0; if *crate::net::CAST_TRACE { @@ -264,19 +186,15 @@ pub(crate) fn feed_cast_bar( } } -/// The cast-bar UI seam: the queue + its drain (before the VM ticks, so an edge and its first -/// OnUpdate land the same frame), and the local self-cancel resolved just ahead of the drain β€” -/// a controller move edge or an ESC `SpellStopCasting()` from the previous frame kills the bar -/// on this frame's VM tick (one engine frame from input to bar-death). -/// The feed only β€” the state it reads is `crate::spell`'s (decision 2328). +/// The cast-bar feed, drained before the VM ticks so an edge and its first `OnUpdate` share a +/// frame, and after the local cancel so a move or Esc kills the bar on the next tick. pub(crate) struct UiCastPlugin; impl Plugin for UiCastPlugin { fn build(&self, app: &mut App) { app.init_resource::().add_systems( Update, - // After the spell's local cancel, whose edges this drains the same frame β€” the - // `(local_self_cancel, feed_cast_bar).chain()` this plugin held until 2328. + // After the spell's local cancel, whose edges this drains the same frame. feed_cast_bar .in_set(UnitFeed) .after(crate::spell::LocalCancel), @@ -288,9 +206,7 @@ impl Plugin for UiCastPlugin { mod tests { use super::*; - /// **B247** (decision 1312): the bar's label honours `SPELL_ATTR_EX3_NO_CASTING_BAR_TEXT`. - /// Spell 22810's Spell.dbc name β€” "Opening - No Text" β€” is Blizzard's own annotation of the - /// attribute, and it reached a player's screen because we printed the name regardless. + /// Spell 22810, "Opening - No Text", carries `SPELL_ATTR_EX3_NO_CASTING_BAR_TEXT`. #[test] fn the_no_casting_bar_text_attribute_blanks_the_label() { use benilla_formats::{SpellCatalog, SpellDisplay}; @@ -317,14 +233,12 @@ mod tests { "the event still fires carrying its duration β€” a full-length UNTITLED bar, \ not a hidden one (0x6e7a33 pushes the empty string, then fires as usual)" ); - // The pre-existing empty cases are untouched: an unknown id, and no client data at all. + // An unknown id, and no client data at all. assert_eq!(cast_bar_label(Some(&spells), 133), ""); assert_eq!(cast_bar_label(None, 6478), ""); } - /// The CHANNEL bar's law β€” [`channel_start_args`], the other half of `0x6e7550`, and the - /// **opposite default** to the cast bar's above: a channel says "Channeling" unless - /// `AttributesEx & 0x2000_0000` permits its own name. Shapes are the real shipped rows. + /// The attribute columns are those of the shipped 5875 rows. #[test] fn the_channel_bar_says_channeling_unless_the_spell_may_name_itself() { use benilla_formats::{SpellCatalog, SpellDisplay}; @@ -337,11 +251,11 @@ mod tests { let spells = crate::ui_action::Spells { catalog: SpellCatalog::from_displays( [ - // Blizzard rank 1 β€” the real 5875 columns (bit 29 clear). + // Blizzard rank 1: bit 29 clear. (10, row("Blizzard", 0x1000_008c, 0)), - // Fishing β€” one of the nine that name themselves. + // Fishing: bit 29 set, one of nine rows that name themselves. (7620, row("Fishing", 0x2100_4004, 0x0800_0000)), - // Blood Siphon β€” the whole-event suppressor, both shipped rows. + // Blood Siphon: `AttributesEx3 & 0x2000` suppresses the event. (24322, row("Blood Siphon", 0x2001_808c, 0x2000)), ] .into_iter() @@ -349,9 +263,7 @@ mod tests { ), ..crate::ui_action::Spells::empty_for_tests() }; - // `FrameScript_GetText` reads the Lua global, so the VM is the source of the word β€” the - // chain-backed end-to-end is `ui_script::cast_tests`; here it is set by hand so the four - // gates are exercised without client data. + // The word is the Lua global, set by hand so the gates run without client data. let script = UiScript::new().expect("VM"); script.eval::<()>("CHANNELING = \"Channeling\"").unwrap(); @@ -388,7 +300,7 @@ mod tests { "no client data is the same no-record leg" ); - // Arg ORDER is the reverse of SPELLCAST_START's, and the stock Lua reads it that way. + // Duration first, the reverse of `SPELLCAST_START`. assert_eq!( channel_start_args(&script, Some(&spells), 10, 8_000).unwrap()[0], ScriptValue::Int(8_000), @@ -396,10 +308,7 @@ mod tests { ); } - /// The word itself comes off the PLAYER's chain, not a table of ours β€” `FrameScript_GetText` - /// on the GlobalStrings key, so a localized install gets its own. Pinned against the real - /// `Interface\FrameXML\GlobalStrings.lua` (l.478 in the shipped enUS file, whose trailing - /// comment is literally "Channeling tag for the channeling bar"). Skips without client data. + /// `CHANNELING` is `GlobalStrings.lua:479`. Skips without client data. #[test] fn the_channeling_word_comes_off_the_players_own_chain() { let _data = benilla_formats::wow_data_or_skip!(); diff --git a/crates/benilla-app/src/ui_char.rs b/crates/benilla-app/src/ui_char.rs index afc34ab0..6e753fd8 100644 --- a/crates/benilla-app/src/ui_char.rs +++ b/crates/benilla-app/src/ui_char.rs @@ -1,43 +1,8 @@ -//! The app-side **character-window feed** (decision 0208 Β§3/Β§4): the bridge that turns our own -//! avatar's descriptor + the item stores into the plain snapshots the paper doll's Lua bindings -//! read, and into the Era events that drive its `OnEvent` repaints β€” the [`crate::ui_unit`] -//! pattern, for the combat-stats/inventory surface. -//! -//! Three jobs, each frame, before the VM ticks ([`UiInput`]): -//! -//! - **[`UnitCombatStats`]** from the self player's [`ObjectStore`] β€” the stat/resistance/ -//! damage/attack-power fields (all OWNER_ONLY, verified streamed in 0208) plus the three resolved -//! skill pairs: the app picks WHICH skill line the equipped main-hand/ranged item uses -//! (`weapon_subclass_skill` β€” vmangos `Item.cpp`'s table; no weapon = unarmed 162), reads its -//! `PLAYER_SKILL_INFO` triplet and the Defense line's ([`SKILL_DEFENSE`]), so the engine-free -//! bindings just serve the pairs. -//! -//! The descriptor half is [`unit_combat_stats`], which any unit's store goes through β€” the pet -//! paper doll's feed ([`crate::ui_pet_doll`], decision 1057) is the second caller. What -//! [`combat_stats`] adds on top is exactly the player-only half: the four values derived from the -//! equipped items, and the skill pairs, neither of which a creature has. -//! - **`InventorySlots`** from the inv-slot guids β†’ [`Items`] objects β†’ templates β†’ the -//! [`ItemDisplays`] icon β€” index 0 the ammo slot (`PLAYER_AMMO_ID`, its count summed across the -//! backpack + the four equipped bags), 1..=19 the equipment slots, 20..=23 the four equipped-bag -//! ICONS (decision 0216 slice 2's bag bar β€” the bag item itself, `INV_SLOT` 19..22, not its -//! contents), all by the client's `GetInventorySlotInfo` ids. A slot whose template answer is -//! still in flight carries its `item_id` with `icon: None` and fills on a later frame (the -//! ask-once cache repaints via `UNIT_INVENTORY_CHANGED` when the view changes). Each equipment -//! slot (1..=23) also carries an `|Hitem:…|h[Name]|h` `link`, its `PendingItemOps`-derived -//! `locked` (decision 0208 phase 1b β€” the doll twin of `ui_items::feed_containers`'s bag-slot -//! `.locked`), and its resolved `equip_slots` (`ui_items::find_equip_slot` over the item -//! template's `inventoryType` β€” the cursor arc's "fit rule", decision 0216/0218). -//! - **The paper-doll booth yaw**: the stock pane's rotate buttons call the reference's own -//! `Model_RotateLeft`/`_RotateRight`, which write `CharacterModelFrame:SetRotation`; the feed -//! mirrors that pane's facing onto [`PaperDollBooth`] so the booth re-bakes at the new angle -//! (decision 0208 Β§5, off the pane itself since 1751). -//! -//! Events, fired on snapshot transitions (grouped by the ref's own registration set, -//! `PaperDollFrame.lua:14-28` β€” arg1 `"player"`): `UNIT_STATS`, `UNIT_RESISTANCES`, `UNIT_DAMAGE`, -//! `UNIT_ATTACK_SPEED`, `UNIT_ATTACK_POWER`, `UNIT_RANGED_ATTACK_POWER`, `UNIT_RANGEDDAMAGE`, -//! `UNIT_ATTACK` (the weapon-skill pairs) β€” [`fire_stat_transitions`], shared with the pet feed β€” -//! and `UNIT_INVENTORY_CHANGED` on any slot-view change. A first snapshot counts as a transition of -//! every group (the `ui_unit` rule). +//! The character-window feed: the [`UnitCombatStats`] and `InventorySlots` snapshots the paper +//! doll's Lua bindings read, built from our avatar's descriptor and item stores; the events that +//! repaint it; and the booth's yaw from the stock `CharacterModelFrame`. [`unit_combat_stats`] is +//! the half any unit shares: a creature has no player fields, so those read absent and keep their +//! defaults. use bevy::prelude::*; @@ -57,67 +22,49 @@ use crate::portrait::PaperDollBooth; use crate::ui_items::{find_equip_slot, item_link}; use crate::ui_script::{gate, UiFeed, UiInput}; -/// Equipment slots, 0-based (`EQUIPMENT_SLOT_*`): the inv-slot array index of the main hand, -/// off hand, and ranged slots the weapon-skill / offhand / wand resolutions read. +/// Equipment slots, 0-based (`EQUIPMENT_SLOT_*`): main hand, off hand, ranged. const SLOT_MAIN_HAND: u8 = 15; const SLOT_OFF_HAND: u8 = 16; const SLOT_RANGED: u8 = 17; -/// `ItemPrototype` class 2 = weapon (vmangos `ItemPrototype.h`); the offhand-speed gate. +/// Item class 2, weapon (vmangos `ItemPrototype.h:139`). const ITEM_CLASS_WEAPON: u32 = 2; -/// Weapon subclass 19 = wand (`HasWandEquipped`). +/// Weapon subclass 19, wand (`ItemPrototype.h:211`), for `HasWandEquipped`. const SUBCLASS_WAND: u32 = 19; -/// A stable comparison key for an f32 snapshot field (the event-transition diffs): quantized to -/// 1/32 so equal wire values always compare equal and real changes always register. +/// An f32 snapshot field as a diff key, quantized to 1/32 so equal wire values compare equal. fn fixed(v: f32) -> i64 { (v * 32.0).round() as i64 } -/// The feed's change-tracking memory (the [`crate::ui_unit::UnitFeedState`] shape): the last -/// snapshots pushed, so events fire on transitions only. -/// -/// All of it is what-we-told-the-VM, so all of it lives behind a [`crate::ui_script::VmMemo`]: a -/// memory about one VM is unreadable against the next (1290), so a `/reload` (1291) β€” which -/// replaces the VM without despawning the avatar β€” re-pushes both snapshots and re-fires their -/// events exactly as a fresh login does. +/// The last snapshots pushed, so events fire on transitions only; held per VM +/// ([`crate::ui_script::VmMemo`]), so a `/reload` re-pushes and re-fires as a login does. #[derive(Resource, Default)] pub(crate) struct CharFeedState { vm: crate::ui_script::VmMemo, } -/// The per-VM half of [`CharFeedState`] β€” the transition-diff bases. +/// The per-VM half of [`CharFeedState`]: the transition-diff bases. #[derive(Default)] struct CharFeedMemo { last_stats: Option, last_inv: Option, last_bank_bags: Option, - /// The item guid **of the bag itself** in each of the six bank bag slots (absolute player - /// slots 63..68). `PLAYERBANKSLOTS_CHANGED`'s producer discriminator for this band β€” the same - /// question `ui_items::feed`'s `SlotGuids::vault` answers for the vault, and for the same - /// reason: the pushed view cannot tell two instances of one bag model apart, so a swap of two - /// identical bags between two slots reads as no change and the reference fires twice - /// (decision 2140). + /// The bag guid in each bank bag slot, `PLAYERBANKSLOTS_CHANGED`'s discriminator: the pushed + /// view cannot tell two copies of one bag apart, and the reference fires when they swap. last_bank_bag_guids: [u64; BANK_BAG_SLOT_COUNT], - /// The gate's counter memories (1439) β€” the stores whose lazy resolves poison `is_changed` - /// for this feed (the equipment templates' ask-once, the enchant-name creator lookups). The - /// item OBJECTS are not among them since 2334: an instance is an entity, so its edges arrive - /// on the ordinary `Changed`/removal watch ([`crate::items::ItemChanges`]). + /// Counters for the stores whose lazy resolves poison `is_changed` (item templates, creator + /// names); item objects are entities, watched through [`crate::items::ItemChanges`]. items_templates: gate::Watch, names_generation: gate::Watch, - /// `ItemChanges::countdown_steps` β€” one step per displayable countdown change (the slot - /// views read second-floored countdowns), including the last elapse's collapsing push; a - /// landing is the item's own change tick (decision 2340). + /// `ItemChanges::countdown_steps`: one step per displayed-second change, the final elapse too. enchant_deadlines: gate::Watch, - /// `PendingItemOps::epoch` β€” one step per change to the in-flight lock set. Watched BESIDE - /// `!is_empty()` and not instead of it: `is_empty()` holds the gate open *while* an op is in - /// flight, and this catches the frame it goes away. Without it a clear that moves no object - /// (`SMSG_INVENTORY_CHANGE_FAILURE`) closes the gate on the same frame it unlocks, and the - /// last `locked: true` this feed pushed stands (1771). + /// `PendingItemOps::epoch`, beside `!is_empty()`: that holds the gate open while an op is in + /// flight, this catches the frame a lock clears with no object moving + /// (`SMSG_INVENTORY_CHANGE_FAILURE`). pending_epoch: gate::Watch, } -/// Adds the per-frame character-window feed. The bindings live in `benilla-ui`'s `char_stats`; -/// this supplies their data (and the events) from ECS state. +/// The character-window feed; its bindings are `benilla-ui`'s `char_stats`. pub(crate) struct UiCharPlugin; impl Plugin for UiCharPlugin { @@ -125,13 +72,9 @@ impl Plugin for UiCharPlugin { app.init_resource::().add_systems( Update, ( - // `.before(feed_containers)`: the container feed's `BAG_UPDATE` handlers read the - // inventory surface synchronously β€” an addon mirroring a bag asks the bag ITEM's - // own row (`GetInventoryItemLink("player", ContainerIDToInventoryID(bag))`, - // Bagnon_Forever's size row) β€” so the inventory push must land first. Unordered, - // the first in-world burst raced it and recorded every equipped bag linkless - // (`s = "8,0,"` in the director's Bagnon_Forever data). The house "push before - // fire" rule, across the two feeds. + // Before `feed_containers`: a `BAG_UPDATE` handler may read the bag item's own + // row (`GetInventoryItemLink("player", ContainerIDToInventoryID(bag))`), so the + // inventory push lands first. feed_char .before(crate::ui_items::feed::feed_containers) .in_set(UiFeed), @@ -143,43 +86,16 @@ impl Plugin for UiCharPlugin { } } -/// The skill block [`watch_skill_ups`] last announced: the self guid it was read off, and that -/// character's `skillId β†’ value` map β€” the diff base for both the chat lines and the event. -/// `None` = nothing announced yet. +/// The skill block [`watch_skill_ups`] last announced: the self guid and its `skillId β†’ value` +/// map, the diff base for the chat lines and the event. type SkillBlock = Option<(u64, std::collections::HashMap)>; -/// The client-generated skill-up feedback (decision 0437, landed with phase 2; gate corrected by -/// 1309): the server sends NO skill-up message at all β€” skills mutate only as -/// `PLAYER_SKILL_INFO` descriptor deltas (verified at the vmangos source: no chat send anywhere -/// in `UpdateSkill*`/`SetSkill`) β€” so the client diff-watches its own skill block exactly as the -/// real one does (the rank-field UpdateField watcher `0x5de180` β†’ message facility `0x496720`, -/// tokens `ERR_SKILL_UP_SI` / `ERR_SKILL_GAINED_S` β€” correcting 0437's "SKILL_RANK_UP" token -/// guess), prints the GlobalStrings lines on the Skill chat channel, and fires -/// `SKILL_LINES_CHANGED` on ANY block change (the skills pane's repaint event; the real client -/// fires it from a separate skill-manager TU (`0x4d2fec`) on add/remove only β€” ours riding the -/// same watcher is a benign coarsening, the pane just repaints). **The message gate** (B19/B245, -/// decisions 1309/1314): both lines are skipped when the line's `SkillRaceClassInfo.flags` -/// carries `0x402` (`SkillRaceClass::skill_up_silent`) β€” the class spec lines, racials, -/// `GENERIC (DND)`, `Dual Wield` and the mount lines β€” **or when no row admits this race/class -/// at all** (the resolve's own empty branch, `0x5de352`) β€” which is why a real 1.12 ding -/// announces no skill at all (the level-up movers are all flagged) while weapon/profession -/// skill-ups still print. The -/// login fill (empty β†’ populated) and a character switch (self guid change) re-seed silently β€” -/// the event fires, the lines don't. Still open: the exact chat channel of the real emitter -/// (`0x496720`'s routing is untraced; Skill is the `ChatTypeInfo` family's own key). -/// One skill-watcher line: resolve the key off the player's own `GlobalStrings.lua`, fill it, and -/// push it as `CHAT_MSG_SKILL`. -/// -/// **Deliberately not `ui_action::show_messages`, and the reason is a real gap rather than a -/// preference.** `ERR_SKILL_UP_SI`/`ERR_SKILL_GAINED_S` (rows 54/53) are the two of the three -/// catalog rows whose `chat_type` is **23 = `CHAT_MSG_SKILL`**, not the `10 = CHAT_MSG_SYSTEM` -/// every other `kind 0` row carries β€” and that field is exactly what the shared sink does not -/// read yet (its own doc says so: "benilla raises none of them yet; when it does, this is the -/// line that has to read the field"). Routing these through it today would silently demote them -/// to SYSTEM and out of the Skill filter. So the key, the fill and the data-suppression rule are -/// the shared ones; only the surface is carried here, until the sink learns `chat_type`. -/// -/// An absent or empty key shows nothing β€” the reference's own null/empty guard. +/// One skill-up chat line, `ERR_SKILL_UP_SI` or `ERR_SKILL_GAINED_S`, pushed as `CHAT_MSG_SKILL`. +/// The server sends no skill-up message; the reference's watcher on the skill value words +/// (`0x5de180`) prints these through `0x496720`, silent for a line whose `SkillRaceClassInfo` +/// flags carry `0x402` or that no row admits for this race and class (`0x5de352`), so a level-up +/// prints none. Not `ui_action::show_messages`: that sink sends every chat row as +/// `CHAT_MSG_SYSTEM`, and these two ask for `CHAT_MSG_SKILL` (23). fn skill_line(script: &UiScript, chat: &mut crate::ui_chat::ChatLog, key: &str, args: &[Arg<'_>]) { let Some(template) = script .lua() @@ -207,10 +123,11 @@ fn watch_skill_ups( let (Some(guid), Ok(store)) = (self_guid.0, self_store.single()) else { return; }; - // The memo is about the VM β€” it gates `SKILL_LINES_CHANGED`, the pane's only repaint wire β€” so - // it re-seeds with the VM (decision 1290). With no VM there is nothing to announce to and - // nothing worth remembering: the diff would only queue chat lines for a VM that does not exist - // yet, which is a stale skill-up replayed at the next login. The next VM re-seeds silently. + // The memo is per VM and a new VM re-seeds silently; with no VM nothing is diffed, or a stale + // skill-up would replay at the next login. `SKILL_LINES_CHANGED` fires here only when a line + // is added, removed or changes value; the reference fires it at the end of every skill-list + // rebuild (`0x4d2fec`), which its value, max, permanent-bonus and temporary-bonus watchers + // run (registered `0x5dda54`-`0x5ddaf0`), and from both header collapse and expand verbs. let Some(mut script) = script else { return; }; @@ -224,7 +141,7 @@ fn watch_skill_ups( } } match prev.as_ref() { - // First fill for this character: seed silently (no lines), announce the block once. + // First fill: seed silently, no lines; the event fires once. None => { if cur.is_empty() { return; @@ -233,7 +150,7 @@ fn watch_skill_ups( *prev = Some((guid, cur)); } Some((prev_guid, _)) if *prev_guid != guid => { - // A different character logged in β€” re-seed, never diff across characters. + // A different character: re-seed, never diff across characters. script.fire_event("SKILL_LINES_CHANGED", vec![]); *prev = Some((guid, cur)); } @@ -241,8 +158,7 @@ fn watch_skill_ups( if *prev_map == cur { return; } - // The 0x402 message gate resolves through the player's own race/class row, the same - // lookup the pane's display law makes ([`skills_row`]). + // The `0x402` gate reads the player's own race and class row, as `skills_row` does. let (race, class) = ( store.0.unit_race().unwrap_or(0), store.0.unit_class().unwrap_or(0), @@ -255,20 +171,16 @@ fn watch_skill_ups( .and_then(|s| s.catalog.line(line_id)) .map(|l| l.name.clone()) }; - // The fn doc's message gate; `false` with no catalog (nothing to name it by - // either β€” the line stays silent, as before the catalog loads). let announces = || { skill_lines .as_ref() .is_some_and(|s| s.catalog.announces_skill_ups(line_id, race, class)) }; match prev_map.get(&id) { - // A rank-up: `ERR_SKILL_UP_SI`, catalog row 54 β€” **not** `SKILL_RANK_UP`, - // which is the same enUS sentence and no catalog row at all (decision 2045). + // A rank-up: `ERR_SKILL_UP_SI` (catalog row 54), not `SKILL_RANK_UP`, the + // same enUS sentence with no catalog row. Some(&old) if value > old => { if let Some(name) = name() { - // Both verdicts are logged β€” the retest's instrument: a moved line - // either announces or names the gate that held it. if announces() { debug!("chat: skill-up announced ({name} {old}β†’{value})"); skill_line( @@ -282,9 +194,7 @@ fn watch_skill_ups( } } } - // A NEW line: the ERR_SKILL_GAINED_S line (GlobalStrings.lua:1837) β€” the - // fold-back's second token (the watcher `0x5de180` emits both, first-gain vs - // rank-up). + // A new line: `ERR_SKILL_GAINED_S` (catalog row 53). None => { if let Some(name) = name() { if announces() { @@ -311,19 +221,8 @@ fn watch_skill_ups( } } -/// The skills-pane feed (decision 0437 phase 4, corrected by 1091): every `PLAYER_SKILL_INFO` line -/// the real client would LIST, resolved to a flat [`SkillEntry`] β€” name off `SkillLine.dbc`, group -/// off the line's `categoryId` Γ— `SkillLineCategory.dbc` (name + displayOrder) β€” pushed via -/// `set_skills` on change; the ENGINE groups/sorts/folds. -/// -/// The inclusion predicate is the client's own list build (`0x4d2cb0`), transcribed in its -/// order: a line needs -/// a `SkillLine.dbc` row, an admitting `SkillRaceClassInfo` row and a real `SkillLineCategory` row; -/// `flags & 0x2` drops it outright; and a line held at **rank 0** appears only if its flags admit -/// it at this player's level. Note what is NOT a filter: the `Not Displayed` category (12). We used -/// to hide on it, which happened to hide `GENERIC (DND)` for the right-looking reason β€” the client -/// drops that line by its `0x2` bit like `Dual Wield` and the racials, and lists a category-12 line -/// without `0x2` under its own header (decision 1091). +/// The skills-pane feed: every `PLAYER_SKILL_INFO` line the reference lists, as a flat +/// [`SkillEntry`] pushed through `set_skills` on change; the engine groups, sorts and folds. fn feed_skills( script: Option>, self_store: Query, With>, @@ -338,15 +237,10 @@ fn feed_skills( let (Ok(store), Some(skill_lines)) = (self_store.single(), skill_lines.as_deref()) else { return; }; - // The list is a pure function of our descriptor and the catalog: with both still and a - // push already made on this VM, nothing below can differ from what the memo holds. It used - // to build ~40 rows with a cloned name each, every frame, to compare and drop them. + // A pure function of the descriptor and the catalog: with both still, nothing can differ. if last.is_some() && !store.is_changed() && !lines_moved { return; } - // The display predicate reads the player's own race/class (the SkillRaceClassInfo row-match β€” - // the spellbook's own General-collapse inputs, `ui_spellbook::build_book`) and level (the - // untrained gate). let (race, class) = ( store.0.unit_race().unwrap_or(0), store.0.unit_class().unwrap_or(0), @@ -369,10 +263,8 @@ fn feed_skills( *last = Some(fresh); } -/// One `PLAYER_SKILL_INFO` slot as the Skills tab's row β€” or `None` when the real client's list -/// build would not list it at all ([`feed_skills`]'s doc for the citations; the order of the tests -/// is the client's own, `0x4d2cb0`). Pure, so the whole display predicate is testable against real -/// DBC data and a real character's skill block (`ui_script::skills_frame_tests`). +/// One skill slot as a Skills-tab row, or `None` where the reference's list build (`0x4d2cb0`) +/// skips it; the checks run in that build's order. pub(crate) fn skills_row( slot: &benilla_protocol::messages::PlayerSkillSlot, catalog: &benilla_formats::SkillLineCatalog, @@ -384,14 +276,11 @@ pub(crate) fn skills_row( return None; } let id = u32::from(slot.skill_id); - // No SkillLine row β€” nothing to name it by, and the client lists nothing. let line = catalog.line(id)?; - // No row admitting this race/class β†’ the client drops the line (its `!srci β†’ continue`). let rc = catalog.race_class(id, race, class)?; - // A category that must really exist (the client's own null-row test) β€” and note the category - // ITSELF is never a filter: `Not Displayed` (12) is a header like any other. + // The category row must exist; no category filters, `Not Displayed` (12) included. let (category_name, category_order) = catalog.category(line.category_id)?; - // The hide bit: `Dual Wield`, the racials, the mount lines, `GENERIC (DND)`. + // Flag `0x2` hides `Dual Wield`, the racials, the mount lines and `GENERIC (DND)`. if rc.hidden() { return None; } @@ -412,17 +301,14 @@ pub(crate) fn skills_row( category_name: category_name.to_string(), category_order, description: line.description.clone(), - // The client ANDs the DBC's unlearnable bit with a nonzero skill STEP (`0x4d3953`). + // The unlearnable bit and a nonzero skill step (`0x4d3953`). abandonable: slot.step != 0 && rc.unlearnable(), mono: rc.mono(), }) } -/// The abandon drain (the skills pane's unlearn button β†’ the `UNLEARN_SKILL` popup's accept β†’ -/// the engine's `AbandonSkill` queue): each queued skill line id becomes one -/// `CMSG_UNLEARN_SKILL`. Nothing else happens client-side β€” the server's `SetSkill(id, 0, 0)` -/// returns as a `PLAYER_SKILL_INFO` delta and [`feed_skills`] re-pushes the shrunken list -/// (vmangos `SkillHandler.cpp`; the gossip/trainer drains' own shape). +/// Each queued `AbandonSkill` becomes one `CMSG_UNLEARN_SKILL`; nothing changes locally, as the +/// server's `SetSkill(id, 0, 0)` (vmangos `SkillHandler.cpp:69`) returns as a skill-field update. fn drain_skill_abandons(script: Option>, commands: Res) { let Some(mut script) = script else { return; @@ -433,15 +319,14 @@ fn drain_skill_abandons(script: Option>, commands: Res Option { let guid = store.0.player_inv_slot(slot0)?; objects.object(guid)?.object_entry() } -/// The equipped item's weapon-skill line id: the vmangos `Item.cpp` subclass table for a class-2 -/// item, unarmed (162) for an empty hand or a non-weapon / unmapped subclass. +/// The weapon-skill line for `slot0`: vmangos's subclass table (`Item.cpp:700`) for a weapon, +/// Unarmed (162) for anything else. fn weapon_skill_id( store: &ObjectStore, objects: &Objects, @@ -453,7 +338,7 @@ fn weapon_skill_id( return SKILL_UNARMED; }; let Some(t) = items.template(entry, 0, commands) else { - return SKILL_UNARMED; // in flight β€” refined next frame when the template lands + return SKILL_UNARMED; // in flight: refined when the template lands }; if t.class != ITEM_CLASS_WEAPON { return SKILL_UNARMED; @@ -461,25 +346,8 @@ fn weapon_skill_id( weapon_subclass_skill(t.subclass).unwrap_or(SKILL_UNARMED) } -/// Read a skill line's `(value + PERM bonus, TEMP bonus)` pair from the `PLAYER_SKILL_INFO` -/// triplets; `(0, 0)` when the line isn't known (a ranged pair with nothing equipped). -/// -/// **The split is not `(value, temp + perm)`, which is what this returned until decision 1812.** -/// The reader every skill-shaped binding goes through is `0x5ea460`, and it writes -/// `*out1 = value + permBonus` / `*out2 = tempBonus` β€” the permanent half folds into the BASE and -/// only the temporary half is the modifier (the field reads are `+0x84C` `movzx` value, -/// `+0x850` `movsx` temp β€” the block's only sign-extended read β€” and `+0x852` `movzx` perm). -/// -/// The old shape had the right TOTAL and the wrong halves, which is why nothing caught it: the -/// paper doll's Attack row prints `base` and colours `base + mod`, so a 300-value line with a +5 -/// talent and a +10 aura read "300" with a green +15 where the reference reads "305" with a green -/// +10. -/// -/// **The perm add is skipped when the value is 0** (`0x5ea4b4 jle`), so an unknown line stays a -/// flat 0 rather than reading back a bare talent bonus. -/// -/// Signed, because the sum is: `fild dword` on both pushes, and a perm malus deeper than the skill -/// is representable on the wire even if no live server sends one. +/// A skill's `(value + permanent bonus, temporary bonus)`, split as the reference's `0x5ea460` +/// splits it; value 0 skips the permanent add (`0x5ea4b4`). Signed: a malus can exceed the value. fn skill_pair(store: &ObjectStore, skill_id: u32) -> (i32, i32) { for i in 0..PLAYER_SKILL_SLOTS { if let Some(s) = store.0.player_skill(i) { @@ -497,22 +365,8 @@ fn skill_pair(store: &ObjectStore, skill_id: u32) -> (i32, i32) { (0, 0) } -/// Build the **descriptor-only** combat-stats snapshot for ANY unit's store (field mappings pinned -/// in decision 0208; the absent shapes are the descriptor's zero defaults, `percent` 1.0). -/// -/// Nothing here reads inventory, templates or skill lines β€” so it is exactly the part a pet can go -/// through ([`crate::ui_pet_doll`], decision 1057). The `player_*` reads are deliberately left in: -/// a creature has no PLAYER block, so they read absent and fall to their defaults, which is the -/// right pet answer (no buff decomposition, `damage_percent` the divide-safe 1.0) rather than a -/// second unit-only copy of the same twenty lines. -/// -/// **That premise is load-bearing, and it was false until decision 1081.** A created store used to -/// answer `Some(0)` for *any* absent index, PLAYER block or not, so on a live pet every default -/// here was dead code β€” `damage_percent` came through as `0` and the ref's `damage / percent` made -/// the pet sheet read `inf - inf` / `nan`. The protocol layer now bounds "absent = 0" to the -/// object's own descriptor, which is what makes this shared core honest for a creature. -/// A swing time in ms, with `0` (and absent) read as "no swing time yet" β†’ the vanilla 2000. Never -/// zero, because it is the reference's `damage / speed` divisor. +/// A swing time in ms, with 0 or absent read as not yet streamed: the vanilla 2000, never zero, +/// as the reference divides damage by it. fn base_swing(streamed: Option) -> u32 { streamed.filter(|&ms| ms != 0).unwrap_or(2000) } @@ -525,9 +379,7 @@ pub(crate) fn unit_combat_stats(store: &ObjectStore) -> UnitCombatStats { let mut stat_neg = [0i32; 5]; for i in 0..5u8 { stats[usize::from(i)] = f.unit_stat(i).unwrap_or(0) as i32; - // The four buff-split arrays are INT on the wire (decision 1397) β€” they used to be read as - // f32 and rounded, which turned every real value into `0` and is why B165/B251's stats - // never went green. + // The buff-split arrays are integers on the wire, not floats. stat_pos[usize::from(i)] = f.player_posstat(i).unwrap_or(0); stat_neg[usize::from(i)] = f.player_negstat(i).unwrap_or(0); } @@ -557,11 +409,8 @@ pub(crate) fn unit_combat_stats(store: &ObjectStore) -> UnitCombatStats { physical_bonus_pos: f.player_mod_damage_done_pos(0).unwrap_or(0), physical_bonus_neg: f.player_mod_damage_done_neg(0).unwrap_or(0), damage_percent: f.player_mod_damage_done_pct(0).unwrap_or(1.0), - // 2000 ms is the vanilla base swing when the field hasn't streamed β€” keeps the ref's - // damage/speed division finite on the first frames. **`0` counts as unstreamed**, not as a - // swing time: on a created store an absent in-block field reads `Some(0)` (the descriptor's - // zero), so a plain `unwrap_or` would never fire and `damage / speed` would go to infinity - // (the shape of the bug decision 1081 is about, one field over). + // `0` counts as unstreamed: a created store reads an absent in-block field as `Some(0)`, + // so a plain `unwrap_or` would leave `damage / speed` infinite. main_attack_time_ms: base_swing(f.unit_base_attack_time(0)), offhand_attack_time_ms: base_swing(f.unit_base_attack_time(1)), attack_power: f.unit_attack_power().unwrap_or(0), @@ -573,32 +422,28 @@ pub(crate) fn unit_combat_stats(store: &ObjectStore) -> UnitCombatStats { ranged_attack_time_ms: base_swing(f.unit_ranged_attack_time()), ranged_min_damage: f.unit_min_ranged_damage().unwrap_or(0.0), ranged_max_damage: f.unit_max_ranged_damage().unwrap_or(0.0), - // The equipment- and skill-derived half is the PLAYER feed's ([`combat_stats`]); a unit - // with neither keeps these defaults. + // The equipment and skill half is `combat_stats`'s; any other unit keeps these defaults. has_offhand: false, has_wand: false, main_weapon_skill: (0, 0), offhand_weapon_skill: (0, 0), ranged_weapon_skill: (0, 0), defense_skill: (0, 0), - // Player fields β€” the player's own snapshot fills them (`combat_stats`); a pet has none. dodge_percent: 0.0, parry_percent: 0.0, block_percent: 0.0, } } -/// The player's snapshot: [`unit_combat_stats`] plus the half only *we* have β€” the two values that -/// need the equipped items' templates, and the three `PLAYER_SKILL_INFO` pairs. +/// The player's snapshot: [`unit_combat_stats`] plus the equipment flags and the skill pairs. fn combat_stats( store: &ObjectStore, objects: &Objects, items: &Items, commands: &NetCommands, ) -> UnitCombatStats { - // The offhand-speed gate is an offhand *weapon* (a shield doesn't swing); the wand check is - // the ranged item's subclass. Both read the equipped item's template (ask-once in flight β†’ - // false this frame, refined when it lands). + // The off hand counts only as a weapon (a shield does not swing); both flags read false + // until the template lands. let has_offhand = slot_entry(store, objects, SLOT_OFF_HAND) .and_then(|e| items.template(e, 0, commands)) .is_some_and(|t| t.class == ITEM_CLASS_WEAPON); @@ -617,18 +462,13 @@ fn combat_stats( has_offhand, has_wand, main_weapon_skill: skill_pair(store, main_skill), - // `UnitAttackBothHands`'s SECOND pair (decision 1810) β€” the binding pushes one per hand, - // and `weapon_skill_id` already answers Unarmed for an empty or non-weapon off hand, which - // is what a shield or an empty hand reads as. + // `UnitAttackBothHands`'s second pair; a shield or an empty off hand reads Unarmed. offhand_weapon_skill: skill_pair(store, offhand_skill), ranged_weapon_skill: ranged_skill_id.map_or((0, 0), |id| skill_pair(store, id)), - // `UnitDefense`'s pair. Its repaint wire is `SKILL_LINES_CHANGED` (which the ref's - // `PaperDollFrame` registers, l.28, and `watch_skill_ups` already fires), NOT a - // `UNIT_DEFENSE` β€” the character sheet never registers one. + // `UnitDefense`'s pair. The 1.12 player sheet has no Defense row: its `SKILL_LINES_CHANGED` + // arm (`PaperDollFrame.lua:70-72`) redraws only the Attack row. defense_skill: skill_pair(store, SKILL_DEFENSE), - // `GetDodgeChance`/`GetParryChance`/`GetBlockChance` β€” player fields, so they live on the - // player's snapshot and not on the shared core a pet also fills. Already a percent on the - // wire; an unstreamed field is 0, which is the wire's own default. + // `GetDodgeChance`/`GetParryChance`/`GetBlockChance`: player fields, already percentages. dodge_percent: store.0.player_dodge_percentage().unwrap_or(0.0), parry_percent: store.0.player_parry_percentage().unwrap_or(0.0), block_percent: store.0.player_block_percentage().unwrap_or(0.0), @@ -636,18 +476,9 @@ fn combat_stats( } } -/// One equipped slot's view: entry + count from the item object, icon/name/quality/link/ -/// equip_slots from the ask-once template (icon `None` while the answer is in flight β€” the -/// empty-slot art shows and the landing template flips the view, which fires -/// `UNIT_INVENTORY_CHANGED`), `locked` from the app's `PendingItemOps` (decision 0208 phase 1b β€” -/// the same feed `ui_items::feed_containers` reads for a bag slot's own `.locked`). -/// -/// Takes the item's **guid and its live-API id** rather than a wire slot index, because the two -/// bands that use it resolve their guid out of two different descriptor arrays: the doll's -/// `PLAYER_FIELD_INV_SLOT_*` (live id = wire slot + 1, so equipment 1..=19 and the four -/// equipped-bag icons 20..=23) and the bank bags' own `PLAYER_FIELD_BANK_BAG_SLOT_*` (live ids -/// 64..=69). Everything past the guid β€” template, enchants, durability, the pending lock β€” is -/// identical for both, which is the whole reason there is one function here and not two. +/// One slot's view from the item object and its ask-once template (no icon while in flight). +/// Keyed by guid and live id: the doll (`PLAYER_FIELD_INV_SLOT_*`, live id = slot + 1) and the +/// bank bags (`PLAYER_FIELD_BANK_BAG_SLOT_*`, 64..=69) read their guids from different arrays. fn slot_view( objects: &Objects, items: &Items, @@ -656,16 +487,13 @@ fn slot_view( commands: &NetCommands, pending: &PendingItemOps, names: &crate::names::NameCache, - // `ItemSubClass.dbc` β€” the count gate below reads its `DisplayFlags` bit 2. `None` (the DBC - // failed to load) leaves every bag at 0, which is the plain-bag answer and the safe one. + // `ItemSubClass.dbc`, for the count gate below; absent, every bag counts 0. sub_classes: Option<&crate::ui_items::ItemSubClasses>, - // The player's own `ChrClasses.dbc` relic flag β€” `find_equip_slot`'s second half (1803). It - // decides INVSLOT 17 both ways, so it reaches every fit-rule read rather than one of them. + // The player's `ChrClasses.dbc` relic flag, which decides `find_equip_slot`'s INVSLOT 17. has_relic_slot: bool, guid: u64, live_id: u32, ) -> Option { - // The item's own countdown cells β€” the bag feed's twin (decisions 1933, 2340). let countdowns = objects.countdowns(guid); let enchant_ms: [Option; crate::items::ENCHANT_SLOTS] = std::array::from_fn(|s| countdowns.and_then(|c| c.enchant_remaining_display_ms(s as u32))); @@ -673,36 +501,28 @@ fn slot_view( let obj = objects.object(guid)?; let entry = obj.object_entry()?; let count = obj.item_stack_count().unwrap_or(1).max(1); - // `OBJECT_FIELD_TYPE & TYPEMASK_CONTAINER` β€” the fork `GetInventoryItemCount` takes first - // (`0x4c87a6`), read off the object exactly as the reference reads it rather than inferred - // from the template. + // `OBJECT_FIELD_TYPE & TYPEMASK_CONTAINER`, read off the object as `GetInventoryItemCount`'s + // first fork reads it (`0x4c87a6`). let is_container = obj.is_container(); - // A worn container's contents, collected while the bag object is borrowed β€” summed below if - // its subclass row carries the count gate. let content_guids: Vec = { let slots = obj.container_num_slots().unwrap_or(0).min(36) as u8; (0..slots).filter_map(|s| obj.container_slot(s)).collect() }; - // The live durability pair (see the bag feed's twin) β€” the equipped tooltip's line. let durability = obj .item_durability() .zip(obj.item_max_durability()) .filter(|&(_, max)| max > 0); - // ITEM_FIELD_FLAGS β€” the broken/alert laws' wrapped (0x08) and force-red (0x10) bits. + // `ITEM_FIELD_FLAGS`, for its wrapped (0x08) and force-red (0x10) bits. let flags = obj.item_flags().unwrap_or(0); - // `0x5da2c0` β€” soulbound, or carrying a binding enchant: the equipped tooltip's Soulbound - // override (B310 β€” the doll is exactly where it was reported). Read off the raw descriptor, - // never off the enchant LINES below. + // Soulbound or carrying a binding enchant (`0x5da2c0`), read off the raw descriptor, never off + // the enchant lines below. let already_bound = crate::items::already_bound(obj, rolls.enchants); - // ITEM_FIELD_CREATOR β†’ the ask-once name cache β€” the equipped tooltip's "" - // line (see the bag feed's twin in `ui_items::feed::resolve_slot`). let creator = obj .item_creator() .filter(|&g| g != 0) .and_then(|g| names.resolve(g, commands).map(str::to_string)); - // The item's own 7 enchant slots β€” the equipped tooltip's enchant lines (decision 0915). OUR - // gear streams as item objects, so this is the full array; an INSPECTED player's tooltip sees - // only the 2 slots their descriptor broadcasts (`ui_inspect`), as the reference does. + // All seven enchant slots: our own gear streams as item objects, where an inspected player's + // descriptor carries only two. let enchants = crate::items::enchant_lines( (0..7).map(|s| { ( @@ -714,8 +534,8 @@ fn slot_view( }), rolls.enchants, ); - // `ITEM_FIELD_RANDOM_PROPERTIES_ID` β€” the roll behind the NAME's "of the Bear" (1547); the - // roll's enchants are already in the slots read just above. + // `ITEM_FIELD_RANDOM_PROPERTIES_ID`, the roll behind an "of the Bear" name; its enchants are + // already in the slots above. let roll = obj.item_random_properties_id(); let t = items.template(entry, guid, commands); let (name, quality, display, link, equip_slots, kind, bar_placeable) = match t { @@ -735,16 +555,9 @@ fn slot_view( } None => (None, 0, 0, None, Vec::new(), (0, 0), false), }; - // **A container's count is not its stack count** β€” `GetInventoryItemCount 0x4c8680` forks on - // the TYPEMASK bit and then on `ItemSubClass.dbc`'s `DisplayFlags & 0x4` (`0x4c881a`): set β†’ - // `[CGContainer vtbl+0x10]` β†’ `0x622130`, the sum of the contents' stack counts; clear β†’ 0. - // In the shipped file that bit is on Soul Bag (1/1) and the Quiver class (11/0..3) only, so a - // quiver shows its arrows on the bag bar and an ordinary bag shows nothing at all. - // - // This REPLACES a `class == 11` guess whose own comment admitted it was inferred from what the - // reference UI looked like, and which was wrong twice over: it missed the soul bag, and it - // answered a plain bag's stack count (1) where the client answers 0 β€” the digit the director - // saw on `BankFrameBag1` after 1751's swap put a real `isBag` button in front of it. + // A container counts its contents' stacks when its subclass's `DisplayFlags` has 0x4, else 0 + // (`GetInventoryItemCount` `0x4c8680`: `0x4c881a`, summed by `0x622130`); the bit is on Soul + // Bag (1/1) and the Quiver class (11/0..3) only, so a plain bag shows no count. let counts_contents = sub_classes.is_some_and(|c| c.0.display_flags(kind.0, kind.1) & 0x4 != 0); let contents_count = is_container.then(|| { if counts_contents { @@ -780,9 +593,8 @@ fn slot_view( }) } -/// Total carried ammo matching `ammo_id`: the backpack's 16 slots + the four equipped bags' -/// contents (`GetInventoryItemCount("player", ammo)` counts what you can actually shoot β€” bank -/// and buyback never join). +/// Carried ammo matching `ammo_id`, in the backpack and the four equipped bags; bank and buyback +/// never count. fn ammo_count(store: &ObjectStore, objects: &Objects, ammo_id: u32) -> u32 { let mut n = 0; let mut add = |guid: Option| { @@ -810,10 +622,8 @@ fn ammo_count(store: &ObjectStore, objects: &Objects, ammo_id: u32) -> u32 { n } -/// Build the 24-wide inventory snapshot: `[0]` ammo, `[1..=19]` the equipment slots, `[20..=23]` -/// the four equipped-bag icons (decision 0216 slice 2's bag bar β€” the bag ITEM occupying `INV_SLOT` -/// 19..22, not its contents) β€” all the client's 1-based `GetInventorySlotInfo` ids over the -/// 0-based inv-slot array. +/// The 24-wide inventory snapshot by `GetInventorySlotInfo` id: 0 ammo, 1..=19 the equipment, +/// 20..=23 the equipped bags themselves (inv slots 19..22). fn inventory_slots( store: &ObjectStore, objects: &Objects, @@ -826,8 +636,6 @@ fn inventory_slots( sub_classes: Option<&crate::ui_items::ItemSubClasses>, classes: Option<&benilla_formats::ChrClasses>, ) -> InventorySlots { - // Resolved once from the player's own class byte, right here β€” the store is already in scope, - // so nothing new is threaded through the callers for the class half. let has_relic_slot = store .0 .unit_class() @@ -851,37 +659,28 @@ fn inventory_slots( inv[0] = Some(InvSlotView { durability: None, flags: 0, - // Ammo is the one doll slot with no item OBJECT behind it (it is built from the - // template alone), so the runtime-bind predicate has nothing to read. + // Ammo has no item object, only its template, so there is no bind to read. already_bound: false, item_id: ammo_id, icon, count: ammo_count(store, objects, ammo_id), - // Ammo is not a container β€” slot 0 is `GetInventoryItemCount`'s own `PLAYER_AMMO_ID` - // leg, which never reaches the TYPEMASK fork. + // `GetInventoryItemCount`'s `PLAYER_AMMO_ID` leg, before the container fork. contents_count: None, quality, name, link, - // The ammo slot is a named deferral this slice (decision 0208 phase 1b): no click - // path wires it, so there's nothing for a pending op to lock and nowhere for it to - // fit β€” both stay the inert default. + // No click or drag path reaches the ammo slot: nothing locks it, it fits nowhere. locked: false, equip_slots: Vec::new(), - // Ammo is a named deferral too: no drag path reaches the bar from this slot. bar_placeable: false, creator: None, - // Ammo carries no enchant slots to read: the wire never streams an ammo instance - // here, only the template id off the player descriptor β€” and for the same reason it - // carries no expiry countdown either (there is no instance guid to key one on). + // No ammo instance streams, only its template id: no enchants, no countdown. enchants: Vec::new(), duration_ms: None, }); } - // Equipment 1..=19, then Bag0Slot..Bag3Slot (ids 20..23, PaperDollItemFrame.dbc-verified) β€” - // the bag bar's icon source: the equipped bag ITEM at INV_SLOT 19..22 (BAG_SLOT_FIRST../ - // SLOT_BAG_FIRST in the container feed's own numbering). One band: the doll's live id is its - // wire slot plus one across the whole range. + // Equipment 1..=19, then `Bag0Slot`..`Bag3Slot` (20..=23 in `PaperDollItemFrame.dbc`), the + // equipped bag items: the live id is the inv slot plus one across the band. for live in 1..=23u32 { let Some(guid) = store.0.player_inv_slot(live as u8 - 1).filter(|g| *g != 0) else { continue; @@ -903,14 +702,9 @@ fn inventory_slots( inv } -/// The six bank-bag slots' views (live ids 64..=69) β€” the same [`slot_view`] resolution as any -/// doll slot, off the descriptor's own `PLAYER_FIELD_BANK_BAG_SLOT_*` guids. -/// -/// These are inventory slots, not bank-*window* state: the reference's bank paints and picks up a -/// bank bag through `GetInventoryItemTexture`/`PickupBagFromSlot` at -/// `BankButtonIDToInvSlotID(i, 1)` (BankFrame.lua:28-35, 194-198), and the guids stream in the -/// player descriptor whether or not a banker is open. So they ride the inventory feed, beside the -/// doll snapshot, rather than the `BankState` the bank window pushes. +/// The six bank-bag slots' views (live ids 64..=69). They are inventory slots, streamed whether +/// or not a banker is open, and the stock bank reads them through `BankButtonIDToInvSlotID(i, 1)` +/// (`BankFrame.lua:28-35, 199-204`), so they ride this feed rather than the bank window's state. fn bank_bag_slots( store: &ObjectStore, objects: &Objects, @@ -936,9 +730,7 @@ fn bank_bag_slots( pending, names, sub_classes, - // A bank bag slot holds a BAG, and `find_equip_slot(INVTYPE_BAG)` answers the four - // bag slots whatever this says β€” the flag decides INVSLOT 17 alone, which no item in - // this band can reach. `false` here is provable, not a default. + // A bank bag slot holds a bag; the relic flag decides INVSLOT 17 alone. false, guid, BANK_BAG_LIVE_FIRST + i as u32, @@ -947,21 +739,13 @@ fn bank_bag_slots( bags } -/// `BankButtonIDToInvSlotID(1, isBag)` β€” the live-API id of the FIRST bank-bag slot. The band is -/// six wide (`BANK_BAG_SLOT_COUNT`). +/// `BankButtonIDToInvSlotID(1, isBag)`: the first of the six bank-bag slots' live ids. const BANK_BAG_LIVE_FIRST: u32 = 64; -/// Fire the paper doll's per-group repaint events for one unit's snapshot transition, `arg1 = -/// token` β€” the [`crate::ui_unit::fire_transitions`] shape, for the combat-stats surface. -/// -/// The groups ARE the ref's own event registrations (`PaperDollFrame.lua:14-28`, which -/// `PetPaperDollFrame.lua:12-20` repeats verbatim for the pet page), so each event fires exactly -/// when a value some handler of it reads has moved; `prev == None` (a first snapshot) counts as a -/// transition of every group, the `ui_unit` rule. -/// -/// Shared by both feeds rather than transcribed twice (decision 1057): the pet page calls the same -/// `PaperDollFrame_Set*` helpers off the same events, so two copies of this grouping could only -/// ever drift into one page repainting on an edge the other misses. +/// Fire the paper doll's repaint events for one unit's snapshot transition, `arg1 = token`: the +/// eight unit-stat events both pages register (`PaperDollFrame.lua:14-28`, +/// `PetPaperDollFrame.lua:12-20`), each when a value its handlers read moved. A first snapshot +/// fires them all. pub(crate) fn fire_stat_transitions( script: &mut UiScript, token: &str, @@ -1055,91 +839,63 @@ pub(crate) fn fire_stat_transitions( } pub(crate) fn feed_char( - // `ChrClasses.dbc` field 16, for the fit rule's relic half β€” `ui_items::find_equip_slot` - // (1803). Absent client data reads every class as an ordinary ranged wielder. + // `ChrClasses.dbc` field 16, the relic flag `find_equip_slot` reads; absent, no class has one. classes: Option>, script: Option>, - // The inventory read (2334): the self store, its change tick, the object lookup and the item - // entities' change watch. mut inv: crate::items::Inventory, items: Res, icons: Option>, - // `SpellItemEnchantment`'s name column β€” the equipped tooltip's enchant lines (decision 0915). enchants: Option>, - // `ItemRandomProperties` β€” the roll behind an equipped item's "of the Monkey" name (1547). props: Option>, commands: Res, mut feed: ResMut, mut booth: ResMut, pending: Res, names: Res, - // `ItemSubClass.dbc` β€” `GetInventoryItemCount`'s bag gate (`slot_view`'s own note). + // `ItemSubClass.dbc`, for `GetInventoryItemCount`'s container count bit. sub_classes: Option>, ) { let Some(mut script) = script else { return; }; - // The pane owns the yaw and the booth mirrors it (0208 Β§5). Read off the widget's own - // `SetRotation` state since decision 1751 put the reference's `PaperDollFrame.xml` on the - // chain: the stock file declares a real `` and drives - // it with `Model_OnLoad`/`Model_Rotate*`/`Model_OnUpdate`, where ours used to call a - // benilla-named `BenillaPaperDollModel_SetFacing` into a scalar on the VM. - // Write-if-different: an unconditional write would mark the booth changed every frame. + // The stock `CharacterModelFrame` owns the yaw (`Model_Rotate*` call `SetRotation`); the booth + // mirrors it, written only on change so it is not marked changed every frame. let yaw = script.model_pane_facing("CharacterModelFrame"); if booth.yaw != yaw { booth.yaw = yaw; } - // Resolved against THIS VM (1290/1291): a `/reload`'s replacement reads a fresh memo, so both - // snapshots below re-push and their events re-fire for it. + // Per VM: after a `/reload` both snapshots re-push and their events re-fire. let (memo, vm_reset) = feed.vm.get_reset(&script); let Some(store) = inv.self_store.iter().next() else { - // The self store's absence is NO DATA, never "the player has nothing equipped". The one - // window this branch runs with a previously-fed VM is the logout despawn frames β€” - // `SMSG_LOGOUT_COMPLETE` despawns the entity a full Update before `OnExit(InWorld)` runs - // the shutdown, and the `PLAYER_LOGOUT` handlers that follow still read equipment - // (`GetInventoryItemLink("player", …)` at logout is a stock addon save pattern; the - // reference keeps it valid through its shutdown). The clear this branch used to push was - // a pre-1290 relic: one VM then lived across logouts and the next character must not see - // this one's inventory β€” now the VM dies with the session and a fresh one starts empty - // ([`crate::ui_script::end_ui_session`]), so the clear only ever fired into a VM about to - // run its logout handlers. Same law as the container feed - // (`ui_items::feed::apply_container_source`). + // No self store is no data, not an empty inventory: it happens in the logout frames, where + // `PLAYER_LOGOUT` handlers still read equipment, so nothing is cleared (the next session + // starts a fresh VM). return; }; - // `GetWeaponEnchantInfo`'s whole data path (the buff bar's TemporaryEnchantFrame row, plus 8 - // corpus addons). Pushed EVERY frame and change-gated by nothing β€” including the 1439 gate - // below: the remaining time is a live countdown, so riding a snapshot diff would fire - // `UNIT_INVENTORY_CHANGED` on every tick. The reference recomputes it per call for exactly - // the same reason (`0x5d9d00`). Hoisted above the gate (it used to sit last): the push is - // order-free of the two snapshots, and a handler they fire reads the fresher value this way. - // Bound before the snapshot build below shadows `inv` with the pushed slot array. + // `GetWeaponEnchantInfo`'s data, pushed every frame outside the gate: the remaining time is a + // live countdown, which the reference also recomputes per call (`0x5d9d00`). Pushed first, so + // handlers the snapshots fire read the fresh value. let objects = &inv.objects; script.set_weapon_enchants( weapon_enchant(store, objects, EQUIPMENT_SLOT_MAINHAND), weapon_enchant(store, objects, EQUIPMENT_SLOT_OFFHAND), ); - // The gate (1439), around the two snapshot builds only: the self descriptor (equipment - // slots, every stat field), the item stores behind the equipped templates and enchant - // names, the two catalogs, and the pending-op locks (held open while in flight; the - // resolving frame's own field update moves the object epoch). + // The gate, around the two snapshot builds only. let objects_moved = inv.changes.moved(); let templates_moved = memo.items_templates.moved(items.template_epoch()); let names_moved = memo.names_generation.moved(names.generation()); - // The DISPLAY epoch, not a per-frame hold-open: the snapshot reads second-floored countdowns - // (`enchant_remaining_display_ms`), so it can only differ when this counter steps β€” once a - // second per ticking enchant, plus one final step at the elapse. Holding the gate open on - // `live_enchant_deadlines() > 0` (the first 1439 shape) rebuilt both snapshots and fired - // `UNIT_INVENTORY_CHANGED` at frame rate for the whole life of a poison. + // The display epoch, not a per-frame hold-open: the views read whole-second countdowns, so + // they can change only when it steps. let deadlines_moved = memo.enchant_deadlines.moved(inv.changes.countdown_steps()); let self_changed = !inv.self_changed.is_empty(); - // `is_added` for the icon catalog: the feeds read only its load-once icon column; - // its model-cache half churns every frame (the containers gate's own note). + // `is_added`, not `is_changed`: only the load-once icon column is read, and the model-cache + // half churns every frame. let icons_added = icons.as_ref().is_some_and(|r| r.is_added()); - // Both DBC catalogs load once, at startup β€” one input covers the pair. + // Both catalogs load once at startup; one input covers the pair. let enchants_changed = enchants.as_ref().is_some_and(|r| r.is_changed()) || props.as_ref().is_some_and(|r| r.is_changed()); let pending_held = !pending.is_empty(); @@ -1178,9 +934,7 @@ pub(crate) fn feed_char( let stats = combat_stats(store, objects, &items, &commands); if memo.last_stats.as_ref() != Some(&stats) { gate.audit("feed_char", "the combat-stats snapshot"); - // PUSH before firing: event dispatch runs the Lua handlers synchronously, so the snapshot - // must already be in the VM when they repaint (the ui_unit rule β€” a fire-first ordering - // paints the OLD values and, being transition-gated, never corrects itself). + // Push before firing: handlers run synchronously and would paint the old values. let prev = memo.last_stats.take(); script.set_player_combat_stats(Some(stats.clone())); fire_stat_transitions(&mut script, "player", prev.as_ref(), &stats); @@ -1216,14 +970,10 @@ pub(crate) fn feed_char( &names, sub_classes.as_deref(), ); - // One transition for both bands: they are the same descriptor read at two offsets, and - // `UNIT_INVENTORY_CHANGED` is the one repaint signal either has. Pushing them separately - // would fire it twice for a single wire update. - // `PLAYERBANKSLOTS_CHANGED`'s two producers, planned off the OLD memo before the push - // replaces it. `false` = P1, the player-descriptor path (`0x5ddd6e`, no arguments β€” a bag - // arriving, leaving or exchanged); `true` = P2, the item-object path (`0x4c728d`, - // `arg1 = "player"` β€” the same bag, its own fields changed). The full account and why the guid - // is the only sound discriminator are at `ui_items::feed`'s vault twin; decision 2140. + // Both bands push under one `UNIT_INVENTORY_CHANGED`: one wire update, one event. + // `PLAYERBANKSLOTS_CHANGED`'s two producers, planned off the old memo: `false` is the + // descriptor watcher (`0x5ddd6e`: a bag arrived, left or was swapped), `true` the item-object + // path (`0x4c728d`: the same bag's own fields changed). let bank_bag_guids: [u64; BANK_BAG_SLOT_COUNT] = std::array::from_fn(|i| store.0.player_bank_bag_slot(i as u8).unwrap_or(0)); let repainted: Vec = (0..BANK_BAG_SLOT_COUNT) @@ -1245,29 +995,14 @@ pub(crate) fn feed_char( "UNIT_INVENTORY_CHANGED", vec![ScriptValue::Str("player".to_string())], ); - // The six bank BAG buttons' only repaint signal (1771). `BankItemButtonTemplate`'s - // `` is `CursorOnUpdate()` β€” despite its name, `BankFrameItemButton_OnUpdate` - // (the icon/count/lock repaint) is reached ONLY from `BankFrameItemButton_OnEvent`, and - // only for `PLAYERBANKSLOTS_CHANGED` and `BANKFRAME_OPENED`. Without this the buttons - // showed whatever was true at the last unrelated vault-slot event: a bag dropped in never - // appeared, one moved between slots stayed drawn in its old one, and a lock left over from - // the drop stood greyed until the window was reopened. - // - // The VAULT band's copy of this event is `ui_items::feed_containers`' β€” each feed - // announces the band whose data it owns, and `feed_char` is ordered first, so both fire - // after their own push. - // - // **Which arguments it carries depends on WHICH producer fired** (folded back in 2140). - // The descriptor watcher `0x5ddd6e` calls `FrameScript_SignalEvent 0x703e50`, an - // `__fastcall(ecx = id)` with a plain `ret` and no vararg push β€” zero Lua values; the - // item-object notifier `0x4c728d` goes through `SignalEvent2` and pushes the literal - // string `"player"`. benilla fired the argless one for both until 2140. An event is fired - // per changed slot, as the watcher does β€” the slot travels in *how many* times it fires, - // never in an argument (1776). + // The bank bag buttons repaint only on this and `BANKFRAME_OPENED` + // (`BankFrame.lua:206-208`). One event per changed slot: the descriptor watcher signals + // through `0x703e50` with no arguments, the item path through `SignalEvent2` with + // `"player"`. memo.last_inv = Some(inv); memo.last_bank_bags = Some(bank_bags); } - // Outside the push block, because the identical-bag swap it exists for moves no view at all. + // Outside the push block: an identical-bag swap moves no view at all. if !repainted.is_empty() { gate.audit("feed_char", "a bank bag slot transition"); } @@ -1281,8 +1016,7 @@ pub(crate) fn feed_char( } } -/// Whether two bank-bag views hold the same ITEM β€” everything but `locked`, which is -/// `ITEM_LOCK_CHANGED`'s business and must not masquerade as a slot change. +/// Whether two bank-bag views hold the same item; `locked` is `ITEM_LOCK_CHANGED`'s business. fn same_item(a: Option<&InvSlotView>, b: Option<&InvSlotView>) -> bool { match (a, b) { (None, None) => true, @@ -1297,32 +1031,25 @@ fn same_item(a: Option<&InvSlotView>, b: Option<&InvSlotView>) -> bool { } } -/// The client's own 0-based `EQUIPMENT_SLOT_*` ids for the two weapon slots β€” the numbers -/// `GetWeaponEnchantInfo 0x4c9790` hands its container lookup (`push 0xf` at `0x4c97c3`, -/// `push 0x10` at `0x4c988b`). The live-API `GetInventorySlotInfo` ids the *frames* use are one -/// higher (16 `MainHandSlot`, 17 `SecondaryHandSlot`). +/// The 0-based weapon slots `GetWeaponEnchantInfo` (`0x4c9790`) reads (`0x4c97c3`, `0x4c988b`); +/// the frames' live ids are one higher. const EQUIPMENT_SLOT_MAINHAND: u8 = 15; const EQUIPMENT_SLOT_OFFHAND: u8 = 16; -/// `ITEM_FIELD_ENCHANTMENT`'s temporary slot (0 = permanent, 1 = temporary, 2..6 random property) -/// β€” the slot index the reference passes to its remaining-time reader (`push 0x1`, `0x4c9828`). +/// The temporary enchantment slot (0 permanent, 2..6 random properties), as the reference's +/// remaining-time read passes it (`0x4c9828`). const TEMP_ENCHANTMENT_SLOT: u8 = 1; -/// One weapon's temporary enchantment for [`benilla_ui::script::WeaponEnchant`], read off the -/// equipped item object. -/// -/// **The RAW enchantment triple, not [`InvSlotView::enchants`]**: that view is tooltip-shaped and -/// drops both an id the `SpellItemEnchantment` catalog cannot name and the whole `Flags & 0x2` -/// print-no-line family (decision 0928 β€” the totem weapon imbues), which is exactly the set the -/// enchant row exists to show. The binary's gate is `[descriptor+0x4c] != 0`, the id and nothing -/// else, and `item_enchant` already answers `None` for a zero id. +/// One weapon's temporary enchantment, read off the raw descriptor rather than +/// [`InvSlotView::enchants`], which drops unnamed ids and the totem imbues; the reference's only +/// gate is a nonzero id (`[descriptor+0x4c]`). fn weapon_enchant( store: &ObjectStore, objects: &Objects, slot0: u8, ) -> Option { let guid = store.0.player_inv_slot(slot0)?; - // The *deadline* read, not the tooltip's: an elapsed timer must answer 0, not "no timer". + // The deadline read, not the tooltip's: an elapsed timer answers 0, not "no timer". let remaining_ms = objects .countdowns(guid) .and_then(|c| c.enchant_deadline_ms(u32::from(TEMP_ENCHANTMENT_SLOT))); @@ -1340,24 +1067,14 @@ mod tests { use benilla_protocol::messages::ObjectFields; use bevy::ecs::system::RunSystemOnce; - /// **The skill pair splits `(value + PERM, TEMP)`, not `(value, temp + perm)`** β€” the reader - /// every skill-shaped binding goes through (`0x5ea460`). - /// - /// It reported the right TOTAL and the wrong halves until decision 1812, which is exactly why - /// nothing caught it: `UnitAttackBothHands`'s caller prints the base and colours `base + mod`, - /// so both shapes agree on the number a player adds up and disagree on the two they read. The - /// fixture below is the one the record quotes β€” 300 / +5 talent / +10 aura β€” where the - /// reference shows **305** with a green **+10** and ours showed 300 with a green +15. - /// - /// The zero-value leg is the other half of the law (`0x5ea4b4 jle`): an unknown line must stay - /// a flat 0 rather than reading back a bare talent bonus. + /// The reference's split (`0x5ea460`); a value-0 line skips the permanent add (`0x5ea4b4`). #[test] fn a_skill_pair_folds_the_permanent_bonus_into_the_base() { - /// `PLAYER_SKILL_INFO_1_1` β€” id|step, then value|max, then temp|perm, per slot. + /// `PLAYER_SKILL_INFO_1_1`: id|step, value|max, temp|perm per slot. const F_SKILL: u16 = 718; let pair = |lo: u16, hi: u16| u32::from(lo) | (u32::from(hi) << 16); // Slot 0: Defense (95) at 300, +10 temporary, +5 permanent. - // Slot 1: Swords (43) at 0 β€” known id, no value, a +7 permanent that must NOT surface. + // Slot 1: Swords (43) at 0, with a +7 permanent that must not surface. let store = ObjectStore(ObjectFields::from_pairs(&[ (F_SKILL, pair(95, 0)), (F_SKILL + 1, pair(300, 300)), @@ -1390,19 +1107,17 @@ mod tests { assert_eq!(skill_pair(&cursed, 95), (-5, 0)); } - /// `PLAYER_FIELD_BANK_BAG_SLOT_1` β€” bank bag slot 0's guid pair (protocol test `bank.rs`). + /// `PLAYER_FIELD_BANK_BAG_SLOT_1`, bank bag slot 0's guid pair. const F_BANK_BAG_1: u16 = 612; /// `OBJECT_FIELD_ENTRY` on the item object. const F_OBJECT_ENTRY: u16 = 3; - /// `ITEM_FIELD_STACK_COUNT` β€” one of the six item fields the reference's own watcher covers. + /// `ITEM_FIELD_STACK_COUNT`, one of the six item fields the reference's watcher covers. const F_ITEM_STACK_COUNT: u16 = 14; - /// "Traveler's Backpack" β€” any container entry; the feed only needs it to resolve. + /// Any container entry ("Traveler's Backpack"); the feed only needs it to resolve. const BAG_ENTRY: u32 = 4500; const BAG: u64 = 0x4000_0000_0000_0abc; - /// Re-stream one live item object's fields β€” the wire's values delta, which is what moves - /// the item entity's own change tick (2334); the create-time spawn is - /// [`crate::items::test_spawn_item`]. + /// Re-stream a live item object's fields as a values update does, moving its change tick. fn restream(app: &mut App, guid: u64, fields: ObjectFields) { let entity = app .world() @@ -1414,8 +1129,7 @@ mod tests { *app.world_mut().get_mut::(entity).unwrap() = ObjectStore(fields); } - /// A world with the player carrying `bag` (or nothing) in the FIRST bank bag slot, and a - /// recorder frame registered for the two events the reference's bank bag buttons repaint on. + /// A world with `bag`, or nothing, in the first bank bag slot, and a frame recording events. fn world_with_bank_bag(bag: Option) -> App { let (tx, _rx) = crossbeam_channel::unbounded(); let mut app = App::new(); @@ -1435,7 +1149,7 @@ mod tests { }; app.world_mut().spawn((SelfPlayer, ObjectStore(fields))); if let Some(g) = bag { - // The bag is an object in the one index (2334), its template still on `Items`. + // The bag is an item entity; its template lives on `Items`. crate::items::test_spawn_item( app.world_mut(), g, @@ -1472,12 +1186,6 @@ mod tests { out } - /// **A bank bag slot's only repaint signal** (1771). `PLAYERBANKSLOTS_CHANGED` is the one - /// event that reaches `BankFrameItemButton_OnUpdate` for the six bag buttons β€” - /// `UNIT_INVENTORY_CHANGED`, which this feed already fired, is not registered by them at all, - /// and `ITEM_LOCK_CHANGED` repaints the desaturation without ever touching the icon. Without - /// this fire a bag dropped into the bank never appeared, and one moved between slots stayed - /// drawn where it had been. #[test] fn a_bank_bag_slot_filling_fires_playerbankslots_changed() { let mut app = world_with_bank_bag(Some(BAG)); @@ -1494,18 +1202,14 @@ mod tests { ); } - /// **The second producer** (decision 2140). The same bag, its own `ITEM_FIELD_STACK_COUNT` - /// moving, is not the descriptor path β€” it is `0x4c7180`'s item-object path, which goes - /// through `SignalEvent2` and pushes the unit token `"player"`. benilla fired the argless - /// descriptor shape for both. + /// A bag's own field moving is the item-object path (`0x4c7180`), which pushes `"player"`. #[test] fn a_bank_bags_own_field_moving_fires_the_item_object_producer() { let mut app = world_with_bank_bag(Some(BAG)); app.world_mut().run_system_once(feed_char).unwrap(); assert!(seen(&mut app).contains(&"PLAYERBANKSLOTS_CHANGED nil".to_string())); - // The SAME bag guid, restacked. Only the item object moved β€” a values update on the - // live item entity, which is what the wire's own delta is (2334). + // The same bag guid, restacked: only the item object moved. restream( &mut app, BAG, @@ -1519,17 +1223,13 @@ mod tests { ); } - /// Two IDENTICAL bags exchanged between two bank bag slots β€” the case the pushed view cannot - /// see, because two instances of one bag model push the same `InvSlotView`. The reference - /// watches the descriptor GUIDs, so it fires the argless event twice; benilla, diffing the - /// view, fired nothing at all. Same shape as 1777's `BAG_CLOSED`, one band over. + /// Identical bags push identical views; the reference watches the guids and fires twice. #[test] fn two_identical_bags_swapped_between_bank_bag_slots_still_announce() { let mut app = world_with_bank_bag(Some(BAG)); // A second, byte-identical bag in bank bag slot 2. const BAG2: u64 = 0x4000_0000_0000_0abd; - // The two bank bag slots' guids, rewritten wholesale β€” `ObjectFields` has no setter, and - // a fresh store is what a descriptor update produces anyway. + // Rewritten wholesale: `ObjectFields` has no setter. let set_slots = |app: &mut App, a: u64, b: u64| { let mut q = app .world_mut() @@ -1566,10 +1266,7 @@ mod tests { ); } - /// The other half of the same rule: a LOCK flip is not a slot change. The reference has a - /// separate event for it (`ITEM_LOCK_CHANGED`, fired by `ui_items::feed_containers` off - /// `LockTransitions`), and firing the repaint event for it too would announce a change to the - /// item that did not happen. + /// A lock flip is `ITEM_LOCK_CHANGED`'s, not a slot change. #[test] fn a_lock_flip_alone_does_not_fire_the_repaint_event() { let mut app = world_with_bank_bag(Some(BAG)); @@ -1597,10 +1294,8 @@ mod tests { ); } - /// The gate hole behind the stuck grey: the frame an op's lock CLEARS, `!pending.is_empty()` - /// reads false and every "is anything in flight?" input closes β€” so a feed that had pushed - /// `locked: true` must have some other reason to run, or the stale lock stands forever. - /// [`PendingItemOps::epoch`] is that reason. + /// The frame a lock clears, `!pending.is_empty()` already reads false; + /// [`PendingItemOps::epoch`] is what still runs the feed. #[test] fn the_frame_a_lock_clears_still_repushes_the_slot() { let mut app = world_with_bank_bag(Some(BAG)); @@ -1620,8 +1315,7 @@ mod tests { "in flight, the slot reads locked" ); - // The server answers with a refusal: the lock clears without any object moving, which is - // exactly the case `!is_empty()` cannot see. + // A refusal: the lock clears without any object moving. let cleared = app .world_mut() .resource_mut::() diff --git a/crates/benilla-app/src/ui_chat.rs b/crates/benilla-app/src/ui_chat.rs index ab1456a0..f4a1cfae 100644 --- a/crates/benilla-app/src/ui_chat.rs +++ b/crates/benilla-app/src/ui_chat.rs @@ -1,12 +1,7 @@ -//! The chat system (decision 0288), the reference's own three-stage shape: [`event`] is the -//! internal currency (`CHAT_MSG_*` typed β€” kinds, groups, the shipped color table); [`feed`] turns -//! every source (wire lines, channel notices, rolls, client-composed loot/system lines) into -//! events with names resolved ask-once; [`frames`] is the router + the `ChatFrame_OnEvent` -//! composer fanning lines across the docked windows; [`input`] is the outbound side (the chat -//! EditBox, the slash grammar, the send-side emote posture gate) over [`commands`]'s table of every -//! `/command` the client answers (decision 0881, built from the reference's own alias strings). -//! This face wires them into [`UiChatPlugin`] and re-exports the crate-facing API (`ChatLog`, -//! `ChatEvent`, `ChatEventKind`). +//! The chat system: [`event`] is the internal `CHAT_MSG_*` currency, [`feed`] turns every source +//! into events with names resolved once, [`frames`] routes them through the `ChatFrame_OnEvent` +//! composer to the docked windows, and [`input`] is the outbound side over [`commands`]' table of +//! every `/command`, built from the reference's own alias strings. use bevy::prelude::*; @@ -14,62 +9,51 @@ use crate::ui_script::{UiFeed, UiInput}; #[cfg(test)] mod ace_gate_tests; -/// `/afk` and `/dnd` β€” the two commands' asymmetric law, the optimistic AFK mirror `[0xb6e5cc]`, -/// and the implicit clear every other chat send and every movement press carries (2088). +/// `/afk` and `/dnd`: the optimistic AFK mirror `[0xb6e5cc]` and the implicit clear that every +/// other chat send and every movement press carries. mod away; -/// The world broadcasts (`SMSG_ZONE_UNDER_ATTACK`/`_DEFENSE_MESSAGE`/`_SERVER_MESSAGE`) β€” the -/// AreaTable/ServerMessages resolve and the joined-defense-channel walk they land on. +/// The world broadcasts: `SMSG_ZONE_UNDER_ATTACK`/`_DEFENSE_MESSAGE`/`_SERVER_MESSAGE`. 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. +/// The combat log's chat lines: classification, chat type and each sentence's GlobalString key. pub(crate) mod combat; pub(crate) mod commands; -/// The chat edit box's send path β€” `SendType`, the chat-type TOKEN an addon or the stock -/// `SlashCmdList` passes to `SendChatMessage`, and its map to a wire kind. `pub(crate)` because -/// that tokenβ†’wire seam is what `ui_script::chat_tests` spans: the stock file emitting the token -/// and this module resolving it are two halves that each pass alone while the join is cut (2082). +/// The edit box's send path: the chat-type token passed to `SendChatMessage` and its wire kind. +/// `pub(crate)` for `ui_script::chat_tests`, which joins the stock file's token to this resolve. pub(crate) mod edit; mod event; mod feed; mod frames; -/// The idle handler β€” the 5-minute auto-sit / auto-AFK and the 30-minute camp. +/// The idle handler: the 5-minute auto-sit and auto-AFK, and the 30-minute camp. pub(crate) mod idle; mod input; -/// The language gate β€” the exemptions and the fluency lookup behind the chat garble (B262). +/// The language gate: the exemptions and fluency lookup behind the chat garble. mod language; -/// `LoggingChat`/`LoggingCombat` β€” the two log files `/chatlog` and `/combatlog` toggle. +/// `LoggingChat`/`LoggingCombat`: the two log files `/chatlog` and `/combatlog` toggle. mod logging; mod net; -/// The `AUTO_JOIN_GUILD_CHANNEL` cascade (decision 2144) β€” the one place the client joins or -/// leaves `GuildRecruitment - City` on its own. +/// The `AUTO_JOIN_GUILD_CHANNEL` cascade, the one place the client joins or leaves +/// `GuildRecruitment - City` on its own. mod recruitment; -/// The chat windows' saved look (B246, decision 1589) β€” where the tab menu's tint/alpha/font-size -/// picks are read from at login and written back at logout. +/// The chat windows' saved look: tab tint, alpha and font size, read at login, saved at logout. pub(crate) mod settings; #[cfg(test)] mod tests; pub(crate) use away::AfkMirror; pub(crate) use broadcast::Broadcast; -/// The zone-channel catalog's seed. Called from the world-entry UI load, like the restore below -/// and for the neighbouring reason: the verbs that read it are read at addon file scope, and an -/// `Update` push lands after the whole burst β€” decision 2241. +/// The zone-channel catalog's seed, called from the world-entry UI load: addons read the catalog +/// at file scope, before an `Update` push would land. pub(crate) use channels::seed_zone_channel_catalog; -/// 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". +/// The joined-channel roster and the `ChatChannels.dbc` catalog. pub(crate) use edit::ChannelState; -/// Test-only: `ui_script::chat_tests` checks every name we fire against the live `ChatTypeInfo` -/// table, which lives on that side of the tree. The app itself calls it through `event::` β€” the -/// router is the only production caller and it is inside this module. +/// Test-only: `ui_script::chat_tests` checks every fired name against the live `ChatTypeInfo`. #[cfg(test)] pub(crate) use event::event_name; pub(crate) use event::{default_color, ChatEvent, ChatEventKind}; pub(crate) use feed::ChatLog; -/// The per-character chat cache's restore. Called from the world-entry UI load rather than from a -/// system, because the two events it fires have to precede the session's first chat line and -/// `PLAYER_LOGIN` β€” see [`settings::restore_chat_looks`] and decision 2119. +/// The per-character chat cache's restore, called from the world-entry UI load: its two events +/// must precede the session's first chat line and `PLAYER_LOGIN`. pub(crate) use settings::restore_chat_looks; pub(crate) struct UiChatPlugin; @@ -88,23 +72,11 @@ impl Plugin for UiChatPlugin { .init_resource::() .init_resource::() .init_resource::() - // `CombatLogPeriodicSpells`' knob, beside its sibling range set β€” both are what - // `combat::on_cvar` writes, so a missing one is not a dormant default but a - // PANIC on the first write of either row (2303; before that, at startup). - // 947ba585f registered it only in the `cvar_app()` test helper, and the client - // stopped booting; the unit suites never noticed because each builds its own - // world. `scripts/smoke.sh` is the gate that sees this class. + // `combat::on_cvar` writes this and the range set: a missing one panics on the first + // write, which only `scripts/smoke.sh` sees (the unit suites build their own worlds). .init_resource::() - // `ChatChannels.dbc` β€” six rows, read once; the auto-join walk and every chat event's - // arg7 both come out of it. **`.after(AssetSet::Open)` is load-bearing**: without it - // this runs before the patch chain exists, takes its `assets: Option>` `None` - // arm, and silently loads nothing β€” no zone channels, ever, with no error. A live - // probe is what caught that; no unit test could, because the tests hand the catalog in. - // `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. + // These DBC loads must follow `AssetSet::Open`: before it there is no chain, and each + // silently loads nothing. .add_systems( Startup, ( @@ -114,15 +86,9 @@ impl Plugin for UiChatPlugin { ) .after(benilla_assets::AssetSet::Open), ) - // The slash-command table (decision 0881), built from the reference's own alias strings - // once the VM's globals and the emote catalog exist β€” both are `Startup`, so this runs - // at the next schedule rather than chasing an ordering constraint into two modules. + // The slash-command table needs the VM's globals and the emote catalog, both `Startup`. .add_systems(PostStartup, commands::build_slash_commands) - // Push before the input pass so a line is on screen the same frame it decodes (mirrors - // the loot/merchant feeds). - // The language gate's two feeds, both upstream of the chat drain that reads them: the - // word pool loads once (it retries until the chain is up), and the fluency map - // rebuilds off the spell book + the self descriptors. + // The language gate's feeds, ahead of the chat drain that reads them. .add_systems( Update, ( @@ -132,10 +98,7 @@ impl Plugin for UiChatPlugin { ) .before(feed::feed_chat), ) - // The three combat-log families that are NOT packet-driven (1703): the death reflex, - // the aura arrival/departure/stack callbacks, and the pet-loyalty byte. Each is a - // descriptor diff, so each runs before the drain that would otherwise show its line a - // frame late. + // Descriptor-diff combat-log lines, ahead of the drain so none lands a frame late. .add_systems( Update, ( @@ -145,49 +108,19 @@ 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. + // Before the drain, so a broadcast lands on the frame it decodes. .add_systems( Update, broadcast::feed_broadcasts .in_set(crate::ui_script::UiFeed) .before(feed::feed_chat), ) - // **Never against the boot VM** (B376's second half). `feed_chat` takes the whole - // queue with `mem::take` and fires each line as a real `CHAT_MSG_*` β€” so a drain - // against a VM with no ChatFrame does not defer the lines, it DESTROYS them, with no - // memo and no error. The window is the one frame `not(ingame_ui_pending)` cannot see - // (`ui_script::ingame_ui_up`): `apply_net_updates` drains `Connected` and the login - // burst behind it in one `try_iter` while the state β€” and with it 1978's park β€” is - // still a frame away. Measured: one login in six drained the burst there and ate a - // line of the realm's own welcome. The early return above `mem::take` is what holds - // the queue for the frames after it, and this is what holds it for that one. - // - // **…and after the world-enter cascade, because that is the reference's own order** - // (decision 2221). The real client does not print - // login chat when it arrives either: `[0x8435fc]` is a latch that ships statically - // `1`, so every `SMSG_MESSAGECHAT` in the login burst is queued into - // `__AUPENDINGCHAT__` (`0x49db5c`/`0x49db62` β†’ `0x49cae0`) instead of displayed. It is - // cleared in exactly one place β€” `0x490974`, INSIDE the world-enter cascade, after - // `PLAYER_LOGIN` (`0x490959`) and `PLAYER_ENTERING_WORLD` (`0x49096a`) β€” and the same - // call drains the queue. `SMSG_GUILD_EVENT` 0x02 has no such latch: it fires - // `GUILD_MOTD` synchronously in its own handler (`0x5e7288`). So the reference paints - // the guild line FIRST and the realm's welcome lines after it, inverting the wire - // order. - // - // Our held `ChatLog` queue IS that latch: the early return above `mem::take` is what - // keeps the login burst waiting. This says when it drains. Without these two edges - // the order was whatever the scheduler picked between two systems that declared - // nothing about each other β€” right today, by luck, exactly as the drain above was - // safe by luck until it wasn't. - // - // The edges are on the two feeds that fire the cascade's events, because that is - // where `0x490974` sits β€” DOWNSTREAM of both `0x703e50` calls, not merely inside the - // cascade. `UnitFeed` carries `PLAYER_ENTERING_WORLD`, `GuildFeed` carries - // `GUILD_MOTD`. (The reference's drain is one contiguous walk β€” `0x49d230`, every - // entry through the live path's own composer `0x49a870` β€” so the welcome lines land - // as a block, after those events and before the rest of the cascade.) + // Only against the in-game VM: the drain takes the whole queue and fires every line, + // so a VM with no ChatFrame destroys them. After the unit and guild feeds, as in the + // reference: login-burst chat is queued (`0x49cae0`) behind the `[0x8435fc]` latch and + // drains at `0x490974`, after `PLAYER_LOGIN` (`0x490959`) and `PLAYER_ENTERING_WORLD` + // (`0x49096a`), while `GUILD_MOTD` fires on arrival (`0x5e7288`), so the guild line + // precedes the welcome. .add_systems( Update, feed::feed_chat @@ -196,69 +129,41 @@ impl Plugin for UiChatPlugin { .after(crate::ui_guild::GuildFeed) .run_if(crate::ui_script::ingame_ui_up), ) - // The last-input stamp `[0xcf0bc8]`. Deliberately NOT in-world-gated and - // deliberately ahead of the UI pass: the reference stores it on the raw input bus, - // before dispatch, so a keystroke the chat box swallows still counts as input. + // The last-input stamp `[0xcf0bc8]`, ungated and ahead of the UI pass: the reference + // stamps raw input before dispatch, so a key the chat box swallows still counts. .add_systems(Update, idle::stamp_input.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. + // A fresh VM's joined-channel mirror, before the feed so its first line is numbered. .add_systems( Update, channels::seed_channels .in_set(crate::ui_script::UiFeed) .before(feed::feed_chat), ) - // RequestTimePlayed() -> CMSG_PLAYED_TIME, and SMSG_PLAYED_TIME -> TIME_PLAYED_MSG. - // Beside the chat feed because /played is a chat command and the answer prints there - // too; before the input pass for the same reason feed_chat is. + // `RequestTimePlayed()` -> `CMSG_PLAYED_TIME`; `SMSG_PLAYED_TIME` -> `TIME_PLAYED_MSG`. .add_systems(Update, feed::played_time_bridge.in_set(UiFeed)) - // The input: open on ENTER (after the UI input pass has set UiKeyboardCapture, so we - // don't reopen the box that's already eating keys), then drain any submitted line. Both - // touch the single NonSend VM, so they chain. In-world only (decision 0193): at the - // character-select glue screen ENTER must not open a chat box behind the overlay. .add_systems( Update, ( - // The AFK mirror's reconcile runs FIRST in the chain, so a `/afk` typed this - // frame reads the descriptor's settled state rather than racing it (2088). + // First, so a `/afk` typed this frame reads the descriptor's settled state. away::reconcile_afk_mirror, - // …and the four movement clears, which share the CVar and the mirror. away::movement_clears_afk, - // The idle handler (2092) β€” one timer, three legs. After the clears, so a - // press that both stamps the clock and drops the flag is settled before the - // timer that would otherwise re-raise it is read. + // After the clears, so a press that stamps the clock and drops the flag is + // settled before the idle timer reads it. idle::idle_handler, input::drain_chat_input, - // An addon's own line into the wire (decision 1199). AFTER the box's drain - // and in the same chain, so a `SendChatMessage` fired from a slash handler - // that the box's drain just ran goes out on the same frame. + // After the box's drain, so a `SendChatMessage` or `SendAddonMessage` from a + // slash handler it just ran goes out this frame. input::drain_addon_chat_sends, - // …and its addon-lane twin (decision 1235). Same position in the chain, for - // the same reason: a `SendAddonMessage` fired from a handler that ran earlier - // in this chain goes out on this frame rather than the next. input::drain_addon_message_sends, ) .chain() .after(UiInput) .in_set(crate::char_select::InWorldGated), ) - // The zone-channel auto-join (0288 P6): the client half of a handshake vmangos - // deliberately leaves to us. In-world only, and it early-outs on an unchanged zone. - // - // Both session-end edges clear its state (1284): leaving the world at all, and a socket - // drop that stays in-world for the reconnect. The disconnect twin is chained BEFORE the - // walk so a drop and a walk landing on the same frame cannot re-diff against membership - // the drop just invalidated. - // - // **After `AreaAuthoritySet`** (decision 2130), like the zone-text feed and the breath - // classifier β€” the other two systems that act on the leaf area. The walk turns the zone - // into packets, so reading last frame's answer is not a cosmetic lag: it joined the - // previous character's capital at login and then left it again, and the leave took the - // stock `ChatFrame_OnEvent`'s channel registration with it. - // - // The guild-recruitment cascade (decision 2144) runs **after the walk**, where the - // reference runs it β€” the tail of `ZoneChannelRefresh` β€” and reads the zone the walk - // just published. + // The zone-channel auto-join, which vmangos leaves to the client. The disconnect clear + // runs first, so a same-frame drop cannot leave the walk diffing stale membership. + // After `AreaAuthoritySet`, since a stale area joins and leaves the wrong channels. The + // guild-recruitment cascade follows the walk: it is the tail of `ZoneChannelRefresh`. .add_systems( Update, ( @@ -273,32 +178,17 @@ impl Plugin for UiChatPlugin { OnExit(crate::char_select::ClientState::InWorld), (channels::end_session_channels, end_session_chat), ); - // The per-character saved look (B246) β€” its own load/watch/save edges. settings::plugin(app); logging::plugin(app); } } -/// **The chat module's session end** β€” what the reference gets for free by destroying its Lua -/// state, and we have to do by hand until that teardown lands (1288). +/// The chat session end: the undrained feed, which outlives the Lua state, and both windows' +/// lines. The Lua state is itself replaced at the session end, as the reference destroys its own +/// at logout. /// -/// `shutdown_ui_state`'s own doc records the reference's logout tail: `PLAYER_LEAVING_WORLD` β†’ -/// `PLAYER_LOGOUT` β†’ the saved files β†’ **destroy the Lua state**. That last step is the one -/// `crate::ui_script::IngameUiLoaded` exists to stand in for; while it does, every window in the -/// VM keeps its contents across a character switch. The director saw it as the previous -/// character's `Joined Channel:` lines still sitting under the new character's β€” chat scrollback -/// is simply the most visible tenant of a VM that should have been rebuilt. -/// -/// So this clears what a fresh VM (and a fresh `ui_chat`) would have: both windows' lines, the -/// edit box's whole cross-open memory β€” sticky type, the `lastTell` ring, `toldTarget` β€” and the -/// undrained feed, whose queued lines were addressed to a character that is gone. It is the same -/// shape, and the same reasoning, as [`crate::ui_aura`]'s `end_session_aura_state` (0900). -/// -/// **Not on a reconnect**, deliberately, which is why this is not in [`channels`]'s disconnect -/// twin: channel membership is *server* state and dies with the server's `Player` object however -/// the socket ended, but a seamless same-character reconnect (0065) is benilla's own affordance β€” -/// the reference has no such thing, it goes to the login screen β€” and inside it the window keeping -/// its scrollback is the whole point. +/// Deviation: not run on benilla's same-character reconnect, which the reference lacks (it +/// returns to the login screen), so the window keeps its scrollback there. fn end_session_chat( script: Option>, mut log: ResMut, @@ -306,8 +196,7 @@ fn end_session_chat( end_chat_session(script.map(NonSendMut::into_inner), &mut log); } -/// [`end_session_chat`]'s body, callable without a `World` β€” the clear is the law, the system is -/// the wiring. +/// [`end_session_chat`]'s body, callable without a `World`. pub(crate) fn end_chat_session( script: Option<&mut benilla_ui::script::UiScript>, log: &mut ChatLog, diff --git a/crates/benilla-app/src/ui_chat/ace_gate_tests.rs b/crates/benilla-app/src/ui_chat/ace_gate_tests.rs index 375d20cb..7cb7dc87 100644 --- a/crates/benilla-app/src/ui_chat/ace_gate_tests.rs +++ b/crates/benilla-app/src/ui_chat/ace_gate_tests.rs @@ -1,36 +1,8 @@ -//! **Ace2's initialisation gate, opened end to end** β€” the reason 0288 Β§1's chat-event fire and -//! 0288 phase 6's zone auto-join were worth doing together. -//! -//! ## The failure this closes -//! -//! `AceEvent:IsFullyInitialized()` is called by 85 of the 218 addons in the vanilla corpus, and -//! under benilla it answered **false forever** β€” with no Lua error anywhere, which is why it went -//! unnoticed. The consequences are all silent: RosterLib never scans, BigWigs and oRA2 are -//! complete no-ops, Jostle never repositions, AceComm refuses to join its channels, and FuBar's -//! bar stays permanently blank across ~75 `FuBar_*` plugins. -//! -//! The gate is `AceEvent-2.0.lua:913-947`. On a fresh load `self.postInit` is false, and exactly -//! three things can set it: -//! -//! | armed by | what it schedules | -//! |---|---| -//! | `CHAT_MSG_CHANNEL_NOTICE` | `func` after 0.05 s | -//! | `MEETINGSTONE_CHANGED` | `func` after 1 s | -//! | `LANGUAGE_LIST_CHANGED` β†’ `MINIMAP_ZONE_CHANGED` | `func` after 1 s | -//! -//! `func` sets `postInit = true` and fires `AceEvent_FullyInitialized`. Of the three arming -//! events, `CHAT_MSG_CHANNEL_NOTICE` is the one that can honestly fire at login on a 1.12 server β€” -//! it is the server's answer to joining a channel. benilla fired **no** `CHAT_MSG_*` at all, and -//! joined **no** channels, so none of the three ever arrived. -//! -//! ## What this file proves, and how -//! -//! Against the corpus's own Ace2 chain, loaded in FuBar's `.toc` order into a VM with our whole -//! FrameXML under it β€” not a stub, because the thing under test is third-party library code and a -//! stub would only ever prove that our stub works. -//! -//! **Nothing from the corpus is committed**, and the tests skip cleanly on a machine without it -//! (see [`corpus`]) β€” a checkout with no addon folder must never go red here. +//! Ace2's initialisation gate, `AceEvent:IsFullyInitialized()` (`AceEvent-2.0.lua:913-947`), +//! opened by a zone-channel join. Of its three arming events, `CHAT_MSG_CHANNEL_NOTICE` (0.05 s), +//! `MEETINGSTONE_CHANGED` (1 s) and `LANGUAGE_LIST_CHANGED` then `MINIMAP_ZONE_CHANGED` (1 s), +//! only the notice fires at login on a 1.12 server. Run against the addon corpus's own Ace2 chain +//! over our whole FrameXML; skipped where the corpus is absent. use std::path::Path; @@ -39,14 +11,10 @@ use benilla_ui::script::UiScript; use super::event::{ChatEvent, ChatEventKind as K}; use super::frames::{route, ChatWindows}; -/// The corpus, or a skip β€” `benilla_formats::addon_corpus_or_skip!`, the one resolver (its doc -/// carries the incident: five copies of a ladder that looked beside the checkout, and thirty -/// tests that skipped at every land once the pool moved). +/// The addon corpus, or a skip where it is absent. use benilla_formats::addon_corpus_or_skip as corpus_or_skip; -/// FuBar's `.toc` load order for the four files the gate lives in (`FuBar/FuBar.toc:16-19`). -/// AceEvent is last because it needs all three under it β€” AceLibrary to register into, Compost for -/// its table recycling, AceOO for the class it derives from. +/// FuBar's `.toc` load order for the gate's four files (`FuBar/FuBar.toc:16-19`). const ACE_CHAIN: &[&str] = &[ "FuBar/libs/AceLibrary/AceLibrary.lua", "FuBar/libs/Compost-2.0/Compost-2.0.lua", @@ -54,13 +22,11 @@ const ACE_CHAIN: &[&str] = &[ "FuBar/libs/AceEvent-2.0/AceEvent-2.0.lua", ]; -/// A VM with our whole FrameXML and the corpus's Ace2 chain in it, at the point a real session -/// reaches `PLAYER_LOGIN` β€” which is where `AceEvent` starts waiting. +/// A VM with our FrameXML and the corpus's Ace2 chain, past `PLAYER_LOGIN`. fn ace_vm(root: &Path) -> UiScript { let mut script = UiScript::new().expect("VM"); script.set_screen_size(1024.0, 768.0); - // Our interface, exactly as the app loads it β€” Ace calls `CreateFrame`, `GetTime` and - // `ChatTypeInfo` as readily as it calls anything of its own. + // Ace calls `CreateFrame`, `GetTime` and `ChatTypeInfo`, so the whole interface loads first. let failures = crate::ui_script::load_default_ui(&script); assert!( failures.is_empty(), @@ -74,8 +40,7 @@ fn ace_vm(root: &Path) -> UiScript { .into_owned(); script.run(&src).unwrap_or_else(|e| panic!("{rel}: {e}")) } - // The UI-init tail the app runs once every file has loaded (`ui_script::finish_ui_load`'s - // second half): AceEvent registers PLAYER_LOGIN in `activate` and flips `playerLogin` on it. + // AceEvent registers `PLAYER_LOGIN` in `activate` and sets `playerLogin` on it. script.fire_event("PLAYER_LOGIN", vec![]); script } @@ -86,10 +51,7 @@ fn gate_open(s: &UiScript) -> bool { .expect("AceEvent-2.0 is registered") } -/// **The headline.** A zone-channel join's `YOU_JOINED` notice opens Ace2's gate. -/// -/// The event goes in through the real router, so what is being tested is benilla's own chat path -/// end to end β€” not a hand-rolled `fire_event`. +/// A zone-channel join's `YOU_JOINED` notice, sent through the real router, opens Ace2's gate. #[test] fn a_you_joined_notice_opens_ace2s_initialisation_gate() { benilla_formats::wow_data_or_skip!(); @@ -97,12 +59,7 @@ fn a_you_joined_notice_opens_ace2s_initialisation_gate() { let mut s = ace_vm(&root); let mut windows = ChatWindows::default(); - // An addon's-eye view of the gate opening: AceEvent fires `AceEvent_FullyInitialized` at its - // own registry, which is how every FuBar plugin learns it may start. - // The library-level registration AceEvent documents for a bare function (`:87-91` β€” called on - // AceEvent itself, `self` becomes the handler). `AceEvent_FullyInitialized` is in its - // `eventsWhichHappenOnce` table (`:79-84`), so it is a once-only registration by construction: - // the count below can only ever be 0 or 1, which is what makes 1 mean something. + // A once-only event (`eventsWhichHappenOnce`, `AceEvent-2.0.lua:79-84`): the count is 0 or 1. s.run( r#" AceGateFired = 0 @@ -127,8 +84,7 @@ fn a_you_joined_notice_opens_ace2s_initialisation_gate() { joined.zone_channel_id = 1; // ChatChannels.dbc General route(&mut s, &mut windows, &joined); - // AceEvent does not open on the event itself β€” it schedules `func` 0.05 s out and lets its own - // OnUpdate drive it (`AceEvent-2.0.lua:938`, `:471`). So the clock has to move. + // The open runs 0.05 s later, from AceEvent's OnUpdate (`AceEvent-2.0.lua:938`, `:471`). assert!(!gate_open(&s), "still shut inside the 0.05 s delay"); s.tick(0.02); assert!(!gate_open(&s), "0.02 s is not 0.05 s"); @@ -146,16 +102,14 @@ fn a_you_joined_notice_opens_ace2s_initialisation_gate() { assert!(s.errors().is_empty(), "Lua errors: {:?}", s.errors()); } -/// The control, and the reason the headline is not circular: **without the notice the gate stays -/// shut**, no matter how much time passes. If this ever goes green, something else is arming the -/// gate and the headline is measuring the wrong thing. +/// The control for the test above: with no notice, nothing else arms the gate. #[test] fn without_the_notice_the_gate_stays_shut() { benilla_formats::wow_data_or_skip!(); let root = corpus_or_skip!(); let mut s = ace_vm(&root); for _ in 0..40 { - s.tick(0.1); // four seconds β€” well past both the 0.05 s and the 1 s schedules + s.tick(0.1); // 4 s in all, past both the 0.05 s and the 1 s schedules } assert!( !gate_open(&s), @@ -163,20 +117,14 @@ fn without_the_notice_the_gate_stays_shut() { ); } -/// The other half: the walk that makes the notice happen at all. Composed against the corpus's -/// own expectations is not possible here (the corpus does not know our zone), so this asserts the -/// names the auto-join sends for a real zone and a real capital β€” the strings the server matches -/// to `ChatChannels.dbc` rows, and therefore the strings that decide whether we are in the same -/// channel as everyone else. +/// The auto-join's names for a zone and a capital, which the server matches to its channel rows. #[test] fn the_auto_join_walk_names_the_channels_the_server_resolves() { let data = benilla_formats::wow_data_or_skip!(); let mut chain = benilla_formats::open_chain(&data).expect("open chain"); let cat = benilla_formats::load_chat_channels_catalog(&mut chain).expect("ChatChannels.dbc"); - // The city word is DBC data, so read it the way the walk does rather than spelling it here β€” - // that this resolves to "City" at all is the finding (`AreaTable.dbc` row 3459, - // `Flags & 0x200`, the row `0xb4e4f0` caches). + // The city word: `AreaTable.dbc` row 3459, `Flags & 0x200`, cached at `0xb4e4f0`. let areas = benilla_formats::load_area_table_catalog(&mut chain).expect("AreaTable.dbc"); let city = super::channels::city_word(&areas); assert_eq!( @@ -201,8 +149,8 @@ fn the_auto_join_walk_names_the_channels_the_server_resolves() { "LocalDefense - Stormwind City", ] ); - // Every one of them resolves back to a built-in row β€” i.e. vmangos will treat them as the - // constant channels, not as custom ones (`GetChannelEntryFor`, DBCStores.cpp:531). + // Each resolves to a built-in row, so vmangos treats them as the constant channels, not as + // custom ones (`GetChannelEntryFor`, `DBCStores.cpp:531`). for name in out_in_the_world.iter().chain(in_a_capital.iter()) { assert_ne!( cat.zone_channel_id(name), diff --git a/crates/benilla-app/src/ui_chat/away.rs b/crates/benilla-app/src/ui_chat/away.rs index 9cf3240c..62d9c193 100644 --- a/crates/benilla-app/src/ui_chat/away.rs +++ b/crates/benilla-app/src/ui_chat/away.rs @@ -1,100 +1,45 @@ -//! **`/afk` and `/dnd` β€” the client-side command law** (decision 2088). -//! -//! The two commands look like a pair and are not one. `SendChatMessage 0x49f1e0` handles them -//! **asymmetrically**, and the reference's own string pool shows why: the DND keys live in the chat -//! translation unit, the AFK keys in `Player_C.h`'s. -//! -//! - **DND** (`0x49f3de`-`0x49f591`) is inline, reads `PLAYER_FLAGS` bit `0x4` **live**, and falls -//! through to the generic `CMSG_MESSAGECHAT` send. -//! - **AFK** (`0x49f4f3`-`0x49f562`) reads the client-side mirror [`AfkMirror`] β€” *not* the -//! descriptor bit β€” and returns, delegating to `CGPlayer_C::SetAFK 0x5eb740` / -//! `ClearAFK 0x5eb830`, which build their own packets. -//! -//! **The echo is OPTIMISTIC, never descriptor-driven**: the print and the mirror write are -//! straight-line before the send. `0x5ee990`'s `test al,0xe` arm β€” the `PLAYER_FLAGS` delta arm -//! that carries AFK/DND/GM β€” calls only `0x6c78f0` (name invalidation) and `0x468550` between -//! `0x5ee9c0` and `0x5ee9f8`: no string resolve, no chat sink. So a client that waited for the -//! server's flag to come back before printing would be wrong, and would also print nothing at all -//! when the send is dropped. -//! -//! **All four lines are `CHAT_MSG_SYSTEM`** (`0x49a870` with `edx = 0xa`; `[0x8062e0 + 4*0xa]` = -//! 238, name slot `0xbe1550` ← `"CHAT_MSG_SYSTEM"`), whose yellow is the **engine's** own -//! (`.rdata 0x8049ae` = `ff ff 00`), not a FrameXML `ChatTypeInfo` colour. The incoming events -//! `CHAT_MSG_AFK`/`DND` (249/250) are a different thing entirely β€” those are *other* players' -//! auto-replies, and never these. +//! `/afk` and `/dnd`, which the reference's `SendChatMessage` (`0x49f1e0`) handles differently. +//! DND (`0x49f3de`-`0x49f591`) reads `PLAYER_FLAGS` bit `0x4` live and falls through to the plain +//! `CMSG_MESSAGECHAT` send. AFK (`0x49f4f3`-`0x49f562`) reads the client-side [`AfkMirror`] and +//! hands off to `CGPlayer_C::SetAFK` (`0x5eb740`) or `ClearAFK` (`0x5eb830`), which send their +//! own packets. Both echo before the send, never from the descriptor: the `PLAYER_FLAGS` delta arm +//! (`0x5ee990`) prints nothing. The four lines are `CHAT_MSG_SYSTEM` (`0x49a870`, type `0xA`); +//! the `CHAT_MSG_AFK`/`DND` events are other players' auto-replies. use bevy::prelude::*; use super::event::{ChatEvent, ChatEventKind}; use super::feed::ChatLog; -/// **`[0xb6e5cc]` β€” the write-through optimistic mirror of the local player's `PLAYER_FLAGS` AFK -/// bit**, reconciled by the descriptor. Authoritative for the client's own toggle decision, never -/// for anyone else's state. -/// -/// Eleven sites in the image reference it. Written optimistically at command time -/// (`SetAFK 0x5eb7ae` ← 1, `ClearAFK 0x5eb885` ← 0), reconciled from the descriptor on every local -/// `PLAYER_FLAGS` delta (`0x5ee9f2`), re-seeded at world enter (`0x4989c0`, which is **not** the -/// `/afk` toggle). Consumed by the overhead-name AFK tag as the own-player override, and by the -/// `/afk` toggle itself. -/// -/// **`u32`, not `bool`, and never compared against 1.** The five writers store **three** truthy -/// values β€” `1` (`0x4989eb`, `0x5eb7ae`), `0` (`0x4989f7`, `0x5eb885`) and **`2`** (`0x5ee9f2` -/// stores `PLAYER_FLAGS & 2`). All six readers are pure non-zero tests, so the reference never -/// notices; a re-implementation that types this `bool` or tests `== 1` diverges at the first -/// descriptor-driven set. That is the hazard, and this type is the answer to it. -/// -/// **There is no DND mirror** β€” no global is written on the DND path, which is why [`dnd_line`] -/// takes the live descriptor bit and [`afk_line`] takes this. The observable asymmetry is real and -/// faithful: a second `/dnd` typed before the server's update arrives re-marks, while a second -/// `/afk` in the same window clears. +/// The reference's optimistic mirror of our `PLAYER_FLAGS` AFK bit (`[0xb6e5cc]`): written at +/// command time (`0x5eb7ae`, `0x5eb885`), reconciled from the descriptor (`0x5ee9f2`), reseeded +/// at world enter (`0x4989c0`). A `u32` tested for non-zero: the reconcile stores +/// `PLAYER_FLAGS & 2`, so a set mirror can read 2. DND has no mirror, so a second `/dnd` before +/// the server's update re-marks where a second `/afk` clears. #[derive(Resource, Default, Debug, Clone, Copy, PartialEq, Eq)] pub(crate) struct AfkMirror(pub(crate) u32); impl AfkMirror { - /// The only question anything asks of it β€” every one of the reference's six readers is a - /// non-zero test, so this is the whole API and `== 1` is never written anywhere. + /// Non-zero, as all six of the reference's readers test it. pub(crate) fn is_afk(self) -> bool { self.0 != 0 } } -/// What a `/afk` or `/dnd` resolves to: the line to print (if any), the mirror's new value (AFK -/// only), and the body to put on the wire. -/// -/// A struct rather than three returns because the truth table's whole content is which of them move -/// together β€” notably `/afk M` **while already AFK**, which prints nothing and leaves the mirror -/// alone but still sends (`0x5eb76e` jumps past both the echo and the flag write, not past the -/// packet). +/// What a `/afk` or `/dnd` resolves to: the line to print, the mirror's new value, the wire body. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct AwayOutcome { /// The `CHAT_MSG_SYSTEM` line, or `None` for the one row that prints nothing. pub(crate) line: Option, /// The mirror's value afterwards. `None` = unchanged (every DND row, and `/afk M` while AFK). pub(crate) mirror: Option, - /// The message body the `CMSG_MESSAGECHAT` carries β€” **after** the client's own default - /// substitution, which is why it is returned rather than reusing the caller's string. + /// The `CMSG_MESSAGECHAT` body, after the client's own default-message substitution. pub(crate) body: String, } -/// Resolve `/afk ` against the mirror β€” the AFK arm `0x49f4f3`-`0x49f562`. -/// -/// | `msg` | mirror before | line | mirror after | -/// |---|---|---|---| -/// | `""` | clear | `MARKED_AFK_MESSAGE` % `DEFAULT_AFK_MESSAGE` | 1 | -/// | `""` | set | `CLEARED_AFK` | 0 | -/// | `M` | clear | `MARKED_AFK_MESSAGE` % `M` | 1 | -/// | `M` | set | *(nothing)* | unchanged | -/// -/// **The default text is the CLIENT's and is substituted BEFORE the send** (`0x49f512`): the server -/// receives the literal `"Away from Keyboard"`, never an empty body, so vmangos stores that as the -/// auto-reply. `SetAFK` substitutes again for a NULL message (`0x5eb761`) β€” belt and braces in the -/// reference, one substitution here. -/// -/// `MARKED_AFK_MESSAGE` carries **no** trailing period (`"You are now AFK: %s"`); its sibling -/// `MARKED_AFK` (the no-`%s` variant `GlobalStrings.lua` also defines) has **zero occurrences in -/// the image** and is dead β€” do not reach for it when the message is empty, because the default -/// substitution means it never is. +/// `/afk `, the AFK arm (`0x49f4f3`-`0x49f562`). The default text is substituted before the +/// send (`0x49f512`), so the server stores `"Away from Keyboard"`, never an empty body, and the +/// no-`%s` `MARKED_AFK` goes unused. pub(crate) fn afk_line( msg: &str, mirror: AfkMirror, @@ -103,17 +48,14 @@ pub(crate) fn afk_line( let text = |key: &str| strings(key).unwrap_or_default(); if mirror.is_afk() { if msg.is_empty() { - // The toggle off. `ClearAFK 0x5eb830` prints CLEARED_AFK unformatted and sends an - // EMPTY body β€” the packet still goes, it just carries nothing. + // `ClearAFK` prints `CLEARED_AFK` unformatted and still sends, with an empty body. AwayOutcome { line: Some(text("CLEARED_AFK")), mirror: Some(0), body: String::new(), } } else { - // **Re-marking while AFK is silent** β€” `0x5eb76e` jumps past the echo AND the mirror - // write, but not past the send: the server still learns the new auto-reply text. This - // row is the one that most tempts a re-implementer into printing something. + // Re-marking while AFK prints nothing (`0x5eb76e`), but the new text is still sent. AwayOutcome { line: None, mirror: None, @@ -134,19 +76,9 @@ pub(crate) fn afk_line( } } -/// Resolve `/dnd ` against the **live** `PLAYER_FLAGS` bit `0x4` β€” the DND arm -/// `0x49f3de`-`0x49f591`. -/// -/// | `msg` | DND before | line | -/// |---|---|---| -/// | `""` | clear | `MARKED_DND` % `DEFAULT_DND_MESSAGE` | -/// | `""` | set | `CLEARED_DND` | -/// | `M` | clear | `MARKED_DND` % `M` | -/// | `M` | set | `MARKED_DND` % `M` β€” **re-prints**, unlike AFK | -/// -/// `MARKED_DND` is `"You are now DND: %s."` β€” **the period is inside the format string**, which is -/// why it must be filled rather than composed; writing `"You are now DND: " + msg + "."` here would -/// be right in enUS and wrong in every locale that moved it. +/// `/dnd ` against the live `PLAYER_FLAGS` bit `0x4`, the DND arm (`0x49f3de`-`0x49f591`); +/// unlike `/afk`, a repeat with a message re-prints. `MARKED_DND` carries its period inside the +/// format string, so it is filled, never composed. pub(crate) fn dnd_line( msg: &str, is_dnd: bool, @@ -172,16 +104,10 @@ pub(crate) fn dnd_line( } } -/// The implicit AFK clear every **other** chat send carries β€” `0x49f3c7`/`0x49f3d6`, gated on -/// `autoClearAFK` and on the mirror being set, and skipped for chat type `0x14` alone (which is -/// why `/dnd`, being type `0x15`, clears AFK first β€” that is the whole reason the reference's own -/// capture shows three lines for two typed commands). -/// -/// Returns the `CLEARED_AFK` line when it fires; the caller also sends the empty `0x14` packet and -/// zeroes the mirror, then goes on to send the message's own packet. -/// -/// **It runs before the refusal arms, not after** β€” `0x49f3c7`/`0x49f3d6` sit above `0x49f592`, so -/// a ghost typing `/say` clears AFK and *then* gets `ERR_CHAT_WHILE_DEAD`. +/// The implicit AFK clear on every chat send but type `0x14` (`0x49f3c7`/`0x49f3d6`), gated on +/// `autoClearAFK`, so `/dnd` (`0x15`) clears AFK first. The caller sends the empty `0x14` packet +/// and zeroes the mirror. It runs before the refusal arms (`0x49f592`): a ghost's `/say` clears +/// AFK, then gets `ERR_CHAT_WHILE_DEAD`. pub(crate) fn auto_clear_line( mirror: AfkMirror, auto_clear_afk: bool, @@ -190,8 +116,7 @@ pub(crate) fn auto_clear_line( (auto_clear_afk && mirror.is_afk()).then(|| strings("CLEARED_AFK").unwrap_or_default()) } -/// Fill a one-`%s` GlobalString. `%%` is an escaped percent; a template with no `%s` comes back -/// unchanged, which is the honest answer for a locale that dropped the slot. +/// Fill the first `%s` of a GlobalString, which the reference `snprintf`s; `%%` is a percent. fn fill_one(template: &str, arg: &str) -> String { let mut out = String::with_capacity(template.len() + arg.len()); let mut chars = template.chars().peekable(); @@ -222,41 +147,27 @@ pub(crate) fn push_system(chat: &mut ChatLog, line: String) { chat.push_event(ChatEvent::text_only(ChatEventKind::System, line)); } -/// The mirror's own last-seen `PLAYER_FLAGS`, so the reconcile below can diff like the reference's -/// watcher does. `None` = no local player held (logged out, or not yet in the world). +/// The last `PLAYER_FLAGS` the reconcile saw; `None` with no local player in the world. #[derive(Resource, Default)] pub(crate) struct AfkMirrorMemo(Option); -/// **Reconcile the mirror from our own descriptor** β€” `0x5ee990`'s arm 1, and the world-enter -/// reseed (`0x4989c0`), which are the only two things that ever correct an optimistic write. -/// -/// Its own system rather than a branch in the unit feed because that is the reference's own shape: -/// the write lives in the `PLAYER_FLAGS` delta handler, keyed on its own diff. -/// -/// **The `& 0xE` gate is load-bearing, and dropping it is a real bug rather than a simplification.** -/// Arm 1 runs only when AFK, DND or GM *changed* (`0x5ee9ba a8 0e test al,0xe`), and only then -/// writes `PLAYER_FLAGS & 2` (`0x5ee9ef`/`0x5ee9f2`). A reconcile that instead wrote on *any* -/// flags delta would clobber the optimistic `1` back to `0` the first time an unrelated bit moved -/// β€” walk into an inn while AFK and the resting bit alone would drop your `` a round trip -/// early. The whole point of the mirror is that it is allowed to lead the descriptor (for exactly -/// one round trip), and this gate is what protects that window. -/// -/// The value written is `flags & 0x2` β€” so **`2`, not `1`** β€” which is exactly why [`AfkMirror`] -/// is a `u32` tested for non-zero and never compared against `1`. +/// Reconcile the mirror from our descriptor: the `PLAYER_FLAGS` delta arm (`0x5ee990`) and the +/// world-enter reseed (`0x4989c0`). The delta arm writes `flags & 2` only when AFK, DND or GM +/// moved (`0x5ee9ba test al,0xe`); writing on any delta would let an unrelated bit, such as +/// resting, clear an optimistic AFK a round trip early. pub(crate) fn reconcile_afk_mirror( self_q: Query<&crate::net::ObjectStore, With>, mut mirror: ResMut, mut memo: ResMut, ) { let Some(store) = self_q.iter().next() else { - // No local player: the world-exit half of the world-enter reseed. Forgetting the memo is - // what makes the next login re-seed rather than diff against the last body's flags. + // Forgetting the memo makes the next world enter reseed rather than diff. memo.0 = None; return; }; let flags = store.0.player_flags(); match memo.0.replace(flags) { - // World enter (`0x4989c0`): seed from the descriptor outright, no diff to make. + // World enter (`0x4989c0`): seed outright. None => mirror.0 = flags & 0x2, // The delta arm: only an AFK/DND/GM move reconciles. Some(prev) if (prev ^ flags) & 0xE != 0 => mirror.0 = flags & 0x2, @@ -264,20 +175,10 @@ pub(crate) fn reconcile_afk_mirror( } } -/// The **four movement clears** β€” `autoClearAFK`'s other call sites: -/// `Jump 0x513d36`, forward/back `0x514e23`, strafe `0x514f0b`, keyboard-turn `0x514fca`. All four -/// share one idiom β€” `if (OBJECT_FIELD_TYPE & TYPEMASK_PLAYER) ClearAFK(obj, 0)` sitting right -/// after the same function's stand-up call β€” so they are one rule here, not four. -/// -/// **The press EDGE, and the keyboard's**: these sit in the movement *emitters*, which run off the -/// command, not off displacement. So mouse-look turning does not clear AFK (it never reaches -/// `0x514f50`), and neither does being knocked, feared or driven by a spline β€” you have to press -/// something. `binds` is the binding dispatch, so this follows a rebound key and already carries -/// the typing gate (`WASD` typed into the chat box is not movement). -/// -/// Jump is in the set here where it is deliberately **out** of the follow-cancel set -/// (`player::follow`'s `move_start`, whose six commands are these minus JUMP) β€” two different -/// reference tables that happen to overlap, not one shared idea. +/// The movement AFK clears: jump (`0x513d36`), forward/back (`0x514e23`), strafe (`0x514f0b`) +/// and keyboard turn (`0x514fca`). They sit in the movement emitters, so only a key press +/// clears; mouse-look turning never reaches `0x514f50`, and knockback, fear and splines press +/// nothing. Jump is here but not in `player::follow`'s cancel set: two separate reference tables. pub(crate) fn movement_clears_afk( binds: Res, cvars: Res, @@ -290,7 +191,6 @@ pub(crate) fn movement_clears_afk( if !mirror.is_afk() || cvars.flag("autoClearAFK") == Some(false) { return; } - // The inline-array idiom `player::follow`'s `move_start` uses β€” its six, plus JUMP. let moved = [ cmd::JUMP, cmd::MOVE_FORWARD, @@ -310,7 +210,7 @@ pub(crate) fn movement_clears_afk( .unwrap_or_default(); push_system(&mut chat, line); mirror.0 = 0; - // The same empty `0x14` the command path sends β€” `ClearAFK` builds its own packet. + // `ClearAFK`'s own packet: the empty `0x14` the command path sends. let _ = commands.0.send(crate::net::ClientCommand::Chat { kind: crate::net::ChatKind::Afk, target: None, @@ -322,9 +222,7 @@ pub(crate) fn movement_clears_afk( mod tests { use super::*; - /// The enUS strings, exactly as the reference install's `GlobalStrings.lua` ships them β€” - /// **including `MARKED_DND`'s trailing period, which lives INSIDE the format string** and is - /// the reason these are filled rather than composed. + /// The enUS `GlobalStrings.lua` values; `MARKED_DND` carries its period inside the format. fn strings(key: &str) -> Option { Some( match key { @@ -340,14 +238,11 @@ mod tests { ) } - /// **The reference's truth table, transcribed.** Every row, including the three that a - /// re-implementer gets wrong by symmetry. #[test] fn the_truth_table_row_for_row() { let afk = |msg: &str, m: u32| afk_line(msg, AfkMirror(m), &strings); let dnd = |msg: &str, is: bool| dnd_line(msg, is, &strings); - // `/afk`, not AFK β†’ mark, with the CLIENT's default text substituted before the send. assert_eq!( afk("", 0), AwayOutcome { @@ -357,7 +252,6 @@ mod tests { }, "the server receives the literal default, never an empty body" ); - // `/afk`, AFK β†’ the toggle off. The packet still goes, carrying nothing. assert_eq!( afk("", 1), AwayOutcome { @@ -366,7 +260,6 @@ mod tests { body: String::new(), } ); - // `/afk M`, not AFK β†’ mark with M. assert_eq!( afk("brb", 0), AwayOutcome { @@ -375,8 +268,6 @@ mod tests { body: "brb".into(), } ); - // **`/afk M` while ALREADY AFK prints NOTHING** β€” `0x5eb76e` jumps past the echo and the - // mirror write, but not past the send. The row most likely to be "fixed" into a print. assert_eq!( afk("brb", 1), AwayOutcome { @@ -387,7 +278,6 @@ mod tests { "silent re-mark: the server still learns the new auto-reply" ); - // `/dnd`, not DND β†’ the period comes from the format string, not from us. assert_eq!( dnd("", false), AwayOutcome { @@ -412,9 +302,7 @@ mod tests { body: "busy".into(), } ); - // **The asymmetry.** `/dnd M` while DND RE-PRINTS, where `/afk M` while AFK is silent. - // Not a bug on either side: AFK reads its optimistic mirror, DND reads the live descriptor - // bit and has no mirror at all (`0x49f3f0`). + // `/dnd M` while DND re-prints: DND reads the live bit and has no mirror (`0x49f3f0`). assert_eq!( dnd("busy", true), AwayOutcome { @@ -426,11 +314,8 @@ mod tests { ); } - /// **The director's capture, reproduced: THREE lines from TWO commands.** - /// - /// `/afk` then `/dnd` prints the mark, then the implicit AFK clear, then the DND mark β€” because - /// `/dnd` is chat type `0x15` and the auto-clear skips only `0x14` (`0x49f4f6`). Anyone reading - /// the reference screenshot as three typed commands would build the wrong thing. + /// `/afk` then `/dnd` prints three lines: the auto-clear skips only type `0x14` (`0x49f4f6`), + /// so `/dnd` (`0x15`) clears AFK first. #[test] fn afk_then_dnd_prints_three_lines() { let mut mirror = AfkMirror::default(); @@ -444,8 +329,7 @@ mod tests { let cleared = auto_clear_line(mirror, true, &strings).expect("the implicit clear fires"); out.push(cleared); mirror.0 = 0; - // …and only THEN the DND arm, reading a now-clear AFK state β€” the order the reference - // runs them in (`0x49f3c7` sits above the type dispatch). + // Then the DND arm: `0x49f3c7` sits above the type dispatch. assert!(!mirror.is_afk(), "the clear really cleared it"); out.push(dnd_line("", false, &strings).line.unwrap()); @@ -460,8 +344,6 @@ mod tests { ); } - /// `autoClearAFK` off is a TOTAL no-op β€” no echo, and (at the call site) no mirror write and no - /// packet. And a clear never fires when there is nothing to clear. #[test] fn the_auto_clear_is_gated_both_ways() { assert!( @@ -473,9 +355,7 @@ mod tests { "not afk" ); assert!(auto_clear_line(AfkMirror(1), true, &strings).is_some()); - // **The descriptor-driven value is 2, not 1** (`0x5ee9f2`): the reconcile stores - // `PLAYER_FLAGS & 2`. A mirror typed `bool`, or tested `== 1`, would answer "not AFK" here - // and silently stop clearing after the server's first confirmation. + // The reconcile stores `PLAYER_FLAGS & 2` (`0x5ee9f2`), so a set mirror can read 2. assert!( auto_clear_line(AfkMirror(2), true, &strings).is_some(), "2 is truthy β€” every one of the reference's six readers is a non-zero test" @@ -483,8 +363,6 @@ mod tests { assert!(AfkMirror(2).is_afk()); } - /// A locale that dropped or moved the `%s` must not produce a mangled sentence, and `%%` is an - /// escaped percent. The reference `snprintf`s the shipped string verbatim; so do we. #[test] fn the_fill_handles_the_shapes_globalstrings_can_carry() { assert_eq!( @@ -501,9 +379,6 @@ mod tests { ); } - /// An install with no GlobalStrings (CI, a bare checkout) yields empty lines rather than - /// English literals β€” the same posture `global_string` takes everywhere else. The BODY still - /// carries the message, so the wire half is unaffected by a missing string table. #[test] fn a_missing_string_table_does_not_invent_english() { let none = |_: &str| None; diff --git a/crates/benilla-app/src/ui_chat/broadcast.rs b/crates/benilla-app/src/ui_chat/broadcast.rs index 0ed9e9b9..bc2ffd51 100644 --- a/crates/benilla-app/src/ui_chat/broadcast.rs +++ b/crates/benilla-app/src/ui_chat/broadcast.rs @@ -1,83 +1,19 @@ -//! The world broadcasts β€” the three that become chat lines, and the resolve pass they need first. +//! The world broadcasts that print in chat, resolved from their wire ids once the catalogs, the +//! joined channels and the player's zone are at hand, a pass before the chat drain. //! -//! `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 two defense broadcasts (handlers `0x49dcc0`, `0x49de30`) print as senderless +//! `CHAT_MSG_CHANNEL` lines in language 0, once per joined channel whose `ChatChannels.dbc` row +//! has `DEFENSE` (`0x10000`, `0x49dd94`); a `ZONE_DEP` (`0x2`) row also needs the broadcast's zone +//! to be the player's (`0x49dda4`). Both compare the area's parent zone (`0x49dd2b`, `0x49de80`), +//! while ZONE_UNDER_ATTACK's text names the area itself (`0x49dd05`). With no `AreaTable.dbc` row +//! ZONE_UNDER_ATTACK prints nothing (`0x49dcdc`) and DEFENSE_MESSAGE compares the raw id +//! (`0x49de8a`). vmangos sends DEFENSE_MESSAGE to the whole map (`Map.cpp:1868`), but only rows +//! 1, 2 and 22 carry `INITIAL`, so the auto-join never joins WorldDefense and in practice only +//! LocalDefense, in the broadcast's zone, hears them. //! -//! ## 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`), 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 -//! ([`benilla_ui::messages::MsgKind`]); 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. +//! `SMSG_SERVER_MESSAGE` is one `CHAT_MSG_SYSTEM` line (`0x49e047`). `SMSG_CHAT_RESTRICTED` +//! carries nothing and is `DisplayError` 451 (`0x5e4a09`, `0x496720`), `ERR_CHAT_RESTRICTED`, a +//! chat row, not a red toast (`0x496822`, `0x484cac`); vmangos never sends it. use bevy::prelude::*; @@ -90,33 +26,25 @@ use super::edit::ChannelState; use super::event::{ChatEvent, ChatEventKind}; use super::feed::ChatLog; -/// `ServerMessages.dbc`, read once at Startup β€” the five shutdown/restart sentences. +/// `ServerMessages.dbc`, read once at startup: the shutdown and 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. +/// One parked world broadcast, as the wire sent it, awaiting the catalogs and the channel walk. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum Broadcast { - /// `SMSG_ZONE_UNDER_ATTACK` β€” the `AreaTable.dbc` id of the area under attack. + /// `SMSG_ZONE_UNDER_ATTACK`: the attacked area's `AreaTable.dbc` id. ZoneUnderAttack { area_id: u32 }, - /// `SMSG_DEFENSE_MESSAGE` β€” the zone the broadcast is about, and the server's own text. + /// `SMSG_DEFENSE_MESSAGE`: the zone it is about, and the server's text. Defense { zone_id: u32, text: String }, - /// `SMSG_SERVER_MESSAGE` β€” the `ServerMessages.dbc` row id and the text filling its `%s`. + /// `SMSG_SERVER_MESSAGE`: a `ServerMessages.dbc` row id and the text for 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. + /// `SMSG_CHAT_RESTRICTED`, which carries nothing. 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. +/// Load `ServerMessages.dbc`; it must run after `benilla_assets::AssetSet::Open`, or it finds no +/// patch chain and loads nothing. pub(super) fn load_server_messages(mut commands: Commands, assets: Option>) { let Some(assets) = assets else { return }; let loaded = { @@ -124,9 +52,7 @@ pub(super) fn load_server_messages(mut commands: Commands, assets: Option { info!("chat: {} ServerMessages rows", cat.len()); commands.insert_resource(ServerMessages(cat)); @@ -135,18 +61,10 @@ pub(super) fn load_server_messages(mut commands: Commands, assets: Option 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). +/// The area's parent zone, or the area itself when it has none (`0x49dd2b`, `0x49de80`). 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. +/// Resolve the parked broadcasts into chat events, before [`super::feed::feed_chat`] so each +/// lands the frame it decodes. pub(super) fn feed_broadcasts( mut log: ResMut, script: Option>, @@ -201,19 +110,17 @@ pub(super) fn feed_broadcasts( 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. + // The format string lives in the VM, so the queue waits for one; it clears at session end. 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. + // The player's zone, the reference's `[0xb6e5d0]`; `top_zone` is 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. + // No catalog, template or row: with no name there is no line. let Some((areas, fmt)) = areas .as_ref() .zip(super::combat::global_string(&script, "ZONE_UNDER_ATTACK")) @@ -237,22 +144,9 @@ pub(super) fn feed_broadcasts( push_channel_lines(&mut log, &channels, zone, player_zone, &text); } Broadcast::ChatRestricted => { - // The system line is written out here rather than routed through - // `ui_action::show_messages`, and that is not an oversight: of this feed's four - // arms only this one is a `DisplayError` tenant at all β€” the two defence - // broadcasts are engine-composed CHANNEL lines and `SMSG_SERVER_MESSAGE` composes - // from `ServerMessages.dbc`, so three of the four have no message record to read a - // surface from. The claim that this one's record says "chat" is checked instead, - // in `chat_restricted_is_a_chat_row` below. - // - // 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 (`0x703c02` seeds it), 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. + // Its row is a chat row (tested below), so it is written as a system line here. + // Deviation: a missing global prints nothing, where the reference prints an empty + // line (`0x703c02`), because benilla's other `DisplayError` routes do the same. if let Some(text) = super::combat::global_string(&script, "ERR_CHAT_RESTRICTED") { log.push_event(ChatEvent::text_only(ChatEventKind::System, text)); } @@ -271,11 +165,9 @@ pub(super) fn feed_broadcasts( } } -/// 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. +/// One `CHAT_MSG_CHANNEL` line per defense channel reached, with no sender or language: the +/// handlers push NULL for both (`0x49ddf5`/`0x49ddf7`), hence the bare colon of +/// `[LocalDefense] : text`. fn push_channel_lines( log: &mut ChatLog, channels: &ChannelState, @@ -295,9 +187,7 @@ fn push_channel_lines( #[cfg(test)] mod tests { - /// `ERR_CHAT_RESTRICTED` (id 451) carries kind 0, so the arm above is right to write a chat - /// system line and not a red toast β€” the one hand-set surface left in this feed, checked - /// rather than trusted (decision 1770). + /// `ERR_CHAT_RESTRICTED` (451) is a chat row, so the arm above writes a system line. #[test] fn chat_restricted_is_a_chat_row() { use benilla_ui::messages::{by_key, kind_of, MsgKind}; @@ -308,8 +198,7 @@ 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). + /// Three shipped `ChatChannels.dbc` rows, as `benilla_formats::chat_channels` tests them. fn catalog() -> ChatChannelsCatalog { ChatChannelsCatalog::from_rows(vec![ ChatChannelRow { @@ -347,8 +236,6 @@ mod tests { 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(&[ @@ -360,8 +247,7 @@ mod tests { 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. + // In Elwynn, a Westfall alarm reaches only the global channel. let s = state(&[ "General - Elwynn Forest", "LocalDefense - Elwynn Forest", @@ -373,32 +259,25 @@ mod tests { ); } - /// 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. + /// [`ChannelState::free_slot`] clears in place, leaving a hole the walk skips. #[test] fn a_freed_slot_does_not_end_the_walk() { let mut s = state(&["LocalDefense - Westfall", "WorldDefense"]); @@ -409,9 +288,7 @@ mod tests { ); } - /// 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. + /// The `%s` takes the area's name, not its zone's; the colour codes pass through. #[test] fn the_line_fills_the_frame_xml_template_with_the_area_name() { assert_eq!( @@ -420,11 +297,8 @@ mod tests { ); } - /// **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. + /// The bare colon is the reference's: `ChatFrame_OnEvent` fills `CHAT_CHANNEL_GET` (`"%s: "`) + /// with the empty sender (`ChatFrame.lua:1453`). #[test] fn a_defense_line_renders_with_the_references_bare_colon() { benilla_formats::wow_data_or_skip!(); diff --git a/crates/benilla-app/src/ui_chat/channels.rs b/crates/benilla-app/src/ui_chat/channels.rs index 549dbe58..edb7388a 100644 --- a/crates/benilla-app/src/ui_chat/channels.rs +++ b/crates/benilla-app/src/ui_chat/channels.rs @@ -1,34 +1,10 @@ -//! The zone-channel auto-join (decision 0288 phase 6's remainder) β€” the client half of a -//! handshake the server deliberately does not perform. +//! The zone-channel auto-join, the reference's `ZoneChannelRefresh 0x49a210`. The server joins +//! nobody (vmangos `Player::UpdateLocalChannels` is empty, `Objects/Player.cpp:5121`), so the +//! client joins every [`ChannelState::zone_mask`] row composed for the current zone, and on each +//! zone change leaves and re-joins the zone-named ones, LEAVE then JOIN per slot. //! -//! **Nobody joins these for you.** vmangos's `Player::UpdateLocalChannels` -//! (`src/game/Objects/Player.cpp:5121`) is an empty function whose entire body is the comment -//! `// Updated client-side`, so a client that never sends `CMSG_JOIN_CHANNEL` sits in no channel -//! at all β€” silently, because nothing on the wire complains. That is what benilla did until now: -//! `/join` worked, and General/Trade/LocalDefense simply never existed. -//! -//! The walk is every [`ChatChannels.dbc`](benilla_formats::ChatChannelsCatalog) row whose bit is -//! set in the character's **`ZONECHANNELS` mask** ([`ChannelState::zone_mask`] β€” seeded from the -//! DBC's `INITIAL` rows on a fresh character, then moved only by confirmed joins and explicit -//! leaves; decisions 2120 and 2144), composed against the player's current **zone** and sent as -//! ordinary joins. It re-runs whenever the zone changes: the zone-dependent rows carry the zone -//! name *inside* the channel name, so crossing a border is genuinely leaving one channel and -//! joining another β€” LEAVE(old) then JOIN(new), interleaved per slot. -//! -//! The whole mechanism is one function of the reference, `ZoneChannelRefresh 0x49a210`; each part -//! below cites the address it comes from. -//! -//! ## Why this is worth more than three channel names -//! -//! The server answers each join with `SMSG_CHANNEL_NOTIFY`/`YOU_JOINED`, which becomes a -//! `CHAT_MSG_CHANNEL_NOTICE` at the Lua VM (0288 Β§1's addon-API phase) β€” and that one event is the -//! condition for **Ace2's initialisation gate**. `AceEvent`'s `activate` registers -//! `CHAT_MSG_CHANNEL_NOTICE` and, on the first one, schedules the function that sets -//! `self.postInit = true` and fires `AceEvent_FullyInitialized` -//! (`AceEvent-2.0.lua:913-947`). Until that happens `AceEvent:IsFullyInitialized()` answers false -//! forever, with no error anywhere β€” and ~75 FuBar plugins, BigWigs, oRA2, RosterLib, Jostle and -//! AceComm all wait on it. `ui_chat::ace_gate_tests` is that claim, proved against the corpus's -//! own Ace2 chain. +//! The join's `CHAT_MSG_CHANNEL_NOTICE` is also one of the events that trigger AceEvent-2.0's +//! `AceEvent_FullyInitialized`, which many Ace2 addons wait for. use bevy::prelude::*; @@ -41,26 +17,15 @@ use crate::net::{ClientCommand, NetCommands}; use super::edit::{ChannelState, SlotState}; use super::recruitment::GuildRecruitmentCascade; -/// `AreaTable.dbc` flag `0x08` β€” vmangos calls it `AREA_FLAG_SLAVE_CAPITAL`, with the comment -/// *"Allow trade channel"* (`src/game/Database/DBCEnums.h:58`). In the shipped 5875 table exactly -/// six rows carry it, all top-level zones and all with flags `0x138`: Undercity, Stormwind City, -/// Ironforge, Orgrimmar, Thunder Bluff and Darnassus. (`0x100` = `AREA_FLAG_CAPITAL` selects the -/// identical six in this build; `0x08` is the bit named for this job, so it is the one read.) +/// `AreaTable.dbc` flag `0x08`, the walk's city gate (`0x49a3c1`), set on the six capitals; +/// vmangos names it `AREA_FLAG_SLAVE_CAPITAL`, "Allow trade channel" (`DBCEnums.h:58`). const AREA_FLAG_TRADE_CHANNEL: u32 = 0x08; -/// `AreaTable.dbc` flag `0x200` β€” the sentinel marking the row whose name is the **shared city -/// word** the Trade channel is named after. -/// -/// Not a geographic area at all: the client scans the table once at load for this bit and keeps -/// the row's name at `0xb4e4f0` (single writer `0x4985fd`), then splices it into `Trade - %s`. -/// Exactly one row in the shipped 5875 table carries it β€” **id 3459, `AreaName[enUS] = "City"`** β€” -/// which is the whole reason the word appears nowhere in `WoW.exe`. Reading it here rather than -/// hardcoding `"City"` is what keeps a localized install localized. +/// `AreaTable.dbc` flag `0x200`: the one row (id 3459, "City"), cached at load (`0xb4e4f0`, +/// written at `0x4985fd`), whose localized name the city-named channels take. const AREA_FLAG_CITY_NAME_ROW: u32 = 0x200; -/// The shared city word, scanned out of `AreaTable.dbc` β€” the client's own load-time scan -/// ([`AREA_FLAG_CITY_NAME_ROW`]). `None` when no row carries the sentinel, in which case the -/// city-named rows are skipped rather than joined under a half-formed name. +/// The city word, as the client's load-time scan finds it; `None` skips the city-named rows. pub(crate) fn city_word(areas: &benilla_formats::AreaTableCatalog) -> Option<&str> { areas .rows() @@ -69,42 +34,22 @@ pub(crate) fn city_word(areas: &benilla_formats::AreaTableCatalog) -> Option<&st .filter(|n| !n.is_empty()) } -/// The walk's own state: the zone it last walked, the zone it can act on now, and whether there -/// is a character session to walk for. -/// -/// Deliberately **not** a second copy of the joined list. The reference's pass 1 walks the slot -/// array itself (`0x49a284`, stride `0xa0`) and decides leave-vs-join from each slot's own state -/// (`+0x9c`); this walk does the same over [`ChannelState::joined`] (decision 2144). The request -/// side that once lived here (`held`) fell out the moment 2137 made the walk register its slots -/// at send time β€” from then on the slot array *was* the request side, and a channel joined -/// outside the walk (`/join General`, the guild-recruitment cascade) took a slot the walk did not -/// know about and re-joined it on the next border. +/// The walk's own state; the joined list is not copied here, as the reference's pass 1 walks the +/// slot array itself ([`ChannelState::joined`]). #[derive(Resource, Default)] pub(crate) struct ZoneChannelWalk { - /// The top-level `AreaTable` zone the last walk was composed for. `None` = never walked, or - /// the session ended β€” either way the next in-world frame re-walks from scratch. + /// The top-level zone the last walk ran for; `None` walks from scratch. at: Option, - /// **The zone the walk can act on THIS frame** β€” the reference's `ds:0xb4e314`, the zone id - /// `UpdateZoneText` writes and both the walk (`0x49a243`) and the guild-recruitment cascade - /// (`0x49ead7`) read. `None` while any gate holds it (no session, a cinematic, a world still - /// arriving, no area authority). Published every frame, walked or not, so the cascade one - /// system over resolves the same zone the walk did rather than deriving its own. + /// The zone the walk may act on this frame, the reference's `ds:0xb4e314`, read by the walk + /// (`0x49a243`) and the guild-recruitment cascade (`0x49ead7`); `None` while a gate holds. pub(super) zone_id: Option, - /// **Is there a character session to join channels for?** Armed by `EnteredWorldMessage`, - /// disarmed by [`end_channel_session`] β€” a positive edge in both directions, and the reason - /// this is a field rather than an ordering. - /// - /// Without it the walk is live on the *logout frame*: `ClientState` has not left `InWorld` - /// yet, the avatar is still standing there settled, and the session-end clear has just emptied - /// `at` β€” so the walk faithfully re-diffs from nothing and sends a JOIN for the zone the - /// character is *leaving*. Caught live by the `/logout` + `Enter` probe (1284): two joins - /// stamped at the same millisecond as "tearing down the streamed world". + /// A character session is live: without it the logout frame, still `InWorld` with `at` just + /// cleared, would join the zone being left. live: bool, } impl ZoneChannelWalk { - /// Forget everything: leaving the world took our channel membership with it, so the next entry - /// must re-join rather than assume. See [`end_channel_session`]. + /// Forget the session: the next world entry re-joins from nothing. fn clear_session(&mut self) { self.at = None; self.zone_id = None; @@ -112,28 +57,9 @@ impl ZoneChannelWalk { } } -/// **Channel membership dies with the character session, so this state must die with it too** -/// (1284). -/// -/// Server-side that is not a policy but a destructor: `Player::CleanupChannels` -/// (vmangos `src/game/Objects/Player.cpp:5107`) walks every channel the player is in and leaves -/// it, and it is called from `~Player` and from the logout cleanup. A new character β€” or the same -/// character after a reconnect β€” enters the world in **zero** channels, always. -/// -/// Ours used to survive that boundary, and the director caught it on a character switch: the walk -/// still held the previous character's `General - Tanaris`, so its first diff on the new -/// character sent LEAVE(Tanaris) β€” which the new session is genuinely not in, so the server -/// answered "Not on channel 1. General - Tanaris." β€” and [`ChannelState::joined`] still listed -/// those two dead rows, which is why the real joins came back numbered 3 and 4 instead of 1 and 2. -/// -/// Four things end together because they are one fact: the walk's zone, what the server confirmed -/// ([`ChannelState::joined`]), the VM's mirror of the latter (what `GetChannelName` answers an -/// addon), and the guild-recruitment cascade's watcher (its next login must see the guild id as -/// new). The edit box's channel target goes too β€” it holds the wire name of a channel that no -/// longer exists for us, and a `/2` typed on the new character would otherwise send into it. -/// -/// **The `ZONECHANNELS` mask is deliberately not on that list** (2120): it belongs to the -/// character's file, and the login that reads that file is what seats it. +/// End the channel session: vmangos leaves every channel with the session +/// (`Player::CleanupChannels`, `Objects/Player.cpp:5107`), so the next one starts in none. The +/// mask stays, as it belongs to the character's file. fn end_channel_session( script: Option<&mut benilla_ui::script::UiScript>, channels: &mut ChannelState, @@ -148,13 +74,8 @@ fn end_channel_session( } } -/// Seed a fresh VM's joined-channel mirror (decision 1291). The mirror is otherwise pushed only -/// on the join/leave edges ([`super::feed`]'s YOU_JOINED / YOU_LEFT arms), and a `/reload` -/// replaces the VM *between* edges β€” `ChannelState` (server truth) survives, but the new VM's -/// mirror would stay empty: `GetChannelName`/`GetChannelList` answer nothing, `/1`-`/9` routing -/// is dead, and every channel line renders unnumbered until the player happens to join or leave -/// something. A login goes through [`end_session_channels`] + the auto-join walk instead, where -/// this claim pushes the just-cleared (empty) list β€” a no-op by construction. +/// Seed a fresh VM's joined-channel mirror, which is otherwise pushed only on a join or a leave, so +/// a `/reload` would leave `GetChannelName` and `/N` empty until the next one. pub(super) fn seed_channels( script: Option>, channels: Res, @@ -168,8 +89,7 @@ pub(super) fn seed_channels( } } -/// The session-end edge: a confirmed `/logout` back to the glue layer (`OnExit(InWorld)`), which is -/// the character switch the director's screenshot caught. +/// The session-end edge: a confirmed `/logout` back to the glue screens (`OnExit(InWorld)`). pub(super) fn end_session_channels( script: Option>, mut channels: ResMut, @@ -184,11 +104,8 @@ pub(super) fn end_session_channels( ); } -/// The other end: a socket that died. A **recoverable** drop never leaves `InWorld` (0065 keeps the -/// avatar as the local puppet for the reconnect), so the edge above does not fire for it β€” but the -/// reconnect still builds a fresh `Player` server-side, with the same empty channel list a fresh -/// login has. Both edges therefore clear, and they clear the same things through the same -/// function so neither can drift into being the more thorough one. +/// The other session end, a dead socket: a recoverable drop never leaves `InWorld`, but the +/// reconnect gets a fresh server-side `Player` in no channels, so it clears the same state. pub(super) fn end_session_channels_on_disconnect( script: Option>, mut channels: ResMut, @@ -207,9 +124,7 @@ pub(super) fn end_session_channels_on_disconnect( ); } -/// Startup: read `ChatChannels.dbc` into [`ChannelState`], which owns it because the two things -/// that need it β€” composing the auto-join names and answering a chat event's arg7 β€” are both its -/// business. Absent install β‡’ an empty catalog: no auto-join, arg7 stays 0, nothing errors. +/// Startup: read `ChatChannels.dbc`; without an install nothing auto-joins and arg7 stays 0. pub(super) fn load_chat_channels( mut channels: ResMut, assets: Option>, @@ -228,14 +143,13 @@ pub(super) fn load_chat_channels( } } -/// The name `row` composes to for a player standing in `zone_name`, or `None` when the -/// composition has nothing to substitute: a zone-dependent row with no zone, or a city-named row -/// with no city word. +/// The name `row` composes to in `zone_name`; `None` when there is nothing to substitute. A +/// city-named row takes the city word in every zone here; the reference takes it only in an area +/// with flag `0x100` and the zone name elsewhere (`0x49a2dc`). /// -/// The client has no such guard β€” its capital arm null-checks the DBC row pointer but not the -/// string, so a locale with the sentinel row's name blank would compose exactly `"Trade - "` -/// (`0x49a2d0`–`0x49a2f6`). We decline instead: joining the wrong -/// channel name is invisible and permanent, and joining none is neither. +/// Deviation: with a blank city word the reference composes `"Trade - "` (its capital arm checks +/// the row pointer, not the string, `0x49a2d0`-`0x49a2f6`); we decline, because a wrong channel +/// name is joined silently and for good. fn compose( row: &benilla_formats::ChatChannelRow, zone_name: &str, @@ -250,17 +164,9 @@ fn compose( Some(row.joinable_name(zone_name, city_word.unwrap_or_default())) } -/// The names the walk registers on a character with **no slots yet** β€” every `ChatChannels.dbc` -/// row whose bit is set in `zone_mask` and that composes here, in table order. The city gate is -/// deliberately not applied: this is the set that takes *slots* (decision 2137), and -/// [`wanted_channels`] is the subset the gate then lets the walk join. A test lens over -/// [`plan_walk`]'s pass 2 β€” the walk itself plans over the slot array. -/// -/// The reference registers the slot (`0x49b980`) *before* it asks whether the row is eligible: -/// `0x49a50d` precedes the city gate at `0x49a512`. So a character standing outside a capital -/// still holds a `Trade - City` slot β€” created, never joined, state 3 β€” and it still occupies -/// **number 2**, because the `N.` a channel line carries is `slot[+0x00]`, the client-local index, -/// never the `ChatChannels.dbc` ChannelID. +/// The names the walk registers on a character with no slots, in table order: the reference +/// registers a slot (`0x49a50d`) before its city gate (`0x49a512`), so a city-only row takes its +/// number even outside a capital. The `N.` of a channel line is that slot, never the ChannelID. #[cfg(test)] pub(crate) fn tracked_channels( catalog: &ChatChannelsCatalog, @@ -276,9 +182,8 @@ pub(crate) fn tracked_channels( .collect() } -/// [`tracked_channels`] with the city gate applied β€” the names the walk actually **joins** from -/// `zone_name`. `in_city` gates the city-**only** rows (the `0x10` bit); `city_word` is what a -/// city-**named** row (`0x20`) puts in its `%s`. +/// [`tracked_channels`] with the city gate applied: the names the walk joins. `in_city` gates the +/// city-only rows (`0x10`); `city_word` fills a city-named row's (`0x20`) `%s`. #[cfg(test)] pub(crate) fn wanted_channels( catalog: &ChatChannelsCatalog, @@ -296,60 +201,32 @@ pub(crate) fn wanted_channels( .collect() } -/// One step of a walk, in **wire order** β€” the walk is a plan over the slot array, computed pure -/// and applied in sequence, so that the exact packet-and-state sequence a border produces is a -/// unit-testable value (decision 2144; the harness this module's own tests used to say was -/// missing). +/// One step of a walk, in wire order. #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) enum WalkStep { - /// `CMSG_LEAVE_CHANNEL` with the slot's OLD name (`0x49a367`) β€” only from a server-confirmed - /// slot (`0x49a35e`). + /// `CMSG_LEAVE_CHANNEL` of the old name (`0x49a367`), from a confirmed slot only (`0x49a35e`). Leave(String), /// `CMSG_JOIN_CHANNEL` (`0x49a3e1` in pass 1, `0x49a56e` in pass 2). Join(String), - /// `0x49bc50` β€” the slot renamed **in place, at send time**, before the server can answer the - /// LEAVE that just went out ([`ChannelState::rename_slot`] carries the whole why). + /// `0x49bc50`: renamed in place at send time ([`ChannelState::rename_slot`]). Rename { old: String, new: String }, - /// `0x49b980` β€” a slot claimed for a row not yet tracked, **before** the gate (2137). + /// `0x49b980`: a slot claimed for an untracked row, before the city gate. Register(String), - /// `0x49bcf0` β€” state 3: the row stopped applying; the record and its number survive. + /// `0x49bcf0`: state 3, the row stopped applying; the record and its number survive. Suspend(String), - /// The state-3 bypass's own write, with the name unchanged: `0x49bc50` writes - /// `(old == 3) ? 1 : 2`, so a suspended slot re-joining under its own name goes to **1** β€” - /// a plain `YOU_JOINED` when it confirms, never `YOU_CHANGED`. + /// The state-3 bypass with the name unchanged: `0x49bc50` writes `(old == 3) ? 1 : 2`, so the + /// slot confirms with a plain `YOU_JOINED`. Confirm(String), } -/// **The walk, as a plan** β€” `ZoneChannelRefresh 0x49a210`'s two passes over the current slot -/// array and the mask, for a player standing in `zone_name`. -/// -/// **Pass 1 β€” the slots already held**, in slot order (`0x49a284`), each one whose name resolves -/// to a `ChatChannels.dbc` row. The row's name is composed for this zone, **whatever the mask -/// says** β€” the reference's pass 1 reads no mask bit, and a slot exists only because a join -/// confirmed it or a walk registered it. Then, per slot: -/// -/// - **name unchanged, slot suspended** β‡’ the state-3 bypass (`0x49a31c`): eligible here β‡’ -/// `Confirm` + `Join` β€” walking back into a city re-joins `Trade - City` under a name that never -/// changed; not eligible β‡’ nothing. -/// - **name unchanged, otherwise** β‡’ nothing, unless the row stopped being eligible: then -/// `Leave` (only if server-confirmed) and `Suspend` β€” walking out of the city; the LEAVE goes -/// out *before* the gate (`0x49a3b8`) suspends the slot because it sits earlier in the same -/// iteration. -/// - **name moved** β‡’ `Leave(old)` if server-confirmed (`0x49a35e`), then the gate: eligible β‡’ -/// `Rename` + `Join(new)`; not eligible β‡’ `Suspend(old)`. -/// - **the row no longer composes here** (no city word for a city-named row) β‡’ treated as not -/// eligible: `Leave` if confirmed, `Suspend`. The reference would compose a half-formed name -/// instead ([`compose`]). -/// -/// **Pass 2 β€” rows with no slot** (`0x49a468`), in DBC order, **only those whose mask bit is set** -/// (`0x49a494`, the live predicate β€” decision 2144). Each composes, takes a slot (`Register`, -/// before the gate), and then `Join`s or is `Suspend`ed. This is the one place the mask decides: -/// a `/leave General` clears bit 0 and frees the slot, and from then on β€” this session and every -/// later one, because the mask is the character's file β€” General is never registered again until -/// a `/join General` confirms and sets the bit back. -/// -/// LEAVE strictly precedes JOIN per slot, interleaved across slots β€” never a batch of leaves then -/// a batch of joins. The retail 1.8.1 Winterspring sniff shows exactly this shape. +/// The walk as a plan: `ZoneChannelRefresh 0x49a210`'s two passes, for a player in `zone_name`. +/// Pass 1 (`0x49a284`) composes each held slot, in slot order, for this zone whatever the mask +/// says. A moved name leaves if confirmed (`0x49a35e`), then the city gate (`0x49a3b8`) suspends +/// it or renames and re-joins it; a suspended slot the gate admits re-joins (the state-3 bypass, +/// `0x49a31c`). A row that stopped applying under an unchanged name, or no longer composes, leaves +/// and suspends here, where the reference skips any other unchanged name (`0x49a337`): its +/// city-named rows move to the zone name outside a capital ([`compose`]). Pass 2 (`0x49a468`) +/// registers the unslotted rows whose mask bit is set (`0x49a494`), in DBC order, before the gate. pub(crate) fn plan_walk( channels: &ChannelState, zone_name: &str, @@ -426,8 +303,7 @@ pub(crate) fn plan_walk( steps } -/// Apply a plan in order: packets to the wire, slot writes to [`ChannelState`]. Answers whether -/// the slot *names* moved (a rename or a registration), which is the VM mirror's cue. +/// Apply a plan in order; answers whether a slot name moved, the VM mirror's cue. fn apply_walk(steps: &[WalkStep], channels: &mut ChannelState, commands: &NetCommands) -> bool { let mut names_moved = false; for step in steps { @@ -472,63 +348,27 @@ fn apply_walk(steps: &[WalkStep], channels: &mut ChannelState, commands: &NetCom names_moved } -/// Is the zone under the player the one they are actually standing in? -/// -/// The walk's gate (1280). Three states answer no, and each one cost real wire traffic before this -/// existed: no avatar at all, an avatar not yet active, and β€” the expensive one β€” an avatar still -/// **settling**, which is benilla's stand-in for the reference's loading screen: the body has been -/// snapped but the destination's terrain and WMOs are still arriving, so the leaf area under it is -/// still moving. The reference walks *after* that window, always; we polled inside it. +/// Whether the zone under the player is final: an active avatar done settling, benilla's stand-in +/// for the loading screen the reference walks after. fn zone_is_settled(player: Option<&crate::player::Player>) -> bool { player.is_some_and(|p| p.active && !p.settling) } -/// The walk: re-join the zone channels whenever the zone changes, and drop the ones that no -/// longer apply. +/// The walk, re-run whenever the zone changes. It composes from the real zone name, the +/// `GetRealZoneText` cache (`0xb4b404`), never the display text, which the indoor override +/// (`0x67e670`) renames. /// -/// Reads the zone the same way [`crate::area`]'s splash does β€” the leaf `AreaTable` id under the -/// player, walked to its top-level parent β€” but takes the **real** zone name rather than the -/// display text. That is not a preference: the client composes from `GetRealZoneText`'s own cache -/// (`0xb4b404`, written from the parent zone's `AreaName_lang`), and the indoor/WMO name override -/// `0x67e670` rewrites only the slot feeding `GetZoneText`, never the one that becomes a channel -/// name. So a building never renames your channel. -/// -/// **Timing:** the client re-walks inside `UpdateZoneText 0x494780` on every zone change (callsite -/// `0x494931`), immediately *before* it fires `ZONE_CHANGED_NEW_AREA`, plus once at world -/// entry. This polls the same two inputs each frame and early-outs when they have not moved, which -/// reaches the same states; it is a poll rather than a hook because the zone is already derived -/// here, not published as an event payload. -/// -/// **…but NOT while the world is still arriving** ([`Player::settling`], 1280). The reference's -/// "world entry" is behind a loading screen: by the time it walks, the destination ADT and its WMOs -/// are resident and the zone under the player is final. Ours streams asynchronously, so the leaf -/// area moves *twice* on the way in β€” first while the body still holds a pre-snap position (the -/// map centre: tile 32,32 is Eastern Plaguelands on map 0, The Barrens on map 1 β€” the two bogus -/// zones in the director's login screenshot), then again when the WMO interior claim lands a beat -/// after the outdoor MCNK area (Tanaris β†’ Caverns of Time, reproduced live). Each transient cost a -/// real JOIN and a real LEAVE on the wire, for zones the player was never in β€” chat lines and all. -/// `settling` is exactly the loading screen's own gate (released by the terrain streamer once the -/// destination is resident, decision 0737), which makes this the reference's timing rather than an -/// arbitrary debounce. -/// -/// **…and NOT before the chat cache has seated the mask** ([`ChannelState::zone_mask`] is `None` -/// until then). The reference's walk bails on its "chat system ready" flag (`0x49a219`), which the -/// cache loader sets as its last act before calling the walk itself (`0x499a18`/`0x499a22`). The -/// mask is the walk's row set (decision 2144), so a walk before the seat would join nothing and -/// nothing would call it again. -/// -/// **Selection is the `ZONECHANNELS` mask** β€” the reference's live predicate `0x49a494`, never -/// `flags & INITIAL` read fresh. The DBC bit only seeds the mask on a character with no file -/// (`0x4997fc`). This is what makes `/leave General` stick: the leave clears the bit in the -/// character's file, and the next login's walk never registers the row ([`plan_walk`]). +/// The reference walks in `UpdateZoneText 0x494780` on each zone change (`0x494931`) and once at +/// world entry; this polls the same inputs every frame. It waits while the world arrives +/// (`Player::settling`), as the reference walks after its loading screen and our leaf area moves +/// while terrain streams in, and for the chat cache to seat the mask, as the reference bails on +/// its ready flag (`0x49a219`) until the cache loader calls the walk (`0x499a22`). pub(super) fn auto_join_zone_channels( commands: Res, mut channels: ResMut, areas: Option>, world: benilla_world::world_point::WorldPoint, - // One param (clippy's argument ceiling), and they belong together: both answer "is the zone - // under the player the one this walk may act on?" β€” the body's own settle, and whether the - // view has flown off it for a cinematic. + // One parameter for clippy's argument limit: the body's settle and a cinematic's view. body: ( Option>, Option>, @@ -539,31 +379,19 @@ pub(super) fn auto_join_zone_channels( mut catalog_fed: Local>>, ) { let (player, cinematic) = (&body.0, &body.1); - // World entry arms the walk; the session-end clears disarm it ([`end_session_channels`] and its - // disconnect twin, which own the whole fact β€” walk, confirmed list, VM mirror, edit target). + // World entry arms the walk; the session-end edges disarm it. if entered.read().next().is_some() { walk.live = true; } - // **A cinematic suppresses the rejoin, and it resumes when the shot ends.** Two of the ten - // sites that read the reference's cinematic-state cell exist for exactly this β€” `0x49491e` - // (the zone-text update) and `0x5ff566` (a `UPDATEFLAGS` reflex) both skip - // `ZoneChannelRefresh` (`0x49a210`) while one runs, and `EndCinematic` calls it once at - // `0x48f1d0` (the cell is `[0xb4e310]`; the ten sites are its complete census). - // - // The walk stays armed and only its *zone* goes unknown, so "rejoin once at the end" is what - // the first frame after the shot already does β€” there is nothing to re-arm. It matters because - // a race intro flies the streaming focus hundreds of yards off the body, so `world.area()` - // changes under a player who has not moved, and the joins would otherwise fire against zones - // they are only *looking* at. + // A cinematic suppresses the rejoin: two of the six reads of the reference's cinematic cell + // `[0xb4e310]`, `0x49491c` (the zone-text update) and `0x5ff565` (an `UPDATEFLAGS` reflex), + // skip `ZoneChannelRefresh` (`0x49a210`) while one runs, and `EndCinematic` calls it at + // `0x48f1d0`. A race intro flies the streaming focus far off a body that has not moved. let cinematic_running = cinematic.as_deref().is_some_and(|c| c.is_playing()); let areas = areas.as_deref(); - // **The zone the walk may act on** β€” `None` whenever any gate says the leaf under the player - // is not the one they are standing in: no character session yet ([`ZoneChannelWalk::live`]), a - // cinematic flying the streaming focus off the body, a world still arriving ([`zone_is_settled`]), - // or an area authority with no answer for this session (decision 2130 made that a real state - // again rather than the previous character's zone). Published for the cascade beside it. + // `None` with no session, during a cinematic, while the world arrives, or with no area answer. let zone_id = (walk.live && !cinematic_running && zone_is_settled(player.as_deref())) .then(|| { let areas = areas?; @@ -575,25 +403,10 @@ pub(super) fn auto_join_zone_channels( .and_then(|id| areas?.0.get(id)) .map(|row| (row.name.clone(), row.flags & AREA_FLAG_TRADE_CHANNEL != 0)); - // **The VM's zone-channel catalog is a `ChatChannels.dbc` fact, and it is fed BEFORE any of - // those gates** β€” because the thing the verbs read out of it is the row **Shortcut**, which no - // zone ever changes. - // - // It used to be fed only once a zone was known, and an empty catalog is not "no zone yet" to - // any of its three readers β€” it is *"no such built-in channel"*. `AddChatWindowChannel(1, - // "General")` took the no-match leg and stored `("General", 0)` as a **custom** channel, which - // the chat cache then wrote out as a name in the window's `CHANNELS` block with its zone bit - // dropped from `ZONECHANNELS`; the director's own `Onewarrior` file carries exactly that - // damage, and a window that has lost a zone channel's id drops every line the stock - // `ChatFrame_OnEvent` matches by id (ref `ChatFrame.lua` l.1379). `JoinChannelByName("General")` - // was worse: `(0, nil)` is the custom-channel leg, so it **sent `CMSG_JOIN_CHANNEL("General")`** - // β€” a real custom channel of that name on the server. - // - // Fed zone-less, every zone-dependent row carries `resolved: None`, which is the reference's - // own "matched a row but there is no zone text yet" leg: `AddChatWindowChannel` stores nothing - // and answers nil (`0x4a10d9`), `JoinChannelByName` returns nil and sends nothing - // (`0x49ece5`, decision 1908). Right answer instead of a wrong one, and the walk re-feeds the - // moment a zone lands. + // The VM's catalog is fed before those gates: to its verbs an empty catalog means "no such + // built-in channel", which files `General` as a custom channel and joins one of that name. + // Zone-less, a zone row carries `resolved: None`, the reference's nil leg (`0x4a10d9`, + // `0x49ece5`). let mut script = script; if !channels.channels.is_empty() { if let Some(script) = script.as_mut() { @@ -615,40 +428,24 @@ pub(super) fn auto_join_zone_channels( return; // nothing to walk against yet }; if channels.zone_mask.is_none() { - return; // the chat cache has not seated the mask β€” the reference's ready-flag bail + return; // the mask is not seated: the reference's ready-flag bail } if walk.at == Some(zone_id) { - return; // same zone as last frame β€” the common case, and free + return; // same zone as last frame } let steps = plan_walk(&channels, &zone_name, in_city, city_word(&areas.0)); if apply_walk(&steps, &mut channels, &commands) { if let Some(script) = script.as_mut() { - // `GetChannelName(n)` answers the new name from this frame on, as it does in the - // reference β€” the slot moved, not the numbering. + // `GetChannelName(n)` answers the new name from this frame on, as in the reference. script.set_joined_channels(channels.names()); } } walk.at = Some(zone_id); } -/// **The zone-channel catalog goes into the VM before a single interface file runs** (decision -/// 2241, through the seam 2240 established). -/// -/// The walk above already feeds it "before any of those gates" β€” but its gates are the *zone's*, -/// and the load edge is earlier than all of them: since 2226 the entry mints a fresh VM and runs -/// FrameXML, every addon's file scope, `ADDON_LOADED`, `VARIABLES_LOADED` and `PLAYER_LOGIN` inside -/// one exclusive call, and this system's first `Update` tick is after that whole burst. An empty -/// catalog is not "no zone yet" to the three verbs that read it β€” it is *"no such built-in -/// channel"*, the leg whose damage is documented at length above: a `General` filed as a **custom** -/// channel in the chat cache, and a real `CMSG_JOIN_CHANNEL("General")` on the wire. -/// -/// Zone-less on purpose: at this edge the zone is unknowable by construction (the walk is not armed -/// β€” `EnteredWorldMessage` is read in `Update` β€” and the body is still settling), and zone-less is -/// the right content rather than a degraded one, for the reason [`auto_join_zone_channels`] gives. -/// The walk re-feeds with the resolved names the moment a zone lands, and pays one redundant -/// identical push per login for it: its memo is a `VmMemo` on its own `Local`, which this call -/// cannot reach, and a resource it could reach would not survive a `/reload` correctly. +/// Feed the VM the zone-less catalog before any interface file runs: world entry runs FrameXML, +/// the addons and `PLAYER_LOGIN` in one call, ahead of the walk's first tick. pub(crate) fn seed_zone_channel_catalog( world: &mut World, script: &mut benilla_ui::script::UiScript, @@ -657,7 +454,7 @@ pub(crate) fn seed_zone_channel_catalog( return; }; if channels.channels.is_empty() { - return; // no `ChatChannels.dbc` β€” the no-install case, a no-op exactly as in the walk + return; // no `ChatChannels.dbc`: nothing to feed, as in the walk } let city = world .get_resource::() @@ -665,11 +462,9 @@ pub(crate) fn seed_zone_channel_catalog( script.set_zone_channel_catalog(zone_channel_catalog(&channels.channels, "", false, city)); } -/// Every `ChatChannels.dbc` row as the VM needs it (decision 1908): the id, the Shortcut the verbs -/// compare a typed name against (`0x4a10b2`), the name composed for `zone_name` β€” `None` when the -/// composition has nothing to substitute (a zone-dependent row with no zone, a city row with no -/// city word), which is the verbs' nil leg β€” and whether `EnumerateServerChannels 0x4a1790` lists -/// it here (a city-only row, `flags & 0x10`, only in a city). +/// Every `ChatChannels.dbc` row as the VM needs it: the id, the Shortcut the verbs match a typed +/// name against (`0x4a10b2`), the name composed here (`None` is the verbs' nil leg), and whether +/// `EnumerateServerChannels 0x4a1790` lists it (a city-only row, `flags & 0x10`, only in a city). pub(crate) fn zone_channel_catalog( catalog: &ChatChannelsCatalog, zone_name: &str, @@ -694,8 +489,7 @@ mod tests { use benilla_formats::ChatChannelRow; use WalkStep as W; - /// The shipped table, hand-built so this runs without an install (the real rows are asserted - /// against the DBC in `benilla_formats::chat_channels`). + /// The shipped table, hand-built so the tests run without an install. fn catalog() -> ChatChannelsCatalog { ChatChannelsCatalog::from_rows( [ @@ -717,16 +511,14 @@ mod tests { ) } - /// The city word as the shipped `AreaTable.dbc` supplies it (row 3459, the `Flags & 0x200` - /// sentinel β€” see [`city_word`]). + /// The city word, row 3459 of the shipped `AreaTable.dbc`. const CITY: Option<&str> = Some("City"); - /// A fresh character's mask: the DBC's three `INITIAL` rows, `0x200003` (seeded at `0x4997fc`). + /// A fresh character's mask: the DBC's three `INITIAL` rows (seeded at `0x4997fc`). const SEED: u32 = 0x0020_0003; - /// `1 << (25 - 1)` β€” `GuildRecruitment`'s bit, which the cascade's confirmed join adds. + /// `GuildRecruitment`'s bit, `1 << (25 - 1)`, which the cascade's confirmed join sets. const GUILD_RECRUITMENT_BIT: u32 = 1 << 24; - /// A [`ChannelState`] over the shipped table with the mask seated at `mask`. fn state(mask: u32) -> ChannelState { ChannelState { channels: catalog(), @@ -750,8 +542,7 @@ mod tests { v.to_string() } - /// Out in the world: General and LocalDefense, both zone-named. Trade is a city channel and - /// stays out; WorldDefense, LookingForGroup and GuildRecruitment have no bit in a fresh mask. + /// WorldDefense, LookingForGroup and GuildRecruitment have no bit in a fresh mask. #[test] fn an_ordinary_zone_joins_general_and_local_defense() { assert_eq!( @@ -760,7 +551,6 @@ mod tests { ); } - /// Inside a capital, Trade joins too β€” under the one shared name, not the city's own. #[test] fn a_capital_adds_the_shared_trade_channel() { assert_eq!( @@ -773,21 +563,13 @@ mod tests { ); } - /// No zone name β‡’ no zone-dependent join. "General - " is a real channel on the server, and - /// joining it would be a bug nothing else would ever report. + /// "General - " would be a real channel on the server. #[test] fn an_unknown_zone_joins_nothing_zone_dependent() { assert!(wanted_channels(&catalog(), SEED, "", false, CITY).is_empty()); assert!(wanted_channels(&catalog(), SEED, "", true, CITY).is_empty()); } - /// **The walk does not believe the zone until the world has settled** (1280). - /// - /// The director's login screenshot is what this guards: three zones' channels joined on the - /// way in β€” two of them the map centre (tile 32,32 = Eastern Plaguelands on map 0, The Barrens - /// on map 1), read while the body still sat at its pre-snap position β€” and then leave requests - /// for zones they had never been in, which the server answered "Not on channel …". Every one - /// of those lines existed because the walk ran a frame too early. #[test] fn the_walk_waits_for_the_world_under_the_player() { use crate::player::Player; @@ -809,15 +591,6 @@ mod tests { assert!(zone_is_settled(Some(&p)), "settled: the zone is now final"); } - /// **Leaving the world ends the channel session β€” every half of it but the mask** (1284, - /// 2120). - /// - /// The director's character switch: the walk still held the previous character's - /// `General - Tanaris`, so its first diff on the new character sent a LEAVE the new session - /// answered "Not on channel 1. General - Tanaris.", and the stale confirmed list pushed the - /// real joins to slots 3 and 4. Server-side there is nothing to keep β€” `Player::CleanupChannels` - /// runs in `~Player` β€” so every one of these must be empty at the next world entry. The mask - /// is the one survivor: it is the character's file, not the session's. #[test] fn leaving_the_world_ends_the_channel_session() { let mut channels = state(SEED); @@ -833,8 +606,7 @@ mod tests { let mut cascade = GuildRecruitmentCascade::default(); cascade.observe_guild_id(7); - // No VM in a unit test; the mirror leg is the one line this cannot reach, and - // `end_session_channels` is a thin wrapper over exactly this call. + // No VM in a unit test: the mirror leg is the one line this cannot reach. end_channel_session(None, &mut channels, &mut walk, &mut cascade); assert_eq!(walk.at, None, "the next entry re-walks from scratch"); @@ -857,8 +629,7 @@ mod tests { ); } - /// No city word (a locale whose sentinel row ships blank) β‡’ the city-named rows are skipped, - /// not composed as `"Trade - "`. The client has no such guard; declining is ours. + /// A locale whose sentinel row ships blank: the deviation in `compose`. #[test] fn a_missing_city_word_skips_the_trade_channel_rather_than_half_naming_it() { assert_eq!( @@ -867,9 +638,6 @@ mod tests { ); } - /// A zone change **renames** a row rather than adding one: both zones' General lines carry - /// ChannelID 1, which is what makes the walk's diff a leave/join pair on one row instead of an - /// unrelated drop and add. #[test] fn crossing_a_zone_border_renames_the_same_rows() { let cat = catalog(); @@ -885,10 +653,6 @@ mod tests { assert_eq!(ids(&felwood), vec![1, 22]); } - /// **A fresh character's first walk, out in the world** β€” pass 2 over the mask, in DBC order: - /// every composable mask row takes a slot *before* the gate decides join-versus-suspend - /// (decision 2137), which is what gives `Trade - City` number 2 on a character nowhere near a - /// city. `/2` therefore reaches Trade here, as it does in the reference. #[test] fn a_first_walk_registers_every_mask_row_before_the_gate() { let mut c = state(SEED); @@ -904,14 +668,12 @@ mod tests { W::Join(s("LocalDefense - Elwynn Forest")), ] ); - // Applied, the numbering is 1 General, 2 Trade, 3 LocalDefense. apply_steps(&steps, &mut c); assert_eq!(c.number_of("General - Elwynn Forest"), Some(1)); assert_eq!(c.number_of("Trade - City"), Some(2)); assert_eq!(c.slot_state("Trade - City"), Some(SlotState::Suspended)); assert_eq!(c.number_of("LocalDefense - Elwynn Forest"), Some(3)); - // Inside a capital every row joins, and the numbering agrees β€” the case that hid 2137. let steps = plan_walk(&state(SEED), "Stormwind City", true, CITY); assert_eq!( steps, @@ -926,7 +688,7 @@ mod tests { ); } - /// Apply a plan the way the system does, minus the wire β€” the slot writes only. + /// Apply a plan's slot writes as the system does, without the wire. fn apply_steps(steps: &[WalkStep], c: &mut ChannelState) { for step in steps { match step { @@ -945,10 +707,7 @@ mod tests { } } - /// **A border crossing is LEAVE(old) β†’ rename β†’ JOIN(new), per slot, interleaved, in slot - /// order** β€” pass 1 over the slot array (`0x49a284`; the retail 1.8.1 Winterspring sniff is the - /// control). The suspended Trade slot in the middle is walked too and does nothing: its - /// name never moves and it is not eligible here. + /// The suspended Trade slot between the two is walked too, and stays as it is. #[test] fn a_border_crossing_is_leave_rename_join_per_slot_in_slot_order() { let mut c = state(SEED); @@ -989,11 +748,7 @@ mod tests { assert_eq!(c.number_of("LocalDefense - Winterspring"), Some(3)); } - /// **The slot number never moves across the whole city round trip** (decision 2137), and the - /// walk drives exactly the reference's state sequence for `Trade - City`: joined on the way - /// in, LEAVE-then-suspend on the way out (`0x49bcf0`, record kept), and back to *joined at - /// send time* on the way back in β€” the state-3 bypass, where the reference's rename writes - /// `(old == 3) ? 1 : 2` and so lands on **1**: a plain `YOU_JOINED`, never `Changed Channel`. + /// Trade suspends on the way out (`0x49bcf0`) and re-joins through the state-3 bypass. #[test] fn a_city_round_trip_keeps_the_slot_and_its_number() { let mut c = state(SEED); @@ -1036,8 +791,7 @@ mod tests { c.confirm_slot("General - Elwynn Forest"); c.confirm_slot("LocalDefense - Elwynn Forest"); - // Back in: the bypass sends the join AND clears the suspension in the same pass, with no - // LEAVE β€” the player is not on the channel. + // Back in: the bypass joins and clears the suspension, with no LEAVE. let steps = plan_walk(&c, "Stormwind City", true, CITY); assert_eq!( steps, @@ -1063,14 +817,8 @@ mod tests { assert_eq!(c.number_of("Trade - City"), Some(2), "still slot 2"); } - /// **`/leave General` stays left β€” this session and the next** (decision 2144, the director's - /// report: *"/leave General does not survive a relog"*). - /// - /// The explicit leave clears the row's bit (`0x49f10a`) and the server's `YOU_LEFT` frees the - /// slot (`0x49bbd0`). From then on pass 1 has no General slot to walk and pass 2 skips the row - /// on its bit (`0x49a494`) β€” on the next border here, and on the next login, because the mask - /// is what the character's file carries. Before 2144 the walk read `flags & INITIAL` fresh - /// and re-joined General on the very next border. + /// The explicit leave clears the bit (`0x49f10a`) and `YOU_LEFT` frees the slot (`0x49bbd0`), + /// so neither pass walks General again (`0x49a494`), this session or the next. #[test] fn a_left_channel_stays_left_this_session_and_the_next() { let mut c = state(SEED); @@ -1086,7 +834,7 @@ mod tests { assert_eq!(target, "General - Elwynn Forest", "the slot's wire name"); c.note_zone_channel_left(&target); assert_eq!(c.zone_mask, Some(SEED & !1), "bit 0 cleared"); - // …and the server's YOU_LEFT frees the slot, in place. + // The server's `YOU_LEFT` frees the slot in place. assert_eq!(c.free_slot(&target), Some(1)); // The next border: General is neither walked (no slot) nor registered (no bit). @@ -1117,7 +865,7 @@ mod tests { "General is gone for good; Trade is now number 1" ); - // …until a `/join General` confirms and sets the bit back. + // Until a `/join General` confirms and sets the bit back. let mut relog = relog; apply_steps(&steps, &mut relog); relog.note_zone_channel_joined("General - Elwynn Forest"); @@ -1130,10 +878,7 @@ mod tests { ); } - /// A mask of zero is a legitimate reference state β€” a player who left every zone channel β€” - /// and it walks nothing. An unseated mask walks nothing either, for the opposite reason: the - /// file has not been read yet, and the system-level gate keeps `at` unset so the walk runs - /// once it has. + /// Zero is a real state, every zone channel left; unseated means the file is not read yet. #[test] fn a_zero_or_unseated_mask_registers_nothing() { assert!(plan_walk(&state(0), "Elwynn Forest", false, CITY).is_empty()); @@ -1146,11 +891,8 @@ mod tests { assert!(!unseated.zone_row_wanted(1)); } - /// **`GuildRecruitment` rides the walk like Trade once its bit is set** β€” which is the whole - /// of how the reference keeps an unguilded player in `GuildRecruitment - City` after the - /// cascade's first join (decision 2144): the confirmed join ORs bit 24, and from then on the - /// walk registers, suspends and re-joins it through the same city gate (`flags & 0x10`) and - /// the same city name (`flags & 0x20`). The DBC row order puts it last. + /// Once the cascade's confirmed join sets bit 24, `GuildRecruitment` takes the same city gate + /// (`0x10`) and city name (`0x20`) as Trade, last in DBC order. #[test] fn the_guild_recruitment_bit_rides_the_walk_like_trade() { let steps = plan_walk( @@ -1182,11 +924,7 @@ mod tests { ); } - /// **A channel joined outside the walk is walked from its slot** β€” a `/join General` after a - /// `/leave`, or the cascade's own `GuildRecruitment - City` join. The reference's pass 1 is - /// the slot array, so it needs no separate record of what the walk asked for; ours used to - /// keep one (`held`), and a slot it did not know about was registered and joined a second - /// time on the next border. + /// A `/join General` after a `/leave`, or the cascade's own join: pass 1 is the slot array. #[test] fn a_channel_joined_outside_the_walk_is_walked_from_its_slot() { let mut c = state(SEED | GUILD_RECRUITMENT_BIT); @@ -1209,8 +947,8 @@ mod tests { assert!(!steps.iter().any(|st| matches!(st, W::Register(_)))); } - /// A suspended slot whose name moved re-joins **without a LEAVE** (state β‰  0 sends none) and - /// through the rename's `(old == 3) ? 1 : 2` β€” landing on 1, a plain `YOU_JOINED`. + /// State 3 sends no LEAVE, and the rename's `(old == 3) ? 1 : 2` lands it on 1, a plain + /// `YOU_JOINED`. #[test] fn a_suspended_slot_whose_name_moved_rejoins_without_a_leave() { let mut c = state(SEED); @@ -1234,9 +972,6 @@ mod tests { ); } - /// The numeric leg of leave-by-name ([`ChannelState::leave_target`], `0x49ee70` step 1): a - /// non-zero leading integer names a **confirmed** slot or makes the call a no-op; anything else - /// is already the wire name, composed by the VM or passed through. #[test] fn leave_target_resolves_a_number_to_a_confirmed_slot_or_to_nothing() { let mut c = state(SEED); @@ -1276,16 +1011,13 @@ mod tests { ); assert_eq!(c.leave_target("mychan").as_deref(), Some("mychan")); - // …and the mask clear needs a slot carrying the wire name (`0x49f0f4`). + // The mask clear needs a slot carrying the wire name (`0x49f0f4`). c.note_zone_channel_left("General - Nowhere"); assert_eq!(c.zone_mask, Some(SEED), "no slot carries it: no clear"); c.note_zone_channel_left("General - Elwynn Forest"); assert_eq!(c.zone_mask, Some(SEED & !1)); } - /// The two lists never disagree about *resolvability* β€” only about the city gate. A missing - /// city word or an unknown zone drops the row from both, so a slot is never taken for a name - /// we could not compose. #[test] fn the_slot_set_and_the_join_set_differ_only_by_the_city_gate() { let cat = catalog(); @@ -1300,18 +1032,8 @@ mod tests { ); } - /// **A catalog fed with no zone is not the same thing as no catalog** (decision 2130). - /// - /// The VM's three readers all treat an *empty* catalog as "no such built-in channel": - /// `AddChatWindowChannel` stores the name as a custom channel with id 0, and - /// `JoinChannelByName` returns `(0, nil)` β€” the custom leg β€” and **sends - /// `CMSG_JOIN_CHANNEL("General")`**. Fed zone-less instead, every zone-dependent row is present - /// but carries `resolved: None`, which is the reference's own "matched a row, no zone text yet" - /// leg: store nothing, answer nil, send nothing (`0x4a10d9`, `0x49ece5`). - /// - /// That gap is what wrote `CHANNELS / General / LocalDefense` into the director's `Onewarrior` - /// window block with the zone bits stripped, and it was open on every login until the world - /// settled β€” because the catalog used to be fed only once a zone was known. + /// Zone-less is not empty: every row stays, and a zone-dependent one carries `resolved: None`, + /// the reference's nil leg (`0x4a10d9`, `0x49ece5`), not the custom-channel leg. #[test] fn a_zoneless_catalog_still_carries_every_row() { let rows = zone_channel_catalog(&catalog(), "", false, None); @@ -1331,7 +1053,7 @@ mod tests { "a row whose name carries no %s resolves with no zone at all" ); - // And the moment a zone lands, the same rows resolve. + // Once a zone is known, the same rows resolve. let rows = zone_channel_catalog(&catalog(), "Elwynn Forest", false, CITY); let by = |s: &str| rows.iter().find(|r| r.shortcut == s).unwrap().clone(); assert_eq!( @@ -1344,8 +1066,8 @@ mod tests { ); } - /// arg7: the composed names resolve back to their `ChatChannels.dbc` id, custom ones to 0 β€” - /// matched by the server's own substring rule. + /// arg7: a composed name maps back to its ChannelID by vmangos's substring match + /// (`DBCStores.cpp:531`); a custom one to 0. #[test] fn composed_names_carry_their_channel_id_back() { let cat = catalog(); diff --git a/crates/benilla-app/src/ui_chat/combat.rs b/crates/benilla-app/src/ui_chat/combat.rs index a86a5e2d..1507c648 100644 --- a/crates/benilla-app/src/ui_chat/combat.rs +++ b/crates/benilla-app/src/ui_chat/combat.rs @@ -1,37 +1,7 @@ -//! The combat log's chat lines β€” the content pipeline decision 0288 Β§3 named out of its own scope -//! ("transcribing ATTACKERSTATEUPDATE/spell-log packets into COMBATHIT strings is the combat-log -//! arc"), and B297 is the bill for leaving it. The wire has been decoded for a long time; it fed -//! the floating numbers, the portrait indicator and the center text, and produced **no chat at -//! all**, which is why a damage meter or Quiver's TranqAnnouncer sees nothing to parse. -//! -//! This module is the reference's `UnitCombatLog_C` TU (`~0x625000-0x62e7xx`), the presentation -//! half: it owns **no** arithmetic, only the -//! composition β€” classify both endpoints, pick a chat type, pick a GlobalString *key*, fill it. -//! The reference's own chain is four hops and we take the same four: -//! -//! ```text -//! packet β†’ classify(attacker), classify(victim) β†’ 0..9 (the range table names them) -//! β†’ msgType selector β†’ the chat type (0x5e = drop) -//! β†’ format-string selector β†’ a GlobalString KEY + a variant code -//! β†’ FrameScript_GetText(key) β†’ vsnprintf β†’ the line -//! ``` -//! -//! **We ship keys, never Blizzard's text.** The reference's selectors (`0x62a290`, `0x629f90`, the -//! other 43) return the *name* of a localized template; `0x703bf0` (`FrameScript_GetText`) resolves -//! it, and when it comes back empty `0x6269f0` prints "Warning: string %s not found" instead of a -//! line. benilla already runs the player's own `Interface\FrameXML\GlobalStrings.lua` into the VM -//! at boot ([`crate::ui_script`]'s `load_global_strings`), so [`global_string`] is that same hop: -//! the key is ours, the text is the install's. That is the licence-clean shape *and* the faithful -//! one β€” and it is why a non-enUS install gets its own language for free. -//! -//! **The slot orders are the one thing we author.** A family's format string is Blizzard's, but -//! which value lands in which `%s`/`%d` is a fact about that string, and we state it as an ordered -//! [`Slot`] list. Two properties make that safe rather than fragile: within a family the order is -//! **invariant across the four variants** (the variant only decides whether the `Attacker`/`Victim` -//! slots are *present* β€” "Your %s hits %s for %d." and "%s's %s hits %s for %d." are the same list -//! with one slot dropped), and [`tests`] fills every declared family against the *real* -//! `GlobalStrings.lua` and checks the `%s`/`%d` type signature it produces matches the shipped -//! template's. A transposition cannot survive that; a drift cannot either. +//! The combat log's chat lines, the presentation half of the reference's `UnitCombatLog_C` +//! (`~0x625000-0x62e7xx`): classify both endpoints, pick a chat type (`0x5e` drops the line), pick +//! a GlobalString key and fill it as `vsnprintf` does. The key resolves against the install's own +//! `GlobalStrings.lua` (`0x703bf0`); only the slot order is ours, one [`Slot`] list per family. use bevy::prelude::*; @@ -42,12 +12,7 @@ use crate::net::{GuidIndex, NetCommands, ObjectStore, Reputations, SelfGuid}; use crate::target::ring::Factions; use crate::ui_party::GroupState; -/// Read-only `ObjectStore` lookup by entity β€” the only thing [`classify`] needs from the world. -/// -/// It exists because the two callers hold different query shapes and neither is wrong: the net -/// drain already has a `&mut` store query in hand (whose `get` is read-only anyway), while the -/// watcher systems in [`watch`] hold the `(Entity, &ObjectStore)` pair they sweep. One trait beats -/// either duplicating the classifier or forcing a query shape on a system that does not want it. +/// Read-only `ObjectStore` lookup by entity, the one thing [`classify`] needs from the world. pub(crate) trait Stores { fn store(&self, entity: Entity) -> Option<&ObjectStore>; } @@ -64,7 +29,7 @@ impl Stores for Query<'_, '_, (Entity, &ObjectStore)> { } } -/// [`Stores`]' twin for the range gate: a world position by entity. +/// A world position by entity, for the range gate. pub(crate) trait Poses { fn pose(&self, entity: Entity) -> Option; } @@ -81,24 +46,10 @@ impl Poses for Query<'_, '_, &Transform> { } } -/// One endpoint's half of the reference's display-range gate (`0x626630` β†’ the per-class range -/// getter `0x626810`): a 3-D squared distance from the ACTIVE PLAYER to the endpoint, against the -/// class's range squared. -/// -/// **The comparison is strictly `<`** β€” `fcomp; fnstsw ax; test ah,5; jnp`, so `distΒ² == rangeΒ²` is -/// OUT and a NaN is out. 1571 used `<=`; the boundary is exact in the binary and there is no reason -/// for us to be looser. -/// -/// **The range is the caller's, read off [`CombatLogRanges`]** β€” the live table the reference's -/// `0x626810` walks, not a compiled-in constant. The caller picks which entry: a class range for -/// the fourteen two-ended and one-ended formatters, and [`CombatLogRanges::death`] for the one -/// formatter that has a range CVar of its own. The two sentinels ride in the table like any other -/// entry: `100000.0` makes the gate a no-op for you and your pet, `0.0` refuses an unresolvable -/// unit outright. -/// -/// A pose we do not hold is treated as **in** range: dropping a line because a unit's transform had -/// not landed yet would silently lose the killing blow on a mob that despawns, which is a worse -/// failure than logging one fight too far away. +/// One endpoint's half of the reference's display-range gate (`0x626630`, ranges from `0x626810`): +/// the squared 3-D distance from the active player, strictly below `range` squared, so the +/// boundary and a NaN are out. `100000.0` always passes and `0.0` always refuses; a pose not yet +/// held counts as in range, so a despawning mob's killing blow is not lost. pub(crate) fn in_range( guid: u64, range: f32, @@ -119,26 +70,14 @@ pub(crate) fn in_range( me.distance_squared(them) < range * range } -/// **The combat log's display ranges, live** β€” the reference's `{cvarName, defaultValue}` table at -/// `0x8629e0` plus the one range CVar that sits outside it, `CombatDeathLogRange`. -/// -/// The reference registers all eight in one place (`0x626d00`, a loop over `0x8629e0` skipping the -/// NULL/empty names, then one unrolled call), stores **no -/// handle for any of them**, and looks each up by name at every use. We keep the resolved numbers -/// instead: the lookup-by-name is the reference's way of not caching, not a behaviour, and -/// `crate::cvars` already owns the string table. -/// -/// **Yards, and read as the CVar's FLOAT field.** The record carries both `+0x24` float and `+0x28` -/// int, written from the same string at registration; the range gate reads the float (`0x626810`) -/// and the periodic gates read the int. `0` is a real value and means *silence this class* β€” -/// `distΒ² < 0` is never true β€” which is why nothing here clamps to a floor. +/// The combat log's live display ranges in yards: the seven class CVars of the reference's table +/// at `0x8629e0` plus `CombatDeathLogRange`, registered at `0x626d00`. The gate reads the CVar's +/// float field (`+0x24`); `0` silences a class, so nothing clamps. #[derive(Resource, Clone, Copy)] pub(crate) struct CombatLogRanges { - /// By [`UnitClass`] index `0..=9`. Classes 0/1 (you and your pet) have no CVar and sit at the - /// reference's `100000.0` sentinel; class 9 (unresolvable) sits at its `0.0`. + /// By [`UnitClass`] index; classes 0, 1 and 9 have no CVar and keep their sentinels. class: [f32; 10], - /// `CombatDeathLogRange` β€” the death line's own range, and the **only** formatter that has - /// one. Not part of the `0x8629e0` table. + /// `CombatDeathLogRange`, the death line's own range, outside the `0x8629e0` table. death: f32, } @@ -161,19 +100,15 @@ impl CombatLogRanges { self.class[class as usize] } - /// The death line's live range β€” `CombatDeathLogRange`, for every class alike. - /// - /// **It really is every class.** The death formatter `0x62c160` looks the CVar up first - /// (`0x62c19c`) and falls back to the per-class getter only when the *lookup* fails; the CVar - /// is registered at startup, so the fallback is unreachable in a running client. So your own - /// death passes on distance 0 rather than on class 0's sentinel, and an unresolvable unit's - /// death is logged at 60 yd rather than refused by class 9's `0.0`. + /// `CombatDeathLogRange`, for every class alike: the death formatter `0x62c160` reads the CVar + /// (`0x62c19c`) and falls back to the class range only when the lookup fails, which a + /// registered CVar never does. pub(crate) fn death(&self) -> f32 { self.death } - /// Whether `name` is one of the eight β€” asked before [`Self::set`] so an observer holding - /// the resource mutably does not flag a change it did not make. + /// Whether `name` is one of the eight; asked before [`Self::set`] so another CVar does not mark + /// the resource changed. pub(crate) fn is_range_cvar(&self, name: &str) -> bool { name.eq_ignore_ascii_case(DEATH_LOG_RANGE_CVAR) || (0..self.class.len()).any(|i| { @@ -183,10 +118,7 @@ impl CombatLogRanges { }) } - /// Apply one `SetCVar` to the table β€” `true` if the name was one of the eight. - /// - /// The class names are walked through [`UnitClass::range_cvar`] so this module keeps exactly - /// one copy of them. + /// Apply one `SetCVar`; `true` if the name was one of the eight. pub(crate) fn set(&mut self, name: &str, value: f32) -> bool { if name.eq_ignore_ascii_case(DEATH_LOG_RANGE_CVAR) { self.death = value; @@ -206,8 +138,7 @@ impl CombatLogRanges { } } -/// The combat log rows' change callback (decision 2303): the eight display ranges (yards, the -/// CVar's float field) and the periodic-effects switch. +/// The combat log CVars' change callback: the eight display ranges and the periodic switch. pub(crate) fn on_cvar( ev: On, mut ranges: ResMut, @@ -220,24 +151,10 @@ pub(crate) fn on_cvar( } } -/// **`CombatLogPeriodicSpells`** β€” "Log Periodic Effects", and its blast radius is wider than the -/// options row's wording. -/// -/// Its first read site (`0x626dee`) is at the **top of the `SMSG_PERIODICAURALOG` handler** -/// `0x626dd0`, and a zero jumps to `0x6271b4`, a bare epilogue β€” so the whole packet body is -/// suppressed: every `CHAT_MSG_SPELL_PERIODIC_*` line **and** the floating DoT/HoT tick number -/// **and** the periodic miss-word. The other two sites are pure chat-line filters that leave the -/// floats alone: `0x62d9ae` (the `SMSG_SPELLNONMELEEDAMAGELOG` leg whose `periodicLog` byte is -/// set) and `0x62d25f` (`SMSG_SPELLORDAMAGE_IMMUNE`'s `IMMUNESPELL*` lines, only when the -/// packet's periodic byte is set). -/// -/// Read as the CVar record's **int** `+0x28`, unlike [`CombatLogRanges`] which reads the float β€” -/// both fields are written from the same string at registration (`0x63e127` the int, `0x63e135` -/// the float). -/// -/// A **missing record counts as OFF** in the reference, because the gate is -/// `cvar == NULL || cvar->int == 0`. That cannot happen here β€” the row is registered at startup β€” -/// and it is why the reference's own default `"1"` is load-bearing rather than incidental. +/// `CombatLogPeriodicSpells`. Off, the `SMSG_PERIODICAURALOG` handler `0x626dd0` returns at its +/// first read (`0x626dee`), dropping the chat lines, the floating tick number and the miss word; +/// it also filters the periodic `SMSG_SPELLNONMELEEDAMAGELOG` line (`0x62d9ae`) and the periodic +/// `IMMUNESPELL*` lines (`0x62d25f`). Read as the CVar's int field (`+0x28`, set at `0x63e127`). #[derive(Resource, Clone, Copy)] pub(crate) struct LogPeriodicSpells(pub(crate) bool); @@ -248,38 +165,14 @@ impl Default for LogPeriodicSpells { } } -/// `CombatLogPeriodicSpells`' registered name. pub(crate) const LOG_PERIODIC_CVAR: &str = "CombatLogPeriodicSpells"; -/// `CombatDeathLogRange`'s registered name and default β€” `0x626d5f`, default string `"60"` -/// (`0x862e14`). +/// `CombatDeathLogRange`, registered at `0x626d5f` with default `"60"` (`0x862e14`). pub(crate) const DEATH_LOG_RANGE_CVAR: &str = "CombatDeathLogRange"; pub(crate) const DEATH_LOG_RANGE_DEFAULT: f32 = 60.0; -/// A unit's standing relative to the active player β€” the `0..9` index every combat-log selector in -/// the reference takes, in both parameter positions. -/// -/// **The indices are VERIFIED by name, not inferred from behaviour.** `0x626810` (the combat-log -/// display-range getter) indexes a 10-entry table of `{cvarName, defaultValue}` pairs at -/// `DAT_008629e0`, and those cvar names *are* the class names β€” read out of `WoW.exe` at -/// `0x8629e0` (file offset `0x4629e0`): -/// -/// | idx | cvar | default | meaning | -/// |---|---|---|---| -/// | 0 | *(NULL)* | `100000.0` (`[0x80dcc4]`) | the active player | -/// | 1 | *(NULL)* | `100000.0` | the active player's pet | -/// | 2 | `CombatLogRangeParty` | `50` | a party member | -/// | 3 | `CombatLogRangePartyPet` | `50` | a party member's pet | -/// | 4 | `CombatLogRangeFriendlyPlayers` | `50` | any other friendly player | -/// | 5 | `CombatLogRangeFriendlyPlayersPets` | `50` | that player's pet | -/// | 6 | `CombatLogRangeHostilePlayers` | `50` | a hostile player | -/// | 7 | `CombatLogRangeHostilePlayersPets` | `50` | that player's pet | -/// | 8 | `CombatLogRangeCreature` | `30` | a creature | -/// | 9 | `""` (the empty static `0x882748`) | `0.0` (`[0x7ffd74]`) | anything else β€” never logged | -/// -/// Index 9's range of **zero** is why the msgType selectors can treat 8 and 9 alike: a class-9 -/// endpoint never passes the range gate, so it never reaches them. Index 0/1's `100000.0` is the -/// "no gate" sentinel β€” your own lines are never dropped for distance. +/// A unit's standing relative to the active player: the `0..9` index every combat-log selector +/// takes, in the order of the reference's range table at `0x8629e0`, which `0x626810` indexes. #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] pub(crate) enum UnitClass { Me = 0, @@ -291,14 +184,11 @@ pub(crate) enum UnitClass { HostilePlayer = 6, HostilePet = 7, Creature = 8, - /// Unresolvable β€” not streamed, or a guid family with no unit behind it. Range `0.0`: the - /// reference logs nothing for it, and neither do we. + /// Unresolvable: not streamed, or a guid with no unit behind it. Unknown = 9, } impl UnitClass { - /// The `0..=9` index back to its class β€” the inverse of the discriminant, for walking the - /// reference's own table order. pub(crate) fn from_index(i: usize) -> Self { match i { 0 => Self::Me, @@ -314,12 +204,7 @@ impl UnitClass { } } - /// The CVar naming this class's display range, `None` for the two ungated classes (0/1). - /// - /// **This is the production lookup** β€” `CombatLogRanges::set` walks the classes through it to - /// find the one a `SetCVar` names, so the table above is the only place these seven names are - /// written down. It was `#[cfg(test)]` until the CVars were registered, with a comment saying - /// it "becomes the production lookup the day the live CVar table is wired in". + /// The CVar naming this class's display range; classes 0, 1 and 9 have none. pub(crate) fn range_cvar(self) -> Option<&'static str> { Some(match self { Self::Me | Self::MyPet | Self::Unknown => return None, @@ -333,9 +218,8 @@ impl UnitClass { }) } - /// The **registered default** range in yards β€” the `defaultValue` half of the reference's own - /// `{cvarName, defaultValue}` pairs at `0x8629e0`, and therefore what [`CombatLogRanges`] seeds - /// itself with and what `cvars::REGISTERED` ships. The live value is the CVar's. + /// The registered default in yards, from the `0x8629e0` pairs: the sentinels are `100000.0` + /// (`[0x80dcc4]`) for classes 0 and 1 and `0.0` (`[0x7ffd74]`) for 9. pub(crate) fn default_range(self) -> f32 { match self { Self::Me | Self::MyPet => 100_000.0, @@ -345,38 +229,19 @@ impl UnitClass { } } - /// `true` for the local player β€” the `SELF` half of every `…SELFOTHER`/`…OTHERSELF` key. - /// - /// **Only class 0.** Your own pet is class 1 and is an `OTHER` for string-selection purposes: - /// the reference says "Your pet hits X" through the same `…OTHEROTHER` template it uses for a - /// stranger, and routes it to `COMBAT_PET_HITS` by the *msgType* selector instead. The two - /// classifications are independent, which is the whole reason the selectors take the class - /// index and the string picker takes a pair of booleans. + /// The `SELF` of a `…SELFOTHER`/`…OTHERSELF` key: class 0 only. Your pet is an `OTHER`, told + /// apart by its chat type (`COMBAT_PET_HITS`). fn is_me(self) -> bool { self == Self::Me } } -/// Classify one guid against the active player β€” the reference's `0x5efea0(ecx = GUID*)`. -/// -/// **The rule is settled at the bytes**, not inferred, and settling it corrected 1571's first cut -/// on three points, each of which changed behaviour: -/// -/// - **The owner field is `UNIT_FIELD_CHARMEDBY`, then `UNIT_FIELD_CREATEDBY`.** We read -/// `SUMMONEDBY` first, copying the floating text's source classifier β€” a different field, and a -/// charmed unit was therefore judged on its own faction instead of its charmer's. -/// - **"Party" is the four party slots, NOT the raid roster.** A raid member outside your own -/// subgroup is class 4/5 (another friendly player), not class 2/3. We keyed off the whole -/// `GroupState` roster, so in a raid every one of 39 other players was "party". -/// - **Friend-or-foe is `CanAttack`, not a reaction rank.** A same-faction duel opponent reads -/// friendly by reaction and is unambiguously hostile here β€” which is the case the msgType -/// matrix's own duel arms exist for, so getting it from the reaction was self-defeating. -/// -/// One half of the verdict is **not** implemented and is deliberately named rather than quietly -/// dropped: the reference runs `0x606980 CanAttack` **mutually**, in both directions, and -/// [`crate::target::ring::can_attack_from_player`] is specialised to the local player as the -/// attacker (1530). We run the direction we have. It differs only for a unit that can attack you -/// while you cannot attack it, which needs the general two-unit form to answer. +/// Classify one guid against the active player, the reference's `0x5efea0`. This reads the owner +/// (`UNIT_FIELD_CHARMEDBY`, then `UNIT_FIELD_CREATEDBY`) for every unit, tests party (your own +/// subgroup) before hostility, and runs `CanAttack` (`0x606980`) only with the player attacking +/// ([`crate::target::ring::can_attack_from_player`]). The reference reads the owner only for a +/// unit that is not a player (`0x5f0006`-`0x5f0019`); a player is hostile only if each can attack +/// the other (`0x5eff85`-`0x5effad`), and that test comes before party (`0x5effcd`). pub(crate) fn classify( guid: u64, self_guid: &SelfGuid, @@ -398,8 +263,7 @@ pub(crate) fn classify( let Some(store) = stores.store(entity) else { return UnitClass::Unknown; }; - // The owner: CHARMEDBY first, then CREATEDBY (`0x5f000c`/`0x5f0019`). A - // pet/guardian/totem/charmed unit is classified by WHOSE it is, one rung above its own faction. + // CHARMEDBY first, then CREATEDBY (`0x5f000c`/`0x5f0019`): an owned unit classifies by owner. let owner = store .0 .unit_charmed_by() @@ -409,10 +273,7 @@ pub(crate) fn classify( if owned_by(me) { return UnitClass::MyPet; } - // **Party, not raid.** The reference's party slots hold your own subgroup; a raid member in - // another subgroup is just another friendly player. `flags` bits 0-2 are the subgroup on both - // sides of this comparison (`GroupMemberEntry::flags` / `GroupState::own_flags`), so an - // ordinary 5-man party β€” where everyone is subgroup 0 β€” needs no special case. + // The party slots hold your own subgroup only; `flags` bits 0-2 are the subgroup on both sides. let in_party = |g: u64| { group.is_some_and(|s| { s.in_group @@ -428,9 +289,7 @@ pub(crate) fn classify( return UnitClass::PartyPet; } - // Friend or foe β€” `CanAttack`, judged on **this unit**, never on its owner - // (`0x5f00e3`/`0x5f00f2`). The owner hop was ours and it was wrong: the reference asks the pet - // itself. + // Friend or foe is `CanAttack` on this unit itself, never its owner (`0x5f00e3`/`0x5f00f2`). let hostile = { let me_store = index.0.get(&me).copied().and_then(|e| stores.store(e)); crate::target::ring::can_attack_from_player( @@ -449,8 +308,6 @@ pub(crate) fn classify( UnitClass::FriendlyPlayer }; } - // A creature with a PLAYER owner is that player's pet; a creature owned by nothing (or by - // another creature) is just a creature. if owner.is_some_and(benilla_protocol::guid::is_player) { return if hostile { UnitClass::HostilePet @@ -463,20 +320,9 @@ pub(crate) fn classify( // ─────────────────────────────── the msgType selectors ──────────────────────────────── -/// `0x62a0d0` / `0x62a2e0` β€” the melee family's `(attacker, victim) β†’ chat type`, `None` where the -/// reference returns `0x5e` (94, one past the end of the 94-entry type table = "no type", checked -/// at the emit `0x626850`). -/// -/// Read off the decompiled selectors and cross-checked against the reference's 94-entry default -/// colour table (`0x804710`, whose rows `GetChatTypeIndex` numbers from 1 β€” so the `0x1b` the -/// selector returns is that table's row 28, `COMBAT_SELF_HITS`). `miss` picks the odd twin: every -/// HITS type is immediately followed by its MISSES type, which is why the reference's two selectors -/// differ only by `+1`. -/// -/// **The two reclassifying arms are real and they are the duel/PvP case.** A *party* player (2) or -/// a *friendly* player (4) whose victim is you, your pet, or a party member is reported in the -/// HOSTILEPLAYER bucket β€” the client decides "attacking me makes you hostile" at the log, without -/// consulting faction. Their pets (3 and 5) get no such treatment. +/// The melee `(attacker, victim)` chat type, the reference's selector pair `0x62a0d0`/`0x62a2e0`; +/// a miss is the row after the hit. A party or friendly player hitting you, your pet or your party +/// types as HOSTILEPLAYER, the duel case, without consulting faction; their pets do not. pub(crate) fn combat_kind( attacker: UnitClass, victim: UnitClass, @@ -502,12 +348,8 @@ pub(crate) fn combat_kind( Some(if miss { miss_twin(hits) } else { hits }) } -/// `0x627820` and its damage sibling β€” the direct-spell family's `(attacker, victim) β†’ chat type`. -/// -/// Byte-identical in shape to [`combat_kind`] (same ten source arms, same two reclassifying arms, -/// same victim split for a creature source); only the base row differs, and `buff` picks the odd -/// twin exactly as `miss` does there. A *heal*, a *power gain* and an *aura* are all "BUFF"; damage -/// and every failed-to-land outcome are "DAMAGE". +/// The direct-spell `(attacker, victim)` chat type (`0x627820` and its damage sibling), shaped as +/// [`combat_kind`]: heals, power gains and auras are BUFF, damage and failures are DAMAGE. pub(crate) fn spell_kind( attacker: UnitClass, victim: UnitClass, @@ -533,28 +375,10 @@ pub(crate) fn spell_kind( Some(if buff { buff_twin(damage) } else { damage }) } -/// `0x627d80` (damage) / `0x6274a0` (buffs) β€” the periodic family, and it is **a different shape**. -/// -/// Ten rows, not sixteen: there is no PET bucket and no `CREATURE_VS_*` split, so a pet folds into -/// its owner's row and every creature source lands on one `SPELL_PERIODIC_CREATURE_*` row. -/// -/// **Both selectors take ONE class, and WHICH one is the caller's to know** (decision 2127). This -/// doc used to say the endpoint was "the *source* alone", and it is not: `0x626630` fills classA -/// (the sentence's subject, the caster) and classB (the target), and the four periodic formatters -/// disagree about which one they hand this selector. -/// -/// | formatter | msg-id argument | the byte | -/// |---|---|---| -/// | `PERIODICAURADAMAGE*` `0x628100` | **classB β€” the TARGET** | `0x628235 mov ecx,edi` where `edi = [ebp-0x10]` is `outClassB` | -/// | `PERIODICAURAHEAL*` `0x627240` | **classB β€” the TARGET** | `0x62732c mov ecx,[ebp-0x4]`, its own `outClassB` | -/// | `POWERGAIN*` `0x627520` | classA β€” the caster | `0x6275fb mov ecx,esi`, `esi = [ebp-0x18]` = `outClassA` | -/// | `SPELLPOWERLEECH*`/`…DRAIN*` `0x627930` | classA β€” the caster | `0x627a0f`/`0x627a3a mov ecx,esi`, `esi = [ebp-0x10]` = `outClassA` | -/// -/// Passing the caster for the first two is not a cosmetic slip: a creature's DoT ticking **you** -/// types as `SPELL_PERIODIC_CREATURE_DAMAGE` instead of `SPELL_PERIODIC_SELF_DAMAGE`, and your own -/// DoT on a creature types as SELF instead of CREATURE β€” the two swap. Every addon that parses the -/// combat log by event name (MikScrollingBattleText matches a *different GlobalString pattern* per -/// event) then finds no pattern for either line and drops both. +/// The periodic chat type (`0x627d80` damage, `0x6274a0` buffs), off one class, with no PET row and +/// no `CREATURE_VS_*` split. The class is the target for the `PERIODICAURADAMAGE*` and +/// `PERIODICAURAHEAL*` formatters (`0x628100`, `0x627240`) and the caster for `POWERGAIN*` and +/// `SPELLPOWERLEECH*`/`…DRAIN*` (`0x627520`, `0x627930`). pub(crate) fn periodic_kind(subject: UnitClass, buff: bool) -> Option { use ChatEventKind as K; use UnitClass as C; @@ -568,12 +392,7 @@ pub(crate) fn periodic_kind(subject: UnitClass, buff: bool) -> Option ChatEventKind { if (victim as u8) <= 5 { ChatEventKind::CombatFriendlyDeath @@ -582,12 +401,8 @@ pub(crate) fn death_kind(victim: UnitClass) -> ChatEventKind { } } -/// `0x62b7d0` β€” where an aura's DEPARTURE is logged, off the bearer's class alone. Three rows, not -/// ten: `{0,1}` SELF Β· `{2,3}` PARTY Β· everything else OTHER. -/// -/// The arrival is not this selector's β€” an aura landing rides the two PERIODIC selectors instead -/// (harmful β†’ [`periodic_kind`]'s damage row, helpful β†’ its buff row), which is the reference's own -/// asymmetry (`0x62b480`) and not a simplification of ours. +/// The aura-fade chat type (`0x62b7d0`), off the bearer's class. An aura landing types through +/// [`periodic_kind`] instead (`0x62b480`). pub(crate) fn aura_gone_kind(bearer: UnitClass) -> ChatEventKind { use ChatEventKind as K; use UnitClass as C; @@ -598,11 +413,8 @@ pub(crate) fn aura_gone_kind(bearer: UnitClass) -> ChatEventKind { } } -/// `0x62c140` β€” the damage-shield two-way, off one class: `{0,1}` ON_SELF, else ON_OTHERS. -/// -/// It has **two** users, which is the surprising half: the shield formatter itself, and *every* -/// `SMSG_SPELLLOGMISS` line (`0x5e7f31 push 1` routes `0x62bab0` here instead of the eight-row -/// spell matrix β€” a byte fact; whether it is deliberate is not derivable from the binary). +/// The damage-shield chat type (`0x62c140`), off one class. Every `SMSG_SPELLLOGMISS` line types +/// through it too (`0x5e7f31` routes `0x62bab0` here), not through the spell matrix. pub(crate) fn damage_shield_kind(subject: UnitClass) -> ChatEventKind { if matches!(subject, UnitClass::Me | UnitClass::MyPet) { ChatEventKind::SpellDamageShieldsOnSelf @@ -611,8 +423,7 @@ pub(crate) fn damage_shield_kind(subject: UnitClass) -> ChatEventKind { } } -/// The HITS β†’ MISSES step. Every combat pair is adjacent in the type table, so the reference's two -/// selectors are one table apart; this is that `+1`, spelled as the pairing it encodes. +/// HITS to MISSES, the next row of the reference's chat type table. fn miss_twin(hits: ChatEventKind) -> ChatEventKind { use ChatEventKind as K; match hits { @@ -628,7 +439,7 @@ fn miss_twin(hits: ChatEventKind) -> ChatEventKind { } } -/// The DAMAGE β†’ BUFF step β€” [`miss_twin`]'s spell-family twin, same `+1` in the type table. +/// DAMAGE to BUFF, the next row of the chat type table. fn buff_twin(damage: ChatEventKind) -> ChatEventKind { use ChatEventKind as K; match damage { @@ -651,24 +462,18 @@ fn buff_twin(damage: ChatEventKind) -> ChatEventKind { // ──────────────────────────── the format-string selectors ───────────────────────────── -/// Which of a family's four templates applies β€” the reference's 45 `char*(self, other, *out)` -/// selectors (`0x629f90`, `0x62a290`, …), whose two arguments are booleans ("the subject is not -/// me", "the object is not me") and whose `*out` variant code `0..3` is what tells the caller how -/// many names to push. -/// -/// `SelfSelf` is the code-`0` case for most families: the selector returns NULL and **no line is -/// produced**. Some families do define the key (`SPELLLOGSELFSELF`, `HEALEDSELFSELF`, -/// `PERIODICAURAHEALSELFSELF`); the ones that don't simply miss the lookup, which lands on the same -/// silence by the same route the reference's "string not found" arm takes. +/// Which of a family's four templates applies: the variant code the reference's 45 selectors +/// (`0x629f90`, `0x62a290`, …) return. `SelfSelf` prints nothing unless the family has the key, +/// as `SPELLLOGSELFSELF` and `HEALEDSELFSELF` do. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum Variant { - /// I did it to someone else β€” `…SELFOTHER`, variant code 2. + /// I did it to someone else: `…SELFOTHER`, variant code 2. SelfOther, - /// Someone else did it to me β€” `…OTHERSELF`, code 1. + /// Someone else did it to me: `…OTHERSELF`, code 1. OtherSelf, - /// Someone else, to someone else β€” `…OTHEROTHER`, code 3. + /// Someone else, to someone else: `…OTHEROTHER`, code 3. OtherOther, - /// Me, to me β€” `…SELFSELF`, code 0 for the families that have no such key. + /// Me, to me: `…SELFSELF`, code 0 where the family has no such key. SelfSelf, } @@ -691,87 +496,70 @@ impl Variant { } } - /// Whether the subject is spelled by name in this variant (rather than as "You"/"Your"). + /// Whether the subject is spelled by name rather than as "You". fn names_subject(self) -> bool { matches!(self, Self::OtherSelf | Self::OtherOther) } - /// Whether the object is spelled by name in this variant. fn names_object(self) -> bool { matches!(self, Self::SelfOther | Self::OtherOther) } - /// Whether the SUBJECT is the local player β€” the single bit a [`Keying::Duo`] family keys on, - /// since such a family has only one endpoint in its sentence. + /// Whether the subject is the local player, the one bit a [`Keying::Duo`] family keys on. fn subject_is_me(self) -> bool { matches!(self, Self::SelfSelf | Self::SelfOther) } } -/// How a family builds its GlobalString KEY out of the stem and the variant β€” the reference's three -/// shapes, and they are not interchangeable. -/// -/// The 4-way `…SELFSELF`/`…SELFOTHER`/`…OTHERSELF`/`…OTHEROTHER` selector (`0x62a290` and its 44 -/// siblings) is only the *commonest* one. A family whose sentence has a single participant β€” -/// a death, an aura landing or leaving, a tradeskill create β€” keys on that one endpoint with a -/// two-way `…SELF`/`…OTHER` (`0x62c160`, `0x62b480`, `0x629610`, and the other inline 2-ways), and -/// a handful of families have no variant at all (`DURABILITYDAMAGE_DEATH`, `PET_LOYALTY_GAIN`, -/// `SELFKILLOTHER`). Modelling all three here is what lets one composer serve the whole block. +/// How a family builds its GlobalString key from the stem and the variant: the four-way selectors +/// (`0x62a290` and 44 more), a two-way off a sentence's single participant (`0x62c160`, +/// `0x62b480`, `0x629610`), or one fixed key (`DURABILITYDAMAGE_DEATH`, `SELFKILLOTHER`). #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum Keying { /// `…SELFSELF` / `…SELFOTHER` / `…OTHERSELF` / `…OTHEROTHER`, off both endpoints. Quad, - /// Two keys off the SUBJECT alone. The words are the family's own β€” most spell `SELF`/`OTHER`, - /// but `TRADESKILL_LOG`/`FEEDPET_LOG` say `_FIRSTPERSON`/`_THIRDPERSON`. + /// Two keys off the subject alone, in the family's words: `SELF`/`OTHER`, or + /// `_FIRSTPERSON`/`_THIRDPERSON` for `TRADESKILL_LOG` and `FEEDPET_LOG`. Duo { me: &'static str, other: &'static str, }, - /// One key: the stem (plus any tail) IS the whole name. + /// One key: the stem and tail are the whole name. Single, } -/// One value slot in a family's format string, in the order the shipped template consumes it. -/// -/// `Attacker` and `Victim` are **conditional** β€” present only when [`Variant::names_subject`] / -/// [`Variant::names_object`] says the template spells that endpoint out. Everything else is -/// unconditional. That conditionality is exactly what makes a single ordered list describe all four -/// variants of a family: "Your %s hits %s for %d." and "%s's %s hits %s for %d." are the same -/// `[Attacker?, Spell, Victim?, Amount]`, once with the first slot dropped. +/// One value slot of a family's format string, in the order the shipped template consumes it. +/// `Attacker` and `Victim` appear only in the variants that spell that endpoint by name. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum Slot { - /// `%s` β€” the subject's name, only when the variant spells it. + /// `%s`: the subject's name, when the variant spells it. Attacker, - /// `%s` β€” the object's name, only when the variant spells it. + /// `%s`: the object's name, when the variant spells it. Victim, - /// `%s` β€” the spell's name. + /// `%s`: the spell's name. Spell, - /// `%s` β€” the damage school's lowercase word (`SPELL_SCHOOL_NAME`). + /// `%s`: the damage school's word (`SPELL_SCHOOL_CAP`). School, - /// `%s` β€” a power's word (`MANA_POINTS`/`RAGE_POINTS`/`FOCUS_POINTS`/`ENERGY_POINTS`). + /// `%s`: a power's word (`MANA_POINTS` and kin). Power, - /// `%d` β€” the primary amount. + /// `%d`: the primary amount. Amount, - /// `%d` β€” a second amount (the leech family's "You gain %d"). + /// `%d`: a second amount, the leech family's gain. Amount2, - /// `%s` β€” a second power word (the leech family's gained power). + /// `%s`: the leech family's gained power word. Power2, - /// `%s` β€” a name the ARM already resolved and that the template always spells out: an item - /// ("You create %s."), a gameobject ("You perform %s on %s."), a pet, a faction, or a cast - /// failure's reason. Unconditional β€” unlike [`Self::Attacker`]/[`Self::Victim`] it is never - /// dropped by the variant, because it names something that is never "you". + /// `%s`: a name that is never "you" (an item, gameobject, pet, faction or failure reason), + /// spelled in every variant. Named, } -/// A combat-log message family: the stem its four keys are built from, and the ordered slots its +/// A combat-log message family: the stem its keys are built from, and the ordered slots its /// templates consume. #[derive(Clone, Copy, Debug)] pub(crate) struct Family { pub stem: &'static str, - /// How the stem becomes a key (see [`Keying`]). pub keying: Keying, - /// Appended AFTER the variant word β€” `AURAADDED` + `SELF` + `HARMFUL`, `SPELLEXTRAATTACKS` + - /// `SELF` + `_SINGULAR`. Empty for every other family. + /// Appended after the variant word, as `AURAADDED` + `SELF` + `HARMFUL`; empty for most. pub tail: &'static str, pub slots: &'static [Slot], } @@ -793,52 +581,38 @@ impl Family { format!("{}{mid}{}", self.stem, self.tail) } - /// Whether this family's template spells the SUBJECT out for `variant`. fn names_subject(&self, variant: Variant) -> bool { match self.keying { Keying::Quad => variant.names_subject(), - // The two-way key IS the test: the `…OTHER` half is the one that carries a name. + // Only the `…OTHER` half of a two-way key spells a name. Keying::Duo { .. } => !variant.subject_is_me(), - // A fixed key like `PARTYKILLOTHER` already says "other" in its name. + // A fixed key such as `PARTYKILLOTHER` always names its subject. Keying::Single => true, } } - /// Whether this family's template spells the OBJECT out for `variant`. fn names_object(&self, variant: Variant) -> bool { match self.keying { Keying::Quad => variant.names_object(), - // A Duo family's sentence has one participant; a `Victim` slot in one would be a - // declaration bug, and `tests` is what catches it. + // A two-way family's sentence has one participant, so no `Victim` slot. Keying::Duo { .. } => false, Keying::Single => true, } } } -/// The six suffixes `0x628410` can append to a finished sentence, in its fixed order. -/// -/// **This is a separate pass over the already-formatted line, not more `%s` slots** β€” the reference -/// builds the sentence, appends what applies, and only then hands the whole thing to `0x626850` as -/// a single `%s`. Each trailer is skipped silently when its GlobalString resolves empty, so a -/// locale that ships one blank simply loses that clause. -/// -/// Only four call sites can grow one, and what each can show differs because it passes zeroes for -/// the fields it has no wire source for: melee `COMBATHIT*` is the only family that can ever -/// show GLANCING/CRUSHING/BLOCK; `SPELLLOG*` can show RESIST/VULNERABLE/BLOCK/ABSORB; -/// `PERIODICAURADAMAGE*` and `VSENVIRONMENTALDAMAGE_*` RESIST/VULNERABLE/ABSORB. +/// The six suffixes `0x628410` appends to a finished sentence, in its order, before `0x626850` +/// emits the whole line as one `%s`; a suffix whose GlobalString is empty is skipped. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub(crate) struct Trailers { pub absorbed: u32, - /// **Signed on purpose**: a negative resist is a *vulnerability* bonus and words itself as one. + /// Negative is a vulnerability bonus, worded by `VULNERABLE_TRAILER`. pub resisted: i32, pub blocked: u32, - /// The swing's raw `HitInfo` β€” only `0x4000` GLANCING and `0x8000` CRUSHING are read, and only - /// the melee call site passes a real one. + /// The swing's `HitInfo`, for `0x4000` GLANCING and `0x8000` CRUSHING; melee only. pub hit_info: u32, } -/// Append the trailers `0x628410` would, in its order, skipping each whose GlobalString is empty. fn append_trailers(lua: &benilla_ui::script::UiScript, line: &mut String, t: Trailers) { const GLANCING: u32 = 0x4000; const CRUSHING: u32 = 0x8000; @@ -874,39 +648,30 @@ fn append_trailers(lua: &benilla_ui::script::UiScript, line: &mut String, t: Tra } } -/// The value bound to each [`Slot`] for one line. Built by the caller from the packet; the family's -/// slot list decides which of these are read and in what order. -/// -/// **The school and power slots hold INDICES, not words.** They are resolved to text inside -/// [`compose_line`], the same place the template itself is resolved and for the same reason: both -/// are localized strings off the install, and the packet arm that fills this struct has no VM in -/// hand. `None` in either where the family asks for it drops the line rather than printing a gap. +/// The value bound to each [`Slot`] for one line. School and power are indices, resolved to words +/// in [`compose_line`]; `None` where the family needs one drops the line. #[derive(Clone, Debug, Default)] pub(crate) struct Fills { pub attacker: String, pub victim: String, pub spell: String, - /// The damage school index (`SpellSchools`: 0 physical … 6 arcane) β†’ `SPELL_SCHOOL_NAME`. + /// The damage school index, 0 physical to 6 arcane. pub school: Option, - /// The vmangos `Powers` index (0 mana … 3 energy) β†’ `MANA_POINTS` and kin. + /// The vmangos `Powers` index, 0 mana to 4 happiness. pub power: Option, pub amount: i64, pub amount2: i64, - /// The leech family's *gained* power index β€” usually the same as [`Self::power`]. + /// The leech family's gained power index. pub power2: Option, - /// The [`Slot::Named`] text β€” an item, gameobject, pet, faction or failure reason, already - /// localized by the arm that built this. + /// The [`Slot::Named`] text, already localized. pub named: String, - /// The `0x628410` suffixes, for the four families that can grow one. `None` everywhere else. + /// The `0x628410` suffixes, for the families that can grow one. pub trailers: Option, } -/// Resolve a family + variant to a finished line, or `None` when the key is absent from the -/// install's `GlobalStrings.lua` (the reference's "Warning: string %s not found" arm β€” no line). -/// -/// The `%s`/`%d` walk is `vsnprintf` with a fixed argument list, which is what the reference does -/// at `0x626850`; 1.12's templates carry no positional (`%1$s`) specifiers, and could not β€” the -/// MSVC CRT the client links has never supported them. +/// Resolve a family and variant to a finished line; `None`, silently, when the key is absent, +/// where the reference prints a not-found warning line (`0x6269f0`) for any key but a missing +/// `…SELFSELF`. The fill is `vsnprintf` over a fixed argument list, as at `0x626850`. pub(crate) fn compose_line( lua: &benilla_ui::script::UiScript, family: Family, @@ -942,10 +707,7 @@ pub(crate) fn compose_line( Some(line) } None => { - // A mismatch means our declared slot order disagrees with the shipped template β€” a - // real defect, not a data condition, so it is loud. `tests` proves it cannot happen for - // the enUS file; a locale that reshaped a string would land here rather than printing - // a mangled sentence. + // Our slot order disagrees with the shipped template: a defect, so it warns. warn!( "combat log: {key} does not match our slot order ({:?})", family.slots @@ -961,8 +723,7 @@ enum Arg<'a> { Num(i64), } -/// `vsnprintf` over the `%s`/`%d` subset the combat-log templates use. `None` on any mismatch β€” -/// too few arguments, or a `%d` where a string is queued (and vice versa). +/// `vsnprintf` over the `%s`/`%d` subset the combat-log templates use; `None` on any mismatch. fn fill(template: &str, args: &[Arg<'_>]) -> Option { let mut out = String::with_capacity(template.len() + 32); let mut next = args.iter(); @@ -982,17 +743,15 @@ fn fill(template: &str, args: &[Arg<'_>]) -> Option { Arg::Num(n) => out.push_str(&n.to_string()), Arg::Str(_) => return None, }, - // Any other conversion is outside the vocabulary these templates use; treating it as - // a mismatch is better than silently emitting a half-filled sentence. + // Any other conversion is a mismatch, never a half-filled sentence. _ => return None, } } next.next().is_none().then_some(out) } -/// A GlobalString off the VM's globals, `None` when absent **or empty** β€” the same two tests -/// `0x703bf0`'s callers make (`GetPVPRankInfo` at `0x51aa1c`/`0x51aa20` is the pattern -/// [`crate::ui_script`] already follows for the pvp strings). +/// A GlobalString off the VM's globals, `None` when absent or empty: the two tests `0x703bf0`'s +/// callers make (`GetPVPRankInfo` at `0x51aa1c`/`0x51aa20`). pub(crate) fn global_string(script: &benilla_ui::script::UiScript, key: &str) -> Option { script .lua() @@ -1003,45 +762,15 @@ pub(crate) fn global_string(script: &benilla_ui::script::UiScript, key: &str) -> .filter(|s| !s.is_empty()) } -/// The school word a `…SCHOOL…` template's `%s` takes β€” **capitalized** (decision 2127). -/// -/// The reference does not read a GlobalString here at all: `0x6264b0(schoolIndex)` indexes -/// `Resistances.dbc` (row array `[0xc0d9a4]`, 7 rows of 0x30 bytes, localized name at -/// `row + 0xc + locale*4`) β€” and those seven rows are -/// `Physical Β· Holy Β· Fire Β· Nature Β· Frost Β· Shadow Β· Arcane`. `SPELL_SCHOOL_NAME` is a -/// *different* table in GlobalStrings.lua and holds the same seven words **lowercased** -/// (`GlobalStrings.lua:4102-4114`), which is what this used to return: "12 nature damage" where -/// the reference writes "12 Nature damage". -/// -/// It reads as a nicety and is not one. MikScrollingBattleText classifies a parsed line's damage -/// school by an exact string compare against `SPELL_SCHOOL_CAP` -/// (`MikCombatEventHelper.lua:3471-3492`) β€” an addon written against the live client's own output, -/// so it is independent evidence for the capital. A lowercase word falls through to -/// `DAMAGETYPE_UNKNOWN` and every school-coloured number in the addon loses its tint. -/// -/// **Named residue:** the *source* is still the GlobalString, not the DBC. `SPELL_SCHOOL_CAP` -/// carries the same seven strings as `Resistances.dbc` in enUS (both read, 2127), and this client -/// is enUS-only by construction (`combat_text::law::WORDS` and the rest of the string data are -/// hardcoded enUS). A locale where the two tables disagree would diverge; a `Resistances.dbc` -/// catalog is what closes it, and it is a data change rather than a string one β€” `compose_line` -/// is handed the Lua VM and nothing else. +/// The capitalized school word a `…SCHOOL…` template takes. The reference reads it from +/// `Resistances.dbc` (`0x6264b0`, rows at `[0xc0d9a4]`); this reads `SPELL_SCHOOL_CAP`, the +/// same seven words in enUS, where the lowercase `SPELL_SCHOOL_NAME` would be wrong. pub(crate) fn school_word(script: &benilla_ui::script::UiScript, school: u8) -> Option { global_string(script, &format!("SPELL_SCHOOL{school}_CAP")) } -/// The GlobalString key `0x6278f0` resolves for a power tag, or `None` where it answers NULL. -/// -/// The function is five lines and both of them matter: `cmp ecx,5; jae` β€” **unsigned**, so a tag -/// of 5 or more (and a negative one, which reads as huge) returns NULL β€” else -/// `[4*ecx + 0x85645c]` into `0x703bf0`. The table's five entries are the five `Powers` the wire -/// carries, **happiness included**: it is `HAPPINESS_POINTS = "Happiness"` (shipped enUS -/// `GlobalStrings.lua:2117`), not a hole. This file used to stop the table at energy and call -/// happiness "no GlobalString", which dropped every line the reference words with it β€” the -/// generic leech/drain fall-through out of `0x627de0`, and a happiness `POWERGAIN` tick. -/// -/// What happiness really lacks is a **`COMBAT_TEXT_UPDATE`** tag: `0x627520`/`0x627930`'s four -/// `0x64a4c0` compares match only the other four nouns, so it produces the chat line and no -/// floating text. That is a different table, further down, after the emit. +/// The GlobalString key `0x6278f0` resolves for a power tag: the five-entry table at `0x85645c`, +/// happiness included (`GlobalStrings.lua:2117`), and NULL for 5 or more by an unsigned compare. fn power_key(power: u32) -> Option<&'static str> { match power { 0 => Some("MANA_POINTS"), @@ -1053,37 +782,28 @@ fn power_key(power: u32) -> Option<&'static str> { } } -/// Whether `0x6278f0` answers a noun at all β€” the `cmp ecx,5; jae` bound, without a VM in hand. -/// -/// The formatters gate on the returned pointer (`627964 test edi,edi; 627966 je`) long before they -/// reach a template, so a caller that has no script yet still has to be able to ask. +/// Whether `0x6278f0` answers a noun at all, askable without a VM: the formatters gate on it +/// before any template (`0x627964`). pub(crate) fn power_has_word(power: u32) -> bool { power_key(power).is_some() } -/// The power word a `POWERGAIN`/`SPELLPOWERLEECH`/`SPELLPOWERDRAIN` template takes, by the vmangos -/// `Powers` index the wire carries β€” `0x6278f0`'s table, resolved through the same GlobalString -/// mechanism the reference uses ([`power_key`] carries the law). +/// The power word a `POWERGAIN`/`SPELLPOWERLEECH`/`SPELLPOWERDRAIN` template takes, by the wire's +/// `Powers` index. pub(crate) fn power_word(script: &benilla_ui::script::UiScript, power: u32) -> Option { global_string(script, power_key(power)?) } -/// Resolve one endpoint's display name β€” the reference's `GetObjectName` (`0x6264e0`), which is the -/// same ask-once name cache every other client-composed chat line waits on. `None` = not yet -/// answered; the caller re-tries next frame, exactly as the reference's deferred-name queue -/// (`DAT_00c4e208`, drained by the name-ready callback `0x6294b0`) replays its message. -/// -/// `unit` is the endpoint's descriptor when it is streamed: `0x6264e0` looks the guid up in the -/// object manager first and, for a unit, calls `GetUnitName` (`0x609210`) β€” which keys a pet's or a -/// companion's name off its descriptor, not its guid ([`NameCache::resolve_unit`]). +/// One endpoint's display name, the reference's `GetObjectName` (`0x6264e0`); `None` until the +/// name cache answers, and the caller retries as the deferred queue at `0xc4e208` does. A streamed +/// unit names through its descriptor (`GetUnitName`, `0x609210`), which is how a pet is named. pub(crate) fn object_name( guid: u64, unit: Option<&ObjectStore>, names: &NameCache, commands: &NetCommands, ) -> Option { - // Guid 0 = "the name is already in the fills" β€” no wire endpoint is ever guid 0, so the - // sentinel costs nothing and is what lets `/chattest` drive the real drain with literal names. + // Guid 0 means the name is already in the fills; no wire endpoint is guid 0. if guid == 0 { return None; } @@ -1092,11 +812,8 @@ pub(crate) fn object_name( // ────────────────────────────────── the families ────────────────────────────────────── -/// Declare a family: its key stem and the ordered slots the shipped templates consume. -/// -/// The order is read off the enUS `GlobalStrings.lua` and each declaration carries that line, so a -/// reader can check it without an install. `tests::every_family_matches_the_shipped_template` is -/// what checks it *with* one. +/// Declare a four-way family: its key stem and its slots in the order the enUS template consumes +/// them, with that template and its `GlobalStrings.lua` line as the doc. macro_rules! family { ($name:ident, $stem:literal, [$($slot:ident),* $(,)?], $doc:literal) => { #[doc = $doc] @@ -1109,10 +826,7 @@ macro_rules! family { }; } -/// Declare a **two-way** family β€” one keyed on the subject alone (`Keying::Duo`). `$me`/`$other` -/// are the family's own words, which are `"SELF"`/`"OTHER"` for most and `"_FIRSTPERSON"`/ -/// `"_THIRDPERSON"` for the two tradeskill ones. The optional `tail` is what `AURAADDED` and -/// `SPELLEXTRAATTACKS` append after it. +/// Declare a two-way family, keyed on the subject alone, with an optional `tail`. macro_rules! duo { ($name:ident, $stem:literal, $me:literal, $other:literal, [$($slot:ident),* $(,)?], $doc:literal) => { duo!($name, $stem, $me, $other, tail "", [$($slot),*], $doc); @@ -1128,7 +842,7 @@ macro_rules! duo { }; } -/// Declare a family with **no variant at all** β€” the key is the whole name. +/// Declare a family with no variant: the key is the whole name. macro_rules! single { ($name:ident, $key:literal, [$($slot:ident),* $(,)?], $doc:literal) => { #[doc = $doc] @@ -1638,10 +1352,8 @@ single!( ); // ── environmental damage ─────────────────────────────────────────────────────────────── -/// Declare one `VSENVIRONMENTALDAMAGE__{SELF,OTHER}` pair. The reference builds the key by -/// `snprintf("VSENVIRONMENTALDAMAGE_%s_%s", envTypeName, self ? "SELF" : "OTHER")` over the 6-entry -/// table `0x80dcac`; six declared families are that `snprintf` spelled out, which is what lets the -/// sweep check all twelve keys against the shipped file. +/// Declare one `VSENVIRONMENTALDAMAGE__{SELF,OTHER}` pair, the key the reference builds with +/// `snprintf` over the six type names at `0x80dcac`. macro_rules! env_family { ($name:ident, $stem:literal, $doc:literal) => { duo!($name, $stem, "SELF", "OTHER", [Attacker, Amount], $doc); @@ -1678,8 +1390,8 @@ env_family!( "`\"You suffer %d points of fire damage.\"` (:5410) β€” type 5." ); -/// The environmental family for a `SMSG_ENVIRONMENTALDAMAGELOG` damage type, `None` for a byte -/// outside the six the table holds (`0x62abc5` indexes it unguarded; we decline instead). +/// The environmental family for a `SMSG_ENVIRONMENTALDAMAGELOG` damage type; `None` past the six, +/// where the reference indexes its table unguarded (`0x62abc5`). pub(crate) fn env_family(damage_type: u8) -> Option { Some(match damage_type { 0 => VSENV_FATIGUE, @@ -1692,9 +1404,8 @@ pub(crate) fn env_family(damage_type: u8) -> Option { }) } -/// Every family this module can emit β€” the sweep [`tests`] uses to check each one against the -/// shipped `GlobalStrings.lua`. Adding a family without adding it here is the only way to get an -/// unchecked slot order, so the list is the gate. +/// Every family this module emits, for the sweep against the shipped templates; a family left out +/// goes unchecked. #[cfg(test)] pub(crate) const ALL_FAMILIES: &[Family] = &[ COMBATHIT, @@ -1773,13 +1484,8 @@ pub(crate) const ALL_FAMILIES: &[Family] = &[ VSENV_FIRE, ]; -/// The `SpellMissInfo` byte `SMSG_SPELLLOGMISS` (and `SpellGo`'s own list) carries β†’ the family -/// that words it. vmangos `SpellDefines.h:160-174`; the reference reaches the same ten families -/// through its own miss switch. -/// -/// There is no `None` arm: the reference's jump table sends `0`, `1`, `10` and everything past the -/// enum to one default, `SPELLMISS*`. `0` is `SPELL_MISS_NONE` and should never appear in a miss -/// list, but if one arrives the reference words it as a miss rather than dropping it. +/// The family that words a `SpellMissInfo` byte (vmangos `SpellDefines.h:160-174`), as the +/// reference's miss switch `0x62bb50` maps it. pub(crate) fn miss_family(miss_info: u8) -> Family { match miss_info { 2 => SPELLRESIST, @@ -1791,37 +1497,15 @@ pub(crate) fn miss_family(miss_info: u8) -> Family { 7 | 8 => SPELLIMMUNE, 9 => SPELLDEFLECTED, 11 => SPELLREFLECT, - // **10 ABSORB lands on SPELLMISS, not SPELLLOGABSORB**, and so do 0, 1 and anything out of - // range: `0x62bb50`'s `lea eax,[edi-2]; cmp eax,9; ja default` puts all four on the - // default arm `0x62c0e0`. 1571 had 10 on `SPELLLOGABSORB` β€” a family this switch never - // reaches (it belongs to the direct-damage path) β€” and dropped 0/1 entirely. + // 10 ABSORB, 0, 1 and anything out of range take the default arm `0x62c0e0`, not + // `SPELLLOGABSORB`, which belongs to the direct-damage path. _ => SPELLMISS, } } -/// The melee family a swing's `HitInfo` + `VictimState` select β€” the reference's display dispatcher -/// `0x629b60`, arm for arm, and its order is load-bearing because the tests overlap. -/// -/// ```text -/// HitInfo & 0x10 (MISS) β†’ MISSED -/// VictimState == 5 (BLOCKS) β†’ VSBLOCK -/// damage == 0 && HitInfo & 0x20 β†’ VSABSORB (ABSORB) -/// damage == 0 && HitInfo & 0x40 β†’ VSRESIST (RESIST) -/// VictimState == 1 && damage > 0 β†’ COMBATHIT[CRIT][SCHOOL] -/// otherwise β†’ the VictimState word, or NO LINE -/// ``` -/// -/// **The last arm can decline.** `0x62a710` is gated twice before it words anything: the 10-entry -/// flag table `0x8628f8` = `[0,0,1,1,0,1,1,1,1,0]` indexed by VictimState (`0x62a720`), then -/// `add eax,-2; cmp eax,6; ja` into the jump table `0x62a8ec` β€” so VictimState **0, 1, 4 and 9 -/// emit no line at all**. This used to answer `MISSED` for them and call that "the -/// reference's own fall-through", which it is not: the reference stays silent, and a `MISSED` -/// there is a sentence the real client never prints. -/// -/// The bit values are vmangos's `HitInfo` under the `> 1.9.4` conditional that is compile-time true -/// for 5875 (`Objects/UnitDefines.h:250-268`) and its `VictimState` (`:237-248`) β€” the same pair -/// [`crate::sound::combat`] and [`crate::combat_text`] already read, here named once instead of a -/// fourth set of bare literals. +/// The melee family a swing selects, the reference's dispatcher `0x629b60` arm for arm; the order +/// matters because the tests overlap. The bits are vmangos's `HitInfo` in its `> 1.9.4` branch +/// (`Objects/UnitDefines.h:250-268`) and `VictimState` (`:237-248`). pub(crate) fn melee_family( hit_info: u32, victim_state: u32, @@ -1854,16 +1538,8 @@ pub(crate) fn melee_family( (true, true) => COMBATHITCRITSCHOOL, }); } - // `0x62a710`'s own two gates, and they are the whole arm. The flag table's `1`s are exactly - // these five; VictimState 0 (UNAFFECTED), 1 with no damage, 4 (INTERRUPT) and 9 are `0`s and - // the formatter returns having emitted nothing. - // - // The table's index 5 is a `1`, and it is unreachable: a block is taken above, which is why - // the two readings have to be kept apart rather than collapsed into one list of five. - // A VictimState of 10 or more is not bounds-checked before the table read β€” the load runs on - // a wire `u32` and index 10 lands in the adjacent pointer table `0x862920`, whose entries are - // all non-zero, so gate 1 *passes* and the second gate's `cmp eax,6; ja` is what rejects it. - // No line, nothing fired β€” which is the answer `None` already gives. + // `0x62a710` words only a state its flag table `0x8628f8` marks (`0x62a720`) and its jump + // table `0x62a8ec` covers: 0, 1 without damage, 4, 9 and 10 or more print nothing. match victim_state { 2 => Some(VSDODGE), 3 => Some(VSPARRY), @@ -1876,41 +1552,29 @@ pub(crate) fn melee_family( // ────────────────────────── the queued line, awaiting its names ─────────────────────── -/// A combat-log line with every decision already made and only its **names** outstanding. -/// -/// This split is the reference's, not a convenience. `0x629b60` classifies and picks the family at -/// the packet; when a name the sentence needs is not in the cache yet, the message is pushed onto -/// the deferred queue at `DAT_00c4e208` and replayed by the name-ready callback `0x6294b0`. Doing -/// the same means the classification is made while both units are certainly streamed β€” a creature -/// that dies to the killing blow is gone from the object manager long before its name query -/// answers, and a classification deferred to that moment would read `Unknown` and drop the line. +/// A combat-log line classified at the packet, with only its names outstanding: the reference's +/// `0x629b60` decides while both units are streamed and parks the message on the deferred queue +/// at `0xc4e208` until the name-ready callback `0x6294b0`, so a despawned creature still logs. #[derive(Clone, Debug)] pub(crate) struct PendingCombat { pub kind: ChatEventKind, pub family: Family, pub variant: Variant, - /// The guid whose name fills the `Attacker` slots β€” the sentence's SUBJECT, which for - /// [`DAMAGESHIELD`] is the shield's bearer rather than the packet's `attacker` field. + /// The sentence's subject, naming the `Attacker` slots; for [`DAMAGESHIELD`], the bearer. pub subject: u64, - /// The guid whose name fills the `Victim` slots. + /// The guid naming the `Victim` slots. pub object: u64, - /// Everything the template needs that is not a name; the two name fields are filled at drain. + /// Everything the template needs but the names, which are filled at drain. pub fills: Fills, - /// Where [`Slot::Named`]'s text comes from β€” see [`Named`]. pub named: Named, pub tries: u16, } -/// What still has to be looked up before a line's [`Slot::Named`] can be filled. -/// -/// The reference has both hops and keeps them apart for the same reason: an item name comes from -/// the **item cache** (`0x55ba30`), whose miss parks the whole formatter on the deferred queue and -/// re-runs it when the server answers (`0x6294b0`); a unit name comes from `GetObjectName` -/// (`0x6264e0`), the same resolve the two endpoint slots already use. +/// What still has to be looked up before a line's [`Slot::Named`] can be filled: an item through +/// the item cache (`0x55ba30`), a unit through `GetObjectName` (`0x6264e0`). #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum Named { - /// Nothing to do β€” the arm already put the text in [`Fills::named`], or the family has no - /// `Named` slot at all. + /// The text is already in [`Fills::named`], or the family has no `Named` slot. Ready, /// An item entry, through the ask-once item cache. Item(u32), @@ -1918,11 +1582,7 @@ pub(crate) enum Named { Unit(u64), } -/// Build a queued line from an already-classified pair, or `None` when the reference would emit -/// nothing: an unroutable type (`0x5e`), or an endpoint whose class is out of the range gate. -/// -/// `kind` is the caller's β€” the three msgType matrices differ per family, so the arm that knows -/// which packet it is decides, and this only assembles. +/// Build a queued line from an already-classified pair; `None` when neither endpoint resolves. pub(crate) fn queue( kind: ChatEventKind, family: Family, @@ -1931,11 +1591,8 @@ pub(crate) fn queue( fills: Fills, named: Named, ) -> Option { - // **A class-9 endpoint alone does NOT drop the line**, and 1571 had this wrong. Its range of - // `0.0` means it can never satisfy *its own half* of the gate β€” but the gate is an OR over the - // two endpoints (`0x626630`), so a resolvable one at the other end still carries the line. What - // drops here is only the case the reference's own resolve step fails on: neither endpoint - // resolvable at all. + // The range gate is an OR over the two endpoints (`0x626630`): one unresolvable endpoint alone + // does not drop the line. if subject.1 == UnitClass::Unknown && object.1 == UnitClass::Unknown { return None; } diff --git a/crates/benilla-app/src/ui_chat/combat/tests.rs b/crates/benilla-app/src/ui_chat/combat/tests.rs index 26a08ee9..b4e2b205 100644 --- a/crates/benilla-app/src/ui_chat/combat/tests.rs +++ b/crates/benilla-app/src/ui_chat/combat/tests.rs @@ -1,12 +1,10 @@ -//! The combat log's own tests. Two of them are the ones that matter: the slot orders checked -//! against the *shipped* `GlobalStrings.lua`, and the three msgType matrices checked against the -//! byte-read table they transcribe. Everything else is a spot check on a line that was reported. +//! The combat log's tests: the slot orders against the shipped `GlobalStrings.lua`, the msgType +//! matrices against the reference's selectors, and single lines. use super::*; -/// The `%s`/`%d` conversion sequence of a template, in order β€” its "type signature". Two format -/// strings with the same signature accept the same argument list, which is exactly the property a -/// declared slot order has to have. +/// A template's `%s`/`%d` conversions in order; two templates with the same one take the same +/// arguments. fn signature(template: &str) -> Vec { let mut out = Vec::new(); let mut chars = template.chars().peekable(); @@ -23,8 +21,8 @@ fn signature(template: &str) -> Vec { out } -/// The signature our declared slot list produces for one variant β€” `Attacker`/`Victim` present only -/// where the variant spells that endpoint out. +/// The signature a family's declared slots give for one variant; `Attacker` and `Victim` count +/// only where the variant names them. fn declared_signature(family: Family, variant: Variant) -> Vec { family .slots @@ -38,11 +36,8 @@ fn declared_signature(family: Family, variant: Variant) -> Vec { .collect() } -/// The variants a family can actually be asked for. A [`Keying::Quad`] family answers all four; -/// a `Duo` one collapses to two distinct keys (both `…SELF*` variants give the `me` word), and a -/// `Single` family has exactly one. Sweeping the quad list over a Duo family would check the same -/// key twice and prove nothing extra β€” worse, it would let `every_family_has_the_three_core_keys` -/// demand keys that do not exist. +/// The variants with distinct keys: four for `Quad`, two for `Duo` (both `…SELF*` variants give +/// the `me` word), one for `Single`. fn variants_of(family: Family) -> &'static [Variant] { match family.keying { Keying::Quad => &[ @@ -56,22 +51,10 @@ fn variants_of(family: Family) -> &'static [Variant] { } } -/// **The gate on the one thing this module authors.** Every family Γ— every variant: if the install -/// defines the key, our declared slot order must produce exactly the template's own `%s`/`%d` -/// sequence. -/// -/// This is what makes the slot lists safe to hand-declare. A transposed pair of different types -/// (`Amount` before `School`, `Spell` after `Victim` where the string puts it first) changes the -/// signature and fails here. A transposition of two *same-type* slots does not β€” that residue is -/// covered by [`the_reported_sentences_read_correctly`], which pins whole sentences for the shapes -/// a player actually sees, and by each family's doc comment quoting its line. -/// -/// A key the install does **not** define is not a failure: several families genuinely have no -/// `…SELFSELF` (`COMBATHIT`, `MISSED`, every `VS*`, `DAMAGESHIELD`, `SPELLPOWERLEECH`), and the -/// reference's own selector returns NULL for exactly those. [`every_family_has_the_three_core_keys`] -/// is what stops "absent" from silently covering a typo'd stem. -/// -/// Skips without client data. +/// Every family and variant whose key the install defines: the declared slots give exactly the +/// template's `%s`/`%d` sequence. A swap of two same-type slots passes here and is caught by +/// [`the_reported_sentences_read_correctly`]. An undefined key is skipped: several families have +/// no `…SELFSELF`, for which the reference's selector returns NULL. #[test] fn every_family_matches_the_shipped_template() { let data = benilla_formats::wow_data_or_skip!(); @@ -100,19 +83,15 @@ fn every_family_matches_the_shipped_template() { checked += 1; } } - // A stem typo'd across the board would make every lookup miss and leave this at zero, which - // would otherwise pass silently. + // A stem typo'd everywhere would miss every lookup and pass silently. assert!( checked >= ALL_FAMILIES.len(), "only {checked} keys resolved" ); } -/// Every family defines the variants that are not `…SELFSELF` β€” for a `Quad` family the other -/// three, for a `Duo` one both of its keys, for a `Single` one its single key. Those are the ones -/// the reference's selectors always return a key for, so an absent one is a wrong stem, not a data -/// condition β€” the failure mode [`every_family_matches_the_shipped_template`] cannot see because -/// it skips what it cannot find. Skips without client data. +/// Every key but `…SELFSELF` exists, as the reference's selectors always return one: an absent key +/// is a wrong stem. #[test] fn every_family_has_the_three_core_keys() { let data = benilla_formats::wow_data_or_skip!(); @@ -140,8 +119,6 @@ fn every_family_has_the_three_core_keys() { } } -/// The school words and power words the `…SCHOOL…` and `POWERGAIN` slots take actually resolve. -/// Skips without client data. #[test] fn the_school_and_power_words_resolve() { let data = benilla_formats::wow_data_or_skip!(); @@ -161,9 +138,7 @@ fn the_school_and_power_words_resolve() { "SPELL_SCHOOL{school}_CAP missing" ); } - // `0x6278f0`'s table is FIVE entries β€” `cmp ecx,5; jae` β€” and happiness is the fifth. This - // used to stop at 3 and assert that happiness had no word "deliberately"; the shipped file - // says otherwise, and reading it here is what settles it. + // `0x6278f0`'s power table has five entries (`cmp ecx,5; jae`); happiness is the fifth. for power in 0..=4u32 { assert!( power_word(&script, power).is_some(), @@ -175,10 +150,8 @@ fn the_school_and_power_words_resolve() { assert!(power_word(&script, 5).is_none()); } -/// The sentences a player actually reads, end to end, on the real strings β€” the residue -/// [`every_family_matches_the_shipped_template`]'s type check cannot cover (two adjacent `%s` -/// slots), pinned as whole sentences rather than as an argument order. -/// Skips without client data. +/// Whole sentences on the real strings, which catch the same-type slot swaps the signature check +/// cannot. #[test] fn the_reported_sentences_read_correctly() { let data = benilla_formats::wow_data_or_skip!(); @@ -206,7 +179,6 @@ fn the_reported_sentences_read_correctly() { }; let line = |family, variant| compose_line(&script, family, variant, &fills).expect("composes"); - // The ledger's own example line, and the one a damage meter parses β€” both directions. assert_eq!( line(COMBATHIT, Variant::SelfOther), "You hit Victim for 120." @@ -215,17 +187,15 @@ fn the_reported_sentences_read_correctly() { line(COMBATHIT, Variant::OtherSelf), "Attacker hits you for 120." ); - // The spell name comes BEFORE the victim in this family and AFTER the amount in the periodic - // one β€” the two orders that would look identical to a type check if both were `s,s,d`. + // The spell comes before the victim here and after the amount in the periodic family. assert_eq!( line(SPELLLOG, Variant::SelfOther), "Your Fireball hits Victim for 120." ); assert_eq!( line(PERIODICAURADAMAGE, Variant::SelfOther), - // **Capitalized** β€” the reference words this cell out of `Resistances.dbc`, not out of - // `SPELL_SCHOOL_NAME` (decision 2127). Lowercase is what MikScrollingBattleText's - // school compare misses. + // Capitalized: the reference takes this word from `Resistances.dbc`, not from + // `SPELL_SCHOOL_NAME`. "Victim suffers 120 Fire damage from your Fireball." ); assert_eq!( @@ -252,7 +222,6 @@ fn the_reported_sentences_read_correctly() { ); } -/// The `vsnprintf` subset, including the two ways it is allowed to refuse. #[test] fn the_fill_is_vsnprintf_and_refuses_a_mismatch() { fn s(v: &str) -> Arg<'_> { @@ -273,10 +242,8 @@ fn the_fill_is_vsnprintf_and_refuses_a_mismatch() { assert_eq!(fill("%f", &[Arg::Num(1)]), None); } -/// The melee family dispatcher, arm for arm against `0x629b60`'s order β€” including the two overlaps -/// that make the order load-bearing: a blocked swing that also carries damage is `VSBLOCK` (the -/// VictimState test runs before the damage test), and a fully-absorbed swing is `VSABSORB` and not -/// `MISSED` (the MISS bit is not set for an absorb). +/// `0x629b60`'s arm order: a blocked swing with damage is `VSBLOCK`, and a fully absorbed one +/// `VSABSORB`, not `MISSED`. #[test] fn the_melee_dispatcher_follows_the_reference_order() { let f = |h, v, d, s| melee_family(h, v, d, s).expect("a family").stem; @@ -287,7 +254,7 @@ fn the_melee_dispatcher_follows_the_reference_order() { assert_eq!(f(0x80, 1, 120, 2), "COMBATHITCRITSCHOOL"); // MISS wins over everything, including a VictimState that would say otherwise. assert_eq!(f(0x10, 2, 0, 0), "MISSED"); - // BLOCKS wins over the damage test β€” a partial block still lands damage. + // BLOCKS wins over the damage test: a partial block still lands damage. assert_eq!(f(0, 5, 90, 0), "VSBLOCK"); // Zero damage + the absorb/resist bits. assert_eq!(f(0x20, 1, 0, 0), "VSABSORB"); @@ -302,13 +269,8 @@ fn the_melee_dispatcher_follows_the_reference_order() { assert_eq!(f(0, 8, 0, 0), "VSDEFLECT"); } -/// `0x62a710` declines: the VictimStates whose flag-table byte is `0` produce **no line at all**. -/// -/// The table `0x8628f8` is `[0,0,1,1,0,1,1,1,1,0]`, so 0, 1, 4 and 9 are silent β€” and 1 is the -/// interesting one, because a VictimState of 1 with damage through is the commonest line in the -/// whole log. It is arm 5 that words that, on `damage != 0`; strip the damage and the same state -/// falls to this arm and says nothing. Answering `MISSED` here (which is what we did) invents a -/// sentence the reference never prints. +/// `0x62a710` prints nothing for a VictimState whose byte in `0x8628f8`, `[0,0,1,1,0,1,1,1,1,0]`, +/// is 0. State 1 words a hit only with damage through, in arm 5. #[test] fn the_silent_victim_states_emit_no_melee_line() { for state in [0, 1, 4, 9] { @@ -321,12 +283,12 @@ fn the_silent_victim_states_emit_no_melee_line() { // state 1 with damage through. assert!(melee_family(0x10, 0, 0, 0).is_some(), "the MISS bit wins"); assert!(melee_family(0, 1, 120, 0).is_some(), "a landed hit wins"); - // Index 5 of the table is a `1`, and it is unreachable β€” a block is claimed by arm 2. + // Index 5 of the table is a 1, and unreachable: arm 2 claims a block. assert_eq!(melee_family(0, 5, 0, 0).map(|f| f.stem), Some("VSBLOCK")); } -/// The melee msgType matrix, against `0x62a0d0`/`0x62a2e0` as decompiled and against the 94-entry -/// type table `.rdata 0x804710` (whose 1-based index is one more than the selector's return). +/// The melee msgType matrix of `0x62a0d0`/`0x62a2e0`, through the 94-entry type table at +/// `0x804710`, whose 1-based index is the selector's return plus one. #[test] fn the_melee_matrix_is_the_reference_selector() { use ChatEventKind as K; @@ -334,7 +296,7 @@ fn the_melee_matrix_is_the_reference_selector() { let hits = |a, v| combat_kind(a, v, false).unwrap(); let misses = |a, v| combat_kind(a, v, true).unwrap(); - // src 0 β†’ 0x1b, src 1 β†’ 0x1d β€” no victim dependence. + // src 0 β†’ 0x1b, src 1 β†’ 0x1d, whatever the victim. assert_eq!(hits(C::Me, C::Creature), K::CombatSelfHits); assert_eq!(misses(C::Me, C::Creature), K::CombatSelfMisses); assert_eq!(hits(C::MyPet, C::Creature), K::CombatPetHits); @@ -346,14 +308,14 @@ fn the_melee_matrix_is_the_reference_selector() { hits(C::FriendlyPlayer, C::Creature), K::CombatFriendlyPlayerHits ); - // ...a party or friendly player attacking me or mine reads as HOSTILE β€” the duel case. + // A party or friendly player attacking me or mine reads as hostile: the duel case. for victim in [C::Me, C::MyPet, C::Party, C::PartyPet] { assert_eq!(hits(C::Party, victim), K::CombatHostilePlayerHits); assert_eq!(hits(C::FriendlyPlayer, victim), K::CombatHostilePlayerHits); } assert_eq!(hits(C::HostilePlayer, C::Me), K::CombatHostilePlayerHits); assert_eq!(hits(C::HostilePet, C::Me), K::CombatHostilePlayerHits); - // src 8/9 split on the VICTIM, into three buckets. + // src 8 and 9 split three ways on the victim. assert_eq!(hits(C::Creature, C::Me), K::CombatCreatureVsSelfHits); assert_eq!(hits(C::Creature, C::MyPet), K::CombatCreatureVsSelfHits); assert_eq!(hits(C::Creature, C::Party), K::CombatCreatureVsPartyHits); @@ -363,9 +325,8 @@ fn the_melee_matrix_is_the_reference_selector() { } } -/// The spell matrix is the melee one's shape with a different base row (`0x627820`), and the -/// periodic matrix is a genuinely different, smaller one: no PET row, no `CREATURE_VS_*` split, and -/// **the victim is not consulted at all**. +/// The spell matrix is the melee one from another base row (`0x627820`); the periodic one has no +/// pet row and no `CREATURE_VS_*` split, and ignores the victim. #[test] fn the_spell_and_periodic_matrices() { use ChatEventKind as K; @@ -405,9 +366,8 @@ fn the_spell_and_periodic_matrices() { ); } -/// The variant picker: only class 0 is `SELF`. Your own pet is an `OTHER` for string selection even -/// though the msgType matrix gives it its own row β€” the two classifications are independent, and -/// conflating them is the obvious way to get "Your pet hits X" wrong. +/// Only class 0 is `SELF`: your pet is `OTHER` for the string though the msgType matrix gives it +/// its own row. #[test] fn only_the_player_is_self_for_string_selection() { use UnitClass as C; @@ -418,9 +378,8 @@ fn only_the_player_is_self_for_string_selection() { assert_eq!(Variant::of(C::Me, C::Me), Variant::SelfSelf); } -/// **One `Unknown` endpoint does not drop the line; two do.** 1571 dropped on either, which was a -/// consequence of reading the range gate as an AND β€” class 9's range of `0.0` means it can never -/// satisfy its own half, but the other endpoint's half still carries the line. +/// Class 9's range is 0.0, so it never passes its own half of the range gate, but the other +/// endpoint's half still carries the line. #[test] fn an_unknown_endpoint_alone_does_not_drop_the_line() { use UnitClass as C; @@ -450,11 +409,8 @@ fn an_unknown_endpoint_alone_does_not_drop_the_line() { ); } -/// The miss-code table, against the reference's own jump table (`0x62bb50`, table `0x62bde8`). -/// -/// The default arm is the interesting one: `lea eax,[edi-2]; cmp eax,9; ja default` puts **0, 1, -/// 10 and everything out of range** on `SPELLMISS*`. 1571 had 10 (ABSORB) on `SPELLLOGABSORB` β€” -/// a family this switch never reaches β€” and dropped 0/1/out-of-range entirely. +/// The reference's miss-code switch (`0x62bb50`, table `0x62bde8`): `lea eax,[edi-2]; cmp eax,9; +/// ja default` sends 0, 1, 10 and anything out of range to `SPELLMISS*`. #[test] fn the_miss_codes_map_to_their_families() { assert_eq!(miss_family(2).stem, "SPELLRESIST"); @@ -477,8 +433,7 @@ fn the_miss_codes_map_to_their_families() { } } -/// The range table, read out of `WoW.exe` at `0x8629e0` β€” the evidence the class indices are what -/// they are named. Pinned here so a later reshuffle of the enum has to face it. +/// The class range table at `0x8629e0`, the evidence the class indices are what they are named. #[test] fn the_class_range_table_is_the_binarys() { use UnitClass as C; @@ -511,14 +466,9 @@ fn the_class_range_table_is_the_binarys() { assert_eq!(C::Unknown.default_range(), 0.0); } -/// **The eight range CVars reach the gate** β€” the table is live, not compiled in. -/// -/// The reference registers all eight in one place (`0x626d00`) and looks each up by name at every -/// use; we resolve them once into [`CombatLogRanges`]. What this pins is the property that made -/// them worth registering: a `SetCVar` moves the number the gate actually compares against, for -/// each of the seven classes independently and for the death line separately. -/// -/// The control is class 0: it has NO CVar in the reference's table, so no name may reach it. +/// The reference registers the eight range CVars at `0x626d00` and reads each by name at every +/// use; [`CombatLogRanges`] holds them, and a `SetCVar` moves each class, and the death range, +/// on its own. Class 0 has no CVar. #[test] fn a_set_cvar_moves_the_range_the_gate_compares_against() { use super::{CombatLogRanges, UnitClass as C}; @@ -530,8 +480,7 @@ fn a_set_cvar_moves_the_range_the_gate_compares_against() { assert_eq!(r.class(C::Me), 100_000.0); assert_eq!(r.death(), 60.0); - // Each of the seven moves its own class and nothing else β€” BigWigs' slider, which wrote - // nothing at all before these rows were registered. + // Each of the seven moves its own class and nothing else. assert!(r.set("CombatLogRangeParty", 200.0)); assert_eq!(r.class(C::Party), 200.0); assert_eq!(r.class(C::PartyPet), 50.0, "a sibling class must not move"); @@ -547,13 +496,11 @@ fn a_set_cvar_moves_the_range_the_gate_compares_against() { assert_eq!(r.death(), 5.0); assert_eq!(r.class(C::Party), 200.0); - // Zero is a real value, not "unset": the reference's `distΒ² < 0` is never true, so the class - // goes silent. Nothing may clamp it up to a floor. + // Zero is a real value: the reference's `distΒ² < 0` is never true, so the class goes silent. assert!(r.set("CombatLogRangePartyPet", 0.0)); assert_eq!(r.class(C::PartyPet), 0.0); - // The control: classes 0 and 1 have a NULL name in the reference's table, and an unrelated - // name is refused rather than silently swallowed. + // Classes 0 and 1 have a NULL name in the reference's table; an unrelated name is refused. assert!(!r.set("CombatLogRangeMe", 10.0)); assert!(!r.set("mousespeed", 10.0)); assert_eq!(r.class(C::Me), 100_000.0); diff --git a/crates/benilla-app/src/ui_chat/combat/watch.rs b/crates/benilla-app/src/ui_chat/combat/watch.rs index ac4e6a38..1c3f76fb 100644 --- a/crates/benilla-app/src/ui_chat/combat/watch.rs +++ b/crates/benilla-app/src/ui_chat/combat/watch.rs @@ -1,19 +1,7 @@ -//! The combat-log lines that are **not packet-driven at all** β€” the reference's two families of -//! them, death and aura, plus the pet-loyalty leaf. -//! -//! Everything else in [`super`] hangs off an SMSG arm. These three hang off *descriptor changes*, -//! exactly as the reference does: -//! -//! - **the death line** (`UNITDIES*` / `UNITDESTROYEDOTHER`) rides the unit-death reflex -//! `0x625190`, not a packet β€” there is no "X died" opcode in 1.12; -//! - **the aura lines** (`AURAADDED*`, `AURAREMOVED*`, `AURAAPPLICATIONADDED*`) ride the -//! `UNIT_FIELD_AURA` / `UNIT_FIELD_AURAAPPLICATIONS` change callbacks `0x604d00` / `0x604ea0` -//! through the bridges `0x612320`/`0x6123f0`/`0x612450`; -//! - **the loyalty line** rides the `UNIT_FIELD_BYTES_1` byte-1 callback (`0x5ff860` β†’ `0x62d440`). -//! -//! Each is a slot diff against the last frame's snapshot, the same idiom -//! [`crate::creature_anim::arm_aura_state_fx`] uses for the aura state kits β€” and for the same -//! reason: the *edge* is the event, and a descriptor snapshot is all the wire gives us. +//! The combat-log lines that come off descriptor edges, not packets, as in the reference: the +//! death line (the unit-death reflex `0x625190`; 1.12 has no death opcode), the aura lines (the +//! `UNIT_FIELD_AURA` and `UNIT_FIELD_AURAAPPLICATIONS` callbacks `0x604d00` and `0x604ea0`, +//! through `0x612320`, `0x6123f0` and `0x612450`) and the pet-loyalty line (`0x5ff860`). use bevy::prelude::*; @@ -26,9 +14,7 @@ use crate::target::ring::Factions; use crate::ui_chat::{ChatEventKind, ChatLog}; use crate::ui_party::GroupState; -/// The classification inputs all three watchers share β€” the same six facts -/// [`crate::combat_log::chat::ChatCtx`] carries, as a `SystemParam` so a watcher's own -/// parameter list stays about *its* diff. +/// What the three watchers share to classify, name and range-gate a line. #[derive(bevy::ecs::system::SystemParam)] pub(crate) struct WatchCtx<'w> { pub self_guid: Res<'w, SelfGuid>, @@ -37,7 +23,7 @@ pub(crate) struct WatchCtx<'w> { pub factions: Option>, pub reputations: Res<'w, Reputations>, pub spells: Option>, - /// The live display ranges β€” the reference's `0x8629e0` table plus `CombatDeathLogRange`. + /// The live display ranges: the reference's `0x8629e0` table plus `CombatDeathLogRange`. pub ranges: Res<'w, CombatLogRanges>, } @@ -54,9 +40,8 @@ impl WatchCtx<'_> { ) } - /// [`crate::combat_log::chat::ChatCtx::spell_name`]'s law, shared: the per-spell gates - /// (`Attributes & 0x180`, an empty localized name) apply to every spell-driven line, and an - /// aura line is one. + /// The gates every spell-driven line takes, aura lines included: `Attributes & 0x180` or an + /// empty name drops the line. fn spell_name(&self, spell_id: u32) -> Option { let Some(display) = self.spells.as_ref().and_then(|s| s.catalog.get(spell_id)) else { return Some(String::new()); @@ -68,13 +53,9 @@ impl WatchCtx<'_> { Some(display.name.clone()) } - /// The reference's ONE-SIDED range test β€” the shape 15 formatters run instead of the two-ended - /// gate `0x626630`, the death and aura lines among them. One participant, one distance. - /// - /// **`range` is the caller's**, because the death line does not use the class table: its - /// formatter `0x62c160` reads `CombatDeathLogRange` first (`0x62c19c`) and only falls back to - /// the per-class getter when that *lookup* fails β€” which never happens in a client that - /// registered it at startup. The aura lines have no CVar of their own and stay per-class. + /// The one-sided range test 15 reference formatters run in place of the two-ended `0x626630`. + /// The death line passes `CombatDeathLogRange`, which `0x62c160` reads at `0x62c19c`; the aura + /// lines pass their class's range. fn in_range(&self, guid: u64, range: f32, poses: &Query<&Transform>) -> bool { in_range(guid, range, &self.self_guid, &self.index, poses) } @@ -107,26 +88,17 @@ fn queue_one( }); } -/// `Spell.dbc` `Effect[0]` values that make a unit "destroyed" rather than "dead" β€” `0x62c320`'s -/// byte table, read through `UNIT_CREATED_BY_SPELL`. A totem, a summoned object, a guardian: the -/// things the world *made*, which are unmade rather than killed. +/// The creating spell's (`UNIT_CREATED_BY_SPELL`) `Spell.dbc` `Effect[0]` values that word a death +/// as "destroyed", `0x62c320`'s byte table: totems, summoned objects, guardians. const DESTROYED_EFFECTS: [u32; 10] = [50, 74, 87, 88, 89, 90, 104, 105, 106, 107]; -/// The unit-death reflex's chat line (`0x625190` β†’ `0x62c160`). +/// The unit-death line (`0x625190` β†’ `0x62c160`), off the `UNIT_FIELD_HEALTH` edge as the +/// reference's watcher `0x6046f0` takes it (alive to dead at `0x6047a3`); a unit that streams in +/// dead prints nothing. /// -/// **The edge is the event.** The wire never says "this unit died"; it says the unit's health is -/// zero in an ordinary descriptor update β€” so the line comes off the `UNIT_FIELD_HEALTH` field -/// edge, the reference's own watcher `0x6046f0` whose aliveβ†’dead arm (`OLD > 0 && NEW ≀ 0` at -/// `0x6047a3`) reaches the reflex (decision 2297), and a unit that streams in *already* dead -/// produces none (the edge stream is create-suppressed: the reflex fires on the transition, not -/// on the state). -/// -/// **The XP-award suppression is deliberately not modelled**, and is named rather than dropped: -/// `[CGUnit+0xc58] bit 3`, set only in the `SMSG_LOG_XPGAIN` chain, makes the death reflex emit the -/// award line *instead of* the plain death line (`0x62c23e`). No clearing site for the bit is -/// known, so whether it survives a second death is UNSETTLED β€” and modelling an unsettled latch is -/// how you get a line that silently stops appearing. The visible consequence today is one extra -/// "%s dies." beside the XP line on a kill that awards experience. +/// Not modelled: the reference prints the XP-award line in place of the death line while +/// `[CGUnit+0xc58]` bit 3, set in the `SMSG_LOG_XPGAIN` chain, is set (`0x62c23e`); the bit's +/// clearing site is untraced, so a kill that awards experience prints both lines here. pub(crate) fn death_lines( ctx: WatchCtx, stores: Query<(Entity, &ObjectStore)>, @@ -143,8 +115,7 @@ pub(crate) fn death_lines( }; let guid = e.guid; let class = ctx.classify(guid, &stores); - // A summoned thing is *destroyed*; a living one *dies*. The test is the creating spell's - // first effect, which is why it needs the spell catalog at all. + // A summoned thing is destroyed, a living one dies, by its creating spell's first effect. let destroyed = store .0 .unit_created_by_spell() @@ -163,7 +134,6 @@ pub(crate) fn death_lines( death_kind(class), family, (guid, class), - // The death line's own CVar, not the class table β€” the one formatter that has one. ctx.ranges.death(), Fills::default(), Named::Ready, @@ -171,28 +141,17 @@ pub(crate) fn death_lines( } } -/// The aura lines (`0x62b480` / `0x62b800`). +/// The aura lines (`0x62b480`, `0x62b800`), one per field edge: /// -/// **Three edges out of two watches**, because the reference reads two descriptor arrays through -/// two callbacks and words three sentences β€” and since decision 2297 those are literally the -/// triggers here, one field edge each, with no snapshot of the slots kept anywhere: +/// - a `UNIT_FIELD_AURA` slot gaining a spell words `AURAADDED*` at the periodic chat types +/// (`0x604d00`'s add arm); +/// - a slot losing one words `AURAREMOVED*` at chat types `0x41`-`0x43`; a replace words both, +/// the arrival first; +/// - a `UNIT_FIELD_AURAAPPLICATIONS` byte rising on a slot whose spell did not move words +/// `AURAAPPLICATIONADDED*` (`0x604ea0`). The reference gates it on `StackAmount > 1`, which +/// holds for any count that rises; a first application moves the spell, so it is skipped. /// -/// - a `UNIT_FIELD_AURA` slot whose NEW value is a spell β†’ `AURAADDED{SELF,OTHER}{HARMFUL,HELPFUL}`, -/// at the PERIODIC chat types (harmful takes the `…_DAMAGE` row, helpful the `…_BUFFS` one) β€” -/// not the `AURA_GONE` block, which is the departure's alone (`0x604d00`'s add arm); -/// - a slot whose OLD value was a spell β†’ `AURAREMOVED{SELF,OTHER}`, at `0x41`/`0x42`/`0x43` -/// (the remove arm; a replace is one of each, the arrival worded first); -/// - a `UNIT_FIELD_AURAAPPLICATIONS` byte that ROSE, on a slot whose spell did not move this -/// frame β†’ `AURAAPPLICATIONADDED*` (`0x604ea0`), and only when the spell can stack at all. We -/// approximate the reference's `SpellRec->StackAmount > 1` gate with the observed count: a -/// stack that rose past one is a spell that stacks. It differs only for a first application of -/// a stacking aura, which the reference words as a plain `AURAADDED*` β€” and so do we, because a -/// fresh slot's count is not a rise (its `AURA` dword moved in the same block, so its byte is -/// skipped here). -/// -/// **HARMFUL is the SLOT INDEX, not a flag** (three byte sites, e.g. `0x61238e`): slots -/// `0x20`–`0x2f` are harmful, every other slot helpful. `UNIT_FIELD_AURAFLAGS` is only an occupancy -/// predicate β€” using it here would be the wrong mechanism that happens to agree most of the time. +/// Harmful is the slot index, `0x20`-`0x2f` (`0x61238e`), not `UNIT_FIELD_AURAFLAGS`. pub(crate) fn aura_lines( ctx: WatchCtx, stores: Query<(Entity, &ObjectStore)>, @@ -201,13 +160,13 @@ pub(crate) fn aura_lines( mut log: ResMut, ) { use benilla_protocol::field::{FIELD_UNIT_AURA, FIELD_UNIT_AURAAPPLICATIONS}; - /// The first harmful aura slot β€” `0x20`. Slots below it are helpful (`0x61238e`). + /// Slots below it are helpful (`0x61238e`). const FIRST_HARMFUL_SLOT: u16 = 0x20; const SLOTS: u16 = benilla_protocol::messages::UNIT_AURA_SLOTS as u16; let harmful = |slot: u16| slot >= FIRST_HARMFUL_SLOT; - // The whole frame's batch first: the applications leg must know which slots' `AURA` dword - // moved in the same block, and the two arrays sit 66 dwords apart in the ascending walk. + // The whole batch first: the applications leg skips slots whose `AURA` dword moved in the + // same block. let batch: Vec = edges.read().copied().collect(); let slot_edges: Vec<&FieldChanged> = batch .iter() @@ -222,7 +181,7 @@ pub(crate) fn aura_lines( for e in &slot_edges { let slot = e.index - FIELD_UNIT_AURA; let class = ctx.classify(e.guid, &stores); - // Arrival first: the slot now holds a spell (a fill, or a replace's new half). + // Arrival first, so a replace words the new aura before the old one's removal. if e.new != 0 { if let (Some(spell), Some(kind)) = (ctx.spell_name(e.new), periodic_kind(class, !harmful(slot))) @@ -247,7 +206,6 @@ pub(crate) fn aura_lines( ); } } - // Departure: the slot held a spell and no longer holds THAT spell. if e.old != 0 { if let Some(spell) = ctx.spell_name(e.old) { queue_one( @@ -268,7 +226,7 @@ pub(crate) fn aura_lines( } } - // The stack-count leg: byte-packed, four slots per dword, the wire byte `stack - 1`. + // Stack counts: four slots per dword, one byte each, holding `stack - 1`. for e in batch.iter().filter(|e| { e.unit_array_slot(FIELD_UNIT_AURAAPPLICATIONS, SLOTS / 4) .is_some() @@ -280,7 +238,7 @@ pub(crate) fn aura_lines( for byte in 0..4u16 { let slot = word * 4 + byte; let (before, now) = ((e.old >> (byte * 8)) & 0xff, (e.new >> (byte * 8)) & 0xff); - // A rise past one, on a slot whose spell stayed put this block. + // A rise, to two or more, on a slot whose spell stayed put. if now <= before || now == 0 || slot_moved(e.entity, slot) { continue; } @@ -317,11 +275,9 @@ pub(crate) fn aura_lines( } } -/// The pet-loyalty line (`0x5ff860` β†’ `0x62d440`) β€” `UNIT_FIELD_BYTES_1` byte 1 moving. -/// -/// **Both legs require the pet's owner to be you**, which is the reference's own guard, and the -/// chat type is the literal `0x19` `COMBAT_MISC_INFO`. The reference emits the localized text as -/// the single `%s` argument of a bare `"%s"`; a no-slot family reaches the same sentence. +/// The pet-loyalty line (`0x5ff860` β†’ `0x62d440`) on `UNIT_FIELD_BYTES_1` byte 1 moving, for your +/// own pet only, at chat type `0x19` `COMBAT_MISC_INFO`. The reference prints the text through a +/// bare `"%s"`, which a family with no slots matches. pub(crate) fn pet_loyalty_lines( ctx: WatchCtx, stores: Query<(Entity, &ObjectStore)>, @@ -332,7 +288,7 @@ pub(crate) fn pet_loyalty_lines( if !e.unit_field(benilla_protocol::field::FIELD_UNIT_BYTES_1) { continue; } - // Byte 1 of the word β€” the same shift `ObjectFields::unit_loyalty_level` reads through. + // Byte 1, as `ObjectFields::unit_loyalty_level` reads it. let (before, level) = (((e.old >> 8) & 0xff) as u8, ((e.new >> 8) & 0xff) as u8); if level == before || level == 0 || before == 0 { continue; diff --git a/crates/benilla-app/src/ui_chat/commands.rs b/crates/benilla-app/src/ui_chat/commands.rs index 10d638c5..48fa6fdb 100644 --- a/crates/benilla-app/src/ui_chat/commands.rs +++ b/crates/benilla-app/src/ui_chat/commands.rs @@ -1,46 +1,14 @@ -//! The slash-command **table** (decision 0881): every `/command` the client answers, built at boot -//! from the reference's OWN alias strings rather than hand-typed here. -//! -//! ## Why a table -//! -//! `ChatEdit_ParseText` (ChatFrame.lua l.2163-2199) resolves a typed command in three passes, and -//! **none of them holds a literal**: the chat-type switches walk `SLASH_`, -//! the actions walk `SLASH_`, and the emotes walk `EMOTE_CMD` β†’ -//! `EMOTE_TOKEN` β†’ `DoEmote(token)`. The aliases are DATA (`GlobalStrings.lua`), the handlers -//! are code, and the two are joined by an index name. -//! -//! Benilla used to re-type each command's aliases into a Rust `match` as its system was built. -//! Every alias nobody happened to copy was a command that silently did not exist: the audit behind -//! 0881 found **61 live reference aliases dead** (`/lol`, `/hi`, `/ty`, `/congrats`, `/sorry`, -//! `/yes`, `/bravo`, …) because the emote arm matched the *`EmotesText.dbc` name* instead of the -//! alias table β€” and four names the reference has NO command for (`/joke`, `/puzzle`, -//! `/attackmytarget`, and `/follow`, which fired a text emote where the real client follows your -//! target) answering as emotes. A table keyed on the shipped strings cannot have that class of bug: -//! registering a handler is naming its index, and the aliases come from the data. -//! -//! ## Where each half comes from -//! -//! - **`SLASH_`** β€” the real `GlobalStrings.lua`, already executed into the UI VM at boot -//! (`crate::ui_script`). One [`SlashIndex`] per command benilla implements; the reference's own -//! index name is the key. -//! - **`EMOTE_CMD` / `EMOTE_TOKEN`** β€” the aliasβ†’token join, also read off the VM globals -//! (`crate::ui_script::load_emote_tokens` runs the token half's data lines out of the shipped -//! `ChatFrame.lua`). The token resolves to an `EmotesText` id through the emote catalog. -//! - **benilla's own instruments** ([`DevCmd`]) β€” `/castvis`, `/chattest`, `/shot`, … have no -//! reference strings because they are not reference commands; their aliases are literals here, -//! the one place in this module where that is honest. -//! -//! Precedence is the reference's resolution ORDER, made static at insert time: a slash index wins -//! over an emote alias of the same name (the reference reaches `SlashCmdList` first), and benilla's -//! instruments are inserted last so they can never shadow a real command. +//! The slash-command table: every `/command` the client answers, keyed by the aliases the +//! player's own strings define, read off the UI VM's globals the way `ChatEdit_ParseText` walks +//! them (`ChatFrame.lua:2164-2200`): `SLASH_` for the actions, `EMOTE_CMD` β†’ +//! `EMOTE_TOKEN` for the emotes. An action wins over an emote alias of the same name, as the +//! reference tries `SlashCmdList` first; benilla's own commands, literals, go in after both. use std::collections::HashMap; use bevy::prelude::*; -/// A command benilla implements, identified by the reference's own `SlashCmdList` index β€” the -/// `SLASH_` alias-string prefix ([`SlashIndex::key`]). Adding a command is adding a -/// variant + its handler arm; its aliases arrive with the shipped strings. +/// A command benilla implements, named by its `SlashCmdList` index, the `SLASH_` prefix. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum SlashIndex { Reply, @@ -74,42 +42,28 @@ pub(crate) enum SlashIndex { Target, Assist, Follow, - /// `/cast ` β€” the reference's `SlashCmdList["CAST"]` is one line, `CastSpellByName(msg)`, - /// and that binding is the engine seam benilla implements over the spell book (decision 0983). - /// The command the whole macro system exists to run. + /// `/cast `: the reference's handler calls `CastSpellByName(msg)` (`ChatFrame.lua:1120`). Cast, - /// `/macro` `/m` β€” opens the macro window (the ref's `ShowMacroFrame()`). + /// `/macro`, `/m`: the reference's `ShowMacroFrame()`. MacroUi, - /// `/macrohelp` β€” the reference's own five-line help text. + /// `/macrohelp`: the reference's five-line help text. MacroHelp, - /// `/console ` β€” the reference's `SlashCmdList["CONSOLE"]` pipes into the engine's - /// debug-console command table. Ours implements the one console command this client has: - /// `reloadUI` (the reference's `"reloadUI" 0x82ea38` β†’ `CCommand::ReloadUI 0x4035f0` β†’ - /// `0x491380`, the same flag `ReloadUI()` sets). Anything else answers a plain system line - /// rather than silently doing nothing. + /// `/console `: `ConsoleExec(msg)`, as the reference's handler (`ChatFrame.lua:671`). Console, - /// `/reload` β€” **benilla's own addition** (decision 1291): 1.12 ships `ReloadUI()` and - /// `/console reloadUI` but no slash alias for it (that arrived in later clients β€” the shipped - /// `GlobalStrings.lua` has no `SLASH_RELOADUI`), so this alias is a literal in [`Self::build`] - /// rather than data read off the chain, exactly like the ESC-menu AddOns window is ours (1197). + /// `/reload`, the rebuild `/console reloadUI` runs. Deviation: 1.12 has no such alias (later + /// clients added it); kept so a player can reload the interface after toggling addons. ReloadUi, - /// `/errors` `/err` β€” **benilla's own addition** (decision 1495): opens the script error log, - /// which is ours because 1.12 has no such window to alias. The reference's whole answer to a - /// Lua fault is the `ScriptErrors` modal, which shows a burst's first message and remembers - /// nothing; B293 is two reporters asking for the list that modal cannot be. **Player-facing, - /// not a `DevCmd`**: the people who need it are the people running addons, and gating it on a - /// dev build would leave exactly the reporters who asked unable to type it. + /// `/errors`, `/err`: opens benilla's script error log. Deviation: 1.12 has only the + /// `ScriptErrors` modal, which shows a burst's first error and keeps none; addon users need + /// the list, so it is in every build. ScriptErrors, - /// `/convertraid` β€” **benilla's own addition**: convert the party to a raid - /// (`CMSG_GROUP_RAID_CONVERT`, leader only, server-judged). 1.12's only trigger is the - /// RaidFrame tab's Convert button, which is not built yet β€” until it is, this is the one way - /// to form a raid, so it is player-facing by the `/errors` reasoning, not a `DevCmd`. When - /// the Raid tab lands this alias stays, the way `/reload` outlived `/console reloadUI`. + /// `/convertraid`: convert the party to a raid (`CMSG_GROUP_RAID_CONVERT`, leader only). + /// Deviation: 1.12's only trigger is the Raid tab's Convert button (`RaidFrame.xml`). ConvertRaid, } impl SlashIndex { - /// The reference's index name β€” the `SLASH_` prefix its aliases live under. + /// The reference's index name, the `SLASH_` prefix its aliases live under. fn key(self) -> &'static str { match self { Self::Reply => "REPLY", @@ -147,20 +101,14 @@ impl SlashIndex { Self::MacroUi => "MACRO", Self::MacroHelp => "MACROHELP", Self::Console => "CONSOLE", - // No shipped alias string exists under this key (the walk finds nothing); the `/reload` - // alias is inserted as a literal in `build` β€” see the variant's doc. + // No shipped strings under these three keys: their aliases are literals in `build`. Self::ReloadUi => "RELOADUI", - // No shipped `SLASH_BENILLASCRIPTERRORS1` exists β€” the aliases are literals in - // `build`. The key is still ours to name so the variant round-trips like any other. Self::ScriptErrors => "BENILLASCRIPTERRORS", - // Same shape: a benilla addition, alias a literal in `build`. Self::ConvertRaid => "BENILLACONVERTRAID", } } - /// Every registered index β€” the registry proper. A reference command NOT in this list resolves - /// nowhere and answers `HELP_TEXT_SIMPLE`, exactly as an unknown command does in the reference - /// (better than a registered handler that silently does nothing). + /// Every registered index; any other command answers `HELP_TEXT_SIMPLE`, as unknown ones do. const ALL: [Self; 38] = [ Self::Reply, Self::Join, @@ -203,9 +151,7 @@ impl SlashIndex { ]; } -/// Benilla's own diagnostic commands β€” instruments we built, with no reference strings behind them -/// (decisions 0099 `/castvis`, 0288 `/chattest`, 0434 `/partytest`, 0600 `/shot`, 0634 `/liquid`, -/// 0637 `/reaction`). Their aliases are literals because there is nothing shipped to read. +/// benilla's own diagnostic commands, in dev builds only; their aliases are literals. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum DevCmd { CastVis, @@ -243,7 +189,7 @@ impl DevCmd { pub(crate) enum Command { /// A reference `SlashCmdList` command benilla implements. Slash(SlashIndex), - /// An emote alias β€” carrying the `EmotesText` id its token resolved to (`/lol` β†’ LAUGH β†’ 45). + /// An emote alias, with its token's `EmotesText` id (`/lol` β†’ LAUGH β†’ 45). Emote { text_id: u32 }, /// One of benilla's own instruments. Dev(DevCmd), @@ -253,9 +199,7 @@ pub(crate) enum Command { #[derive(Resource, Default)] pub(crate) struct SlashCommands { by_alias: HashMap, - /// How many aliases each source contributed β€” the boot line that makes a broken table (an - /// unreadable `GlobalStrings.lua`, a missing token table) visible immediately instead of at - /// the first command someone types. `(slash, emote, benilla additions, dev instruments)`. + /// Aliases per source, logged at boot: `(slash, emote, benilla additions, dev instruments)`. counts: (usize, usize, usize, usize), } @@ -265,17 +209,14 @@ impl SlashCommands { self.by_alias.get(&cmd.to_ascii_lowercase()).copied() } - /// Build from the reference's alias strings. `get` reads a global string (the UI VM's globals in - /// production, a stub map in tests); `text_id` resolves an `EmotesText` NAME to its id (the - /// emote catalog). Pure over those two, so the whole table is testable without a VM. + /// Build from `get`, a global-string lookup, and `text_id`, an `EmotesText` name β†’ id map. pub(crate) fn build( get: impl Fn(&str) -> Option, text_id: impl Fn(&str) -> Option, ) -> Self { let mut by_alias: HashMap = HashMap::new(); - // 1 Β· the action commands: SLASH_, walked from 1 until the first gap (the - // reference's own `while cmdString` walk). + // 1 Β· the actions: `SLASH_` from 1 to the first gap, as `while cmdString` walks. for index in SlashIndex::ALL { for n in 1.. { let Some(alias) = get(&format!("SLASH_{}{n}", index.key())) else { @@ -286,14 +227,12 @@ impl SlashCommands { } let slash_aliases = by_alias.len(); - // 2 Β· the emotes: EMOTE_CMD β†’ EMOTE_TOKEN β†’ EmotesText id. The outer walk stops at - // the first index with no CMD1, exactly like `ChatEdit_ParseText`'s. + // 2 Β· the emotes, `EMOTE_CMD` β†’ `EMOTE_TOKEN` β†’ id, to the first missing `CMD1`. for i in 1.. { let Some(first) = get(&format!("EMOTE{i}_CMD1")) else { break; }; - // A token with no `EmotesText` row (EMOTE27 is "UNUSED") has nothing to send: the - // reference's `DoEmote` would find no record either. + // A token with no `EmotesText` row (EMOTE27 is "UNUSED") has nothing to send. let resolved = get(&format!("EMOTE{i}_TOKEN")).and_then(|t| text_id(&t)); if let Some(text_id) = resolved { insert(&mut by_alias, &first, Command::Emote { text_id }); @@ -307,11 +246,7 @@ impl SlashCommands { } let emote_aliases = by_alias.len() - slash_aliases; - // 3 Β· benilla's own player-facing additions β€” literals, because they are OURS and no - // shipped string carries them (each variant's doc records the why). NOT gated on dev - // affordances: `/reload` belongs to a player build as much as the ESC-menu AddOns window - // does (1197, 1291). Inserted after the walks so a shipped alias could never be shadowed - // even if a later client's strings arrived on the chain. + // 3 Β· benilla's player-facing additions, in every build; after the walks, so shipped wins. insert( &mut by_alias, "reload", @@ -331,11 +266,7 @@ impl SlashCommands { ); let added_aliases = by_alias.len() - slash_aliases - emote_aliases; - // 4 Β· benilla's instruments, last so they can never shadow a shipped command. - // benilla's own instruments β€” `/castvis`, `/partytest`, `/chattest`, `/shot`, `/liquid`, - // `/reaction`. A player build claims none of the aliases, so typing one falls through to - // the reference's "unknown command" exactly as it should (decision 1179). They are gated - // HERE, at the table, rather than at each dispatch arm: one door, like the dev chord's. + // 4 Β· the dev instruments, dev builds only; a player build answers them as unknown. let before_dev = by_alias.len(); if crate::run_mode::dev_affordances() { for dev in DevCmd::ALL { @@ -352,22 +283,14 @@ impl SlashCommands { } } - /// `(slash aliases, emote aliases, benilla additions, dev aliases)` β€” how many DISTINCT - /// commands each source made reachable (the shipped strings repeat: `EMOTE87_CMD1` and `_CMD2` - /// are both `"/sit"`). The boot report. - /// - /// The last number is the seam, **made observable** (decision 1179): benilla's own instrument - /// commands are gated on `run_mode::dev_affordances()`, and a gate nobody can see the effect of - /// is a gate nobody checks. A player build must print `0`, and one line of its own log says so. - /// The third β€” the player-facing additions (`/reload`, 1291) β€” is deliberately separate from - /// both: present in every build, and never passed off as a shipped command. + /// Distinct aliases per source (the shipped strings repeat: `EMOTE87_CMD1` and `_CMD2` are + /// both `"/sit"`); a player build reports 0 dev aliases. pub(super) fn counts(&self) -> (usize, usize, usize, usize) { self.counts } } -/// Insert-if-vacant: the FIRST source to claim an alias keeps it, which is how the reference's pass -/// order (`SlashCmdList` before emotes) becomes a static table. +/// The first source to claim an alias keeps it: the reference's pass order, made static. fn insert(map: &mut HashMap, alias: &str, cmd: Command) { let key = alias.trim().trim_start_matches('/').to_ascii_lowercase(); if !key.is_empty() { @@ -375,9 +298,7 @@ fn insert(map: &mut HashMap, alias: &str, cmd: Command) { } } -/// Build the table once the VM (its globals) and the emote catalog both exist. `PostStartup` rather -/// than an ordering constraint: both producers are `Startup` systems whose `insert_resource` lands -/// at the schedule boundary, so this needs no fragile `.after()` chain into two other modules. +/// Build the table in `PostStartup`, after `Startup` has inserted the VM and the emote catalog. pub(crate) fn build_slash_commands( mut commands: Commands, script: Option>, @@ -394,8 +315,7 @@ pub(crate) fn build_slash_commands( |token| emotes.text_id(token), ); let (slash, emote, added, dev) = table.counts(); - // The "it loaded" signal: the shipped 1.12 data yields the pinned alias counts (see - // `real_alias_table_resolves_the_shipped_commands`), plus benilla's own additions (1291). + // The shipped 1.12 data gives the counts `real_alias_table_resolves_the_shipped_commands` pins. info!( "chat: slash table β€” {slash} command aliases, {emote} emote aliases, \ {added} benilla additions, {dev} instrument aliases" diff --git a/crates/benilla-app/src/ui_chat/edit.rs b/crates/benilla-app/src/ui_chat/edit.rs index 0e1ddd80..a1e80bac 100644 --- a/crates/benilla-app/src/ui_chat/edit.rs +++ b/crates/benilla-app/src/ui_chat/edit.rs @@ -1,18 +1,11 @@ -//! The chat SEND types and the joined-channel roster β€” what the app keeps beside the reference's -//! own `ChatEdit_*` machine (ChatFrame.lua l.1782-2242), which owns the edit box since the chat -//! window became the reference's (decision 1948): the sticky type, the live parse, the header, -//! the tell ring, the Tab cycle and the R/`/` bindings are all its Lua now. -//! -//! [`SendType`] names the wire kind an addon's `SendChatMessage` token maps to -//! ([`super::input::drain_addon_chat_sends`]); [`ChannelState`] is the client-side mirror of the -//! joined channels (the `/N` numbering the reference keeps C-side). +//! The chat send types and the joined-channel slots, beside the stock `ChatEdit_*` machine +//! (`ChatFrame.lua:1782-2242`) that owns the edit box. use bevy::prelude::*; use crate::net::ChatKind; -/// The sendable chat types β€” `ChatTypeInfo`'s sendable keys, as the wire kind an addon's -/// `SendChatMessage` token maps to. `Whisper`/`Channel` carry their target in the call. +/// The sendable chat types, as the wire kind an addon's `SendChatMessage` token maps to. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum SendType { Say, @@ -33,29 +26,9 @@ pub(crate) enum SendType { } impl SendType { - /// The chat-type TOKEN an addon passes to `SendChatMessage` (decision 1199) β€” the reference's - /// own `ChatTypeInfo` keys, uppercase. - /// - /// `None` for a token we do not send β€” anything an addon simply made up. The caller reports - /// it rather than guessing SAY, because a raid warning silently going to /say is worse than - /// one that does not go. - /// - /// **`"AFK"` and `"DND"` ARE sends, and this said the opposite** β€” "which set a flag rather - /// than sending a line". They are `CMSG_MESSAGECHAT` types `0x14`/`0x15` like every other row - /// here, carrying the away message as their body; it is the SERVER that toggles the - /// `PLAYER_FLAGS` bit off the packet and streams it back (vmangos `ChatHandler.cpp`'s - /// `CHAT_MSG_AFK` arm β†’ `Player::ToggleAFK`). The wire half was already built and reachable β€” - /// `ChatKind::Afk`/`Dnd`, `writer::chat::send_afk`/`send_dnd`, `CHAT_TYPE_AFK` β€” and this - /// function was the only thing standing between the stock file and it. - /// - /// **What that cost, and why it is 1751's own lesson:** benilla's slash grammar has always - /// had a working `/afk` (`S::ChatAfk` β†’ `ParsedChat::AfkDnd`). Migrating the chat window - /// (1948) put the stock `ChatFrame.lua` on the chain, and its parser claims a slash line - /// before benilla's grammar ever sees it β€” so `/afk` became - /// `SlashCmdList["CHAT_AFK"](msg)` β†’ `SendChatMessage(msg, "AFK")` β†’ here β†’ `None`, and the - /// player got `Unknown chat type "AFK".` Migrating a window means building whatever engine - /// verb the stock file turns out to call; the verb existed, and a wrong sentence in this doc - /// comment is what kept it unreachable. Decision 2079. + /// The `SendChatMessage` chat-type token; `None` for a made-up one, reported rather than sent + /// as SAY. `AFK` and `DND` are sends: types `0x14`/`0x15` carrying the away message, which the + /// server turns into the flag (vmangos `ChatHandler.cpp:611-648`). pub(crate) fn from_token(token: &str) -> Option { Some(match token { "SAY" => SendType::Say, @@ -99,147 +72,68 @@ impl SendType { } } -/// How many channels the client can hold at once β€” its allocator refuses the eleventh -/// (`0x49b9c0: cmp ecx,0xa`), and the ten boot-seeded `CHANNEL1`…`CHANNEL10` color rows are the -/// same ten (seeded by `0x4982c0`). +/// The client refuses an eleventh channel (`0x49b9c0: cmp ecx,0xa`); the boot-seeded +/// `CHANNEL1`-`CHANNEL10` colour rows are the same ten (`0x4982c0`). pub(crate) const MAX_CHANNELS: usize = 10; -/// The channels this session has joined β€” the CLIENT-side number law (`GetChannelName(n)`): `/1` -/// is slot 1, `/2` slot 2; the numbered display form ("1. General - Elwynn Forest") and the -/// `[N. Name]` prefixes all derive from it. Fed by YOU_JOINED / YOU_LEFT notices -/// ([`super::feed`]); the zone AUTO-join walk that fills it at login is [`super::channels`]. -/// -/// **It is a SLOT ARRAY, and leaving punches a hole rather than closing one** (1286). The client's -/// records live in a fixed array at `[0xb4fe04]`, stride `0xa0`, with the entry's own **number** -/// at `+0x00`; the allocator `0x49b980` scans for an entry whose number is `0` and *reuses* it -/// (`0x49b9b0`: `cmp dword [edx],0` / `jz`), only growing when none is free and the count is under -/// **ten** (`0x49b9c0: cmp ecx,0xa`), and the leave path `0x49bbd0` clears that number in place -/// (`0x49bc1b: mov dword [eax+edx],0`) without shrinking the count. Lookup by index then demands -/// the entry's number equal the index asked for (`0x49bf30: cmp esi,ecx / jnz`), so a hole answers -/// "not joined" while every channel above it keeps its number. -/// -/// A `Vec` cannot express that: `retain` closed the hole and renumbered everything above -/// it, so walking out of a zone renamed *other* channels β€” the director saw General and -/// LocalDefense trade numbers on one zone change, and a `/2` typed after that went somewhere else. -/// -/// **And each slot carries a state, because the reference's does** (`+0x9c`, decision 2130). We -/// model the one value of it that changes what the player sees: **3, locally suspended**. States -/// 0 (server-confirmed), 1 (join not yet acknowledged) and 2 (renamed, re-join pending) collapse -/// here, and that is sound rather than lazy β€” the reference reads 0 to decide whether a LEAVE goes -/// out, and our walk decides that from the slot's own state on the request side (1284). State 3 -/// does not collapse: it is the difference between keeping a channel and losing it. +/// The joined channels as the reference's slot array at `[0xb4fe04]` (stride `0xa0`, number at +/// `+0x00`): `/N` is slot N. The allocator `0x49b980` reuses the first free entry before growing, +/// and a leave zeroes the number in place (`0x49bbd0`), so no other channel is renumbered and a +/// hole answers "not joined" (`0x49bf30`). #[derive(Resource, Default)] pub(crate) struct ChannelState { /// Slot `i` is channel number `i + 1`; `None` is a freed slot, kept so the numbers above it /// do not move. Never longer than [`MAX_CHANNELS`]. pub joined: Vec>, - /// `ChatChannels.dbc`, loaded once at Startup ([`super::channels::load_chat_channels`]). - /// - /// It lives here because both of its consumers are this type's own business: composing the - /// auto-join names, and answering a chat event's **arg7** β€” the built-in ChannelID behind a - /// name, which is a pure function of the name (the server resolves it the same way) and so - /// needs no extra bookkeeping at join time. Empty without an install, which degrades to - /// "no zone channels, arg7 always 0" rather than to an error. + /// `ChatChannels.dbc`: it composes the auto-join names and resolves a chat event's arg7 from a + /// name, as the server does. Empty without an install. pub channels: benilla_formats::ChatChannelsCatalog, - /// **The `ZONECHANNELS` mask** β€” the reference's `DWORD ds:0xb6e5e0`, bit `1 << (ChannelID-1)` - /// (decision 2120; the global's complete census is eight sites). + /// The `ZONECHANNELS` mask, the reference's `ds:0xb6e5e0`, bit `1 << (ChannelID - 1)`: seeded + /// from the chat cache (`0x498d83`) or the `INITIAL` rows (`0x4997fc`), set by a confirmed join + /// (`0x49bbaf`) and cleared only by an explicit leave (`0x49f10a`), never the walk's LEAVE. + /// Never derive it from the roster: each saved window's channel bits are ANDed with it, so an + /// empty roster would save every window without its channels. /// - /// **It is durable state, not a view of [`Self::joined`].** The reference seeds it once β€” from - /// the chat cache's header line (`0x498d83`, an overwrite) or, with no usable file, from every - /// `ChatChannels.dbc` row carrying `INITIAL` (`0x4997fc`) β€” then ORs a bit on each - /// server-confirmed join (`0x49bbaf`, the `YOU_JOINED` arm) and clears one only on an explicit - /// leave-by-name (`0x49f10a`/`0x49f11a` inside `0x49ee70`). The zone walk's own LEAVE, sent - /// every time you cross a border or walk out of a capital, does **not** touch it β€” which is - /// why `Trade`'s bit survives a logout in Elwynn Forest. - /// - /// Deriving it from the live roster at write time instead is what decision 2120 corrects, and - /// it was not cosmetic: the per-window line is written as `the window's own bits AND this - /// mask`, so one save taken while the roster was momentarily empty β€” the session-end flush - /// racing `end_session_channels` on the same unordered `OnExit(InWorld)` edge β€” wrote - /// `ZONECHANNELS 0` into every block, and the next login rebuilt window 1 with **no channels - /// at all**. The stock `ChatFrame_OnEvent` drops every `CHANNEL*` line whose channel the - /// window does not carry (ref `ChatFrame.lua` l.1374-1391, `if found == 0 … return`), so that - /// character silently lost its `Joined Channel:` notices *and* all General/Trade speech, for - /// good. Ten of the twenty files in this repo's own config folder had reached that state, - /// including the director's own character. - /// - /// Not cleared by the session end: it belongs to the character's file, and the login that - /// reads that file is what seats it. - /// - /// **`None` until that login has read the file** β€” the reference's "chat system ready" flag - /// `ds:0xb6e5c8`, set at the tail of the cache loader (`0x499a18`) and the first thing - /// `ZoneChannelRefresh` tests (`0x49a219`, a full bail). The walk is the mask's consumer - /// (decision 2144: **the mask is the join predicate**, `0x49a494`), so a walk before the seat - /// would read an empty word and join nothing β€” and nothing re-triggers it when the word - /// lands. An `Option` says "not seated" in the type rather than in a second flag that could - /// drift from it; the saver refuses to compose a file from `None` for the same reason - /// (writing `ZONECHANNELS 0` is the damage 2120 repaired). + /// `None` until the login reads the file: the reference's ready flag `ds:0xb6e5c8`, set by the + /// cache loader (`0x499a18`) and tested first by the walk (`0x49a219`); the saver writes + /// nothing from `None`. pub zone_mask: Option, - /// **The custom channels the character is in** β€” the chat cache header's `CHANNELS` list, the - /// names the next login re-joins. Durable state, for exactly [`Self::zone_mask`]'s reason: the - /// reference writes that list from its live slot array (`0x499b90`-`0x499bb1`: every slot - /// with a number, state 0 and no DBC id) at the chat teardown, when its slots are still - /// there β€” ours are cleared by the session end *before* the flush (the - /// `Disconnected{LoggedOut}` twin runs in `Update`, a frame ahead of `OnExit(InWorld)`), so a - /// list read off [`Self::joined`] at write time was empty on every logout and no custom - /// channel was ever re-joined (2184 Β§6). - /// - /// So it moves where the slot array's custom half moves, minus the session end: seated from - /// the file by `restore_chat_looks` (an overwrite, like the mask), grown on a - /// server-confirmed join of a channel with no DBC id ([`Self::note_custom_channel_joined`]), - /// shrunk on an explicit leave ([`Self::note_custom_channel_left`]), and nothing else. - /// - /// **Named divergence:** a channel whose re-join the server refuses (a ban, a changed - /// password) or that the player is kicked from never takes a confirmed slot in the reference - /// and so drops out of its file; here it stays listed until the player `/leave`s it. + /// The custom channels to re-join at the next login, the chat cache's `CHANNELS` list. The + /// reference writes it from its live slots at teardown (`0x499b90`-`0x499bb1`); ours are + /// cleared before the flush, so the list is kept here: seated from the file, grown by a + /// confirmed custom join, shrunk by an explicit leave. A channel whose re-join is refused, or + /// that kicks the player, stays listed until a `/leave`, where the reference's file drops it. pub custom: Vec, } -/// One joined-channel record β€” the reference's `[0xb4fe04] + n*0xa0` slot, in the fields this -/// client uses. +/// One slot, the reference's `[0xb4fe04] + n*0xa0` record, in the fields this client uses. #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct ChannelSlot { - /// `+0x04` β€” the channel's current name. The zone walk RENAMES this in place - /// ([`ChannelState::rename_slot`]). + /// `+0x04`, the current name; the zone walk renames it in place. pub name: String, - /// `+0x9c` β€” the slot's state, in the three values that change what the player sees. + /// `+0x9c`, the slot's state. pub state: SlotState, } -/// The reference's per-slot state (`slot+0x9c`), modelled in the values whose **notice token** -/// differs β€” because the token is what the stock `ChatFrame_OnEvent` branches on, and one of those -/// branches deletes the window's channel registration. -/// -/// Its complete writer census is six sites. +/// The reference's per-slot state (`+0x9c`), in the values whose notice token differs: the stock +/// `ChatFrame_OnEvent`'s `YOU_LEFT` branch deletes the window's channel registration. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub(crate) enum SlotState { - /// `0` (server-confirmed) and `1` (join not yet acknowledged), which we cannot tell apart and - /// do not need to: the reference reads `0` to decide whether a LEAVE goes out, and our walk - /// reads this state for the same decision β€” a slot it registered and never confirmed sends - /// one LEAVE the server answers "Not on channel", which is the one cost of the collapse. + /// `0` (confirmed) and `1` (join sent) in one: the reference's walk sends a LEAVE only from 0, + /// so an unconfirmed slot here sends one the server answers "Not on channel". #[default] Joined, - /// `2` β€” **renamed, re-join pending**: the zone walk moved this row's name because the player - /// crossed a border (`0x49bcd3`, inside the rename `0x49bc50`). The confirming `YOU_JOINED` - /// then carries the `YOU_CHANGED` token instead, which is `CHAT_YOU_CHANGED_NOTICE` β€” - /// *"Changed Channel: [%s]"*, a line the reference has and we never printed. + /// `2`, renamed with its re-join pending (`0x49bcd3`, in the rename `0x49bc50`): the confirming + /// `YOU_JOINED` carries the `YOU_CHANGED` token, "Changed Channel: [%s]". Renamed, - /// `3` β€” **locally suspended**: the row lost its eligibility, which in the 1.12 data means - /// exactly one thing, walking out of a capital with `Trade` joined. `0x49bcf0` sets it - /// and sends nothing; the LEAVE has already gone out earlier in the same iteration. - /// - /// The record and its number survive, and the arriving `YOU_LEFT` carries the `SUSPENDED` - /// token instead (`0x49c0e0`) β€” same rendered text, different arg1, which is exactly what stops - /// `ChatFrame_OnEvent` from deleting the registration. Walking back into a city then re-joins - /// through the state-3 bypass (`0x49a31c`), where the name has not changed and the - /// comparison would otherwise say there was nothing to do. + /// `3`, suspended (`0x49bcf0`): the row stopped applying, in 1.12 data a city-only channel + /// outside a capital. The record and number survive, and `YOU_LEFT` carries the `SUSPENDED` + /// token (`0x49c0e0`), which keeps the window's registration. Suspended, } impl ChannelSlot { - /// A server-confirmed slot β€” the only way one is ever born here, because this client registers - /// a slot on `YOU_JOINED` rather than at send time (the reference's `0x49b980` does the latter, - /// at state 1; the difference is invisible to everything we model). + /// A new slot in state `Joined`: claimed by the walk at send time or by a confirmed join. pub(crate) fn joined(name: &str) -> Self { ChannelSlot { name: name.to_string(), @@ -248,8 +142,7 @@ impl ChannelSlot { } } -/// The bit `id` occupies in a `ZONECHANNELS` word β€” `1 << (ChannelID - 1)`; nothing outside -/// `1..=32` has one. +/// `id`'s `ZONECHANNELS` bit, `1 << (ChannelID - 1)`; nothing outside `1..=32` has one. pub(crate) fn zone_bit(id: u32) -> u32 { if id == 0 || id > 32 { 0 @@ -259,16 +152,12 @@ pub(crate) fn zone_bit(id: u32) -> u32 { } impl ChannelState { - /// A server-confirmed join sets the channel's `ZONECHANNELS` bit β€” the reference's `0x49bbaf`, - /// which ORs `1 << (slot.ChannelID - 1)` in the `YOU_JOINED` arm. A custom channel has no DBC - /// id and so no bit, which is why this is a no-op for one. + /// A confirmed join sets the channel's mask bit (`0x49bbaf`, in the `YOU_JOINED` arm). pub(crate) fn note_zone_channel_joined(&mut self, name: &str) { let bit = zone_bit(self.channels.zone_channel_id(name)); match &mut self.zone_mask { Some(mask) => *mask |= bit, - // Nothing sends a join before the cache loader has run β€” the walk waits for the seat - // and the file's own custom re-joins come after it β€” so this is a broken ordering, - // not a state to absorb. + // No join precedes the cache loader, so this is a broken ordering. None if bit != 0 => warn!( "chat: {name:?} confirmed joined before the chat cache seated the zone mask β€” bit {bit:#x} dropped" ), @@ -276,13 +165,8 @@ impl ChannelState { } } - /// An **explicit** leave clears the bit β€” `0x49f10a`/`0x49f11a` inside leave-by-name - /// `0x49ee70`, and only there. The zone walk's LEAVE goes out on a different path and leaves - /// the mask alone: crossing a border is not "I left this channel". - /// - /// Keyed the way the reference keys it (`0x49f0f4`): the slot - /// found by the **wire name**, and its own DBC id β€” so a name no slot carries clears nothing, - /// whatever row it would resolve to. + /// An explicit leave clears the bit (`0x49f10a`/`0x49f11a` in leave-by-name `0x49ee70`), found + /// as the reference finds it (`0x49f0f4`): by the slot carrying the wire name. pub(crate) fn note_zone_channel_left(&mut self, name: &str) { if self.number_of(name).is_none() { return; @@ -292,9 +176,7 @@ impl ChannelState { } } - /// A server-confirmed join of a **custom** channel (no `ChatChannels.dbc` id) enters the - /// durable re-join list ([`Self::custom`]) β€” the slot the reference's writer would list at - /// teardown. A zone channel is a no-op here; it travels as its mask bit instead. + /// A confirmed join of a channel with no `ChatChannels.dbc` id enters [`Self::custom`]. pub(crate) fn note_custom_channel_joined(&mut self, name: &str) { if self.channels.zone_channel_id(name) != 0 || self.custom.iter().any(|c| c.eq_ignore_ascii_case(name)) @@ -304,27 +186,19 @@ impl ChannelState { self.custom.push(name.to_string()); } - /// An **explicit** leave drops a custom channel from the re-join list β€” the one thing - /// besides the file that shrinks it ([`Self::custom`]). Case-insensitive, as the server's - /// channel names are. + /// An explicit leave drops a custom channel, case-insensitively as the server's names are. pub(crate) fn note_custom_channel_left(&mut self, name: &str) { self.custom.retain(|c| !c.eq_ignore_ascii_case(name)); } - /// Does the mask carry `id`'s bit β€” is this `ChatChannels.dbc` row one the walk joins? The - /// reference's live predicate `0x49a494` (decision 2144). `None` (not seated) answers false. + /// Whether the mask carries `id`'s bit, the walk's live predicate `0x49a494`. pub(crate) fn zone_row_wanted(&self, id: u32) -> bool { self.zone_mask.is_some_and(|mask| mask & zone_bit(id) != 0) } - /// **The numeric leg of leave-by-name** β€” `0x49ee70` step 1: a `SStrToInt` of the argument that - /// is not zero names joined slot `n`, and only a **server-confirmed** one (`slot+0x9c == 0`, - /// `0x49be50`); a hole, an out-of-range number or a suspended slot make the whole call a - /// no-op β€” no packet, no mask change. `None` is that no-op. Anything else is already the wire - /// name: the VM's `LeaveChannelByName` composed a shortcut or passed a custom name through. - /// - /// Here rather than in the VM because the slot **states** live here; the VM's mirror carries - /// names alone. + /// The numeric leg of leave-by-name (`0x49ee70`): a nonzero `SStrToInt` names a confirmed slot + /// (`0x49be50`), and a hole, an out-of-range number or a suspended slot make the whole call a + /// no-op (`None`). Anything else is already the wire name. pub(crate) fn leave_target(&self, arg: &str) -> Option { let digits: String = arg .strip_prefix('-') @@ -348,48 +222,16 @@ impl ChannelState { .map(|slot| slot.name.clone()) } - /// **Rename a slot in place β€” the zone walk crossing a border** (decision 2130). - /// - /// The reference's `0x49bc50(oldName, newName)`, called from `ZoneChannelRefresh`'s pass 1 at - /// `0x49a3dc`: it copies the new name into the slot and moves its state to "re-join pending" β€” - /// **at send time**, before the server has answered the `CMSG_LEAVE_CHANNEL` that went out a - /// moment earlier. - /// - /// That ordering is the whole point, and it is not bookkeeping. When the server's `YOU_LEFT` - /// for the *old* name arrives, no slot carries that name any more β€” `0x49be90` is a pure name - /// scan with no state filter β€” so the marshaller (`0x49b0b0`) takes its NULL-slot leg - /// (`0x49b12f`) and defaults `arg7`, `arg8`, `arg9` and `arg10` to `0 / 0 / "" / 0` **together**, - /// and the stock `ChatFrame_OnEvent` finds no match and returns (`found == 0`). Which means it - /// never reaches its own `YOU_LEFT` arm, and that arm is the one that **deletes the window's - /// channel registration** (`ChatFrame.lua` l.1382-1384): - /// - /// ```lua - /// this.channelList[index] = nil; - /// this.zoneChannelList[index] = nil; - /// ``` - /// - /// Nothing in stock FrameXML ever re-adds one on a join, and **nothing in the engine does - /// either**: a closed census of event 395 (`0x18b`) leaves five fire sites in the image, none of - /// them reachable from a zone change, for guilded and unguilded players alike (`0x49a6a4`). - /// The rename is not one repopulation mechanism among several β€” it is the only thing - /// standing between a border crossing and a dead channel. - /// - /// So without it, one crossing costs the window General and LocalDefense **for the rest of the - /// session** β€” the replacement join notice dropped unprinted, and every line spoken in the new - /// zone with it. `ui_chat::tests::a_zone_change_must_not_deregister_the_channel_it_renames` is - /// that claim. - /// - /// Freeing and re-claiming instead would also renumber: the slot is the channel's `/N`. - /// - /// Returns the slot number when there was one to rename. + /// Rename a slot in place at send time, the reference's `0x49bc50` from the walk (`0x49a3dc`): + /// the server's `YOU_LEFT` for the old name then finds no slot (`0x49be90`), its args default + /// (`0x49b12f`), and the stock `ChatFrame_OnEvent` returns before its `YOU_LEFT` arm deletes + /// the window's channel registration (`ChatFrame.lua:1382-1384`), which nothing re-adds. pub(crate) fn rename_slot(&mut self, old: &str, new: &str) -> Option { let n = self.number_of(old)?; let slot = self.joined[n as usize - 1].as_mut()?; slot.name = new.to_string(); - // `0x49bcd3`: `(old == 3) ? 1 : 2`. A suspended slot comes back as a plain pending join β€” - // our `Joined` is that state 1 β€” so its confirming notice reads `YOU_JOINED`, not - // `YOU_CHANGED`; any other state is a rename awaiting its re-join, and the notice resolves - // it. + // `0x49bcd3`: `(old == 3) ? 1 : 2`. A suspended slot comes back as a pending join, our + // `Joined`, so its notice reads `YOU_JOINED`; any other is a rename awaiting its re-join. slot.state = if slot.state == SlotState::Suspended { SlotState::Joined } else { @@ -398,25 +240,16 @@ impl ChannelState { Some(n) } - /// **Suspend the slot holding `name` β€” the row stopped applying** (`0x49bcf0`, state 3). - /// - /// Walking out of a capital with `Trade` joined is the only case the 1.12 data produces. The - /// LEAVE has already gone out; this is what keeps the record, so the notice that comes back is - /// `SUSPENDED` rather than `YOU_LEFT` and the window keeps its registration - /// ([`ChannelSlot::suspended`]). Freeing it instead is the same bug the rename fixes, on the - /// one row a rename cannot reach. + /// Suspend the slot holding `name` (`0x49bcf0`): the LEAVE has gone, and keeping the record + /// makes the returning notice `SUSPENDED`, so the window keeps its registration. pub(crate) fn suspend_slot(&mut self, name: &str) -> Option { let n = self.number_of(name)?; self.joined[n as usize - 1].as_mut()?.state = SlotState::Suspended; Some(n) } - /// **The confirming `YOU_JOINED` resolves the slot's state** β€” the reference's `0x49bb20` - /// writes `+0x9c = 0` unconditionally on that arm. Without this a renamed slot would stay in - /// [`SlotState::Renamed`] for good and every later join notice would read "Changed Channel". - /// - /// Separate from [`Self::claim_slot`] because the notice arrives for slots we already number β€” - /// a rename is exactly that case, and it is the one that must not take a second slot. + /// The confirming `YOU_JOINED` writes state 0 unconditionally (`0x49bb20`); separate from + /// [`Self::claim_slot`], as a renamed slot is already numbered. pub(crate) fn confirm_slot(&mut self, name: &str) { if let Some(n) = self.number_of(name) { if let Some(slot) = self.joined[n as usize - 1].as_mut() { @@ -425,16 +258,14 @@ impl ChannelState { } } - /// The state of the slot holding `name` β€” the notice arms' own split. A name we hold no slot - /// for answers `None`, which is the leg where the reference defaults every derived arg. + /// The state of the slot holding `name`; `None` is the reference's no-record leg. pub(crate) fn slot_state(&self, name: &str) -> Option { self.number_of(name) .and_then(|n| self.joined[n as usize - 1].as_ref()) .map(|s| s.state) } - /// The roster as the VM's mirror wants it β€” `GetChannelName`/`GetChannelList` read names, and - /// the holes have to survive the trip or `/N` addresses the wrong channel. + /// The names for the VM's mirror, holes kept so `/N` addresses the right channel. pub(crate) fn names(&self) -> Vec> { self.joined .iter() @@ -458,19 +289,14 @@ impl ChannelState { .map(|i| i as u32 + 1) } - /// Give `name` a slot: **the first free one**, else a new one while under [`MAX_CHANNELS`] β€” - /// the reference's allocator `0x49b980` (see [`ChannelState`]). Already-joined answers its own - /// number rather than taking a second slot. `None` = all ten are taken. - /// - /// The reference also prints a chat error when full (`0x49b9c5: push 0x199` β†’ `0x496720`); we - /// decline the join and warn instead β€” one line of feedback we cannot quote without the - /// error-string table this build indexes by id, and the structural half is what matters. + /// Give `name` the first free slot, else a new one under [`MAX_CHANNELS`] (`0x49b980`); a name + /// already held keeps its number. With all ten taken, the reference prints + /// `ERR_TOO_MANY_CHAT_CHANNELS` (`0x199`, `0x49b9c5` via `0x496720`) and still sends the join; + /// here the callers only log it. pub(crate) fn claim_slot(&mut self, name: &str) -> Option { if let Some(n) = self.number_of(name) { - // A confirmed join on a slot we already hold clears its suspension β€” the reference's - // `0x49bb20` writes state 0 unconditionally. This is the other half of the state-3 - // bypass: walking back into a capital re-joins `Trade - City` under the name the slot - // already carries. + // A confirmed join on a held slot clears its suspension (`0x49bb20` writes state 0 + // unconditionally): the other half of the state-3 bypass. if let Some(slot) = self.joined[n as usize - 1].as_mut() { slot.state = SlotState::Joined; } @@ -487,36 +313,20 @@ impl ChannelState { Some(self.joined.len() as u32) } - /// Free the slot holding `name` β€” **cleared in place** (`0x49bbd0`), so every other channel - /// keeps its number. Answers the number that just went empty. + /// Free the slot holding `name` in place (`0x49bbd0`), so no other channel renumbers. pub(crate) fn free_slot(&mut self, name: &str) -> Option { let n = self.number_of(name)?; self.joined[n as usize - 1] = None; Some(n) } - /// Fill an event's four channel slots (arg4, arg7, arg8, arg9) in place. - /// - /// **They are one record, not four fields.** In the reference all four are read off the - /// client's local channel record β€” `slot+0x00`, `+0x04`, `+0x94`, `+0x98` β€” so a name that is - /// *not* in the local list has no record to read and every one of them is empty: arg4 falls - /// back to the bare incoming name and arg7/arg8/arg9/arg10 are `0/0/""/0` together. They are - /// never independently populated. (`0x49b12f` installs the empty defaults together; the - /// `"%d. %s"` prefix at `0x8445c8` is applied on the hit leg `0x49aa48`, and `0x49aa86` is the - /// bare-name miss leg.) - /// - /// So: on entry `event.channel` holds the name as the wire gave it ("General - Elwynn Forest"). - /// If we are in that channel, on exit arg4 is the numbered display form, arg9 the stored name - /// **with its " - Zone" tail intact** (`0x49a4ea`: the DBC name column *is* the format string - /// the client built the stored name with), arg8 the 1-based local slot and arg7 the - /// `ChatChannels.dbc` ChannelID β€” 0 for a custom channel. If we are not, nothing is stamped. - /// - /// arg7 is resolved from the name against `ChatChannels.dbc` rather than remembered per join. - /// That is safe *because* it only ever runs on the hit leg: the id the client stores in - /// `slot+0x94` came from the same DBC row at join time, and vmangos resolves the name the same - /// way (`GetChannelEntryFor`), so no two of the three can disagree. + /// Stamp an event's channel args from our slot, as the reference reads them off one record + /// (`+0x00` number, `+0x04` name, `+0x94` ChannelID, `+0x98` split index): held, arg4 becomes + /// `"N. Name"` (`0x8445c8`, the hit leg `0x49aa48`), arg8 the number, arg9 the name with its + /// zone tail and arg7 the ChannelID; not held, nothing is stamped (`0x49aa86`, `0x49b12f`). + /// arg7 comes from the name, as vmangos resolves it (`DBCStores.cpp:531`). pub(crate) fn stamp_channel(&self, event: &mut super::event::ChatEvent) { - // A miss leaves all four alone β€” see the "one record" note above. + // A miss stamps nothing: the four args are one record. let Some(n) = self .number_of(&event.channel) .filter(|_| !event.channel.is_empty()) diff --git a/crates/benilla-app/src/ui_chat/event.rs b/crates/benilla-app/src/ui_chat/event.rs index 3fb0cd96..2d42e241 100644 --- a/crates/benilla-app/src/ui_chat/event.rs +++ b/crates/benilla-app/src/ui_chat/event.rs @@ -1,28 +1,10 @@ -//! The chat arc's internal currency (decision 0288 Β§1): [`ChatEvent`] mirrors the reference -//! client's `CHAT_MSG_*` event + `arg1..argN` shape as typed fields, so every source β€” the wire -//! (`SMSG_MESSAGECHAT`, `SMSG_CHANNEL_NOTIFY`, whisper-fail errors, `SMSG_TEXT_EMOTE`) and the -//! client-composed feeds (loot receive lines, played time, rolls) β€” speaks one vocabulary, and -//! the router/composer ([`super::frames`]) is the single place lines are formatted, colored, and -//! fanned across the docked windows. -//! -//! The kind set is the renderable subset of the reference's `ChatTypeInfo` (its `COMBAT_*`/ -//! `SPELL_*` block is the combat-log content arc, deliberately out β€” 0288 Β§3, except -//! `COMBAT_XP_GAIN`, pulled in by the ding arc 0304: the XP line is part of leveling feedback); -//! the group tables are `ChatTypeGroup` transcribed; the colors are the complete shipped default -//! table (the ref client's own `chat-cache.txt` COLORS block ≑ the -//! `.rdata 0x804710` table, double-sourced in 0288's pin). -//! -//! This module also carries the **Lua face** of that currency: [`event_name`] -//! (kind β†’ the reference's `CHAT_MSG_*` event name) and [`ChatEvent::script_args`] (the ten -//! positional args the client's own fire helper passes). 0288 Β§1 left that door open in its own -//! words β€” *"a future 0068 addon-API phase can move it into the VM (fire CHAT_MSG_* events at Lua) -//! without touching sources or sinks"* β€” and this is that phase: the router -//! ([`super::frames::route`]) now fires the real event beside the Rust render, so an addon sees the -//! same chat the window does. +//! [`ChatEvent`]: the reference's `CHAT_MSG_*` event and its `arg1..arg10`, typed. Every source, +//! the wire and the client-composed lines alike, produces one, and [`super::frames::route`] fires +//! it at the VM, where the stock `ChatFrame_OnEvent` prints it. use benilla_ui::script::ScriptValue; -/// The renderable chat-event kinds β€” `ChatTypeInfo`'s keys, minus the combat-log block. +/// The chat-event kinds: the `ChatTypeInfo` keys benilla produces. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub(crate) enum ChatEventKind { Say, @@ -52,35 +34,25 @@ pub(crate) enum ChatEventKind { Skill, Loot, Money, - /// The client-composed XP line (`SMSG_LOG_XPGAIN`, decision 0304) β€” the one `COMBAT_*` - /// family member modeled (0288 Β§3 keeps the rest for the combat-log content arc). + /// The client-composed XP line (`SMSG_LOG_XPGAIN`). CombatXpGain, - /// The client-composed honor line (`SMSG_PVP_CREDIT`, decision 1512) β€” the XP line's twin, - /// and the second `COMBAT_*` family member modeled. Composed here rather than fired from the - /// wire because the packet carries a guid and a rank *number*: the sentence needs the - /// victim's NAME and their rank TITLE, so it is built after the name resolve exactly as - /// [`Self::CombatXpGain`] is. + /// The client-composed honor line (`SMSG_PVP_CREDIT`), built once the victim's name resolves. CombatHonorGain, RaidLeader, RaidWarning, RaidBossEmote, - /// `CHAT_MSG_FILTERED` (`0x5B`) β€” the server's "your message was filtered" notice, whose - /// `arg2` is the addressee the stock frame formats `CHAT_FILTERED` over (2077). + /// `CHAT_MSG_FILTERED` (`0x5B`), the server's "your message was filtered" notice; `arg2` is the + /// addressee the stock frame formats `CHAT_FILTERED` over (`ChatFrame.lua:1406`). Filtered, Battleground, BattlegroundLeader, BgSystemNeutral, BgSystemAlliance, BgSystemHorde, - // ── the combat log (B297; completed by 1703) ─────────────────────────────────────────── - // The `COMBAT_*`/`SPELL_*` block 0288 Β§3 held back as "the combat-log content arc". 1571 shipped - // the damage/heal/power 44; 1703 added the eleven leaves it named as "deliberately out because - // their wire sources are undecoded" β€” the death pair, the three AURA_GONE rows, MISC_INFO, - // TRADESKILLS, ITEM_ENCHANTMENTS, BREAK_AURA, FACTION_CHANGE and FAILED_LOCALPLAYER. Every kind - // here has a producer in [`super::combat`]; a kind with none is dead weight the sweeps cannot - // police, which is why the enum grew only as the arms did. - /// `0x19` β€” the reference's catch-all combat line: `DURABILITYDAMAGE_DEATH`, `SPELLDISMISSPET*`, - /// `PET_LOYALTY_*`, `SPELLHAPPINESSDRAIN*`, and the "string not found" warning. + // ── the combat log ─────────────────────────────────────────────────────────────── + // Every kind below has a producer. + /// `0x19`, the reference's catch-all combat line: `DURABILITYDAMAGE_DEATH`, `SPELLDISMISSPET*`, + /// `PET_LOYALTY_*`, `SPELLHAPPINESSDRAIN*` and the "string not found" warning. CombatMiscInfo, CombatSelfHits, CombatSelfMisses, @@ -98,9 +70,9 @@ pub(crate) enum ChatEventKind { CombatCreatureVsPartyMisses, CombatCreatureVsCreatureHits, CombatCreatureVsCreatureMisses, - /// `0x2b` β€” a death whose victim classifies 0..5 (you, yours, a friendly player or their pet). + /// `0x2b`, a death whose victim classifies 0..5 (you, yours, a friendly player or their pet). CombatFriendlyDeath, - /// `0x2c` β€” every other death: a hostile player, a creature, an unresolvable victim. + /// `0x2c`, every other death: a hostile player, a creature, an unresolvable victim. CombatHostileDeath, SpellSelfDamage, SpellSelfBuff, @@ -118,19 +90,19 @@ pub(crate) enum ChatEventKind { SpellCreatureVsPartyBuff, SpellCreatureVsCreatureDamage, SpellCreatureVsCreatureBuff, - /// `0x3e` β€” the item a cast produced: `TRADESKILL_LOG_*` and `FEEDPET_LOG_*`. + /// `0x3e`, the item a cast produced: `TRADESKILL_LOG_*` and `FEEDPET_LOG_*`. SpellTradeskills, SpellDamageShieldsOnSelf, SpellDamageShieldsOnOthers, - /// `0x41` β€” an aura leaving you or your pet (`AURAREMOVED*`). + /// `0x41`, an aura leaving you or your pet (`AURAREMOVED*`). SpellAuraGoneSelf, - /// `0x42` β€” an aura leaving a party member or their pet. + /// `0x42`, an aura leaving a party member or their pet. SpellAuraGoneParty, - /// `0x43` β€” an aura leaving anyone else. + /// `0x43`, an aura leaving anyone else. SpellAuraGoneOther, - /// `0x44` β€” an enchant landing on or fading from an item (`ITEMENCHANTMENT*`). + /// `0x44`, an enchant landing on or fading from an item (`ITEMENCHANTMENT*`). SpellItemEnchantments, - /// `0x45` β€” an aura dispelled or stolen (`AURADISPEL*`, `AURASTOLEN*`). + /// `0x45`, an aura dispelled or stolen (`AURADISPEL*`, `AURASTOLEN*`). SpellBreakAura, SpellPeriodicSelfDamage, SpellPeriodicSelfBuffs, @@ -142,22 +114,16 @@ pub(crate) enum ChatEventKind { SpellPeriodicHostilePlayerBuffs, SpellPeriodicCreatureDamage, SpellPeriodicCreatureBuffs, - /// `0x50` β€” your own cast that failed (`SPELLFAIL{CAST,PERFORM}SELF`). + /// `0x50`, your own cast that failed (`SPELLFAIL{CAST,PERFORM}SELF`). SpellFailedLocalPlayer, - /// `0x55` β€” a reputation delta (`FACTION_STANDING_INCREASED`/`_DECREASED`). + /// `0x55`, a reputation delta (`FACTION_STANDING_INCREASED`/`_DECREASED`). CombatFactionChange, } impl ChatEventKind { - /// The `COMBAT_*` / `SPELL_*` block β€” the combat log's own types. - /// - /// The reference asks this by string prefix (`ChatFrame_OnEvent` l.1397/1399: - /// `strsub(type,1,7) == "COMBAT_"`, `strsub(type,1,6) == "SPELL_"`), which over a typed enum is - /// a membership test. Both `COMBAT_XP_GAIN` and `COMBAT_HONOR_GAIN` are inside the reference's - /// prefix and are deliberately **outside** this one: they were already modeled by the ding and - /// honour arcs, already listed in the verbatim arm by name, and answer to a different composer - /// path β€” including them here would change nothing but would make the predicate lie about what - /// [`super::combat`] produces. + /// The combat log's own kinds. The reference tests the `COMBAT_`/`SPELL_` prefix + /// (`ChatFrame.lua:1397-1399`); `COMBAT_XP_GAIN` and `COMBAT_HONOR_GAIN`, composed apart from + /// the combat log, are outside this set. pub(crate) fn is_combat_log(self) -> bool { use ChatEventKind as K; matches!( @@ -220,13 +186,8 @@ impl ChatEventKind { ) } - /// Every kind, for the sweeps that must be exhaustive to be worth anything β€” chiefly - /// `ui_script::chat_tests::fired_event_names_are_all_chat_type_info_keys`, which checks each - /// name we fire against the live `ChatTypeInfo` table rather than against a second copy of the - /// same list. Adding a variant makes [`event_name`]'s match fail to compile; the length - /// assertion in `tests::every_kind_is_in_all` is what makes you add it here too. - /// - /// Test-only: the app itself never sweeps the kinds β€” it always has one in hand. + /// Every kind, for the exhaustive sweeps; `ui_chat::tests::every_kind_is_in_all` fails when one + /// is missing. #[cfg(test)] pub(crate) const ALL: &'static [ChatEventKind] = { use ChatEventKind as K; @@ -328,67 +289,26 @@ impl ChatEventKind { }; } -/// One chat event β€” the reference's `CHAT_MSG_*` fire, typed. +/// One `CHAT_MSG_*` fire, typed. The fire helper `0x49b0b0` signals it (`0x703f50`) with the +/// format `"%s%s%s%s%s%s%d%d%s%d"` (`.rdata 0x844608`): arg7, arg8 and arg10 are numbers, the +/// rest strings, never nil, as `ChatFrame_OnEvent` compares `arg7 > 0` bare. /// -/// **The arg list is TEN wide, and its shape is byte-pinned.** The client's per-type fire helper -/// `0x49b0b0` (reached from the chat chokepoint `0x49a870` at `0x49ac9a`) calls -/// `FrameScript_SignalEvent 0x703f50` with the format string `"%s%s%s%s%s%s%d%d%s%d"` -/// (`.rdata 0x844608`). So **arg1..arg6 are strings, arg7 and arg8 are numbers, arg9 -/// is a string, arg10 is a number**, and not one of them is ever `nil`: `ChatFrame_OnEvent` -/// compares `arg7 > 0` and `arg10 > 0` bare, which under Lua 5.0 errors on a nil. Every slot is -/// always passed β€” zero or empty when unused. -/// -/// Field ↔ arg mapping, each slot named by the consumer that reads it in the shipped -/// `ChatFrame.lua` (0288's pin; line numbers are that file's): -/// -/// | arg | field | what it is | +/// | arg | field | what `ChatFrame_OnEvent` reads | /// |---|---|---| -/// | 1 | `text` / `notice` | the message body; for the CHANNEL_NOTICE family instead the **notice token** selecting `CHAT__NOTICE` (l.1416/1424) | -/// | 2 | `sender` | the speaker, or the notice's affected player (l.1404, l.1416) | -/// | 3 | `language` | already a *name* ("Orcish"); empty = no header (l.1442) | -// -// (arg3's empty case, checked rather than assumed: l.1442 also guards `arg3 ~= "Universal"`, which -// reads like the client passes that word for language 0. It does not β€” "Universal" is in neither -// `Languages.dbc` (13 rows, ids 1-33, no 0) nor `WoW.exe` nor `GlobalStrings.lua`, so that arm is -// vestigial in 1.12 and `strlen(arg3) > 0` is what actually suppresses the header. Our -// [`language_name`] answering "" for 0 is therefore the right shape, not a shortcut.) -/// | 4 | `channel` | the display form, "N. Name - Zone" when numbered (l.1373, l.1463) | -/// | 5 | `target` | the second name of a two-name notice β€” "X kicked by Y" (l.1414-1416) | -/// | 6 | `flag` | "AFK"/"DND"/"GM", empty none; read as `CHAT_FLAG_` (l.1431) | -/// | 7 | `zone_channel_id` | the **`ChatChannels.dbc` ChannelID** behind a zone channel, 0 for a custom one β€” matched against `ChatFrame.zoneChannelList` (l.1379) | -/// | 8 | `channel_number` | the client-local joined-channel slot; `ChatTypeInfo["CHANNEL"..arg8]` (l.1381) | -/// | 9 | `channel_base` | the channel name **without** the leading number (l.1378's own comment) | -/// | 10 | β€” (always 0) | the channel's **split/instance index**, appended as `arg4.." "..arg10` when `> 0` (l.1421-1423) | +/// | 1 | `text`, `notice` | the body, or a channel notice's `CHAT__NOTICE` token | +/// | 2 | `sender` | the speaker, or a notice's affected player | +/// | 3 | `language` | a language name; empty prints no header | +/// | 4 | `channel` | the display form, "N. Name - Zone" when numbered | +/// | 5 | `target` | a two-name notice's second name, "X kicked by Y" | +/// | 6 | `flag` | "AFK", "DND" or "GM", as `CHAT_FLAG_` | +/// | 7 | `zone_channel_id` | the ChannelID, 0 for a custom channel (`ChatFrame.lua:1379`) | +/// | 8 | `channel_number` | the local slot, `ChatTypeInfo["CHANNEL"..arg8]` | +/// | 9 | `channel_base` | the channel name without its number | +/// | 10 | none | the split index, 0 from vmangos (`Chat/Channel.cpp:823-827`) | /// -/// **arg10 is deliberately not a field.** It is `slot+0x98`, the same value `GetChannelName` -/// returns third, and its wire source is the **second** `u32` of `SMSG_CHANNEL_NOTIFY`'s YOU_JOINED -/// tail β€” the one `Channel::MakeYouJoined` (`Chat/Channel.cpp:823-827`) hardcodes to 0 with the -/// comment *"the non-zero number will be appended to the channel name"*, which is `ChatFrame.lua` -/// l.1421-1423 exactly. (The *first* u32 of that tail is the channel flags, which the client reads -/// and discards.) Our decode already drops the second for that reason -/// (`ChannelNoticeTail::YouJoined`), so [`ChatEvent::script_args`] passes the literal 0 rather than -/// carrying a field that can only ever hold it. -/// -/// **arg4/arg7/arg8/arg9 are one record**, all `""`/`0`/`0`/`""` together when the channel is not -/// in the local list β€” see [`super::edit::ChannelState::stamp_channel`], which is the only place -/// they are written. -/// -/// **arg1 IS the garbled text** (B262, decision 1485). The reference fills `0x49a870`'s one buffer -/// exactly once β€” a plain `SStrCopy` at `0x49a9f0` or the garble `0x49b560` at `0x49aa7c` β€” and -/// never reads the raw wire pointer again, so every consumer downstream shares it: the chat line, -/// this event's arg1, and the bubble. **An addon receiving a foreign-language line cannot recover -/// the plaintext**, and no other slot in this ten-argument tuple carries the body. Ours now behaves -/// the same way ([`super::language`] owns the gate, [`benilla_formats::garble`] the substitution). -/// -/// Still unmodelled: the profanity filter `0x4a1ca0` (`0x49ab23`), which in the reference can -/// suppress the whole event for non-whisper types. -/// -/// **Corrections on record.** This comment previously listed args 1-6, 8 and 9 only β€” arg7 and -/// arg10 were absent, and arg7 is a real slot the reference's own channel routing turns on. The -/// omission was ours. The whole map is now read end to end off `0x49b0b0`, which also **refutes** -/// 0288's standing lead that an earlier trace had covered this marshaller: that trace was opcode -/// `0x92` = SMSG_GUILD_EVENT, misfiled. Real `SMSG_MESSAGECHAT` is `0x96` β†’ `0x49d560` and matches -/// vmangos branch for branch, which is what benilla's decode already did. +/// arg4, arg7, arg8 and arg9 are one record ([`super::edit::ChannelState::stamp_channel`]). arg1 +/// is the garbled text: the reference fills one buffer (`SStrCopy` at `0x49a9f0`, the garble +/// `0x49b560` at `0x49aa7c`) that the line, arg1 and the bubble share ([`super::language`]). #[derive(Clone, Debug, Default, PartialEq)] pub(crate) struct ChatEvent { pub kind: Option, @@ -398,16 +318,14 @@ pub(crate) struct ChatEvent { pub channel: String, pub target: String, pub flag: String, - /// arg7 β€” the `ChatChannels.dbc` ChannelID behind a zone channel (1 General, 2 Trade, - /// 22 LocalDefense); 0 for a custom channel. Filled from [`super::edit::ChannelState`]. + /// arg7, the ChannelID behind a zone channel (1 General, 2 Trade, 22 LocalDefense), else 0. pub zone_channel_id: u32, pub channel_number: u32, pub channel_base: String, pub notice: String, - /// The state of our own slot for this channel **as the notice arrived**, or `None` when we - /// hold no slot for it. Set once at the top of [`super::feed::deliver`], before that function - /// changes any of it β€” because the reference's notice arms read `slot+0x9c` to pick the token - /// and only then move it (`0x49c0c2` reads, `0x49bb20` writes). + /// Our slot's state for this channel as the notice arrived (`None`: no slot), set before + /// [`super::feed::deliver`] changes it: the reference's notice arms read `slot+0x9c` for the + /// token (`0x49c0c2`) before they write it (`0x49bb20`). pub slot_state: Option, } @@ -421,19 +339,14 @@ impl ChatEvent { } } - /// The notice byte behind a CHANNEL_NOTICE(_USER) event, if this is one. - /// - /// `notice` carries the `SMSG_CHANNEL_NOTIFY` byte in decimal because the composer and the - /// event bridge both need it and the field predates both; this is the single parse. + /// The `SMSG_CHANNEL_NOTIFY` byte behind a channel notice, which `notice` holds in decimal. pub(crate) fn notice_byte(&self) -> Option { self.notice.parse().ok() } - /// This event's `arg1..arg10`, in the reference's own order and types (see the struct doc's - /// table). Ten values, always β€” `nil` is not a legal value in any slot. + /// This event's `arg1..arg10` in the reference's order and types; ten values, never nil. pub(crate) fn script_args(&self) -> Vec { - // arg1 is the notice TOKEN for the notice family and the body for everything else β€” the - // one slot whose meaning is type-dependent (`ChatFrame_OnEvent` l.1416/1424 vs l.1396). + // arg1 is the token for a channel notice, else the body. let arg1 = match (self.kind, self.notice_byte()) { (Some(ChatEventKind::ChannelNotice | ChatEventKind::ChannelNoticeUser), Some(byte)) => { notice_token(byte, self.slot_state) @@ -452,18 +365,14 @@ impl ChatEvent { ScriptValue::Int(i64::from(self.zone_channel_id)), ScriptValue::Int(i64::from(self.channel_number)), ScriptValue::Str(self.channel_base.clone()), - // arg10 β€” see the struct doc: always 0 off this server. + // arg10: always 0, see the struct doc. ScriptValue::Int(0), ] } } -/// The reference's event NAME for a kind β€” `"CHAT_MSG_" ++ `. -/// -/// The key spellings are the reference's own (they are what `ChatFrame_OnEvent` recovers with -/// `strsub(event, 10)` and looks up in `ChatTypeInfo`), so this table and the `ChatTypeInfo` the -/// engine seeds are the same set by construction β€” asserted in -/// `ui_script::chat_tests::every_fired_event_name_is_a_chat_type_info_key`. +/// The reference's event name for a kind, `CHAT_MSG_` and its `ChatTypeInfo` key, which +/// `ChatFrame_OnEvent` recovers with `strsub(event, 10)`. pub(crate) fn event_name(kind: ChatEventKind) -> &'static str { use ChatEventKind as K; match kind { @@ -563,32 +472,11 @@ pub(crate) fn event_name(kind: ChatEventKind) -> &'static str { } } -/// The notice TOKEN a `SMSG_CHANNEL_NOTIFY` byte becomes in `arg1` β€” the token -/// `ChatFrame_OnEvent` splices into `getglobal("CHAT_"..arg1.."_NOTICE")` (l.1416/1424). -/// -/// The token set is read off the shipped `GlobalStrings.lua`'s own `CHAT__NOTICE` keys -/// (l.494-745 of the extracted file), paired to the vmangos notice byte that produces each line β€” -/// the same pairing [`super::frames::compose_notice`] already renders, which is why the two tables -/// are asserted against each other rather than left to drift -/// (`ui_chat::tests::every_rendered_notice_has_a_token`). -/// -/// Byte-for-byte identical to the client's own jump table (`0x49c60c`, 32 direct arms) β€” checked -/// against that table after the fact, not derived from it. -/// -/// `None` = a byte the reference passes no token for: `0x00`/`0x01` are the CHANNEL_JOIN / -/// CHANNEL_LEAVE member lines (their arg1 is the empty string, not a token), `0x0C` MODE_CHANGE -/// fires **no chat event at all** (`0x49c24d` calls `0x49e910` and returns β€” which is why -/// [`super::feed::ChatLog::push_channel_notice`] drops it before it becomes an event), and anything -/// past `0x1F` is outside vmangos's range. -/// -/// **Two of the arms are state-dependent** (decision 2130): the client answers `"YOU_CHANGED"` for -/// `0x02` and `"SUSPENDED"` for `0x03` when its own channel record is in the matching state -/// (`rec+0x9c == 2` at `0x49c087` / `== 3` at `0x49c0e0`), and both alternates are real -/// `CHAT__NOTICE` strings β€” -/// *"Changed Channel: [%s]"* and *"Left Channel: [%s]"*. We modelled neither until the zone walk's -/// registration loss made it matter: `arg1` is what the stock `ChatFrame_OnEvent` branches on, and -/// the `YOU_LEFT` branch **deletes the window's channel registration** (`ChatFrame.lua` -/// l.1382-1384). A suspended channel that answers the plain token loses its registration for good. +/// The `arg1` token for a `SMSG_CHANNEL_NOTIFY` byte, read as `CHAT__NOTICE` +/// (`GlobalStrings.lua:494-745`), matching the client's jump table `0x49c60c`. `None`: the +/// member-line bytes `0x00`/`0x01`, MODE_CHANGE `0x0C`, which fires no event (`0x49c24d`), and +/// anything past `0x1F`. `0x02` answers `YOU_CHANGED` in state 2 (`0x49c087`); `0x03` answers +/// `SUSPENDED` in state 3 (`0x49c0e0`), where `YOU_LEFT` would delete the window's channel. pub(crate) fn notice_token( byte: u8, state: Option, @@ -596,12 +484,10 @@ pub(crate) fn notice_token( use super::edit::SlotState; use benilla_protocol::messages::channel_notice as n; Some(match byte { - // The confirming notice for a slot the zone walk renamed β€” crossing a zone border prints - // "Changed Channel: [1. General - Westfall]", not a leave and a join. + // A border crossing prints "Changed Channel: [1. General - Westfall]". n::YOU_JOINED if state == Some(SlotState::Renamed) => "YOU_CHANGED", n::YOU_JOINED => "YOU_JOINED", - // Walking out of a capital: the record and its number stay, so this must NOT be the token - // that tears the registration down. + // Walking out of a capital keeps the record, so not the token that deletes it. n::YOU_LEFT if state == Some(SlotState::Suspended) => "SUSPENDED", n::YOU_LEFT => "YOU_LEFT", n::WRONG_PASSWORD => "WRONG_PASSWORD", @@ -631,18 +517,12 @@ pub(crate) fn notice_token( n::PLAYER_INVITED => "PLAYER_INVITED", n::PLAYER_INVITE_BANNED => "PLAYER_INVITE_BANNED", n::THROTTLED => "THROTTLED", - // MODE_CHANGE (0x0C) has no CHAT_*_NOTICE string in 1.12 β€” the reference renders nothing, - // so there is no token. JOINED/LEFT (0x00/0x01) are not notices at all: they are the - // CHANNEL_JOIN/CHANNEL_LEAVE member-line events. _ => return None, }) } -/// The kind's row in the shipped colour table β€” what the chat bubble and the edit box's header -/// tint with. The window's own line colour is `ChatTypeInfo`'s, in the reference's Lua (1948). -/// -/// The complete shipped table (chat-cache COLORS ≑ `.rdata 0x804710`, both quoted in -/// 0288's pin; entries this kind set carries). +/// The kind's row in the shipped default colour table (`.rdata 0x804710`), which the chat bubble +/// and the edit box header tint with; the window's line colour is `ChatTypeInfo`'s. pub(crate) fn default_color(kind: ChatEventKind) -> [u8; 3] { use ChatEventKind as K; match kind { @@ -663,10 +543,8 @@ pub(crate) fn default_color(kind: ChatEventKind) -> [u8; 3] { K::ChannelJoin | K::ChannelLeave | K::ChannelList => [192, 128, 128], K::ChannelNotice | K::ChannelNoticeUser => [192, 192, 192], K::Ignored => [255, 0, 0], - // `ChatTypeInfo["FILTERED"] = { sticky = 0 }` (ChatFrame.lua l.112) carries **no colour**, - // so the stock frame passes nil r/g/b to `AddMessage` and the line takes the window's own - // default. White is that default, and this table's consumers (the bubble tint, the edit - // box header) never see this kind anyway β€” it is not a speech line. + // `ChatTypeInfo["FILTERED"]` has no colour (`ChatFrame.lua:112`), so the line takes the + // window's default, white; no bubble or edit box header shows this kind. K::Filtered => [255, 255, 255], K::Skill => [85, 85, 255], K::Loot => [0, 170, 0], @@ -680,20 +558,12 @@ pub(crate) fn default_color(kind: ChatEventKind) -> [u8; 3] { K::BgSystemNeutral => [255, 120, 10], K::BgSystemAlliance => [0, 174, 239], K::BgSystemHorde => [255, 0, 0], - // The two `8080ff` rows, both from the reference's table: the catch-all combat line and - // the reputation line share a colour and nothing else. K::CombatMiscInfo | K::CombatFactionChange => [128, 128, 255], // ── the combat log ─────────────────────────────────────────────────────────────── - // The shipped defaults are overwhelmingly plain white; the three that are not are the - // ones about YOU being hit, and they are the reason the block is grouped by colour - // rather than listed in table order β€” the exceptions are the content. - // Your own spell work: the one gold pair in the block. + // White but for your own spells and what hits you. K::SpellSelfDamage | K::SpellSelfBuff => [255, 255, 0], - // A creature hitting YOU β€” the red that makes incoming melee read. K::CombatCreatureVsSelfHits | K::CombatCreatureVsSelfMisses => [255, 47, 47], - // A creature's spell landing on YOU. K::SpellCreatureVsSelfDamage => [202, 76, 217], - // Everything else in the block: plain white. K::CombatSelfHits | K::CombatSelfMisses | K::CombatPetHits @@ -745,9 +615,8 @@ pub(crate) fn default_color(kind: ChatEventKind) -> [u8; 3] { } } -/// Map a wire `ChatMsg` byte (`SMSG_MESSAGECHAT.chat_type`) to its event kind. `None` = a type -/// vmangos never emits as wire chat (the combat-log block) or one we don't model β€” the router -/// drops it loudly. +/// Map a wire `ChatMsg` byte to its kind; `None` for a type vmangos never sends as wire chat (the +/// combat log) or one not modelled, which the router drops loudly. pub(crate) fn kind_of_wire(chat_type: u8) -> Option { use benilla_protocol::messages as m; use ChatEventKind as K; @@ -783,8 +652,8 @@ pub(crate) fn kind_of_wire(chat_type: u8) -> Option { }) } -/// The ``/``/`` flag token for a wire chat-tag byte (`Player::GetChatTag`) β€” the -/// event's `flag` field (the ref's arg6), consumed as `CHAT_FLAG_`. +/// The arg6 flag token for a wire chat-tag byte (vmangos `Player::GetChatTag`), read as +/// `CHAT_FLAG_`. pub(crate) fn flag_of_tag(chat_tag: u8) -> &'static str { use benilla_protocol::messages::chat_tag as t; match chat_tag { @@ -795,9 +664,9 @@ pub(crate) fn flag_of_tag(chat_tag: u8) -> &'static str { } } -/// The 1.12 language names by wire id (vmangos `SharedDefines.h` `LANG_*` β€” the small racial -/// set), for the `[Language]` header when a line isn't Universal/our default. Unknown ids render -/// no header (v1: benilla speaks Common; the picker is a later arc). +/// The 1.12 language names by wire id (vmangos `SharedDefines.h`, `LANG_*`), for the `[Language]` +/// header. Language 0 has no `Languages.dbc` row, so the stock `arg3 ~= "Universal"` test never +/// fires: an empty arg3 is what suppresses the header (`ChatFrame.lua:1442`). pub(crate) fn language_name(id: u32) -> &'static str { match id { 1 => "Orcish", @@ -813,6 +682,6 @@ pub(crate) fn language_name(id: u32) -> &'static str { 13 => "Gnomish", 14 => "Troll", 33 => "Gutterspeak", - _ => "", // 0 = Universal (no header), unknowns likewise + _ => "", // 0, Universal, and unknown ids: no header } } diff --git a/crates/benilla-app/src/ui_chat/feed.rs b/crates/benilla-app/src/ui_chat/feed.rs index d712e68d..406b5365 100644 --- a/crates/benilla-app/src/ui_chat/feed.rs +++ b/crates/benilla-app/src/ui_chat/feed.rs @@ -1,8 +1,5 @@ -//! The chat sources β†’ [`ChatEvent`] bridge (decision 0288 Β§1): every inbound line β€” a decoded -//! `SMSG_MESSAGECHAT`, a channel notice, a `/random` roll, a client-composed loot/system line β€” -//! becomes one typed event here, names resolved ask-once through [`crate::names::NameCache`] -//! (a line whose sender name is still in flight re-checks each frame, bounded), then routes -//! through [`super::frames::route`] β€” the one composer/color/fan-out seam. +//! Every inbound chat source becomes one [`ChatEvent`] here, its names resolved ask-once through +//! [`crate::names::NameCache`] with bounded re-checks, then routed by [`super::frames::route`]. use bevy::prelude::*; @@ -20,22 +17,17 @@ use super::edit::ChannelState; use super::event::{flag_of_tag, kind_of_wire, language_name, ChatEvent, ChatEventKind}; use super::frames::{route, ChatWindows}; -/// Give up re-checking a line's pending sender name after this many frames (a negative-cached or -/// genuinely-unknown guid never resolves; ~2s at 60fps is well past a normal name-query -/// round-trip). The line renders with a placeholder rather than being lost. +/// Frames a line waits on a name, about 2 s at 60 fps, before it gives up. const NAME_MAX_TRIES: u16 = 120; -/// The text-emote sentence tables (decision 1274), read once off the patch chain. +/// The text-emote sentence tables, read once off the patch chain. #[derive(Resource)] pub(crate) struct EmoteTexts(pub(crate) EmoteTextCatalog); -/// **The locale column is 0.** `[0xc0e080]` is the client's locale slot; only enUS is populated in -/// this install, and every other DBC catalog in the tree reads column 0 for the same reason. +/// The DBC string column: 0, enUS; the reference reads its locale slot `[0xc0e080]`. const LOCALE: usize = 0; -/// Load `EmotesText.dbc` Γ— `EmotesTextData.dbc`. `.after(benilla_assets::AssetSet::Open)` at the -/// call site is load-bearing for the reason [`super::channels::load_chat_channels`] records: -/// without it the patch chain does not exist yet and this silently loads nothing. +/// Load `EmotesText.dbc` with `EmotesTextData.dbc`; before `AssetSet::Open` it loads nothing. pub(super) fn load_emote_texts(mut commands: Commands, assets: Option>) { let Some(assets) = assets else { return }; let loaded = { @@ -53,10 +45,9 @@ pub(super) fn load_emote_texts(mut commands: Commands, assets: Option), /// A ready event (client-composed lines; name-carrying notices). Event(ChatEvent), } -/// Fire `CHAT_MSG_ADDON` with the reference's four arguments, in the reference's order. -/// -/// Split out of the drain so the shape that reaches Lua is testable without standing up -/// `feed_chat`'s dozen resources β€” the scaffolding is not what can be wrong here; the argument -/// ORDER is. The reference fires `SignalEvent2(227, "%s%s%s%s", prefix, message, distribution, -/// sender)` (`0x49a95f`), and `BigWigs` independently self-delivers -/// `self:CHAT_MSG_ADDON("BigWigs", msg, "RAID", playerName)` β€” a 2006 addon author and the binary -/// agreeing. -/// -/// Fired DIRECTLY rather than through [`route`]: `CHAT_MSG_ADDON` is not a `ChatTypeInfo` key and -/// carries four arguments against the chat family's ten, so the chat pipeline would mis-shape it -/// and `every_fired_event_name_is_a_chat_type_info_key` would rightly reject it. +/// Fire `CHAT_MSG_ADDON(prefix, message, distribution, sender)` (`0x49a95f`), not through +/// [`route`]: it is no `ChatTypeInfo` key and takes four arguments, not ten. pub(super) fn fire_addon_message( script: &mut benilla_ui::script::UiScript, prefix: String, @@ -161,19 +121,10 @@ pub(super) fn fire_addon_message( ); } -/// One text emote's chat event β€” the sentence plus the performer name β€” or `None` when the -/// sentence table has nothing to say (the reference's `0x49b4bd` tail: SIT/STAND/TRAIN ship blank -/// in every locale, so a vanilla `/sit` prints no line at all). -/// -/// Split out of the drain for [`fire_addon_message`]'s reason: the scaffolding around it is not -/// what can be wrong, the **shape** is β€” which sentence form the facts select, and which slot the -/// performer's name lands in. -/// -/// **`sender` is the PERFORMER**, and it never reaches the rendered line: TEXT_EMOTE is one of the -/// verbatim families in `ChatFrame_OnEvent`, so the sentence *is* the line and this slot only ever -/// reaches addons as arg2. VERIFIED at the bytes: the last push before the event fire at -/// `0x49b495` is `[ebp-0x4]`, the `NameCache` record resolved for the performer at `0x49b289` -/// (`record+0` is the name string). +/// A text emote's chat event, or `None` when the sentence is blank, as SIT, STAND and TRAIN are +/// in every locale (`0x49b4bd`). `sender` is the performer, seen only by addons as arg2: +/// `TEXT_EMOTE` prints verbatim (`ChatFrame.lua:1395`), and the fire (`0x49b495`) pushes the name +/// record resolved at `0x49b289`. pub(super) fn text_emote_event( cat: &EmoteTextCatalog, text_id: u32, @@ -187,26 +138,15 @@ 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. +/// The chat items the feeds queue and [`feed_chat`] drains. World broadcasts wait apart, as their +/// resolve needs more than [`feed_chat`] can hold at Bevy's `SystemParam` limit; +/// `feed_broadcasts` resolves them onto `pending` earlier in the same frame. #[derive(Resource, Default)] pub(crate) struct ChatLog { pending: Vec, broadcasts: Vec, - /// The ding's gain tuple, parked for `ui_unit`'s `PLAYER_LEVEL_UP` fire (decision 1884). - /// - /// The net layer has no `UiScript` β€” deliberately β€” so the gains arrive here, on the same - /// net-to-UI channel `broadcasts` uses, and the feed that owns the level edge picks them up. - /// They are matched BY LEVEL rather than just drained, because the trigger is a descriptor - /// diff and the gains are packet-borne: the two land together today, and matching means a - /// stale entry can never attach itself to a later ding if they ever stop doing so. + /// A level-up's gains for `ui_unit`'s `PLAYER_LEVEL_UP` fire, matched by level: the fire + /// follows a descriptor change and the gains a packet. level_up_gains: Vec<(LevelUpInfo, u32)>, } @@ -216,7 +156,7 @@ impl ChatLog { self.broadcasts.push(b); } - /// How many broadcasts are waiting β€” the drain's cheap early-out. + /// How many broadcasts are waiting, the drain's early-out. pub(crate) fn broadcasts_pending(&self) -> usize { self.broadcasts.len() } @@ -226,14 +166,12 @@ impl ChatLog { std::mem::take(&mut self.broadcasts) } - /// Park the ding's gains for the `PLAYER_LEVEL_UP` fire (see [`ChatLog::level_up_gains`]). + /// Park a level-up's gains for the `PLAYER_LEVEL_UP` fire. pub(crate) fn push_level_up_gains(&mut self, info: &LevelUpInfo, talent_points: u32) { self.level_up_gains.push((*info, talent_points)); } - /// The parked gains for `level`, removed from the queue. `None` when the level edge came from - /// somewhere the packet did not β€” a GM demotion's descriptor write, or a first observation β€” - /// in which case the caller fires zeros, which is what those gains actually are. + /// Take the parked gains for `level`; with none (a GM's level write), the caller fires zeros. pub(crate) fn take_level_up_gains(&mut self, level: u32) -> Option<(LevelUpInfo, u32)> { let i = self .level_up_gains @@ -249,22 +187,17 @@ impl ChatLog { self.pending.push(Pending::Wire { msg, tries: 0 }); } - /// Queue a ready, client-composed event (loot receive lines, quest/system lines, played - /// time β€” [`ChatEvent::text_only`] covers the common case). + /// Queue a client-composed event: loot, quest and system lines, the `/played` answer. pub(crate) fn push_event(&mut self, event: ChatEvent) { self.pending.push(Pending::Event(event)); } - /// Queue one combat-log line ([`super::combat`] did the classification at the packet; this - /// only parks it for its names). + /// Queue one combat-log line, classified at the packet by [`super::combat`], for its names. pub(crate) fn push_combat(&mut self, line: super::combat::PendingCombat) { self.pending.push(Pending::Combat(Box::new(line))); } - /// Queue a decoded `SMSG_CHANNEL_NOTIFY`. JOINED/LEFT become the ref's CHANNEL_JOIN/LEAVE - /// *events* (a member line, hyperlinked β€” ChatFrame.lua's ChatTypeGroup["CHANNEL"]); every - /// other notice becomes a CHANNEL_NOTICE composed by the `CHAT__NOTICE` law. Guid tails - /// park here for the ask-once resolve; name tails are ready immediately. + /// Queue a decoded `SMSG_CHANNEL_NOTIFY`; a guid tail waits for its names. pub(crate) fn push_channel_notice( &mut self, notice_byte: u8, @@ -293,32 +226,15 @@ impl ChatLog { } } - /// Queue an inbound addon line (`SMSG_MESSAGECHAT` carrying `LANG_ADDON`) for the sender-name - /// resolve, then `CHAT_MSG_ADDON`. - /// - /// **The split is the counter-intuitive part and it is the reference's** (`0x49a8d0`): the text - /// divides on its **FIRST** tab, and with **no tab at all the whole text is the PREFIX** and - /// the message is empty β€” not the other way round, which is what a reimplementation guesses. - /// - /// `distribution` is the remap at `0x49aff4`: only PARTY / RAID / GUILD / BATTLEGROUND have - /// names, and every other type byte reports `"UNKNOWN"` rather than being dropped β€” the - /// reference hands the addon a string it can branch on either way. + /// Queue an inbound addon line for its sender's name. The text splits on its first tab, and + /// with none it is all prefix (`0x49a8d0`); `distribution` names PARTY, RAID, GUILD and + /// BATTLEGROUND, any other type `"UNKNOWN"` (`0x49aff4`). pub(crate) fn push_addon(&mut self, text: &str, chat_type: u8, guid: u64) { let (prefix, message) = match text.find('\t') { Some(i) => (text[..i].to_string(), text[i + 1..].to_string()), None => (text.to_string(), String::new()), }; - // **The protocol constants, not hand-copied bytes.** Three of the four literals here were - // WRONG: RAID is `0x2` and this said `0x03`, GUILD is `0x3` and this said `0x04`, and - // BATTLEGROUND is `0x5C` and this said `0x18`. The visible effect was not a dropped - // message but a MISLABELLED one β€” a real guild addon message arrived at - // `CHAT_MSG_ADDON` as `"RAID"`, while real raid and battleground traffic fell to - // `"UNKNOWN"` β€” so an addon branching on the distribution acted on the wrong lane. - // - // The outbound half (`net::addon_wire_chat_type`) always used the named constants and its - // own doc calls itself "the one place a distribution becomes a wire byte". It was not: this - // was the other one, and it disagreed. Both ends now read the same symbols, so they cannot - // drift apart again. + // The protocol's constants, the same ones `crate::net::addon_wire_chat_type` sends. let distribution = { use benilla_protocol::messages as m; match u32::from(chat_type) { @@ -350,13 +266,9 @@ impl ChatLog { }); } - /// Queue an XP award's chat line (`SMSG_LOG_XPGAIN` β†’ CHAT_MSG_COMBAT_XP_GAIN; decision - /// 0304). A named kill waits on the victim's name; everything else composes immediately - /// (COMBATLOG_XPGAIN_FIRSTPERSON_UNNAMED "You gain %d experience."). + /// Queue an XP award's line (`SMSG_LOG_XPGAIN`); a kill waits on the victim's name. pub(crate) fn push_xp_gain(&mut self, x: &XpGain) { - // Both forks park. The unnamed one has no name to wait for β€” `victim: 0` is what says so - // at the drain β€” but it still has a template to resolve, and the string table is the VM's - // (decision 2045). One node, one composer, one place the wording can be wrong. + // Both forks park: the template resolves at the drain; `victim: 0` is the unnamed form. let named = x.kill && x.victim != 0; self.pending.push(Pending::XpGain { victim: if named { x.victim } else { 0 }, @@ -370,12 +282,9 @@ impl ChatLog { }); } - /// Queue an honor award's chat line (`SMSG_PVP_CREDIT` β†’ CHAT_MSG_COMBAT_HONOR_GAIN; - /// decision 1512). A credit with **no victim guid** is the bonus/objective form and needs no - /// resolve at all, so it composes here and now β€” the same fork [`Self::push_xp_gain`] takes. + /// Queue an honor award's line (`SMSG_PVP_CREDIT`); victim 0 is a bonus or objective award. pub(crate) fn push_pvp_credit(&mut self, honor: i32, victim: u64, rank: u8) { - // Both forks park, for [`Self::push_xp_gain`]'s reason: `victim: 0` is the award form and - // waits for nothing, but its template still has to be resolved where the VM is. + // Both forks park: the template resolves at the drain. self.pending.push(Pending::HonorGain { victim, honor, @@ -384,8 +293,7 @@ impl ChatLog { }); } - /// Queue a text emote's chat line (`SMSG_TEXT_EMOTE` β†’ CHAT_MSG_TEXT_EMOTE; decision 1274) for - /// the performer-name resolve, then the `EmotesText`/`EmotesTextData` composition. + /// Queue a text emote (`SMSG_TEXT_EMOTE`) for its performer's name, then its sentence. pub(crate) fn push_text_emote(&mut self, performer: u64, text_id: u32, target: String) { self.pending.push(Pending::TextEmote { performer, @@ -395,11 +303,8 @@ impl ChatLog { }); } - /// Queue an area discovery's announcement (`SMSG_EXPLORATION_EXPERIENCE`; decision 0828, - /// surfaces corrected by the 0829 RE): the ERR_ZONE_EXPLORED toast fires on **every** packet - /// (UIErrorsFrame, via `UI_INFO_MESSAGE` β€” never chat), and the ERR_ZONE_EXPLORED_XP chat - /// system line rides **additionally** iff `xp > 0`. The caller resolved `area_name` from - /// `AreaTable.dbc` by the packet's area id. + /// Queue an area discovery: the `ERR_ZONE_EXPLORED` toast on every packet, never chat, and the + /// `ERR_ZONE_EXPLORED_XP` system line when `xp > 0`. pub(crate) fn push_exploration(&mut self, area_name: &str, xp: u32) { self.pending.push(Pending::Discovery { area: area_name.to_string(), @@ -407,13 +312,12 @@ impl ChatLog { }); } - /// Disconnect: drop every pending item (mirrors the merchant/gossip/loot session clears). + /// Drop every pending item on disconnect. pub(crate) fn clear_session(&mut self) { self.pending.clear(); } - /// The parked addon lines as `(prefix, message, distribution)` β€” test-only, so the split and - /// the remap can be asserted without standing up a name cache and a VM. + /// The parked addon lines as `(prefix, message, distribution)`. #[cfg(test)] pub(crate) fn pending_addons(&self) -> Vec<(String, String, String)> { self.pending @@ -430,12 +334,7 @@ impl ChatLog { .collect() } - /// How many parked items are headed for a CHAT WINDOW. - /// - /// Addon lines are excluded on purpose. They park in the same queue for the same ask-once name - /// resolve, but they are not speech and never render β€” so counting them here would make - /// `addon_chat_never_reaches_the_chat_window` fail the moment the receive half opened, which is - /// exactly what it did. "Pending" stopped meaning "will render" when `Pending::Addon` arrived. + /// How many parked items will render in a chat window; addon lines never do. #[cfg(test)] pub(crate) fn pending_len(&self) -> usize { self.pending @@ -444,10 +343,7 @@ impl ChatLog { .count() } - /// The text of every already-composed line waiting to render, in order β€” the queue's - /// [`Pending::Event`] entries, which is what everything that pushes a finished sentence - /// ([`Self::push_event`]) lands as. The `Wire`/`Notice`/`Roll` shapes are deliberately absent: - /// those have no text yet, and a caller asserting on a sentence wants only the ones that do. + /// The text of every composed line waiting to render, in order. #[cfg(test)] pub(crate) fn pending_lines(&self) -> Vec { self.pending @@ -460,15 +356,12 @@ impl ChatLog { } } -/// The VM's own string table as a lookup β€” `getglobal(key)`, which is where every sentence this -/// feed shows comes from (decision 2045). Taken for exactly as long as one composition needs it, -/// so the `route` that follows can take the VM mutably. +/// The VM's globals as a string lookup, borrowed per composition so `route` can take the VM. fn globals(script: &benilla_ui::script::UiScript) -> impl Fn(&str) -> Option + '_ { |key: &str| script.lua().globals().get::(key).ok() } -/// Fill a key's template, or nothing at all if the chain has no string for it β€” the reference's -/// data-suppression face, and the reason nothing here carries a fallback sentence. +/// Fill a key's template; a missing or empty string prints no line, as in the reference. fn keyed( get: &dyn Fn(&str) -> Option, key: &str, @@ -478,16 +371,8 @@ fn keyed( (!text.is_empty()).then_some(text) } -/// The XP award's chat line: `COMBATLOG_XPGAIN_FIRSTPERSON`, its `_EXHAUSTION1` rested form, or -/// the `_UNNAMED` form (no victim). -/// -/// **The rested template takes four arguments, not two** β€” -/// `"%s dies, you gain %d experience. (%s exp %s bonus)"` β€” and the last two are the bonus -/// *rendered with its sign* and the state WORD. That word is the one thing on this line the -/// reference does not keep in GlobalStrings: 1.12 ships no `EXHAUSTION_STATE*` key, so it comes -/// out of the client's own table and stays a literal here, with the same INTERIM it always had. -/// "Rested" is the only state the live server produces (the beta tired/exhausted penalties are -/// dead data); the client's table is the in-flight 0304's to pin. +/// The XP line: `COMBATLOG_XPGAIN_FIRSTPERSON`, the `_UNNAMED` form, or the rested +/// `_EXHAUSTION1`, whose last two arguments are the signed bonus and the state word. pub(super) fn xp_gain_line( victim: Option<&str>, total: u32, @@ -519,21 +404,13 @@ pub(super) fn xp_gain_line( } } -/// The rest-state word the `_EXHAUSTION1` template's fourth argument takes. Not a GlobalString in -/// 1.12 β€” see [`xp_gain_line`]. +/// The `_EXHAUSTION1` template's state word. 1.12 has no GlobalString for it and the reference's +/// own table is untraced; rested is the only bonus state vmangos sets (`Player.cpp:17990-17993`). const RESTED_STATE: &str = "Rested"; -/// The honor line an `SMSG_PVP_CREDIT` becomes β€” the three GlobalStrings forms (COMBATLOG_HONORAWARD -/// :786, COMBATLOG_HONORGAIN :787, COMBATLOG_DISHONORGAIN :785), decision 1512. -/// -/// **The fork** (formatter `0x625270`) is three-way, not two: no victim guid β†’ AWARD; victim and -/// `honor > 0` β†’ GAIN; victim and **`honor <= 0`** β†’ DISHONOR. The boundary is `<=`, not `<` β€” a -/// zero-honor kill takes the dishonorable arm, which the earlier reading had on the honorable side. -/// -/// An absent `rank_title` fills an EMPTY rank slot rather than dropping the clause. That is the -/// reference's own shape β€” it is precisely the emptiness vmangos floors a rankless victim's rank at -/// 5 to avoid showing (1512), and compensating for it a second time on our side would hide what the -/// server is actually sending. +/// The honor line (`0x625270`): `COMBATLOG_HONORAWARD` with no victim, `COMBATLOG_HONORGAIN` for +/// `honor > 0`, else `COMBATLOG_DISHONORGAIN`. No `rank_title` leaves the rank slot empty, as in +/// the reference; vmangos floors a rankless player at rank 5 (`HonorMgr.cpp:1086-1087`). pub(super) fn honor_gain_line( victim: Option<&str>, rank_title: Option<&str>, @@ -556,9 +433,7 @@ pub(super) fn honor_gain_line( } } -/// The discovery toast β€” `ERR_ZONE_EXPLORED` ("Discovered: %s"), fired on every exploration packet -/// to the UIErrorsFrame (byte-verified: error-table route 1 β†’ `AddErrorMessage 0x4945b0` β†’ -/// UI_INFO_MESSAGE; decisions 0828/0829). +/// The `ERR_ZONE_EXPLORED` toast (error route 1, `AddErrorMessage` `0x4945b0`), every packet. pub(super) fn exploration_toast( area_name: &str, get: &dyn Fn(&str) -> Option, @@ -570,9 +445,7 @@ pub(super) fn exploration_toast( ) } -/// The discovery chat line β€” `ERR_ZONE_EXPLORED_XP` ("Discovered %s: %d experience gained"), fired -/// **in addition to** the toast iff the packet carried XP (byte-verified: the signed `jle` skip at -/// `0x5e422f`; route 0 β†’ CHAT_MSG_SYSTEM; decisions 0828/0829). +/// The `ERR_ZONE_EXPLORED_XP` system line, only when the packet carries XP (`jle` at `0x5e422f`). pub(super) fn exploration_line( area_name: &str, xp: u32, @@ -586,19 +459,15 @@ pub(super) fn exploration_line( ) } -/// Build the event a channel notice becomes: JOINED/LEFT β†’ the member-line kinds; the rest β†’ -/// CHANNEL_NOTICE (composed by [`super::frames::compose_notice`], the notice byte riding the -/// event's `notice` field). +/// The event a channel notice becomes; [`super::frames::compose_notice`] reads its `notice`. pub(super) fn notice_event( notice_byte: u8, channel: String, a: Option, b: Option, ) -> Option { - // The display type per notice byte β€” the `0x49c60c` jump table, read - // off each arm's `mov edi`: 0x00/0x01 are the join/leave lines, MODE_CHANGE (0x0c) fires no - // chat event at all, a byte past THROTTLED fires SYSTEM with an empty token, and the rest - // split between CHANNEL_NOTICE (0x12) and CHANNEL_NOTICE_USER (0x13) exactly as listed. + // Each arm's `mov edi` in the `0x49c60c` jump table: MODE_CHANGE fires nothing, a byte past + // THROTTLED fires SYSTEM, the rest CHANNEL_NOTICE (0x12) or CHANNEL_NOTICE_USER (0x13). use channel_notice as n; let kind = match notice_byte { n::JOINED => ChatEventKind::ChannelJoin, @@ -645,34 +514,12 @@ pub(super) fn notice_event( }) } -/// Deliver one built event: the joined-list upkeep on **both sides** of the render, with the -/// channel stamp and [`route`] between them β€” the client's own split, and the reason a leave line -/// still knows its number. -/// -/// **YOU_JOINED lands before, YOU_LEFT lands after.** The notice's arg4/arg7/arg8/arg9 are read off -/// the client's channel record ([`ChannelState::stamp_channel`] is our leg of that), so a record -/// torn down before the line is composed costs it the slot number β€” and, since the color resolves -/// through `ChatTypeInfo["CHANNEL"..arg8]` β€” the stock window's own Lua since 1948 β€” its color -/// with it. -/// The reference's YOU_LEFT arm only *flags* the teardown (`0x49c115 mov dword [ebp-0xc],1`); the -/// event fires first (`0x49c5b0 call 0x49a870`) and only then does `0x49c5b5 test` / -/// `0x49c5c2 call 0x49bbd0` destroy the record β€” VERIFIED by disassembly at those addresses (1275). -/// We removed first and printed "Left Channel: [General]" where the client prints "Left Channel: -/// [2. General - Elwynn Forest]"; an addon reading `GetChannelName` from its own handler also saw -/// the channel already gone, which the reference never shows it either. -/// -/// **Unmodeled, and visible right there in that arm:** the teardown flag is the *YOU_LEFT* leg's -/// alone. Notice `0x03` splits on `rec+0x9c == 3` β€” the SUSPENDED leg (`0x49c0e9`) takes a -/// different token AND jumps past `0x49c115`, so a suspended channel keeps its record and its -/// number. We do not model that state field (it is the same one that makes `0x02` "YOU_CHANGED"), -/// so every `0x03` here is a genuine leave. -/// -/// The two arms log, because this edge is where "we asked to join" becomes "the server says we are -/// in": [`super::channels`]'s walk only ever proves the request went out, and the round trip is -/// what actually arms an addon (it is the `CHAT_MSG_CHANNEL_NOTICE` Ace2's whole init gate waits -/// on). A join the server refuses is otherwise completely silent on this side. Each arm also -/// mirrors the list into the VM, where `GetChannelName` reads it (17 corpus sites across 6 addons): -/// these two are the only places it ever changes. +/// Deliver one built event: a `YOU_JOINED` claims its slot before the render, a `YOU_LEFT` frees +/// it after, so the leave line keeps its number and colour ("Left Channel: [2. General - Elwynn +/// Forest]") and a handler's `GetChannelName` still finds it. The reference flags the teardown +/// (`0x49c115`), fires the event (`0x49c5b0` calling `0x49a870`), then drops the record +/// (`0x49c5b5`, `0x49c5c2` calling `0x49bbd0`); its suspended leg (`0x49c0e9`) skips the flag. +/// Both arms mirror the list into the VM for `GetChannelName`. pub(super) fn deliver( script: &mut benilla_ui::script::UiScript, windows: &mut ChatWindows, @@ -682,14 +529,11 @@ pub(super) fn deliver( let notice = event .notice_byte() .filter(|_| event.kind == Some(ChatEventKind::ChannelNotice)); - // **First, before anything below moves it.** The reference's notice arms read `slot+0x9c` to - // choose the token and only then write it (`0x49c0c2` reads, `0x49bb20` writes), and the two - // alternates are what keep a renamed or suspended channel registered with the window - // ([`super::event::notice_token`], decision 2130). + // Read first: the reference picks the token from `slot+0x9c` (`0x49c0c2`) before writing it + // (`0x49bb20`), and the renamed and suspended tokens keep the window's registration. event.slot_state = channels.slot_state(&event.channel); if let Some(byte) = notice { - // The one trace of the server's half of every join and leave β€” a probe log's only way to - // tell "we asked" from "the server agreed" (decision 2144's live runs read it). + // The server's half of every join and leave, for a log to tell a request from its answer. debug!( "chat: channel notice {byte:#04x} for {:?} (slot {:?}, {:?})", event.channel, @@ -698,17 +542,11 @@ pub(super) fn deliver( ); } if notice == Some(channel_notice::YOU_JOINED) { - // The reference's `0x49bbaf`: the confirmed join is what sets the channel's - // `ZONECHANNELS` bit, and it is the ONLY thing that grows that mask at runtime - // (decision 2120). Outside the slot claim below because it is not about slots β€” a - // re-confirmation of a channel we already number still owns the bit. + // A confirmed join sets the `ZONECHANNELS` bit (`0x49bbaf`), its only runtime growth. channels.note_zone_channel_joined(&event.channel); - // …and a custom channel's confirmed join is what enters the durable re-join list the - // chat cache writes (`ChannelState::custom`), the same "server agreed" edge. + // A custom channel's confirmed join enters the chat cache's re-join list. channels.note_custom_channel_joined(&event.channel); - // …and the slot the walk RENAMED is already numbered, so the claim below skips it β€” but - // its state still has to come back to `Joined`, or the next notice on that row reads as - // another rename. A no-op for a channel we hold no slot for, which is the claim's case. + // A slot the walk renamed is numbered already, but its state must return to `Joined`. channels.confirm_slot(&event.channel); } if notice == Some(channel_notice::YOU_JOINED) && channels.number_of(&event.channel).is_none() { @@ -720,8 +558,8 @@ pub(super) fn deliver( ); script.set_joined_channels(channels.names()); } - // The reference's own ceiling, reached: ten slots, all taken. It answers with a chat - // error and no record, so the channel stays unnumbered here too. + // All ten slots taken: no number, as in the reference, whose slot allocator also + // prints `ERR_TOO_MANY_CHAT_CHANNELS` (`0x199` at `0x49b9c5`); here it is only logged. None => warn!( "chat: server confirms channel {:?} joined but all {} slots are taken β€” it has no \ number, so /N cannot reach it", @@ -732,19 +570,16 @@ pub(super) fn deliver( } // The wire name, kept before `stamp_channel` decorates arg4 with the slot number. let leaving = (notice == Some(channel_notice::YOU_LEFT)).then(|| event.channel.clone()); - // A member-line / notice channel renders numbered when we know its slot. + // The channel renders numbered when its slot is known. channels.stamp_channel(event); route(script, windows, event); if let Some(name) = leaving { - // **A suspended slot survives its own leave** (`0x49c0e0`, decision 2130). The reference's - // `0x03` arm jumps past the teardown (`0x49c115`) when the record is in state 3, so walking - // out of a capital keeps `Trade`'s record AND its number β€” which is what lets walking back - // in re-join through the state-3 bypass, and what stops the stock handler deregistering it. + // A suspended slot (state 3, `0x49c0e0`) survives its leave: the reference skips the + // teardown (`0x49c115`), so leaving a capital keeps Trade's number for the re-join. if event.slot_state == Some(super::edit::SlotState::Suspended) { debug!("chat: channel {name:?} suspended β€” the record and its number stay"); } else { - // Cleared in place, never compacted: slot 2 going empty must not make slot 3 into 2 - // ([`ChannelState`], 1286). + // Cleared in place, never compacted: slot 3 stays 3 when slot 2 empties. let freed = channels.free_slot(&name); debug!("chat: server confirms channel {name:?} left (slot {freed:?} now free)"); script.set_joined_channels(channels.names()); @@ -752,12 +587,8 @@ pub(super) fn deliver( } } -/// What the **speaker** does when a line lands β€” the over-the-head bubble and the talk/laugh -/// gesture, bundled because the reference arms both from the one display path (`ChatFrame.cpp`) and -/// because a Bevy system takes at most sixteen parameters, which [`feed_chat`] had already reached. -/// -/// They stay separate mechanisms inside the bundle: the bubble has a 20 yd range test and two CVars -/// ([`crate::chat_bubble`]), the gesture has neither (decision 1469). +/// The speaker's bubble and gesture, bundled for Bevy's sixteen-parameter limit on +/// [`feed_chat`]; the bubble has a 20 yd range test and two CVars, the gesture neither. #[derive(bevy::ecs::system::SystemParam)] pub(super) struct SpeakerEffects<'w> { bubbles: ResMut<'w, crate::chat_bubble::BubbleQueue>, @@ -765,8 +596,7 @@ pub(super) struct SpeakerEffects<'w> { gestures: ResMut<'w, crate::creature_anim::GestureQueue>, } -/// Drain [`ChatLog`]: resolve names (ask-once, bounded), build events, [`route`] them. Also ticks -/// the whisper-chime throttle. +/// Drain [`ChatLog`]: resolve names (ask-once, bounded), build the events and [`route`] them. pub(super) fn feed_chat( script: Option>, mut log: ResMut, @@ -775,24 +605,18 @@ pub(super) fn feed_chat( names: Res, mut speaker: SpeakerEffects, commands: Res, - // The text-emote sentence seam (decision 1274): the tables, plus the guid the composer's - // "are you the performer?" test compares against. + // The text-emote tables, and our guid for the "are you the performer?" test. emote_texts: Option>, self_guid: Res, - // The `$`-macro subject seam: monster/BG lines expand against the guid the line is ADDRESSED to, - // which needs the object index + the streamed unit's descriptors. See [`macro_subject`]. + // The `$`-macro subject: monster and BG lines expand against their addressee's descriptors. guids: Res, stores: Query<&ObjectStore>, states: Res, - // The language gate (B262): the word pool + this character's fluency + the GM bit. + // The language gate: the word pool, this character's fluency and the GM bit. langs: Res, - // The combat log's item-name seam (1703): the four families whose sentence names an ITEM - // (`TRADESKILL_LOG`, `FEEDPET_LOG`, `ITEMENCHANTMENT*`, `SPELLDURABILITYDAMAGE`) resolve it - // here, through the same ask-once cache the reference's own deferred queue re-runs against. + // Item names for `TRADESKILL_LOG`, `FEEDPET_LOG`, `ITEMENCHANTMENT*`, `SPELLDURABILITYDAMAGE`. items: Res, - // The two 1.12 text filters (decision 2077), bundled for the same reason `SpeakerEffects` is: - // this list is at the sixteen-parameter ceiling. This IS the reference's `0x49a870`, so both - // arms belong here and nowhere else. + // The spam and profanity filters, which run in the reference's display function `0x49a870`. mut text_filter: crate::text_filter::ChatTextFilter, ) { let Some(mut script) = script else { @@ -836,25 +660,14 @@ pub(super) fn feed_chat( }); continue; } - // The channel's base name; the numbered display form, its slot number and its - // zone id are stamped on below ([`ChannelState::stamp_channel`]) β€” arg4/arg7-arg9. + // The base name; `stamp_channel` adds arg4's number and arg7-arg9 below. let channel_base = msg.channel.clone().unwrap_or_default(); - // `$`-macro expansion (decision 0754, corrected by 0759): the reference runs its one - // server-text expander over the monster/boss + BG-system types and nothing else, - // against the guid the line is ADDRESSED to. Every other type reaches the frame - // verbatim. - // - // The FAILURE arms are the reference's, and they are not what a panel does: the chat - // path never puts a `$` on screen. VERIFIED `0x49dac2-0x49db1e` (mirrored at - // `0x49d9c9`): expanded β†’ show; failed with a zero subject β†’ **drop the line**; - // failed with the name already known β†’ **drop** (a known name means retrying cannot - // help); failed with the name still unknown β†’ hold the RAW text, let the name query - // run, and re-expand when it answers β€” only a second failure there shows the raw - // text. Our bounded `tries` retry IS that hold, and exhausting it is that second - // failure. + // `$`-macros expand for the monster, boss and BG system types only, against the + // addressee. A failed expansion (`0x49dac2`-`0x49db1e`, `0x49d9c9`) with a zero + // subject or a known name drops the line, one with an unknown name holds it for + // the name query (`tries`), and only a second failure shows the raw text. let expanded = if MACRO_EXPANDED_TYPES.contains(&msg.chat_type) { - // The addressee where the shape carries one, else the only guid it has (the - // `default:`-shaped BG_SYSTEM lines have no target slot at all). + // The addressee, else the sender: the BG system lines carry one guid only. let subject_guid = if msg.target_guid != 0 { msg.target_guid } else { @@ -881,55 +694,34 @@ pub(super) fn feed_chat( "chat: dropping unexpandable [{:#04x}] {:?} (subject {subject_guid:#x})", msg.chat_type, msg.text ); - continue; // the reference drops it β€” no line at all + continue; // the reference prints no line } else if tries < NAME_MAX_TRIES { - // Name query is already in flight (`subject_for_guid` issued it); hold the - // raw line and re-expand when it lands. + // `subject_for_guid` sent the name query: hold the raw line until it lands. still.push(Pending::Wire { msg, tries: tries + 1, }); continue; } else { - None // the post-query second failure: show the raw text verbatim + None // the second failure: show the raw text } } else { None }; let plain = expanded.unwrap_or_else(|| msg.text.clone()); - // **The language gate** (B262). The wire always - // carries plaintext; whether this character can read it is entirely ours to decide, - // and the answer is one rewritten buffer that EVERY consumer below shares β€” the - // chat line, the Lua `arg1`, the bubble, the gesture. That is the reference's own - // shape: `0x49a870` fills `[ebp-0xd0c]` exactly once (either a plain `SStrCopy` at - // `0x49a9f0` or the garble at `0x49aa7c`) and never reads the raw wire pointer - // again, so an addon receiving a foreign-language line cannot recover the - // plaintext. Ours cannot either, deliberately. + // The language gate: the garble writes the one buffer the line, `arg1` and the + // bubble read, as `0x49a870` fills `[ebp-0xd0c]` once (`0x49a9f0` copy, `0x49aa7c` + // garble) and never reads the wire text again. let language = langs.effective_language(msg.chat_type, msg.language); let mut text = langs.garble(language, &plain); - // ── The two text filters, in the reference's own order (decision 2077) ───────── - // - // They run HERE, on the one buffer the garble just filled, because that is where - // `0x49a870` runs them: it fills `[ebp-0xd0c]` once (plain copy or garble) and - // both arms operate on that buffer, so a filtered line is filtered in the language - // the viewer actually reads. - // - // **Spam first, mask second, fire third**, and the order is not cosmetic: a line - // the spam filter drops is never masked, so its characters must not consume mask - // indices β€” the mask phase is a process-global that carries across every message - // and subsystem, and masking before deciding to drop would drift it. + // The text filters, on the garbled buffer as in `0x49a870`: spam first, then the + // mask, so a dropped line consumes no mask indices (the phase is global). if text_filter.should_drop(msg.chat_type, msg.chat_tag, langs.is_gm(), &text) { - // **Nothing is shown in its place.** `0x49ab33`'s not-a-whisper leg jumps to - // `0x49afd7`, the bare epilogue β€” no event, no substitute text, no chat line. - // - // NOT built, and named rather than faked: the whisper leg. A dropped - // `CHAT_MSG_WHISPER` with a non-zero report guid also sends `CMSG_CHAT_FILTERED` - // (`0x331`) before returning β€” still firing no event, so the player sees the - // same nothing either way. The packet's BODY is not settled (`0x508680`'s - // send-path verdict is still open), vmangos has no handler for the - // opcode, and inventing a body to send a server that ignores it would be worse - // than the honest gap. + // Nothing shows in its place: the not-a-whisper leg (`0x49ab33`) jumps to the + // epilogue `0x49afd7`. A whisper with a non-zero report guid also sends + // `CMSG_CHAT_FILTERED` (`0x331`, the `u64` guid, `0x49ab50`) in the reference; + // not sent here, as vmangos leaves the opcode unhandled (`Opcodes.cpp:932`). debug!( "chat: spam filter dropped [{:#04x}] {:?}", msg.chat_type, text @@ -948,10 +740,8 @@ pub(super) fn feed_chat( String::new() } }), - // The EFFECTIVE language, not the wire's: a narration type and GM mode both - // force it to 0, which is one decision in the reference rather than two β€” the - // `[Language]` header keys off this same field, so suppressing the garble - // suppresses the header with it. + // The effective language, not the wire's: narration types and GM mode force + // 0, which also drops the `[Language]` header. language: language_name(language).to_string(), channel: channel_base, flag: flag_of_tag(msg.chat_tag).to_string(), @@ -959,33 +749,15 @@ pub(super) fn feed_chat( }; channels.stamp_channel(&mut event); route(&mut script, &mut windows, &event); - // The speech bubble spawns the moment the line routes β€” the reference's - // `0x49acd9` sits in the same SMSG display path ([`crate::chat_bubble`]). + // The bubble gets the same text: the reference spawns it in this path (`0x49acd9`). if let Some(kind) = event.kind { - // The bubble shows the same expanded line the feed does β€” the reference's - // bubble spawn sits inside this same SMSG display path, downstream of the - // expander, so a `$n` must never survive into it either. speaker .bubbles .push(&speaker.bubble_cfg, msg.sender_guid, kind, &text); } - // …and the speaker gestures. Independent of the bubble in the reference too - // (different function, different gates: the bubble has a 20 yd range test and two - // CVars, the gesture has neither). The selector reads the RAW wire type and the - // expanded text, and takes its laugh words off the player's own FrameXML globals β€” - // the enumeration is the mechanism, the words are content (decision 1469). - // - // **It reads `plain`, NOT the garbled text.** The selector is not on - // this display path at all: it lives in the *parser* `0x49d560` at - // `0x49d820`-`0x49d8ae`, and the slot it matches against β€” `[ebp-0x10]` β€” is the - // very buffer `0x49dbc2` then hands to `0x49a870` as its `src`. The garbled buffer - // is a local of a frame that does not exist yet. - // - // So the gesture is **language-independent**: a Horde player yelling `lol` laughs - // for every observer, Alliance included. We had this wired to the garbled text on - // an inference from the consumer census β€” that census was complete for - // `0x49a870` and could never have found a consumer reading the pre-garble value in - // the *caller's* frame. + // The gesture reads the raw type and `plain`, not the garbled text: the selector is + // in the parser (`0x49d560`, `0x49d820`-`0x49d8ae`) on the buffer `0x49dbc2` hands + // `0x49a870`, so a Horde `lol` laughs for every observer. if let Some(gesture) = crate::creature_anim::select_gesture(msg.chat_type, &plain, |n| { script @@ -1021,17 +793,10 @@ pub(super) fn feed_chat( }); continue; } - // NOT stamped, deliberately β€” and this arm is inconsistent with the other two - // because of it. A guid-tail notice (a join/leave member line, a kick, a - // moderation change) reaches the composer with its channel name UNNUMBERED, so it - // renders "[World] Ann joined channel." where the same channel's speech renders - // "[1. World]". The reference numbers both: `ChatFrame_OnEvent` l.1463 strips only - // the " - Zone" tail from arg4, never the number. - // - // Adding `channels.stamp_channel(&mut event)` here fixes it in one line β€” and - // changes what the player sees, which this pass is not allowed to do. Left for the - // director's call; the cost of leaving it is that arg7/arg8/arg9 are 0/0/empty on - // these events alone. + // Not stamped: a guid-tail notice (a member joining or leaving, a kick, a + // moderation change) has arg7-arg9 empty, so the stock filter + // (`ChatFrame.lua:1374-1392`) finds no channel for it and it never prints; the + // reference stamps it as it stamps speech. if let Some(event) = notice_event( notice, channel, @@ -1085,7 +850,7 @@ pub(super) fn feed_chat( }); continue; } - // RANDOM_ROLL_RESULT = "%s rolls %d (%d-%d)" (GlobalStrings:3290). + // `RANDOM_ROLL_RESULT`, "%s rolls %d (%d-%d)" (`GlobalStrings.lua:3290`). let name = name.unwrap_or_else(|| "Unknown".into()); let line = { use benilla_ui::strings::Arg; @@ -1114,7 +879,7 @@ pub(super) fn feed_chat( bonus, tries, } => { - // `victim == 0` is the UNNAMED form β€” nothing to resolve, straight to the fill. + // `victim == 0` is the unnamed form, with no name to wait for. let name = if victim == 0 { None } else { @@ -1145,7 +910,7 @@ pub(super) fn feed_chat( rank, tries, } => { - // `victim == 0` is the AWARD form β€” no name, no rank title, nothing to wait for. + // `victim == 0` is the award form: no name, no rank title. let name = if victim == 0 { None } else { @@ -1161,16 +926,9 @@ pub(super) fn feed_chat( } resolved }; - // **The side is the VICTIM's and the gender is OURS**, and that asymmetry is the - // reference's own (`0x625270`): the team digit is computed inline over the victim's - // faction template, while the gendered GlobalString resolve runs against the local - // player. It reads like a bug in the real client and it is what the bytes do. The - // victim's side rides the same name-query record we just resolved the name from; - // ours rides our own. - // - // A victim with no record is a creature β€” a racial leader is the only one the - // server ranks, at 19, and "Leader" is the same word on both sides β€” or an - // unanswered guid, so the side digit cannot change the answer there. + // The title's side is the victim's and its gender ours (`0x625270` genders against + // the local player). No name record means side 0: a creature, where only a racial + // leader is ranked (19, "Leader" either side, `HonorMgr.cpp:1075-1076`). let team = names .player_traits(victim) .and_then(|(race, _, _)| crate::ui_unit::race_faction_group(race)) @@ -1201,9 +959,7 @@ pub(super) fn feed_chat( target, tries, } => { - // Both names resolve ask-once: the performer's is the sentence's `%s`, and our own - // is what the "is the target me?" compare needs (`GetOwnName` in the reference, - // which never has to wait for it β€” we can, so we park like every other line). + // Both names ask-once: the performer's, and ours for the "is the target me?" test. let performer_name = names.resolve(performer, &commands).map(str::to_string); let your_name = self_guid .0 @@ -1217,10 +973,8 @@ pub(super) fn feed_chat( }); continue; } - // No name for the performer β‡’ **no line**, not an "Unknown" one: the reference - // bails outright when `NameCache::GetRecord` misses (`0x49b28c`), and that cache - // is player-only. The emote is still an animation and a voice β€” those rode the - // `EmoteMessage` path at decode and do not depend on this. + // No performer name, no line (not "Unknown"): the reference bails when its + // player-only name cache misses (`0x49b28c`); the animation rode `EmoteMessage`. let (Some(performer_name), Some(cat)) = (performer_name, emote_texts.as_deref()) else { debug!("chat: text emote {text_id} from {performer:#x} has no sentence source"); @@ -1232,8 +986,7 @@ pub(super) fn feed_chat( &EmoteLine { performer: &performer_name, performer_is_you: self_guid.0 == Some(performer), - // Sex 1 = Female, from the same `SMSG_NAME_QUERY_RESPONSE` the name came - // from β€” exactly where the reference reads it (`record+0x13c`). + // Sex 1 is female, where the reference reads it (`record+0x13c`). performer_female: names .player_traits(performer) .is_some_and(|(_, _, sex)| sex == 1), @@ -1241,18 +994,12 @@ pub(super) fn feed_chat( your_name: your_name.as_deref().unwrap_or_default(), }, ); - // A dry ladder is a real outcome, not a failure: SIT/STAND/TRAIN ship blank in - // every locale, so a vanilla `/sit` prints nothing. + // No sentence is a real outcome: `/sit` prints nothing. let Some(event) = event else { continue }; route(&mut script, &mut windows, &event); } Pending::Combat(mut line) => { - // Both names, ask-once. The reference's queue holds the record until its - // creature-name DBC row is loaded and replays it then; ours holds it until the - // name query answers, bounded by the same `tries` budget every other pending item - // uses so a guid the server will never name cannot pin the queue. - // Guid 0 means the arm already put the name in the fills (`/chattest`); any other - // guid is asked for, once, and the line waits. + // Both names, ask-once within `tries`; guid 0 is already filled (`/chattest`). let mut wait = false; for (guid, slot) in [(line.subject, 0usize), (line.object, 1usize)] { if guid == 0 { @@ -1269,11 +1016,8 @@ pub(super) fn feed_chat( None => wait = true, } } - // The `Named` slot, on the same terms as the endpoints. A cached NEGATIVE on an - // item (the server does not know the entry) is not something to wait on β€” it would - // pin the line for the whole `tries` budget and then drop it β€” so it composes with - // whatever the arm left in `named`, one level down the reference's own - // `"UKNOWNOBJECT"` degrade. + // An item the server answered unknown does not wait: the line composes with what + // the arm left in `named` (the reference falls back to `"UKNOWNOBJECT"`). match line.named { super::combat::Named::Ready => {} super::combat::Named::Item(entry) => { @@ -1313,7 +1057,7 @@ pub(super) fn feed_chat( } } Pending::Discovery { area, xp } => { - // The toast fires every time; the chat line only rides XP (decisions 0828/0829). + // The toast fires every time; the chat line only rides XP. let (toast, line) = { let get = globals(&script); ( @@ -1342,9 +1086,7 @@ pub(super) fn feed_chat( log.pending = still; } -/// Whether a wire chat type carries a **player guid** whose name must be resolved (vs a monster -/// type with its name inline, or a nameless system line). The player-message families, now -/// including the tagged self-notice types (AFK/DND auto-replies, IGNORED) and the raid/BG set. +/// Whether a wire chat type carries a player guid to name (not a monster's inline name). fn needs_name(chat_type: u8) -> bool { use benilla_protocol::messages as m; matches!( @@ -1369,18 +1111,10 @@ fn needs_name(chat_type: u8) -> bool { ) } -/// **`RequestTimePlayed()` out, `TIME_PLAYED_MSG` back** β€” the two halves of `/played` for an addon. -/// -/// The request is a count-drain ([`benilla_ui::script::UiScript::take_played_time_asks`], the pvp -/// queue's shape): the packet is empty, so two asks are two sends. The answer is a one-slot mailbox -/// the net apply pass fills, and it is delivered as the event rather than a return value, because -/// that is how the API answers β€” `RequestTimePlayed()` itself returns nothing. -/// -/// **This does NOT replace the chat breakdown beside it.** `ui_chat::net::played_time` prints -/// the TIME_PLAYED_TOTAL/LEVEL lines because we do not ship `ChatFrame_DisplayTimePlayed`, which is -/// what the reference's own `TIME_PLAYED_MSG` handler does. The two are the reference's two -/// consumers of one packet, not a doubling: an addon that registers the event does its own thing -/// with the numbers, and the player still sees `/played` answer in chat. +/// `/played` for scripts: each `RequestTimePlayed()` sends one empty `CMSG_PLAYED_TIME`, and the +/// answer fires `TIME_PLAYED_MSG`. The stock `ChatFrame_OnEvent` prints that event as the +/// `TIME_PLAYED_TOTAL` and `TIME_PLAYED_LEVEL` lines (`ChatFrame.lua:1279`), so the English lines +/// `ui_chat::net::played_time` also prints are a duplicate. pub(crate) fn played_time_bridge( script: Option>, commands: Res, diff --git a/crates/benilla-app/src/ui_chat/frames.rs b/crates/benilla-app/src/ui_chat/frames.rs index 8dc37dc2..33bb2716 100644 --- a/crates/benilla-app/src/ui_chat/frames.rs +++ b/crates/benilla-app/src/ui_chat/frames.rs @@ -1,16 +1,7 @@ -//! The window model + router + composer (decision 0288 Β§1): [`ChatWindows`] holds each docked -//! window's message-group registration (the ref client's own chat-cache defaults, quoted from the -//! pin's `WTF/.../chat-cache.txt`); [`route`] fans one [`ChatEvent`] across every subscribed -//! window; [`compose`] is the `ChatFrame_OnEvent` composition law transcribed β€” the `CHAT_*_GET` -//! patterns, the `//` flag prefix, the `|Hplayer:…|h[Name]|h` link (never on EMOTE -//! or monster lines), the `[Language]` header, the `[N. Name]` channel prefix with its " - Zone" -//! tail stripped (the SPEECH branch only β€” a notice prints arg4 whole, 1275), and the -//! `CHAT__NOTICE` channel-notice strings. **Every one of those formats is a KEY, resolved from -//! the player's own `GlobalStrings.lua` at render time** (decision 2045) β€” and resolved the way -//! `ChatFrame_OnEvent` itself does it, by splicing the type into `CHAT__GET` and the notice -//! token into `CHAT__NOTICE` rather than by carrying a table of English. Colors come from -//! the stock window's `ChatTypeInfo` table (1948), whose shipped defaults are transcribed in -//! [`super::event::default_color`]. +//! The chat router: [`route`] fires each [`ChatEvent`] as its `CHAT_MSG_*` event, which the stock +//! `ChatFrame_OnEvent` composes and prints, and tees the composed line to the log files. +//! [`compose`] transcribes that composition for the log, every format resolved by key from the +//! player's `GlobalStrings.lua` as `ChatFrame_OnEvent` resolves it. use bevy::prelude::*; @@ -18,34 +9,18 @@ use benilla_ui::strings::{fill, Arg}; use super::event::{event_name, notice_token, ChatEvent, ChatEventKind}; -/// What the app keeps beside the reference's chat frames: the default language its composer -/// needs for the log-file line, and the log files themselves. The per-window registration that -/// lived here (0288 Β§1) is the record's MESSAGES set now, read by the reference's own -/// `ChatFrame_RegisterForMessages` (decision 1948). +/// What the app keeps beside the stock chat frames: the default language and the log files. #[derive(Resource, Default)] pub(crate) struct ChatWindows { - /// The frame's own `this.defaultLanguage` β€” the name `GetDefaultLanguage()` answers, which is - /// the **faction** tongue (Common for every Alliance race, Orcish for every Horde one). - /// - /// It lives here rather than being derived per line because that is where the reference keeps - /// it: `ChatFrame.lua` stores it on the frame and the language-header test reads it from there - /// ([`compose`]). Empty until the self descriptors and `Languages.dbc` are both up, which - /// suppresses no header the reference would show β€” an empty default only ever makes the test - /// *more* likely to print one. + /// The frame's `this.defaultLanguage`, the faction tongue `GetDefaultLanguage()` answers; + /// empty until the player and `Languages.dbc` are loaded. pub default_language: String, - /// `LoggingChat`/`LoggingCombat`'s files ([`super::logging`]) β€” here because every - /// rendered line passes [`route`], which is the one place to tee them. + /// The `LoggingChat`/`LoggingCombat` files, teed in [`route`]. pub logs: super::logging::ChatLogFiles, } -/// Route one event: fire the real `CHAT_MSG_*` at the VM β€” the reference's own `ChatFrame_OnEvent` -/// composes and prints it, in every window whose MESSAGES set carries the type, with -/// `ChatTypeInfo`'s colour, the whisper chime and the tab flash (decision 1948) β€” and tee the -/// rendered line to the log files. A kind-less event (an unmodeled wire type) drops with a warn, -/// never silently. -/// -/// The composer that used to print here survives for the log line only: `LoggingChat`'s file -/// wants the text the window shows, and the reference writes it C-side, not from Lua. +/// Fire one event's `CHAT_MSG_*` at the VM, where the stock `ChatFrame_OnEvent` prints it, and +/// tee its composed line to the log files, which the reference writes C-side, not from Lua. pub(crate) fn route( script: &mut benilla_ui::script::UiScript, windows: &mut ChatWindows, @@ -55,15 +30,8 @@ pub(crate) fn route( warn!("chat: unroutable event (no kind): {:?}", event.text); return; }; - // The window shows what the reference's `ChatFrame_OnEvent` prints from the event β€” its - // own composition, `ChatTypeInfo`'s colours, the per-window registration - // (`ChatFrame_RegisterForMessages` over the record's MESSAGES set), the tell chime and the - // tab flash. The app's transcription of that composition survives only for the log files, - // which want the rendered line the window will show. let default_language = windows.default_language.clone(); - // The composer's strings are the VM's own β€” `getglobal` against the `GlobalStrings.lua` the - // reference's Lua reads, so the log line and the window line cannot say different things - // (decision 2045). + // The VM's own globals, so the log line matches the window's. if let Some(line) = compose(event, kind, &default_language, &|key| { script.lua().globals().get::(key).ok() }) { @@ -72,8 +40,8 @@ pub(crate) fn route( script.fire_event(event_name(kind), event.script_args()); } -/// `ChatFrame_OnEvent`'s composition, transcribed (ref ChatFrame.lua l.1369-1468 + the quoted -/// GlobalStrings). Returns `None` for a notice the 1.12 UI renders silently (MODE_CHANGE). +/// `ChatFrame_OnEvent`'s composition (`ChatFrame.lua:1369-1468`); `None` for a notice the 1.12 UI +/// does not print (MODE_CHANGE). pub(crate) fn compose( event: &ChatEvent, kind: ChatEventKind, @@ -82,8 +50,7 @@ pub(crate) fn compose( ) -> Option { use ChatEventKind as K; Some(match kind { - // Verbatim families (l.1395-1402): the text IS the line. COMBAT_XP_GAIN rides the same - // default handler tail (client-composed, no sender) β€” ref l.1425's fall-through. + // Verbatim (`ChatFrame.lua:1395-1402`), the XP and honor gains by their `COMBAT_` prefix. K::System | K::TextEmote | K::Skill @@ -94,23 +61,15 @@ pub(crate) fn compose( | K::BgSystemNeutral | K::BgSystemAlliance | K::BgSystemHorde => event.text.clone(), - // The whole combat-log block is verbatim too, and the reference says so by PREFIX rather - // than by name: l.1397-1400 is two arms, `strsub(type,1,7) == "COMBAT_"` and - // `strsub(type,1,6) == "SPELL_"`, each doing nothing but `AddMessage(arg1, …)`. The - // sentence was already built by the time it became an event β€” that is what - // [`super::combat`] is β€” so there is nothing left for the composer to do. + // The combat log is verbatim too, by prefix: `COMBAT_` and `SPELL_` (`:1397-1400`). k if k.is_combat_log() => event.text.clone(), - // `format(TEXT(CHAT_IGNORED), arg2)` (l.1404). **`CHAT_IGNORED`, not - // `ERR_IGNORING_YOU_S`** β€” both read "%s is ignoring you." in enUS and only the key says - // which one the client actually shows here (2045's "assert the identifier, not the - // sentence"); a locale that words them apart is where the difference surfaces. + // `format(TEXT(CHAT_IGNORED), arg2)` (`:1404`): `CHAT_IGNORED`, not the same-worded + // `ERR_IGNORING_YOU_S`. K::Ignored => fill( &get("CHAT_IGNORED").unwrap_or_default(), &[Arg::S(&event.sender)], ), - // `format(CHAT_CHANNEL_LIST_GET .. arg1, arg4)` (l.1409) β€” the member list IS the message - // and the channel fills the hole, with arg4 WHOLE: see [`strip_zone`] for why only the - // speech branch runs the gsub. The reference does not escape arg1 in this arm. + // `format(CHAT_CHANNEL_LIST_GET .. arg1, arg4)` (`:1409`): arg4 whole, arg1 unescaped. K::ChannelList => fill( &format!( "{}{}", @@ -122,11 +81,9 @@ pub(crate) fn compose( K::ChannelNotice | K::ChannelNoticeUser => { return compose_notice(event, kind, get); } - // Everything else is the player/monster-line branch (l.1425-1467). + // Everything else is the player and monster line branch (`:1425-1467`). _ => { - // `pflag = TEXT(getglobal("CHAT_FLAG_"..arg6))` (l.1430) β€” `CHAT_FLAG_AFK`/`_DND`/ - // `_GM`, spliced from the flag the wire sent rather than matched against three - // hardcoded angle-bracket tokens. An empty flag looks up nothing, as it does there. + // `pflag = TEXT(getglobal("CHAT_FLAG_"..arg6))` (`:1431`); an empty flag is "". let pflag = if event.flag.is_empty() { String::new() } else { @@ -140,8 +97,8 @@ pub(crate) fn compose( | K::MonsterWhisper | K::RaidBossEmote ); - // The sender as rendered: hyperlinked `[Name]` for player lines (l.1451), bare for - // monsters + RAID_BOSS_EMOTE (l.1437-1438) and for EMOTE (l.1450's `type ~= "EMOTE"`). + // The sender: a `[Name]` link (`:1451`), bare for monster lines, RAID_BOSS_EMOTE + // (`:1437-1438`) and EMOTE (`:1450`). let named = if event.sender.is_empty() { String::new() } else if monster || kind == K::Emote { @@ -149,34 +106,17 @@ pub(crate) fn compose( } else { format!("{pflag}|Hplayer:{0}|h[{0}]|h", event.sender) }; - // The language header (l.1442-1448): non-empty, non-Universal (mapped to "" by the - // bridge), and not our own default tongue. - // - // **That last clause used to read `!= "Common"`** β€” the comment was already right and - // the code was not, which cost every Horde character a `[Orcish]` tag on ordinary - // faction chat and stripped the tag from Common. The reference's test is - // `arg3 ~= this.defaultLanguage` and `GetDefaultLanguage()` answers the **faction** - // tongue, so it reads Orcish for a Horde body (`0x5ec890`; the tag is FrameXML's and - // its condition is about the *default* language, never about whether the language is - // understood β€” a character who knows both Common and Dwarvish still sees `[Dwarvish]` - // on a line they read perfectly). - // - // The `~= "Universal"` clause of the reference's condition is deliberately absent: - // "Universal" is in neither `Languages.dbc` nor `WoW.exe` nor `GlobalStrings.lua`, so - // it is vestigial in 1.12 and the empty-string test is what actually suppresses - // language 0 (see [`super::event::ChatEvent`]'s arg3 note). + // The language header (`:1442-1448`) shows when arg3 is not `this.defaultLanguage`, + // the faction tongue (`0x5ec890`), understood or not. Its `~= "Universal"` test is + // left out: no 1.12 data names "Universal", and language 0 arrives as "". let header = if !event.language.is_empty() && event.language != default_language { format!("[{}] ", event.language) } else { String::new() }; - // `arg1 = gsub(arg1, "%%", "%%%%")` (l.1436), then - // `format(CHAT__GET .. languageHeader .. arg1, name)` β€” ONE substitution over - // the pattern and the body concatenated, which is why the body has to be escaped - // first: a `%s` a player typed must not eat the name. The monster family and - // RAID_BOSS_EMOTE are the reference's deliberate exception (l.1434-1437): their - // `%s` IS the name's hole (`CHAT_MONSTER_EMOTE_GET = ""`), so the escape is skipped - // and the fill reaches into the text. + // `gsub(arg1, "%%", "%%%%")` (`:1440`) before one `format` over pattern, header and + // text, so a typed `%s` cannot take the name. Monster lines and RAID_BOSS_EMOTE skip + // it (`:1437-1438`): their own `%s` is the name's slot. let text = if monster { event.text.clone() } else { @@ -186,9 +126,7 @@ pub(crate) fn compose( &format!("{}{header}{text}", get_pattern(kind, get)), &[Arg::S(&named)], ); - // The channel prefix (l.1462-1466): arg4 with its " - Zone" tail stripped, - // bracketed. arg4 arrives already numbered ("2. Trade - City") once the channel - // wiring (P6) assigns numbers. + // The channel prefix (`:1462-1466`): arg4, numbered ("2. Trade - City"), zone cut. if !event.channel.is_empty() { format!("[{}] {body}", strip_zone(&event.channel)) } else { @@ -198,14 +136,8 @@ pub(crate) fn compose( }) } -/// The `CHAT__GET` prefix pattern for a kind β€” `getglobal("CHAT_"..type.."_GET")`, the -/// reference's own splice (l.1445-1453), where `type` is the event name minus its `CHAT_MSG_` -/// prefix. So this is a *derivation*, not a table: every kind the client fires has such a key by -/// construction, and the ones with nothing to prefix ship as `""` (the whole combat/spell family, -/// `CHAT_MONSTER_EMOTE_GET`, `CHAT_RAID_BOSS_EMOTE_GET`) rather than being absent. -/// -/// A key the chain has no string for resolves to `""` β€” the same nothing an unprefixed line gets, -/// and the reference's own data-suppression posture. +/// `getglobal("CHAT_"..type.."_GET")` (`ChatFrame.lua:1445-1453`), `type` being the event name +/// after `CHAT_MSG_`; a missing string is `""`. fn get_pattern(kind: ChatEventKind, get: &dyn Fn(&str) -> Option) -> String { let ty = event_name(kind) .strip_prefix("CHAT_MSG_") @@ -213,15 +145,9 @@ fn get_pattern(kind: ChatEventKind, get: &dyn Fn(&str) -> Option) -> Str get(&format!("CHAT_{ty}_GET")).unwrap_or_default() } -/// Strip the zone tail from a channel display name (`gsub(arg4, "%s%-%s.*", "")` β€” -/// "General - Elwynn Forest" β†’ "General", "2. Trade - City" β†’ "2. Trade"). -/// -/// **The speech branch is the ONLY caller, and that is the reference's own shape** (1275): the -/// gsub sits at l.1463, inside the `else` arm that builds a player/monster line, *after* every -/// notice arm has already returned. CHANNEL_NOTICE (l.1424), CHANNEL_NOTICE_USER (l.1416/1418) -/// and CHANNEL_LIST (l.1409) each pass **arg4 whole** into their format β€” so the real client's -/// join line reads "Joined Channel: [1. General - Elwynn Forest]" while a line spoken in that same -/// channel is prefixed "[1. General]". We stripped in all four and lost the tail from three. +/// `gsub(arg4, "%s%-%s.*", "")`: "2. Trade - City" β†’ "2. Trade". Only speech lines strip it +/// (`ChatFrame.lua:1463`); the notice and list arms print arg4 whole, as in +/// "Joined Channel: [1. General - Elwynn Forest]". fn strip_zone(channel: &str) -> &str { match channel.find(" - ") { Some(i) => &channel[..i], @@ -229,22 +155,10 @@ fn strip_zone(channel: &str) -> &str { } } -/// The `SMSG_CHANNEL_NOTIFY` β†’ chat line law: the notice byte becomes a **token** -/// ([`notice_token`], the client's own `0x49c60c` jump table) and the token names the string β€” -/// `getglobal("CHAT_"..arg1.."_NOTICE")`, l.1416/1424. `None` = the 1.12 UI shows nothing for this -/// notice: MODE_CHANGE has no token and no NOTICE string (flag-change chatter is silent), and -/// neither does a byte past the table. -/// -/// **The argument list is the reference's, and its order never varies with the notice.** -/// CHANNEL_NOTICE passes arg4 alone; CHANNEL_NOTICE_USER passes arg4 then arg2, plus arg5 when the -/// packet carried a second name ("X kicked by Y"). One line reads the other way round β€” -/// `CHAT_INVITE_NOTICE = "%2$s has invited you to join the channel '%1$s'."` β€” and it gets there -/// with **positional specifiers in the string**, not with a special case at the call site. That is -/// the whole argument for resolving these by key: hand-typing the English silently hardcodes one -/// locale's word order (decision 2045). -/// -/// `chan` is arg4 **whole**, zone tail and all β€” see [`strip_zone`] for why the notice arms are -/// not the gsub's callers. +/// A channel notice: the notice byte's token ([`notice_token`], the jump table at `0x49c60c`) +/// names `CHAT__NOTICE` (`ChatFrame.lua:1416-1424`), filled with arg4, then for +/// CHANNEL_NOTICE_USER arg2 and any arg5; a string such as `CHAT_INVITE_NOTICE` reorders them with +/// `%2$s`. `None` for MODE_CHANGE and bytes past the table, which print nothing. pub(crate) fn compose_notice( event: &ChatEvent, kind: ChatEventKind, diff --git a/crates/benilla-app/src/ui_chat/idle.rs b/crates/benilla-app/src/ui_chat/idle.rs index 8429b8d7..d34ac27d 100644 --- a/crates/benilla-app/src/ui_chat/idle.rs +++ b/crates/benilla-app/src/ui_chat/idle.rs @@ -1,38 +1,13 @@ -//! **The idle handler** β€” `WorldFrame::Render 0x482ea0`, the one reference function that watches -//! how long it has been since the player last touched anything and does three unrelated-looking -//! things about it. +//! The idle handler, the reference's `WorldFrame::Render 0x482ea0`: from 5 minutes without input +//! it sits the player (`0x482f91`) and marks them AFK (`0x482fba`); from 30 minutes it prints +//! `IDLE_MESSAGE` and requests a logout (`0x5ab000`) instead, with no sit and no AFK. //! -//! One module because it is **one timer**, read once per frame against one global stamp, with a -//! three-way branch hung off it: -//! -//! | idle | what happens | -//! |---|---| -//! | `< 300 000 ms` | nothing at all β€” `0x482ed8 jl 0x482fbf` leaves the whole block | -//! | `300 000 – 1 799 999 ms` | **auto-sit** (`0x482f91 call 0x5ed430(1)`), then **auto-AFK** (`0x482fba call 0x5eb740(NULL)`) | -//! | `>= 1 800 000 ms` | print `IDLE_MESSAGE`, then `0x5ab000` β€” and pointedly **no** sit and **no** AFK | -//! -//! Both thresholds are read off the immediates: `0x482ecd lea ecx,[eax-0x493e0]` is `-300 000`, and -//! `0x482ede add eax,0xffe488c0` is `-1 800 000`. -//! -//! **The 30-minute leg is a CAMP, not a kick.** `0x5ab000` is `ClientServices::SendLogout(flag, -//! force)` β€” the *same* function the game menu's Logout button -//! reaches through `Logout 0x489390`, sending `CMSG_LOGOUT_REQUEST 0x4B`. So the server decides: -//! it refuses outright in combat, logs you out instantly in an inn, and otherwise runs the -//! twenty-second countdown that [`crate::ui_logout`] already narrates as the CAMP dialog. A -//! re-implementation that tore the socket down here would be both unfaithful and much ruder than -//! the thing it was imitating. -//! -//! **Nothing here needs a latch of its own, because every leg is gated on the state it is about to -//! create.** The sit is refused once seated, the auto-AFK is gated on the mirror `[0xb6e5cc] == 0` -//! and sets it, and the logout request is swallowed by the dispatcher's own pending byte -//! `[session+0x1b1d]`. That self-limiting shape is the reference's, and it is why a handler that -//! runs every single frame for twenty-five minutes does not spam the wire. -//! -//! The third of those did not exist here until this landed. [`crate::ui_logout`] knew about -//! `[session+0x1b1d]` in prose and never modelled it, because the only caller was a button a human -//! presses once; a caller that asks sixty times a second is what made the gap load-bearing. It is -//! built where the reference has it β€” in the dispatcher, covering every caller β€” rather than as a -//! private flag here. +//! The logout is a camp, not a kick: `0x5ab000` sends the game menu's `CMSG_LOGOUT_REQUEST`, which +//! the server refuses in combat, grants at once when resting, or counts down for 20 seconds +//! (vmangos `MiscHandler.cpp:284-339`). The reference skips the whole 30-minute leg, line +//! included, while the logout-pending byte `[session+0x1b1d]` is set (`0x482eee`); here +//! [`crate::ui_logout`] holds that byte and drops only the repeated request, so `IDLE_MESSAGE` +//! prints again every frame. use std::time::Duration; @@ -44,91 +19,36 @@ use bevy::time::Real; use super::away::{afk_line, push_system, AfkMirror}; use super::feed::ChatLog; -/// **`[0xcf0bc8]` β€” the last-input millisecond stamp**, the one thing the idle timer subtracts -/// from. -/// -/// Elapsed real time, which is the same quantity both sides of the reference's subtraction come -/// from: the stamp is written by exactly one call (`0x42d7bb`, inside the ring writer `0x42d780`) -/// to the same `0x42c010` the handler reads at `0x482ebe`. `GetMessageTime` is not imported and -/// `MSG.time` is never read β€” the OS message's own clock never enters this. -/// -/// **Process-global, zero-initialised, and never reset** β€” including at world enter, which is the -/// obvious-looking thing to do and is wrong. One cell serves the login screen, character select -/// and the world alike, and it starts at zero, so a client whose first five minutes are untouched -/// is idle by the reference's own reckoning. [`stamp_input`] is ungated for the same reason. +/// `[0xcf0bc8]`, the last-input stamp: elapsed real time on the clock (`0x42c010`) both its one +/// writer (`0x42d7bb`) and the handler (`0x482ebe`) read. Process-global, zero at start and never +/// reset, not even at world entry, so a client untouched from launch is idle at five minutes. #[derive(Resource, Debug, Clone, Copy, Default)] pub(crate) struct LastInput(Duration); impl LastInput { - /// Stamp the clock as an **unattended probe** β€” the one caller outside [`stamp_input`]. - /// - /// A probe is a client with nobody at the keyboard, and this timer is the one place where that - /// is not a harmless difference: a probe that stands still in a battleground goes AFK at five - /// minutes exactly as the reference would, and vmangos then removes an AFK player from the - /// battleground outright (`Player::ToggleAFK` β†’ `LeaveBattleground`). Two faithful behaviours, - /// one on each side, combining to eject an instrument before a match can end β€” which is what - /// `capture::probe_bg`'s first long run found, at t=300 s, with the census reading Stormwind. - /// - /// So a probe that means to stand in for a **present** player says so here, rather than the - /// alternative dodges: turning the idle handler off (it would stop testing the thing the - /// reference does), or having the probe jiggle the mouse (a synthetic input event to fool our - /// own dispatcher, which is a lie told one layer lower down and harder to see). + /// Stamp the clock for an unattended probe standing in for a present player, as vmangos + /// removes an AFK player from a battleground (`Player.cpp:1740-1742`). pub(crate) fn stamp_present(&mut self, now: Duration) { self.0 = now; } } -/// **5 minutes** β€” `0x482ecd lea ecx,[eax-0x493e0]`, `0x493e0` = 300 000 ms. +/// 5 minutes: `0x482ecd lea ecx,[eax-0x493e0]`, `0x493e0` = 300 000 ms. const AUTO_AFK_AFTER: Duration = Duration::from_millis(300_000); -/// **30 minutes** β€” `0x482ede add eax,0xffe488c0`, i.e. `eax - 1 800 000`. -/// -/// **Absolute, not five-plus-thirty.** The subtraction above it writes `ecx`, so `eax` still holds -/// the raw elapsed when this one runs. Reading the pair as two chained deductions gives 35 minutes -/// and is the natural misreading of the two-instruction sequence. +/// 30 minutes from the last input, not 5 + 30: `0x482ede add eax,0xffe488c0` subtracts from the +/// raw elapsed, since the 5-minute subtraction wrote `ecx`. const AUTO_LOGOUT_AFTER: Duration = Duration::from_millis(1_800_000); -/// Stamp the clock β€” the **seven** writers of `[0xcf0bc8]`, all registered on the one input ring -/// at priority `1.0f` (`0x765c04`–`0x765cfe` via `0x41fca0`), all storing the same ring field -/// `payload[3]` (a closed image-wide census of the dword: 37 occurrences, 7 W / 30 R). +/// Stamp the clock on raw input, as the reference's seven writers of `[0xcf0bc8]` do: key down, +/// first press only (`0x765f34`), key up (`0x765fec`), button down (`0x7662fb`) and up +/// (`0x766461`), motion (`0x766152`, `0x766291`, mouselook included) and the wheel (`0x76651f`). +/// Holding a key is idle: auto-repeat is category 0xa (`0x424810`), whose handler `0x766070` has +/// no store, so running with W held reaches the auto-AFK. A zero move does not stamp (`0x7660d0`). +/// A horizontal wheel stamps here; the reference never translates `WM_MOUSEHWHEEL`. /// -/// | writer | bus category | what the player did | -/// |---|---|---| -/// | `0x765f34` | 8 | a key went **down** β€” first press only | -/// | `0x765fec` | 9 | a key came **up** | -/// | `0x7662fb` | 0xb | a mouse button went **down** | -/// | `0x766152` | 0xc | the mouse **moved** (absolute, filtered) | -/// | `0x766291` | 0xd | the mouse **moved** (delta) | -/// | `0x766461` | 0xe | a mouse button came **up** | -/// | `0x76651f` | 0x10 | the **wheel** turned | -/// -/// **The keyboard and the mouse follow opposite rules, and there is no single sentence covering -/// both.** A held key stamps once; a moved mouse stamps continuously. What does *not* stamp: -/// -/// - **Auto-repeat.** `0x424810` promotes category 8 to 0xa at `0x4248b3` when the key is already -/// held, and `0x766070` (category 0xa) has no store. So **holding a key is idle** β€” running with -/// W held, or on autorun, reaches the five-minute auto-AFK. This is the single most surprising -/// thing the reference does and it is deliberate here, not an omission. -/// - Zero-motion moves and the pump's synthetic hover re-pick (`0x7660d0`'s `0x766122`–`0x766140` -/// filter, and its `0x766120` bypass). -/// - Double-clicks, `WM_MOUSEHWHEEL`, `WM_CHAR`, the joystick, and any VK outside `0x42d800`'s -/// table. -/// - Mouse **movement and left-button-up during an OS window drag** β€” a title-bar drag or border -/// resize, mirrored from `SetCapture`/`ReleaseCapture` in `[0x884e64]`. We inherit that for free: -/// an OS-driven window drag delivers no motion to the app at all. -/// -/// **Mouselook is NOT an exclusion** β€” it is `[0x884e5c]`, a different global entirely, and all it -/// picks is which arm enqueues (absolute β†’ category 0xc, delta β†’ category 0xd, both stamping). A -/// player turning the camera with the right button held is *not* idle. This module briefly recorded -/// the opposite, off a mislabelled global; `[0x884e64]`'s writers are `SetCapture`'s. -/// -/// **The store sits on the raw input bus, ahead of dispatch**, so a keystroke a frame *consumes* -/// still stamps: typing into the chat edit box is input, and so is a click that only moves a -/// window. That is why this reads the raw messages rather than [`crate::bindings::BindingsState`], -/// which is the post-dispatch view and would let a player who is busy typing go AFK mid-sentence. -/// -/// Runs **outside** the in-world gate the handler itself carries, because the stamp is -/// process-global: character select, the login screen and the world all write the one cell. +/// The store sits ahead of dispatch, so a keystroke the chat edit box consumes still stamps: this +/// reads the raw messages, not [`crate::bindings::BindingsState`], outside the in-world gate. pub(crate) fn stamp_input( time: Res>, mut last: ResMut, @@ -137,12 +57,8 @@ pub(crate) fn stamp_input( mut wheel: MessageReader, motion: Res, ) { - // `count()` rather than `next().is_some()`, and bitwise `|` rather than `||`: both are about - // draining. A reader that stops at the first message leaves the rest on its cursor and re-sees - // them next frame, and a short-circuited `||` never touches the later queues at all β€” either - // one keeps the clock warm for frames after the input actually stopped. - // `repeat` is category 0xa, which has no store β€” a held key stops counting after its first - // press. (`Released` never carries the flag, so this one filter covers both edges.) + // `count()` and a bitwise `|` drain every reader, or unread messages warm the clock next frame. + // `repeat` is category 0xa, which has no store; `Released` never carries the flag. let any = (keys.read().filter(|k| !k.repeat).count() != 0) | (buttons.read().count() != 0) | (wheel.read().count() != 0) @@ -152,69 +68,41 @@ pub(crate) fn stamp_input( } } -/// **What the timer decides this frame** β€” a value rather than three writes, for the reason -/// [`super::away::AwayOutcome`] is one: the whole content of `0x482ea0`'s block is which legs move -/// together and which are mutually exclusive, and that is exactly what a re-implementer gets wrong -/// by treating "idle" as a single state with three effects. +/// What the timer decides this frame, one flag per leg. #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] pub(crate) struct IdleAction { /// `0x482f91 call 0x5ed430(1)`. pub(crate) sit: bool, /// `0x482fba call 0x5eb740(NULL)`. pub(crate) afk: bool, - /// `0x482f1a` + `0x5ab000` β€” `IDLE_MESSAGE` and the logout request. + /// `0x482f1a` and `0x5ab000`: `IDLE_MESSAGE` and the logout request. pub(crate) camp: bool, } -/// The state the two five-minute legs are gated on. Read off our own descriptor; each field is one -/// test in the reference's block, and no two of them share a gate. +/// The gates of the five-minute legs, read off our descriptor; no two legs share one. #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] pub(crate) struct IdleGates { - /// `UNIT_FIELD_BYTES_1` byte 0. The sit wants `0`. + /// `UNIT_FIELD_BYTES_1` byte 0; the sit wants `0`. pub(crate) stand_state: u8, - /// `UNIT_FIELD_FLAGS` bit 19 (`0x80000`) β€” the sit's. + /// `UNIT_FIELD_FLAGS` bit 19 (`0x80000`), the sit's. pub(crate) in_combat: bool, - /// `UNIT_FIELD_MOUNTDISPLAYID > 0` β€” the sit's. + /// `UNIT_FIELD_MOUNTDISPLAYID > 0`, the sit's. pub(crate) mounted: bool, - /// The sit's fourth gate, `0x482f84 call 0x6103a0` / `jne` past the setter β€” which requires the - /// predicate **false**, and for a solo player reduces to `[0xc4d888] == 0xc`, its normal - /// in-world value. - /// - /// **Exactly one thing benilla has puts it elsewhere.** The global's writer census is closed β€” - /// 25 dword occurrences, 4 writes, three of them the literal `0xc` β€” so every non-`0xc` value - /// comes through the one `StartMoveAction 0x611130`, whose eleven kinds are the click-to-move - /// family (4, 8), the move-to-interact family (5, 6, 7, 9, 0xa) and **`3` = `/follow`**. Mode 3 - /// is co-extensive with follow being active, proven twice over: `0x489e00` is `FollowUnit`, and - /// `AUTOFOLLOW_BEGIN`/`AUTOFOLLOW_END` (367/368) have exactly one fire site each, the second - /// gated on `[0xc4d888] == 3`. - /// - /// **Autorun never touches the global** β€” no writer lies in the movement-command band at all; - /// the only traffic runs the other way (`0x5146d6` *calls the cancel*). So this gate is not a - /// stand-in for "moving", and it is not redundant with `stand_state_refused`: a follow whose - /// target has stopped walking is motionless, passes every movement test, and is still the one - /// player the reference will not seat. + /// The sit's fourth gate (`0x482f84 call 0x6103a0`): for a solo player, the movement-action + /// mode `[0xc4d888]` at its in-world `0xc`. `/follow` sets 3 (`FollowUnit 0x489e00`) and + /// autorun never moves it, so a follower whose target has stopped is not seated. pub(crate) following: bool, - /// `UNIT_FIELD_FLAGS` bit 20 (`0x100000`) β€” the **AFK's**, not the sit's. + /// `UNIT_FIELD_FLAGS` bit 20 (`0x100000`), the AFK's, not the sit's. pub(crate) on_taxi: bool, - /// The optimistic mirror `[0xb6e5cc]` β€” the AFK's second gate, and the anti-repeat. + /// The optimistic mirror `[0xb6e5cc]`: the AFK's second gate, and its anti-repeat. pub(crate) afk: bool, } -/// Resolve the timer against the gates β€” `0x482ea0`'s block, byte for byte. -/// -/// **The two bands are exclusive, and that is the easy thing to get wrong.** `0x482ee5 jl -/// 0x482f39` is what jumps *into* the 5–30 minute block, so reaching thirty minutes takes the -/// other arm entirely: the camp leg neither sits you nor marks you AFK. A client that let the -/// thirty-minute case fall through the five-minute one would sit a player down on the same frame -/// it asked to log them out. -/// -/// **Nothing latches.** Every leg is gated on the state it is about to create β€” the sit on -/// `stand_state == 0`, the AFK on the mirror it sets, and the camp on the logout request's own -/// pending latch downstream β€” so a handler that runs every frame for twenty-five minutes emits -/// exactly one of each. +/// Resolve the timer against the gates. The bands are exclusive (`0x482ee5 jl 0x482f39`): the +/// thirty-minute leg neither sits nor marks AFK. pub(crate) fn idle_action(idle: Duration, gates: IdleGates) -> IdleAction { if idle < AUTO_AFK_AFTER { - // `0x482ed8 jl 0x482fbf` β€” the whole block, all three legs, sits below this. + // `0x482ed8 jl 0x482fbf`: all three legs sit below this. return IdleAction::default(); } if idle >= AUTO_LOGOUT_AFTER { @@ -230,8 +118,7 @@ pub(crate) fn idle_action(idle: Duration, gates: IdleGates) -> IdleAction { } } -/// The handler proper. In-world only: `0x482ea0` is `WorldFrame::Render`, so there is no idle -/// timer at the glue screens. +/// The handler, in-world only: `0x482ea0` is `WorldFrame::Render`. pub(crate) fn idle_handler( time: Res>, last: Res, @@ -263,9 +150,8 @@ pub(crate) fn idle_handler( }); let action = match gates { Some(g) => idle_action(idle, g), - // No body streamed yet is not "every gate clear": the reference resolves the local player - // (`0x468550` β†’ `0x468460`) inside the block, so the sit and the AFK have nothing to read. - // The camp leg never touches the player object, and survives. + // No body yet: the reference resolves the local player inside the block (`0x468550`, + // `0x468460`), so the sit and the AFK have nothing to read; the camp leg never reads it. None => IdleAction { camp: idle >= AUTO_LOGOUT_AFTER, ..IdleAction::default() @@ -273,20 +159,15 @@ pub(crate) fn idle_handler( }; if action.sit { - // Through the ONE setter, like the reference's own call: `player::posture` runs - // `stand_state_refused` on the way (so an idle *swimmer* is not seated, bug B155) and - // drops a request equal to the state it already holds, consulting its own `stand_pending` - // rather than the descriptor β€” which is what makes re-asking every frame across the server - // round trip produce exactly one `CMSG_STANDSTATECHANGE`. + // Through the one setter, like the reference: `player::posture` refuses a swimmer and + // drops a request equal to its pending state, so one `CMSG_STANDSTATECHANGE` goes out. stand.write(crate::player::StandStateRequest { state: 1 }); } let strings = |key: &str| crate::ui_chat::combat::global_string(&script, key); if action.afk { - // `SetAFK(NULL)` is `/afk` with an empty message and the mirror clear β€” the one row of the - // `/afk` truth table this can reach, which is why it goes through the same function rather - // than composing its own line: same `MARKED_AFK_MESSAGE % DEFAULT_AFK_MESSAGE` echo, the - // same client-side default substitution reaching the wire, the same optimistic mirror. + // `SetAFK(NULL)` is `/afk` with an empty message and a clear mirror, so it shares that + // path: the `DEFAULT_AFK_MESSAGE` echo and body, and the optimistic mirror. let out = afk_line("", *mirror, &strings); if let Some(line) = out.line { push_system(&mut chat, line); @@ -304,8 +185,7 @@ pub(crate) fn idle_handler( if let Some(line) = strings("IDLE_MESSAGE") { push_system(&mut chat, line); } - // `0x5ab000` with the game menu's own arguments: `CMSG_LOGOUT_REQUEST`, the server's - // clock, the CAMP dialog. `crate::ui_logout` owns everything from here. + // `0x5ab000` with the game menu's arguments; `crate::ui_logout` owns the rest. script.queue_session_request(benilla_ui::script::SessionRequest::Logout); } } @@ -326,14 +206,12 @@ mod tests { const FIVE_MIN: Duration = Duration::from_millis(300_000); const THIRTY_MIN: Duration = Duration::from_millis(1_800_000); - /// A body that is standing, out of combat, unmounted, off a taxi and not yet AFK β€” every gate - /// open, so a test that flips one field is testing exactly that field. + /// Every gate open, so a test that flips one field tests that field. fn open() -> IdleGates { IdleGates::default() } - /// **The two thresholds, at the millisecond.** `0x493e0` = 300 000 and `0xffe488c0` = - /// βˆ’1 800 000, both read off the immediates rather than off "5" and "30". + /// `0x493e0` = 300 000 and `0xffe488c0` = -1 800 000, read off the immediates. #[test] fn the_thresholds_are_the_immediates_not_round_numbers() { let one_ms = Duration::from_millis(1); @@ -369,9 +247,6 @@ mod tests { ); } - /// **The bands are exclusive** β€” the thirty-minute leg neither sits nor marks, because - /// `0x482ee5 jl 0x482f39` is what jumps *into* the five-minute block. A fall-through would - /// seat a player on the frame it asked to log them out. #[test] fn the_camp_leg_does_not_also_sit_or_mark_afk() { for idle in [THIRTY_MIN, THIRTY_MIN + Duration::from_secs(3600)] { @@ -386,9 +261,6 @@ mod tests { } } - /// **Each gate refuses its own leg and only its own.** The sit's three and the AFK's two are - /// different tests at different addresses on different fields; the one thing a re-implementer - /// is tempted to do is fold them into a single "can I go idle" predicate. #[test] fn the_gates_are_per_leg_never_shared() { let cases: [(&str, IdleGates, IdleAction); 7] = [ @@ -484,8 +356,7 @@ mod tests { } } - /// The steady state is what makes the missing latch safe: run the decision a thousand times - /// with the state each leg creates fed back in, and exactly one sit and one mark come out. + /// A thousand frames with each leg's state fed back in: exactly one sit and one mark. #[test] fn the_handler_is_self_limiting_without_any_latch() { let mut gates = open(); @@ -504,10 +375,6 @@ mod tests { assert_eq!((sits, marks), (1, 1)); } - /// **The seven writers stamp; auto-repeat does not.** The reference's store sits on the raw - /// message bus ahead of dispatch, so this reads the raw Bevy messages rather than - /// [`crate::bindings::BindingsState`] β€” a keystroke the chat edit box swallows is still a - /// keystroke, and a player mid-sentence must not be marked away. #[test] fn every_writer_stamps_and_auto_repeat_pointedly_does_not() { use bevy::input::keyboard::{Key, KeyboardInput}; @@ -599,11 +466,7 @@ mod tests { "and bare mouse movement, with no button held" ); - // **The one that is not an omission.** `0x424810` promotes a held key from bus category 8 - // to 0xa, and `0x766070` β€” category 0xa's handler β€” has no store. So running with W held, - // or sitting on autorun, walks straight into the five-minute auto-AFK. It is the single - // most surprising thing the reference does, and a re-implementation - // that "fixed" it would diverge from the reference in a way a player would notice. + // Auto-repeat is category 0xa, whose handler `0x766070` has no store. assert_eq!( stamped(&|app| { app.world_mut().write_message(KeyboardInput { @@ -660,10 +523,7 @@ mod tests { app.world().resource::().pending_lines() } - /// **The whole five-minute arm, through the real systems.** The sit is a - /// [`crate::player::StandStateRequest`] (so it inherits `stand_state_refused`), the mark is the - /// `/afk` line and the `/afk` packet, and the mirror leads the descriptor exactly as a typed - /// `/afk` would. + /// The sit is a [`crate::player::StandStateRequest`]; the mark, the `/afk` line and packet. #[test] fn five_minutes_idle_sits_you_down_and_marks_you_afk() { let (mut app, rx) = world(300_000, &[]); @@ -671,8 +531,7 @@ mod tests { assert_eq!(lines(&app), vec!["You are now AFK: Away from Keyboard"]); assert!(app.world().resource::().is_afk()); - // The CLIENT's default substitution reaches the wire, never an empty body β€” the server - // stores this string as the character's auto-reply. + // The client's default reaches the wire, never an empty body: it is the auto-reply. let sent = rx.try_iter().collect::>(); assert!( matches!( @@ -691,7 +550,6 @@ mod tests { assert_eq!(sat, vec![1], "one sit, through the one setter"); } - /// Four minutes fifty-nine is not idle. Nothing at all β€” no line, no packet, no posture. #[test] fn just_under_five_minutes_is_completely_silent() { let (mut app, rx) = world(299_999, &[]); @@ -701,10 +559,7 @@ mod tests { assert!(rx.try_iter().next().is_none()); } - /// **The thirty-minute arm is a CAMP, and it is not an AFK.** `0x5ab000` is - /// `ClientServices::SendLogout`, the same function the game menu's Logout button reaches, so - /// what comes out here is a [`benilla_ui::script::SessionRequest::Logout`] and nothing else: - /// no `/afk` line, no `/afk` packet, no sit. + /// `0x5ab000` is the game menu's Logout: a session request and nothing else. #[test] fn thirty_minutes_idle_camps_instead_of_marking_afk() { let (mut app, rx) = world(1_800_000, &[]); @@ -740,8 +595,7 @@ mod tests { .is_none()); } - /// The descriptor is what the gates read β€” a body in combat is not seated, and one on a taxi - /// is not marked. Driven through the real system so the field decode is under test too. + /// Through the real system, so the descriptor decode is under test too. #[test] fn the_descriptor_bits_reach_the_gates() { // In combat (`UNIT_FIELD_FLAGS` bit 19): marked, not seated. diff --git a/crates/benilla-app/src/ui_chat/input/mod.rs b/crates/benilla-app/src/ui_chat/input/mod.rs index cf32c966..34da3a70 100644 --- a/crates/benilla-app/src/ui_chat/input/mod.rs +++ b/crates/benilla-app/src/ui_chat/input/mod.rs @@ -1,14 +1,8 @@ -//! The **submitted-line** side of the chat (decision 0288 P5) β€” what we DO about a line. -//! [`drain_chat_input`] routes each Entered line: a plain line sends as the box's CURRENT type (the -//! edit machine's law, [`super::edit`]); a `/`-line runs the Enter-path type switch, the reply arm, -//! then the action table. What a line *means* is [`parse`]'s ([`parse_line`] β†’ [`ParsedChat`]); -//! which strings reach which arm is [`super::commands`]'s table. -//! -//! The emote arm is the client's `DoEmote` (`0x5ef560`) in full: the eligibility gate -//! ([`emote_send_eligible`]), the asleep gate, the stow, the **posture** branch that makes `/sit` -//! sit, then `CMSG_TEXT_EMOTE`. Opening keys + live parsing live in [`super::edit`]; the inbound -//! half is [`super::feed`]. Own sends are never echoed locally β€” the wire echoes back (vanilla -//! behavior). +//! The submitted-line side of the chat: [`drain_chat_input`] executes what [`parse`] resolved and +//! the verbs the stock `ChatFrame.lua` handlers queue in the VM; the addon drains send +//! `SendChatMessage` and `SendAddonMessage`. The emote arm is `DoEmote` (`0x5ef560`): the +//! eligibility gate, the asleep gate, the stow, the posture, then `CMSG_TEXT_EMOTE`. Own sends are +//! never echoed locally; the server echoes them. use bevy::prelude::*; @@ -21,9 +15,8 @@ use crate::creature_anim::{move_flags, MovementState}; use crate::net::{ClientCommand, NetCommands, SelfPlayer}; use crate::target::Selection; -/// The target half of the ref's `GetSlashCmdTarget` (ChatFrame.lua:650-658): a bare party -/// command falls back to the current selection iff it's a PLAYER; anything else is `None` (the -/// ref's silent no-op). The name is cache-resolved β€” a streamed player target is always cached. +/// The target half of `GetSlashCmdTarget` (`ChatFrame.lua:650-658`): a bare command falls back +/// to the selection only when it is a player. A streamed player's name is always cached. fn target_player_name( selection: &Selection, names: &crate::names::NameCache, @@ -36,29 +29,9 @@ fn target_player_name( names.resolve(guid, commands).map(str::to_string) } -/// The target guid a `CMSG_TEXT_EMOTE` carries β€” the current selection, **except that emoting at -/// yourself goes out untargeted** (decision 1282). -/// -/// That exception is `DoEmote`'s last act before it builds the packet, VERIFIED at `0x5ef611`: -/// -/// ```text -/// 5ef611: mov eax,[edi+8] ; &ownGuid -/// 5ef614: mov ecx,[ebp+0xc] ; the target guid low ... cmp / jne past -/// 5ef61e: cmp edx,[eax+4] ; ... and high ... cmp / jne past -/// 5ef623: mov [ebp+0xc],ebx ; ebx = 0 (xor at 0x5ef56b, never reloaded) -/// 5ef626: mov [ebp+0x10],ebx ; -> the packet's target guid is ZERO -/// ``` -/// -/// **This is why vanilla has no self-emote sentence.** 1274 read the receive-side composer -/// correctly and then asserted an outcome β€” "You wave at ⟨YourName⟩." β€” from an input this gate -/// makes unreachable: the server never echoes your own name back as the target, so the untargeted -/// column is what you *and* everyone in range read ("You wave." / "Sam waves."). Without the gate -/// we would emit "Sam waves at Sam." to the whole zone. -/// -/// Compared by **entity** rather than guid, which is the same predicate and costs no 17th system -/// param: `SelfPlayer` marks the entity whose guid *is* `SelfGuid` (`net.rs`), and [`Selection`]'s -/// only writer (`target::scan`) sets `target` and `guid` together from one streamed entity β€” so -/// "the selection is my entity" and "the selection is my guid" cannot disagree. +/// The target guid a `CMSG_TEXT_EMOTE` carries: the selection, except that an emote at yourself +/// goes out untargeted (`0x5ef611`), so 1.12 has no self-emote sentence. Compared by entity, which +/// agrees with the guid: [`Selection`]'s one writer sets both from one streamed entity. pub(super) fn emote_target(selection: &Selection, me: Option) -> u64 { match selection.guid { Some(guid) if !(me.is_some() && selection.target == me) => guid, @@ -66,18 +39,8 @@ pub(super) fn emote_target(selection: &Selection, me: Option) -> u64 { } } -/// The client-local diagnostics' inputs, as one [`SystemParam`] β€” [`drain_chat_input`] is at the -/// 16-parameter ceiling, and a named struct beats a nested tuple nobody can read. -/// -/// - `camera`/`clock` feed **`/shot`**, the framing instrument (decision 0600). -/// - `world` feeds **`/liquid`**, the swim diagnostic (decision 0634 follow-up): the interior -/// claim is what decides which liquid surfaces the swim query may see, and it arrives with the -/// query rather than beside it. -/// - `stores`/`self_store`/`factions`/`reputations`/`kinds` feed **`/reaction`**, the -/// attackability diagnostic (decision 0637): the exact inputs [`crate::target::ring_reaction`] -/// judges on, so "why is this unit not attackable" is one command instead of a guess β€” and -/// with them the V-plate CATEGORY, which turns on a different predicate entirely -/// ([`crate::target::ring::plate_is_friendly`]) and so cannot be read off the rank. +/// The inputs of `/shot`, `/liquid` and `/reaction`, bundled because [`drain_chat_input`] is at +/// Bevy's 16-parameter ceiling. #[derive(bevy::ecs::system::SystemParam)] pub(super) struct ChatProbes<'w, 's> { camera: Query< @@ -87,70 +50,46 @@ pub(super) struct ChatProbes<'w, 's> { (With, Without), >, clock: Option>, - /// The `/liquid` instrument's whole world side β€” the claims, the verdict, the candidates. world: benilla_world::world_point::WorldPoint<'w, 's>, stores: Query<'w, 's, &'static crate::net::ObjectStore>, self_store: Query<'w, 's, &'static crate::net::ObjectStore, With>, factions: Option>, reputations: Res<'w, crate::net::Reputations>, - /// `/reaction `'s resolve β€” so a scripted probe can ask about a player it has not - /// clicked (the two-client duel run has no way to select the other side). + /// `/reaction `'s resolve, for a player a scripted probe cannot click. guids: Res<'w, crate::net::GuidIndex>, - /// The subject's [`benilla_protocol::EntityKind`] β€” the plate gate's own player test, so the - /// diagnostic reports the branch the gate actually took rather than a second guess at it. kinds: Query<'w, 's, &'static crate::net::NetEntity>, - /// **`/partytest raid`** (decision 1549): the synthetic raid seats US as its leader, and - /// "leader" on this wire is a guid. Here rather than as a 17th drain parameter for the - /// reason this struct exists at all β€” the drain is at Bevy's 16-param ceiling. + /// `/partytest raid` seats us as the leader, and the wire names a leader by guid. self_guid: Res<'w, crate::net::SelfGuid>, } -/// Everything the drain **queues into another subsystem's one setter** rather than applying itself, -/// bundled (the drain is at Bevy's 16-param ceiling). -/// -/// - `stand`/`sheath` β€” the two setters `DoEmote` drives besides the packet: the **posture** -/// (`EmoteSpecProc == 1` β†’ `SetStandState`) and the **stow** (`SetSheatheState(0, SNAP)`, -/// unconditional on every emote that passes the gates β€” site `0x5ef630`). -/// - `target`/`assist` β€” the by-name selection asks (decision 0886), answered by -/// [`crate::target`]'s shared resolver so they commit through the same SetSelection path a click -/// does. Chat never writes [`Selection`] itself. +/// What the drain hands to another subsystem's setter, bundled for the same ceiling. Chat never +/// writes [`Selection`]: [`crate::target`]'s resolver commits through the click's path. #[derive(bevy::ecs::system::SystemParam)] pub(super) struct ChatOut<'w, 's> { - /// The console registry's lane: a `/console` line runs against the world at the next sync - /// point ([`crate::console::execute`]). + /// A `/console` line runs against the world at the next sync point. console: Commands<'w, 's>, stand: MessageWriter<'w, crate::player::StandStateRequest>, sheath: MessageWriter<'w, crate::creature_anim::SheathRequest>, target: MessageWriter<'w, crate::target::TargetByNameRequest>, assist: MessageWriter<'w, crate::target::AssistRequest>, follow: MessageWriter<'w, crate::player::FollowRequest>, - /// **`/partytest ping`** (decision 1596) β€” the minimap ping's setter. The LOCAL leg needs no - /// instrument (click the map), but a *group member's* ping otherwise needs a second client - /// logged in and standing somewhere else; this seats one as if Alice had sent it, through the - /// same `seat` the wire arm calls, so the remote leg β€” the `partyN` token, the marker, the - /// pin holding while you walk β€” is exercisable solo. + /// `/partytest ping` seats a group member's ping through the wire arm's `seat`. ping: ResMut<'w, crate::minimap::MinimapPing>, - /// The red UIErrorsFrame line by GlobalStrings key β€” the emote-while-moving refusal is its one - /// tenant here (decision 1904). It rides this bundle rather than the system's own parameter - /// list because that list is at Bevy's arity limit; checked first against every other param, - /// since a resource reachable twice from one system is a `B0002` panic on the first live frame - /// (1903). Nothing else in this system touches `UiErrorKeys`. + /// The red error line by GlobalStrings key. The system's only `UiErrorKeys` access: a second + /// one panics on the first frame. ui_errors: ResMut<'w, crate::ui_action::UiErrorKeys>, } -// One parameter per concern β€” the chat drain fans out to every command's consumer. -/// The chat verbs the stock `ChatFrame.lua` slash handlers call once *they* have parsed the -/// line β€” `DoEmote`, `RandomRoll`, `UninviteByName`, `ConsoleExec`, the channel verbs β€” drained -/// from the VM into the same [`ParsedChat`] values our own parser produces, so one executor -/// below serves both the reference's Lua and the lines it never sees. +/// The verbs the stock `ChatFrame.lua` handlers call once they have parsed a line (`DoEmote`, +/// `RandomRoll`, `UninviteByName`, `ConsoleExec`, the channel verbs), drained from the VM as +/// [`ParsedChat`] values, so one executor serves them and the lines the Lua never sees. fn engine_verbs( script: &mut benilla_ui::script::UiScript, emotes: Option<&crate::sound::EmoteSounds>, ) -> Vec<(String, ParsedChat)> { let mut out = Vec::new(); for e in script.take_emote_requests() { - // The token is the `EmotesText.dbc` NAME (`EMOTE_TOKEN`, "WAVE"), which is how the - // slash table resolved `/wave` before the Lua did the resolving. + // The token is the `EmotesText.dbc` name (`EMOTE_TOKEN`, "WAVE"). match emotes.and_then(|c| c.text_id(&e.token)) { Some(id) => out.push((String::new(), ParsedChat::TextEmote(id))), None => warn!( @@ -166,10 +105,9 @@ fn engine_verbs( out.push((String::new(), ParsedChat::Uninvite { name: Some(name) })); } for line in script.take_console_lines() { - // `ConsoleExec` already wrote the valued CVar lines to the store; what reaches here is - // a console COMMAND β€” a name the engine's own command table owns rather than - // `CVar::Register`'s (`detailDoodadAlpha`: registrar `0x63f9e0`, so it never persists β€” - // 2012) β€” or a bare CVar name for the registry to print (2303). + // `ConsoleExec` already applied a valued CVar line; what reaches here is a console + // command (`detailDoodadAlpha`, registered by `0x63f9e0`, never persists) or a bare CVar + // name to print. out.push((line.clone(), ParsedChat::Console { line })); } for cmd in script.take_channel_commands() { @@ -178,12 +116,9 @@ fn engine_verbs( out } -/// **A manual join or leave of `GuildRecruitment` turns the auto-join option off** β€” the -/// reference's `0x49ed3d` (join) and `0x49ef8f` (leave), each `call 0x49ea70(0)` gated on the -/// matched `ChatChannels.dbc` row carrying `flags & 0x20000` and on the caller's own flag, which -/// the Lua bindings pass and the cascade's internal calls do not (decision 2144). The player has -/// taken manual control, and an option left checked would silently re-join or re-leave behind -/// them. +/// A manual join or leave of `GuildRecruitment` turns its auto-join option off: `0x49ed3d` (join) +/// and `0x49ef8f` (leave) call `0x49ea70(0)` when the `ChatChannels.dbc` row has `flags & 0x20000` +/// and the caller passes its flag, which the Lua bindings do and the cascade's own calls do not. fn manual_join_or_leave( channels: &super::edit::ChannelState, script: &mut benilla_ui::script::UiScript, @@ -201,25 +136,19 @@ fn manual_join_or_leave( pub(super) fn drain_chat_input( script: Option>, mut chat_log: ResMut, - // Mutable for one reason: an EXPLICIT leave clears the channel's `ZONECHANNELS` bit - // (decision 2120, the reference's `0x49f10a` inside leave-by-name `0x49ee70`). The zone - // walk's own LEAVE, one module over, deliberately does not. + // Mutable because an explicit leave clears the channel's `ZONECHANNELS` bit (`0x49f10a` in + // leave-by-name `0x49ee70`); the zone walk's own leave does not. mut channels: ResMut, commands: Res, emotes: Option>, selection: Res, - // The party slash commands (decision 0434): the roster for /promote's nameβ†’guid resolve, the - // name cache for the bare-command player-target fallback (`GetSlashCmdTarget`). mut group: ResMut, mut names: ResMut, - // Our own live stand-state + move flags for the posture-eligibility gate (the pending-aware - // component the player controller writes each frame); the entity doubles as `/castvis`'s - // fallback subject. + // Our stand state and move flags for the emote gates; the entity is `/castvis`'s fallback. self_player: Query<(Entity, &MovementState, &GlobalTransform), With>, mut cast_events: MessageWriter, mut play_seq: ResMut, mut go_targets: MessageWriter, - // The command table (decision 0881) β€” the reference's own aliases, resolved at boot. table: Res, mut chat_out: ChatOut, probes: ChatProbes, @@ -240,22 +169,15 @@ pub(super) fn drain_chat_input( return; }; let mut queue = engine_verbs(&mut script, emotes.as_deref()); - // What `take_chat_input` carries now is benilla's own commands, handed over by the - // `SlashCmdList` shim in ScriptLogFrame.xml once the reference's `ChatEdit_ParseText` has - // found no built-in for the line β€” the history line, the type switch and the send are the - // reference's Lua before the line ever reaches here (1948). + // benilla's own commands, from the `SlashCmdList` entries in `ScriptLogFrame.xml` once the + // stock `ChatEdit_ParseText` has found no built-in, and probe lines. for raw in script.take_chat_input() { let msg = raw.trim(); if msg.is_empty() { continue; } - // A line with no slash is SPEECH β€” which is also how a `.gm`-style server command - // travels (the server reads the dot off a SAY). That is the seam's contract - // (`UiScript::push_chat_input`): a typed line takes the stock `ChatEdit_SendText` β†’ - // `SendChatMessage` route since 1948, and a probe's line, which never touched the edit - // box, must reach the wire the same way. The migration dropped this branch with the - // old edit-state sender, and every `WOW_PROBE_CHAT` rig β€” the crowd raid's forty - // `.partybot add`s, the probe shield's `.cheat god on` β€” went silent on the server. + // A probe's line with no slash is a SAY, which is also how a `.gm`-style server command + // travels; a typed one goes out through the stock `ChatEdit_SendText` instead. if !msg.starts_with('/') { let cmd = ClientCommand::Chat { kind: super::edit::SendType::Say.wire(), @@ -272,16 +194,11 @@ pub(super) fn drain_chat_input( for (msg, parsed) in queue { let msg = msg.as_str(); match parsed { - // `/r` is `SLASH_REPLY`, one of the reference's own built-ins (`ChatEdit_ParseText`'s - // REPLY arm over the box's tell ring) β€” the line never reaches this queue. + // `/r` is the stock `ChatEdit_ParseText`'s REPLY arm; a typed one never gets here. ParsedChat::Reply { .. } => {} - // `/join` and `/leave` are stock built-ins (`SlashCmdList["JOIN"]`/`["LEAVE"]`, ref - // `ChatFrame.lua` l.778/l.803), so a typed line never reaches here β€” only a probe's, - // which skips the edit box. It takes the same handler, not a shortcut past it: the - // command-table arm used to send the token verbatim, and a probe's `/join General` - // created a CUSTOM channel called "General" on the server (decision 2144's live - // run D). The handlers resolve through `JoinChannelByName`/`LeaveChannelByName`, - // whose commands the `Channel` arm below drains next frame. + // Stock built-ins (`ChatFrame.lua:778`, `:803`), so only a probe's line gets here, and + // it runs the same handler: sent verbatim, `/join General` would create a custom + // channel of that name. The handlers queue their verbs for the `Channel` arm. ParsedChat::Join { name, password } => { let body = format!( "SlashCmdList['JOIN']({:?})", @@ -307,9 +224,7 @@ pub(super) fn drain_chat_input( let _ = commands.0.send(ClientCommand::PlayedTime); } ParsedChat::Shot => { - // The framing instrument (decision 0600): the CURRENT camera pose, in the raw WoW - // coords a capture `Scenario` takes, echoed to chat and appended to - // `benilla-config/shots.txt` so a chosen spot survives the session. + // The camera pose in a capture `Scenario`'s raw WoW coords, to chat and a file. let Ok(cam) = world_camera.single() else { continue; }; @@ -344,9 +259,7 @@ pub(super) fn drain_chat_input( } } ParsedChat::Liquid => { - // What the swim query actually sees here, and why. Prints the interior claim, the - // resolved verdict, and EVERY candidate footprint β€” a surface that should not be - // claiming shows up beside the one that should. + // Every candidate footprint, so a wrong surface shows beside the right one. let Ok((_, feet)) = self_player.single().map(|(_, _, t)| ((), t.translation())) else { chat_log.push_event(super::event::ChatEvent::text_only( @@ -373,9 +286,8 @@ pub(super) fn drain_chat_input( None => "no WMO interior claim".into(), } ), - // The camera EYE is a separate subject with its own claim (the reference's - // `[0xc7b748]`), and it is the one that decides the underwater filter. Printing - // only the player's is what let the two disagree unseen for a whole bug. + // The camera eye has its own claim (`[0xc7b748]`), and it decides the + // underwater filter. format!("liquid: EYE claim {eye:?}"), match verdict { Some(h) => format!( @@ -401,9 +313,7 @@ pub(super) fn drain_chat_input( } } ParsedChat::Reaction { name } => { - // Everything the reaction ladder judges the subject on, in the order it judges: - // the PvP rung's three duel gates first (naming the one that refused), then the - // faction work, then the `can_attack` verdict the cast/attack paths use. + // In the ladder's order: the duel gates, the faction work, then `can_attack`. let subject_guid = match &name { Some(n) => crate::ui_duel::streamed_player_named(n, guids, &names), None => selection.guid, @@ -411,7 +321,6 @@ pub(super) fn drain_chat_input( let subject_entity = subject_guid.and_then(|g| guids.0.get(&g).copied()); let target_store = subject_entity.and_then(|e| stores.get(e).ok()); let own_store = self_store.iter().next(); - // The plate gate's own player test, read the same way it reads it. let is_player = subject_entity .and_then(|e| kinds.get(e).ok()) .is_some_and(|n| n.kind == benilla_protocol::EntityKind::Player); @@ -462,11 +371,8 @@ pub(super) fn drain_chat_input( own_store ) )); - // **The at-war bit, named** (decision 1674). It is the entire content of the - // playerβ†’unit reaction's leg 3, it is what the world cursor, the plate category and - // `UnitCanAttack` all turn on for a reputation-slot faction, and it is printed - // nowhere else β€” so "why does this friendly NPC take the sword?" is unanswerable - // from any other line here. + // The at-war bit: the whole of the player-to-unit reaction's leg 3 for a faction + // with a reputation slot, which the cursor, the plate and `UnitCanAttack` turn on. let war = (|| { let catalog = factions.as_deref()?.catalog(); let tpl = catalog.template(target_store?.0.unit_faction_template()?)?; @@ -493,10 +399,8 @@ pub(super) fn drain_chat_input( })() .unwrap_or_else(|| "at-war not evaluated (no catalog, store or template)".into()); lines.push(format!("reaction: {war}")); - // The world cursor's two branch predicates, in the order `0x482200` runs them β€” - // `CanInteract` picks the service ladder or the loot/skin/attack block, then - // `CanAttack` decides the sword. Neither is a reaction threshold, so neither can - // be read off RANK above. + // The world cursor's two predicates in `0x482200`'s order: `CanInteract` picks the + // service ladder, then `CanAttack` the sword. Neither is a reaction threshold. let interactable = crate::target::can_interact( target_store, factions.as_deref(), @@ -521,13 +425,10 @@ pub(super) fn drain_chat_input( "nothing matched β†’ Point" }, )); - // The V-plate CATEGORY, which is a **different predicate** from the rank above - // (`CanAttack` playerβ†’unit, plus `CanCooperate` for a player subject β€” decision - // 1530) and so cannot be read off it. Both legs are printed, with the faction-group - // masks they compare: a player in the wrong bucket is a mask disagreement roughly - // every time, and the mask is invisible everywhere else in the client. A GM-mode - // character is mask 0 on a vmangos realm (`.gm on` β†’ faction template 35), which is - // exactly how a friendly player ends up with an enemy plate. + // The V-plate category is its own predicate (`CanAttack` player-to-unit, plus + // `CanCooperate` for a player), printed with the faction-group masks it compares. + // A GM-mode character is mask 0 on vmangos (`.gm on` sets faction template 35), + // so a friendly player can plate as an enemy. let mask = |s| { crate::target::ring::faction_group_mask(factions.as_deref(), s) .map_or("none".to_string(), |m| m.to_string()) @@ -575,12 +476,10 @@ pub(super) fn drain_chat_input( } ParsedChat::PartyTest { arg } => match arg.as_str() { "off" => group.clear_session(), - // The raid grid's instrument (decision 1549) β€” 25 synthetic rows, us leading. + // 25 synthetic raid rows, us leading. "raid" => { - // Onto the same by-key queue the wire arm uses (2045/2054), so the instrument - // shows the lines a real roster would β€” resolved from GlobalStrings, on the - // surface each catalog row names, with its sound. An instrument that composed - // its own text would be eyeballing something the game never prints. + // The wire arm's by-key queue, so the lines resolve from GlobalStrings with + // each row's surface and sound. chat_out.ui_errors.0.extend(crate::ui_party::synthetic_raid( &mut group, &mut names, @@ -588,15 +487,12 @@ pub(super) fn drain_chat_input( )); } "invite" => group.pending_invite = Some("Partner".to_string()), - // A group member's ping, without the group member (decision 1596). 35 yd - // north-east: off both axes so a mirrored sign is obvious, and inside every - // outdoor view radius (the tightest is 66.7 yd) β€” indoors at the two tightest - // zooms it is off the disc, which is itself worth seeing (the marker hides and - // the ping survives, so walking back brings it into view). + // A group member's ping 35 yd north-east: off both axes, so a mirrored sign + // shows, and inside every outdoor view radius (the tightest is 66.7 yd). "ping" => { let line = if let Ok((_, _, tf)) = self_player.single() { let w = benilla_assets::coords::bevy_to_wow(tf.translation()); - // +x is north, βˆ’y is east (0203's north-up mapping). + // +x is north, -y is east. let at = (w[0] + 24.75, w[1] - 24.75); chat_out.ping.seat(at, 0xF001); format!( @@ -611,15 +507,13 @@ pub(super) fn drain_chat_input( line, )); } - // Serverless mark eyeball: skull the current target on the LOCAL board (the - // real send round-trips through the server's echo, which /partytest lacks). + // Skull the target on the local board; a real mark waits for the server's echo. "mark" => { if let Some(guid) = selection.guid { group.apply_raid_target(7, guid); } } arg => { - // The live position seats the synthetic members' blips around us. let player_xy = self_player.single().ok().map(|(_, _, tf)| { let w = benilla_assets::coords::bevy_to_wow(tf.translation()); (w[0], w[1]) @@ -629,9 +523,8 @@ pub(super) fn drain_chat_input( .0 .extend(crate::ui_party::synthetic_roster(&mut group, player_xy)); if arg == "lead" { - // The leader-view variant: an unmatched leader guid resolves to - // leader_index 0 in the feed β€” "we lead" β€” so the leader-only popup - // rows (promote/uninvite/the loot submenus) are eyeballable serverless. + // An unmatched leader guid reads as `leader_index` 0, "we lead", which + // shows the leader-only popup rows. group.leader = 0xF000; } } @@ -643,9 +536,8 @@ pub(super) fn drain_chat_input( let _ = commands.0.send(ClientCommand::GroupInvite { name }); } } - // Fire-and-forget: the server judges leader/group state and answers with either a - // raid-typed SMSG_GROUP_LIST (the "joined a raid group" line rides the apply) or a - // SMSG_PARTY_COMMAND_RESULT error, both already handled. + // The server judges it: a raid-typed `SMSG_GROUP_LIST`, or an + // `SMSG_PARTY_COMMAND_RESULT` error. ParsedChat::ConvertRaid => { let _ = commands.0.send(ClientCommand::GroupRaidConvert); } @@ -660,9 +552,9 @@ pub(super) fn drain_chat_input( if let Some(name) = name.or_else(|| target_player_name(&selection, &names, &commands)) { - // The 1.12 wire form is a guid (CMSG_GROUP_SET_LEADER) β€” resolve against the - // roster; a miss answers with the server's own would-be error string - // (INTERIM: the ref's PromoteByName miss behavior is a 0434-dispatch item). + // `CMSG_GROUP_SET_LEADER` takes a guid, resolved against the roster. A miss + // prints the server's error wording; the reference's `PromoteByName` miss is + // untraced. match group .members .iter() @@ -674,7 +566,7 @@ pub(super) fn drain_chat_input( .send(ClientCommand::GroupSetLeader { guid: m.guid }); } None => { - // ERR_TARGET_NOT_IN_GROUP_S (GlobalStrings:1861). + // `ERR_TARGET_NOT_IN_GROUP_S` (`GlobalStrings.lua:1861`). chat_log.push_event(super::event::ChatEvent::text_only( super::event::ChatEventKind::System, format!("{name} is not in your party."), @@ -683,10 +575,8 @@ pub(super) fn drain_chat_input( } } } - // The duel verbs (decision 0633) enter the SAME intent queue the Era globals feed β€” - // the reference's own slash handlers are one-liners over `StartDuel`/`CancelDuel`, so - // routing through the queue keeps a single resolution path (spell lookup, streamed- - // player gate, arbiter echo) instead of a second one here. + // The duel verbs join the intent queue `StartDuel` and `CancelDuel` feed: the stock + // handlers are one-line calls to them. ParsedChat::Duel { name } => { if let Some(name) = name.or_else(|| target_player_name(&selection, &names, &commands)) @@ -697,14 +587,9 @@ pub(super) fn drain_chat_input( ParsedChat::Forfeit => { script.queue_duel_request(benilla_ui::script::DuelRequest::Cancel); } - // The by-name selection pair (decision 0886). Both hand the name to `crate::target`'s - // shared resolver β€” the reference's own `0x493aa0`, parameterised per caller β€” so the - // commit goes through the one SetSelection path a click, TAB and `TargetUnit` share. - // - // A bare `/target` reproduces `GetSlashCmdTarget`'s fallback (your current target's - // name, iff it is a player) and is therefore a no-op re-select; a bare `/target` with a - // creature selected resolves to nothing, exactly as the reference's `if - // GetSlashCmdTarget(msg)` guard does. + // `crate::target`'s resolver (the reference's `0x493aa0`) commits through the one + // SetSelection path. A bare `/target` takes `GetSlashCmdTarget`'s fallback: a no-op + // re-select of a player target, nothing for a creature. ParsedChat::Target { name } => { if let Some(name) = name.or_else(|| target_player_name(&selection, &names, &commands)) @@ -714,31 +599,26 @@ pub(super) fn drain_chat_input( .write(crate::target::TargetByNameRequest { name }); } } - // A bare `/assist` is the ref's `AssistUnit("target")` β€” assist whoever is selected, - // creature or player β€” so it passes `None` straight through rather than taking the - // player-name fallback. The ref reaches the same unit by resolving its NAME first - // (`AssistByName`), which can pick a different same-named player; going by the live - // selection is the same intent without that ambiguity (recorded in 0886). + // Deviation: a probe's bare `/assist` assists the live selection, where the stock + // handler (`ChatFrame.lua:740`) resolves a selected player's name through + // `AssistByName`, because a name resolve can land on a different player. ParsedChat::Assist { name } => { chat_out.assist.write(crate::target::AssistRequest { name }); } - // `/follow` (decision 0890) β€” the subject is resolved by `crate::target` and the motion - // is `crate::player`'s; chat only carries the ask. The two arms are the ref handler's - // own two calls: bare is `FollowUnit("target")`, named is `FollowByName(name)` with no - // second argument, i.e. prefix matching live. + // Bare is `FollowUnit("target")`, where the stock handler (`ChatFrame.lua:754`) first + // tries a selected player's name; named is `FollowByName(name)` with no exact flag, so + // prefix matching. ParsedChat::Follow { name } => { chat_out.follow.write(match name { Some(name) => crate::player::FollowRequest::Name { name, exact: false }, None => crate::player::FollowRequest::Unit("target".into()), }); } - // The ref's handler is `SlashCmdList["PVP"] = function() TogglePVP() end` β€” one line - // over the same binding the popup row calls, so it enters the same intent queue - // (decision 0646 Β§3; the `/duel` reasoning above, verbatim). + // `SlashCmdList["PVP"]` is `TogglePVP()`, the popup row's intent queue. ParsedChat::Pvp => script.queue_pvp_toggle(), ParsedChat::Help => { - // An honest benilla summary (the ref's HELP_TEXT_LINE pages are a settings-era - // nicety; this stays useful and never stale-quotes them). + // benilla's own summary, for a line that skipped the edit box; a typed `/help` + // runs the stock `ChatFrame_DisplayHelpText`. for line in [ "Chat: /s /y /p /g /o /raid /rw /bg, /w , /r, /e", "Channels: /join [pw], /leave , /chatlist ", @@ -758,9 +638,7 @@ pub(super) fn drain_chat_input( )); } } - // `ChatFrame_DisplayMacroHelpText` (ChatFrame.lua): the five shipped lines, read off - // the VM's own GlobalStrings so they are the install's text, never a transcription - // (decision 0983; the 0881 posture β€” the strings are data, the handler is ours). + // `ChatFrame_DisplayMacroHelpText` (`ChatFrame.lua:1695`), off the VM's GlobalStrings. ParsedChat::MacroHelp => { for i in 1..=5 { let key = format!("MACRO_HELP_TEXT_LINE{i}"); @@ -775,19 +653,17 @@ pub(super) fn drain_chat_input( } } } - // `DoEmote` (`0x5ef560`) end to end. The gates in the client's own order, then the two - // things it DOES: set a posture (the `/sit` family) and send the packet. + // `DoEmote` (`0x5ef560`): the gates in the client's order, then posture and packet. ParsedChat::TextEmote(text_id) => { let (stand_state, flags) = self_player .single() .map_or((0, 0), |(_, m, _)| (m.stand_state, m.flags)); let swimming = flags & move_flags::SWIMMING != 0; let emote_id = emotes.as_deref().and_then(|e| e.text_emote(text_id)); - // A chat-only text emote (no Emotes.dbc row) has no EmoteFlags to test and no - // posture to set β€” it always sends. + // A chat-only text emote (no Emotes.dbc row) has no flags or posture: it sends. let posture = emote_id.and_then(|id| emotes.as_deref()?.posture_state(id)); - // GATE A (`CheckEmoteEligible` `0x47db40`): suppresses the anim AND the packet β€” - // except its `0x4000` arm, which refuses out loud instead (decision 1904). + // Gate A (`CheckEmoteEligible`, `0x47db40`) suppresses the anim and the packet, + // except its `0x4000` arm, which refuses out loud. let gate = match emotes.as_deref() { Some(e) => emote_id .and_then(|id| e.emote_flags(id)) @@ -796,9 +672,8 @@ pub(super) fn drain_chat_input( }), None => EmoteGate::Send, }; - // The `0x4000` arm's own half of the law, which lives in `DoEmote` (`0x5ef5d0`) - // and not in the eligibility check: the red line fires only while the caster is - // acting under its OWN control, so a feared or confused player emotes normally. + // `DoEmote`'s half of the `0x4000` arm (`0x5ef5d0`): the red line only while + // self-controlled, so a feared or confused player emotes normally. if gate == EmoteGate::Moving { let controlled = self_store .single() @@ -813,23 +688,19 @@ pub(super) fn drain_chat_input( } } let eligible = gate != EmoteGate::Suppressed; - // GATE B (`0x5ef5f3`): asleep, only a POSTURE emote gets through β€” which is how - // `/stand` (and `/sit`) is the way out of `/sleep`, while a `/wave` in bed does - // nothing at all. + // Gate B (`0x5ef5f3`): asleep, only a posture emote gets through, so `/stand` ends + // `/sleep` and a `/wave` in bed does nothing. let awake_or_posture = stand_state != 3 || posture.is_some(); if !eligible || !awake_or_posture { - // Byte-verified whole-send suppression: a seated /bow sends nothing (no packet, - // no anim) β€” the client's DoEmote returns before building the packet. + // `DoEmote` returns before the packet: no send, no anim. debug!( "chat: emote {text_id} suppressed (eligible {eligible}, \ awake-or-posture {awake_or_posture}, stand {stand_state})" ); continue; } - // The stow (`0x5ef630`, VERIFIED unconditional at this point in the flow): every - // emote that reaches here puts the weapons away, instantly β€” a `/wave` mid-fight - // sheathes in the reference too. The anim layer's one setter owns the idempotency - // refusal, so an already-stowed body costs nothing. + // The stow (`0x5ef630`), unconditional here: every emote past the gates sheathes + // instantly, mid-fight too. if let Ok((entity, _, _)) = self_player.single() { chat_out.sheath.write(crate::creature_anim::SheathRequest { entity, @@ -837,11 +708,9 @@ pub(super) fn drain_chat_input( ceremony: false, }); } - // The posture branch (`EmoteSpecProc == 1` β†’ `SetStandState(param)`): the emote's - // whole visible effect, since the SERVER deliberately does nothing for a STATE text - // emote (vmangos `HandleTextEmoteOpcode` breaks out for SIT/SLEEP/KNEEL). Through - // the one setter in `crate::player`, which sends `CMSG_STANDSTATECHANGE`, holds the - // local commit until the echo lands, and runs the sit-stow rider. + // The posture branch (`EmoteSpecProc == 1` β†’ `SetStandState`) is the whole + // visible effect: the server plays nothing for SIT, SLEEP and KNEEL + // (`ChatHandler.cpp:728`). if let Some(state) = posture { chat_out .stand @@ -864,9 +733,7 @@ pub(super) fn drain_chat_input( kind, ground, } => { - // The dev instrument: fire the synthesized cast edge at the selection, else self β€” - // the same message the net bridge writes, so it exercises the whole resolve/hold/ - // release path (decision 0099's iteration loop). + // The net bridge's own message, on the selection or else self. let me = self_player.single().ok().map(|(e, _, _)| e); let subject = selection.target.or(me); match subject { @@ -878,13 +745,9 @@ pub(super) fn drain_chat_input( kind, seq: play_seq.next(), }); - // A selected caster's GO also fires its target list at *us* β€” the one - // live unit the dev loop always has β€” so a Speed>0 spell's missile and - // impact run end to end (0099 phase 4). A self-cast has no target: no - // missile, matching a hit-less GO. `ground` instead sends the pure DEST - // shape β€” empty lists, a point 15 yd ahead of the player at the player's - // own height (a dev stand-in for the terrain pick; flat enough to watch a - // Flare fly) β€” which is the only shape that reaches the location fallback. + // A selected caster's GO hits us, so a missile flies end to end; a + // self-cast has no target. `ground` sends the pure dest shape instead, a + // point 15 yd ahead at the player's height. if kind == crate::creature_anim::CastEventKind::Go { let dest = ground.then(|| { self_player.single().ok().map(|(_, _, tf)| { @@ -908,10 +771,8 @@ pub(super) fn drain_chat_input( hits: vec![me], misses: Vec::new(), dest: None, - // Dev stand-in only: a real GO's ammo block carries the - // caster's actual ammo; `/castvis` has none, so shot - // spells (`75 go`, `2480 go`) fly the Rough Arrow - // display (5996). + // A real GO carries the caster's ammo; this flies the + // Rough Arrow, display id 5996. ammo_display_id: Some(5996), seq: play_seq.next(), }); @@ -929,28 +790,21 @@ pub(super) fn drain_chat_input( .ok() .filter(|t| !t.is_empty()) }), - // `/logout` (and `/camp`) is the reference's own `SlashCmdList["LOGOUT"]` β†’ `Logout()`, - // so it takes the SAME route the game menu's Logout button does (decision 0674): the - // request queues on the script seam, `crate::ui_logout` sends it and narrates the - // server's answer with the CAMP countdown. It used to send `CMSG_LOGOUT_REQUEST` - // straight from here, which meant a field logout looked like nothing happening for 20 s. + // `SlashCmdList["LOGOUT"]` is `Logout()`, the game menu's route: `crate::ui_logout` + // sends the request and narrates the answer with the CAMP countdown. ParsedChat::Logout => { script.queue_session_request(benilla_ui::script::SessionRequest::Logout) } - // `/quit` `/exit` β€” the ref's `Quit()`, same queue as the game menu's Exit button, so - // the field countdown and the confirmation are the ones already built (decision 0674). + // `Quit()`, the game menu Exit button's queue, countdown and confirmation. ParsedChat::Quit => { script.queue_session_request(benilla_ui::script::SessionRequest::Quit) } - // `/reload` β€” the same deferred rebuild `ReloadUI()` queues (decision 1291), through - // the same session seam as the exit verbs above. + // The deferred rebuild `ReloadUI()` queues. ParsedChat::ReloadUi => { script.queue_session_request(benilla_ui::script::SessionRequest::ReloadUi) } - // A `/console` line for the command registry (decision 2303). Deferred to the world - // because a command's body is `fn(&mut World, &str)` β€” the reference's handlers run - // against the whole client too β€” and its lines land in the chat frame as system - // text, the seam `/console` output has always used. + // Deferred to the world, since a command is `fn(&mut World, &str)`; its lines print + // as system text. ParsedChat::Console { line } => { chat_out.console.queue(move |world: &mut World| { let lines = crate::console::execute(world, &line); @@ -963,12 +817,11 @@ pub(super) fn drain_chat_input( } }); } - // The reference's own handler body, run in the VM (the 0668 posture) β€” `/trade`, - // `/inspect`, the loot-method trio, and `/script`'s raw chunk. + // The reference's handler body, run in the VM. ParsedChat::Lua { body } => { if let Err(e) = script.run(&body) { - // `/script` hands the player's own typo back to them; a built-in body failing - // is ours to see in the log. + // Logged and printed: the player sees a `/script` typo, the log a failing + // built-in. warn!("ui_chat: {body:?}: {e}"); chat_log.push_event(super::event::ChatEvent::text_only( super::event::ChatEventKind::System, @@ -984,10 +837,9 @@ pub(super) fn drain_chat_input( ClientCommand::JoinChannel { name, password } } C::Leave { name } => { - // `LeaveChannelByName` (`0x4a0000` β†’ `0x49ee70`): the VM composed a - // shortcut or passed a custom name; a number names a confirmed slot here - // or the call is a no-op. The mask clear is this path's and no other's - // (decisions 2120, 2144), and so is the custom re-join list's. + // `LeaveChannelByName` (`0x4a0000` β†’ `0x49ee70`): a number names a + // confirmed slot or the call does nothing. Only this path clears the zone + // mask and the custom re-join list. let Some(name) = channels.leave_target(&name) else { continue; }; @@ -997,7 +849,7 @@ pub(super) fn drain_chat_input( ClientCommand::LeaveChannel { name } } C::List { name } => ClientCommand::ChannelList { name }, - // `ListChannels()` β€” the joined roster, numbered the way `/N` addresses it. + // `ListChannels()`: the joined roster, numbered as `/N` addresses it. C::ListAll => { let roster: Vec = channels .joined @@ -1041,10 +893,8 @@ pub(super) fn drain_chat_input( let _ = commands.0.send(cmd); } ParsedChat::Unknown => { - // Before the help line: an ADDON may claim it (decision 1195). The reference - // resolves `SlashCmdList` in the same pass as its own commands; ours runs after - // the boot table misses, which gives the same precedence β€” a shipped command can - // never be shadowed β€” without moving our handlers into Lua. + // `SlashCmdList` gets the line before the help reply: an addon's command, or a + // stock one with no arm here. if let Some(rest) = msg.strip_prefix('/') { let rest = rest.trim(); let (cmd, args) = rest.split_once(char::is_whitespace).unwrap_or((rest, "")); @@ -1052,10 +902,8 @@ pub(super) fn drain_chat_input( continue; } } - // HELP_TEXT_SIMPLE (the ref's unknown-command reply, ChatEdit_ParseText l.2203), - // read off the player's own table rather than re-typed here (decision 2045). It is - // no message-catalog row β€” the reference emits it from Lua straight into chat β€” so - // there is no surface or sound to look up, only the wording. + // `HELP_TEXT_SIMPLE`, the reference's unknown-command reply (`ChatFrame.lua:2205`), + // a plain chat line with no message-catalog surface or sound. if let Some(text) = script .lua() .globals() @@ -1073,9 +921,8 @@ pub(super) fn drain_chat_input( } } -/// `/chattest` (the 0288 instrument): one synthetic line of every renderable form through the -/// real event pipeline β€” kinds, flags, the language header, channel prefixes, notices, and both -/// link forms (item + player), so formats/colors/links verify in one screen. +/// `/chattest`: one synthetic line of every renderable form (kinds, flags, the language header, +/// channel prefixes, notices, item and player links) through the real event pipeline. fn chattest_battery(log: &mut super::feed::ChatLog, get: &dyn Fn(&str) -> Option) { use super::event::{ChatEvent, ChatEventKind as K}; let player = |kind: K, text: &str, sender: &str| { @@ -1136,18 +983,9 @@ fn chattest_battery(log: &mut super::feed::ChatLog, get: &dyn Fn(&str) -> Option info!("chattest: battery queued"); } -/// The combat-log half of `/chattest` (B297): one synthetic line of every family, through the real -/// [`super::combat`] composer and the real drain β€” so the whole `COMBAT_*`/`SPELL_*` block's -/// wording, colours and window routing verify in one screen without a fight. -/// -/// It exists because the alternative is a live combat probe, and unattended ones are out (the -/// director's standing rule). The lines are driven with **guid 0 on both endpoints and literal -/// names in the fills**, so the drain's name resolve is a no-op and nothing here touches the wire; -/// everything downstream of that β€” the family's template lookup, the slot fill, the chat type, the -/// route, the `CHAT_MSG_*` fire an addon sees β€” is the production path exactly. -/// -/// The chat TYPES are picked to show the block's spread rather than one row: your own melee and -/// spells, your pet, a hostile player, and a creature hitting you (which is the one that is red). +/// The combat-log half of `/chattest`: one line of every family through the real +/// [`super::combat`] composer and drain. Guid 0 on both ends and literal names in the fills make +/// the name resolve a no-op; everything after it is the production path. fn combat_log_battery(log: &mut super::feed::ChatLog, get: &dyn Fn(&str) -> Option) { use super::combat::{self, Family, Fills, PendingCombat, Variant}; use super::event::ChatEventKind as K; @@ -1200,7 +1038,7 @@ fn combat_log_battery(log: &mut super::feed::ChatLog, get: &dyn Fn(&str) -> Opti Variant::SelfOther, fills(0, None, None), ), - // A creature hitting you β€” the red rows, and the ones a player notices first. + // A creature hitting you: the red rows. line( K::CombatCreatureVsSelfHits, combat::COMBATHIT, @@ -1225,7 +1063,7 @@ fn combat_log_battery(log: &mut super::feed::ChatLog, get: &dyn Fn(&str) -> Opti Variant::OtherSelf, fills(0, None, None), ), - // Your own spells: the gold pair, and the outcomes that are not damage. + // Your own spells, and the outcomes that are not damage. line( K::SpellSelfDamage, combat::SPELLLOGSCHOOL, @@ -1299,10 +1137,7 @@ fn combat_log_battery(log: &mut super::feed::ChatLog, get: &dyn Fn(&str) -> Opti Variant::SelfOther, fills(150, None, Some(0)), ), - // ── the completeness block (1703) ──────────────────────────────────────────────── - // One line per family that was absent until now, in the order a player meets them. - // The `Named` slot's text is already in the fills, so these drive the same drain the - // wire does with the same composer β€” only the resolve hops are short-circuited. + // ── The remaining families, in the order a player meets them ───────────────────── line( K::CombatHostileDeath, combat::UNITDIES, @@ -1399,8 +1234,7 @@ fn combat_log_battery(log: &mut super::feed::ChatLog, get: &dyn Fn(&str) -> Opti Variant::OtherOther, fills(0, None, None), ), - // The two families whose `Named` slot is not an item name: a faction and a failure - // reason. Overridden here so the battery reads as the sentences a player will see. + // The two families whose `Named` slot is not an item: a faction and a failure reason. line( K::CombatFactionChange, combat::FACTION_STANDING_INCREASED, @@ -1410,12 +1244,8 @@ fn combat_log_battery(log: &mut super::feed::ChatLog, get: &dyn Fn(&str) -> Opti ..fills(250, None, None) }, ), - // The failure reason is the ONE fill in this battery that is a reference string rather - // than a synthetic name: production puts a resolved GlobalString in this slot - // (`ui_action::feed`'s `CAST_FAIL_KEYS` lookup), so a battery that typed English here - // would read as the only untranslated line on a localized install. `ERR_OUT_OF_MANA` and - // not `OUT_OF_MANA` β€” the same enUS sentence, but the byte-verified NO_POWER pick table - // `0x8118dc` names the former (`ui_action::cast_fail`). + // A resolved GlobalString, as production fills this slot: `ERR_OUT_OF_MANA`, the key + // the NO_POWER pick table names (`0x8118dc`). line( K::SpellFailedLocalPlayer, combat::SPELLFAILCAST, @@ -1425,7 +1255,7 @@ fn combat_log_battery(log: &mut super::feed::ChatLog, get: &dyn Fn(&str) -> Opti ..fills(0, None, None) }, ), - // The trailer pass, on the one family that can show all six. + // The trailers, on the one family that can show all six. line( K::CombatSelfHits, combat::COMBATHIT, @@ -1446,53 +1276,22 @@ fn combat_log_battery(log: &mut super::feed::ChatLog, get: &dyn Fn(&str) -> Opti } } -/// The send-side emote **posture-eligibility gate**: the real client's `CheckEmoteEligible` -/// (`0x47db40`), the *only* site that reads an `Emotes.dbc` `EmoteFlags` β€” called from `DoEmote` -/// (`0x5ef560`) *before* `CMSG_TEXT_EMOTE` is built, so a suppressed emote sends no packet and -/// plays no local anim at all (a seated `/bow` self-censors; the server round-trip never happens). -/// The predicate is exactly these four tests, in the client's own order. `true` = eligible (send + -/// play); `false` = suppress both. -/// -/// # The fifth flag, `0x4000` β€” NOT built, and the reason recorded here was WRONG -/// -/// What `CheckEmoteEligible 0x47db40` decides about one emote β€” three outcomes, not two, which is -/// why this replaced a `bool` (decision 1904). +/// What `CheckEmoteEligible` (`0x47db40`) decides about one emote. #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub(super) enum EmoteGate { /// Send it: packet, posture, animation. Send, - /// **Silently** dropped β€” no packet, no animation, and no line. A seated `/bow` is this. + /// Dropped silently: no packet, no animation, no line. A seated `/bow` is this. Suppressed, - /// The `0x4000` "requires standing still" arm tripped. The reference does NOT suppress here: - /// `CheckEmoteEligible` writes an out-param and `DoEmote` reads it at `0x5ef5d0`, raising - /// `ERR_NOEMOTEWHILERUNNING` and aborting **only when `IsSelfControlled 0x5fa550` is - /// non-zero** β€” so the toast fires for an ordinary moving player and a **feared** one emotes - /// away happily. That caster-state test is the caller's, exactly as it is the reference's. + /// The `0x4000` "requires standing still" arm tripped. `DoEmote` (`0x5ef5d0`) raises + /// `ERR_NOEMOTEWHILERUNNING` and aborts only when `IsSelfControlled` (`0x5fa550`), false while + /// `UNIT_FIELD_FLAGS & 0xc00004`, so a feared player emotes; that test is the caller's. Moving, } -/// This doc used to say `0x4000` ("requires standing still") *"only sets an out param the client -/// acts on while fear/confuse-controlled, which benilla doesn't model"*. The polarity is the -/// **inverse** (2026-08-23; decision 1582). The bytes: -/// -/// - `0x47dbab` tests `EmoteFlags & 0x4000`, and if set, `0x47dbb3` tests the live `CMovement` -/// word against **`0x20ff`** β€” the four direction bits, the two turn bits, the two pitch bits and -/// `FALLING` β€” writing `*out = 1`. Pointedly **not** `SWIMMING`. -/// - `DoEmote` then reads that out-param at `0x5ef5d0` and, when `0x5fa550` returns non-zero, -/// raises message `0x139` = **`ERR_NOEMOTEWHILERUNNING`** ("You can't do that while moving!") and -/// **aborts**: no emote packet, no `SetStandState`. -/// - `0x5fa550` is `IsSelfControlled`, not "is fear/confuse-controlled" β€” it returns **1** for an -/// ordinary player and **0** while `UNIT_FIELD_FLAGS & 0xc00004` (DISABLE_MOVE / CONFUSED / -/// FLEEING). So the toast fires for the **ordinary moving player** and is *suppressed* while -/// feared. That is the exact reversal of what this comment claimed. -/// -/// It stays unbuilt β€” deliberately, and as a named gap rather than a settled reading: implementing -/// it puts a red error line on screen for every emote typed while moving, turning, pitching or -/// falling, which is a visible behaviour change for the director to weigh rather than a bug fix to -/// slip in. What it is **not** is a water gate: `0x20ff` carries no `SWIMMING`, so it has nothing -/// to do with B155 and could never have covered it (`super::super::tests:: -/// the_posture_emotes_carry_no_swim_suppression_flag` is the data half of that same negative). The -/// posture path's water refusal lives in [`crate::player::state`]'s `stand_state_refused`. +/// `CheckEmoteEligible` (`0x47db40`), the only reader of `Emotes.dbc` `EmoteFlags`, in the +/// client's order; `DoEmote` (`0x5ef560`) calls it before building `CMSG_TEXT_EMOTE`. The `0x4000` +/// arm tests movement, not `SWIMMING`, so it is no water gate. pub(super) fn emote_send_eligible( emote_flags: u32, stand_state: u8, @@ -1516,25 +1315,18 @@ pub(super) fn emote_send_eligible( if emote_flags & 0x0200 == 0 && matches!(stand_state, 3 | 7) { return EmoteGate::Suppressed; } - // `0x4000` "requires standing still" (client `0x47dbab`), tested against the live CMovement - // word at `0x47dbb3` β€” the reference's own `0x20ff`, which is exactly - // [`crate::creature_anim::move_flags::INTEGRATED`]: the four direction bits, the two turn - // bits, the two pitch bits and FALLING. Pointedly **not** SWIMMING. - // - // This arm does not suppress: it writes `*out = 1`, and DoEmote turns that into a red line - // (or not) on a caster-state test the caller owns β€” see [`EmoteGate::Moving`]. + // `0x4000` "requires standing still" (client `0x47dbab`), against the live movement word's + // `0x20ff` at `0x47dbb3` (direction, turn and pitch bits and FALLING), which is + // [`crate::creature_anim::move_flags::INTEGRATED`]. It only reports; `DoEmote` decides. if emote_flags & 0x4000 != 0 && move_flags & crate::creature_anim::move_flags::INTEGRATED != 0 { return EmoteGate::Moving; } EmoteGate::Send } -/// `PLAYER_FLAGS_DND` (`0x4`) off our own live descriptor β€” the DND arm's state test. -/// -/// **Live, not mirrored, and that asymmetry is the reference's** (`0x49f3f0`): the AFK path keeps -/// an optimistic global and the DND path keeps nothing, so a second `/dnd` typed before the -/// server's `PLAYER_FLAGS` update lands still reads DND clear and re-marks, where a second `/afk` -/// in the same window clears. Do not "fix" this into a symmetric pair. +/// `PLAYER_FLAGS_DND` (`0x4`) off our live descriptor. Live, not mirrored, as in the reference +/// (`0x49f3f0`): AFK keeps an optimistic global and DND nothing, so a second `/dnd` before the +/// server's update re-marks where a second `/afk` clears. fn is_dnd(self_q: &Query<&crate::net::ObjectStore, With>) -> bool { self_q .iter() @@ -1542,31 +1334,24 @@ fn is_dnd(self_q: &Query<&crate::net::ObjectStore, With> .is_some_and(|s| s.0.player_flags() & 0x4 != 0) } -/// `autoClearAFK` β€” registered default `"1"` (`0x5e24d4 push 0x82e748`). Its reader tests -/// `[cvar+0x28]` for non-zero (`0x5eb84b`), and with the CVar OFF the clear is a **total** no-op: -/// no echo, no mirror write, no packet. +/// `autoClearAFK`, registered default `"1"` (`0x5e24d4`). Off, the clear is a total no-op: no +/// echo, no mirror write, no packet (`0x5eb84b`). fn auto_clear_afk(cvars: &crate::cvars::Cvars) -> bool { cvars.flag("autoClearAFK").unwrap_or(true) } -/// Turn an addon's `SendChatMessage` calls into sends (decision 1199). -/// -/// Its own system rather than a branch inside [`drain_chat_input`], for the reason -/// `benilla_ui::script::chat_send`'s module doc gives: the box's drain runs the **slash grammar** -/// and this path must not. An addon announcing `"/dance"` is saying six characters. -/// -/// An unknown chat-type token is **reported, not guessed**. `SendChatMessage(msg, "RAID_WARNING")` -/// silently going to /say is worse than not going: the addon believes it warned the raid. +/// Turn `SendChatMessage` calls into sends. No slash grammar runs here: an addon sending +/// `"/dance"` says six characters. An unknown chat-type token prints a system line and sends +/// nothing. pub(super) fn drain_addon_chat_sends( script: Option>, commands: Res, mut chat_log: ResMut, mut tutorials: Option>, - // The optimistic AFK mirror (`[0xb6e5cc]`) the `/afk` toggle reads and writes β€” 2088. + // The optimistic AFK mirror (`[0xb6e5cc]`) the `/afk` toggle reads and writes. mut mirror: ResMut, - // `autoClearAFK`, whose registered default is `"1"` β€” the gate on the implicit clear. cvars: Res, - // Our own descriptor, for the DND arm's LIVE `PLAYER_FLAGS & 0x4` read (DND has no mirror). + // Our descriptor, for the DND arm's live `PLAYER_FLAGS & 0x4`. self_q: Query<&crate::net::ObjectStore, With>, ) { let Some(mut script) = script else { @@ -1584,9 +1369,8 @@ pub(super) fn drain_addon_chat_sends( )); continue; }; - // `SendChatMessage`'s acknowledge (`0x49f5fc`, 1976): the twelve social wire types β€” - // party, raid, guild, officer, whisper, channel, AFK/DND, raid leader/warning, the two - // battleground types β€” say, yell and emote are not among them. + // `SendChatMessage`'s tutorial acknowledge (`0x49f5fc`) covers the twelve social types, + // not say, yell or emote. if !matches!( kind, super::edit::SendType::Say | super::edit::SendType::Yell | super::edit::SendType::Emote @@ -1597,12 +1381,11 @@ pub(super) fn drain_addon_chat_sends( }); } } - // ── The away commands, and the implicit clear every other send carries (2088) ──────── + // ── The away commands, and the AFK clear the other sends carry ─────────────────────── // - // `SendChatMessage 0x49f1e0` is not a uniform dispatcher: AFK and DND each carry their own - // arm ahead of the generic send, and EVERY other type first clears a standing AFK. The - // whole law β€” the four `CHAT_MSG_SYSTEM` lines, the client-side default substitution, the - // optimistic mirror β€” is `super::away`, off the reference's truth table. + // `SendChatMessage` (`0x49f1e0`): AFK and DND each have an arm ahead of the generic send, + // and every type but AFK (`0x14`) first clears a standing AFK. The system lines, the + // default text and the mirror are `super::away`'s. let strings = |key: &str| crate::ui_chat::combat::global_string(&script, key); let wire = kind.wire(); let text = match wire { @@ -1617,17 +1400,16 @@ pub(super) fn drain_addon_chat_sends( out.body } crate::net::ChatKind::Dnd => { - // The live descriptor bit, not a mirror: DND has none (`0x49f3f0`), which is what - // makes a repeated `/dnd` re-mark where a repeated `/afk` clears. + // DND has no mirror (`0x49f3f0`): the live descriptor bit. The reference's DND + // also clears a standing AFK first (`0x49f3d6`); this arm does not. let out = super::away::dnd_line(&send.text, is_dnd(&self_q), &strings); if let Some(line) = out.line { super::away::push_system(&mut chat_log, line); } out.body } - // Any other type: clear a standing AFK first (`0x49f3d6` skips only type `0x14`), then - // send the line's own packet. `/dnd` is type `0x15`, so it takes this path too β€” which - // is why the reference prints THREE lines for a typed `/afk` then `/dnd`. + // Clear a standing AFK first (`0x49f3d6` skips only type `0x14`), then send the + // line's own packet. _ => { if let Some(line) = super::away::auto_clear_line(*mirror, auto_clear_afk(&cvars), &strings) @@ -1655,25 +1437,10 @@ pub(super) fn drain_addon_chat_sends( } } -/// Turn an addon's `SendAddonMessage` calls into sends (decision 1235) β€” the addon-to-addon lane, -/// not speech. -/// -/// Its own drain rather than a branch in [`drain_addon_chat_sends`] because the two queues carry -/// different things: a `SendChatMessage` line still needs a chat-type token resolved and may be -/// refused here, while a `SendAddonMessage` broadcast arrives **already validated** β€” the binding -/// owns the four-value whitelist, the `prefix` TAB `message` composition and the outside-a-raid -/// downgrade, exactly as the reference's `0x49f920` does, so nothing is left for this side to -/// decide. That is why there is no "unknown distribution" arm here and no way to add one: the -/// queue's own type is the enum. -/// -/// **The lane is traced in both directions, under one tag.** An addon channel is invisible by -/// construction β€” it never reaches a chat frame, which is the entire point of `LANG_ADDON` β€” so -/// without a line here a broadcast that went out and one that never happened look identical from -/// our own logs, which is exactly how a silently-discarded wire body survives (docs/METHOD.md's rule -/// that new wire bodies are proved, not assumed). `ui_chat::net` already writes inbound addon -/// traffic to the `addon` trace tag; this writes the outbound half to the same tag, so -/// `WOW_MOVE_TRACE= WOW_MOVE_TRACE_TAGS=addon` on a live run is the whole conversation in -/// one file, in order. +/// Turn `SendAddonMessage` calls into sends. The binding has already applied the reference's +/// rules (`0x49f920`: the four distributions, `prefix` TAB `message`, the downgrade outside a +/// raid), so nothing is refused here. This writes the outbound half of the `addon` trace tag and +/// `ui_chat::net` the inbound: `WOW_MOVE_TRACE= WOW_MOVE_TRACE_TAGS=addon` logs both. pub(super) fn drain_addon_message_sends( script: Option>, commands: Res, diff --git a/crates/benilla-app/src/ui_chat/input/parse.rs b/crates/benilla-app/src/ui_chat/input/parse.rs index bd1c22fe..6094d78d 100644 --- a/crates/benilla-app/src/ui_chat/input/parse.rs +++ b/crates/benilla-app/src/ui_chat/input/parse.rs @@ -1,18 +1,13 @@ -//! What a submitted line MEANS β€” the grammar half of the chat's outbound side (decision 0288 P5, -//! re-based on the command table in decision 0881). Three resolvers, in the reference's own -//! `ChatEdit_ParseText` order: the chat-TYPE switch ([`parse_enter_type_switch`] β€” `/say`, `/w`, -//! a channel number), then the ACTION table ([`parse_line`] β†’ [`ParsedChat`], every -//! `SlashCmdList` command benilla registers plus the `/wave`-style emotes), then nothing, which -//! is `HELP_TEXT_SIMPLE`. Doing anything about the result is [`super`]'s (the drain). -//! -//! No aliases live here: which strings reach which arm is [`super::super::commands`]'s table, -//! built from the shipped `GlobalStrings.lua`. +//! What a submitted slash line means: [`parse_line`] resolves it through the boot-built command +//! table ([`super::super::commands`], the shipped `GlobalStrings.lua` aliases) into a +//! [`ParsedChat`] for [`super`] to execute. A typed line reaches it only through benilla's own +//! `SlashCmdList` entries, once the stock `ChatEdit_ParseText` finds no built-in; a probe's line +//! reaches it directly. use crate::ui_chat::commands::{Command, DevCmd, SlashCommands, SlashIndex}; -/// Escape a player-typed string for embedding in a Lua double-quoted literal: backslashes and -/// quotes are escaped, and the two line terminators are dropped rather than escaped (nothing a -/// slash command means can contain them, and a stray one would end the statement). +/// Escape a typed string for a Lua double-quoted literal: `\` and `"` are escaped, and line +/// terminators are dropped, since one would end the statement. pub(in crate::ui_chat) fn escape_lua_string(s: &str) -> String { s.chars() .filter(|c| *c != '\n' && *c != '\r') @@ -26,190 +21,108 @@ pub(in crate::ui_chat) fn escape_lua_string(s: &str) -> String { .collect() } -/// A social slash line as the reference runs it: `SlashCmdList[""]()` (1959). +/// A social slash line as the reference runs it: `SlashCmdList[""]()`. fn social_body(key: &str, args: &str) -> ParsedChat { ParsedChat::Lua { body: format!("SlashCmdList[\"{key}\"](\"{}\")", escape_lua_string(args)), } } -/// What a submitted, already-trimmed chat line resolves to (see [`parse_line`]). Kept separate from -/// [`ClientCommand`] so the parser is unit-testable without a live [`crate::sound::EmoteSounds`] -/// resource or a network channel. +/// What a trimmed slash line resolves to. #[derive(Debug, Clone, PartialEq)] pub(in crate::ui_chat) enum ParsedChat { - /// `/r [text]` β€” reply to the last received tell (the tell ring is the stock `ChatEdit_*` - /// Lua's since 1948 β€” see [`crate::ui_chat::edit`]). + /// `/r [text]`: reply to the last tell. Reply { text: String }, - /// `/join [password]` (aliases /channel /chan β€” SLASH_JOIN). + /// `/join [password]`. Join { name: String, password: String }, - /// `/leave ` (aliases /chatleave /chatexit). + /// `/leave `. Leave { name: String }, - /// `/chatlist ` (aliases /chatwho /chatinfo) β€” the member roster ask. + /// `/chatlist `: the channel's member roster. ChatList { name: String }, - /// `/random [min] [max]` (aliases /rand /rnd /roll): bare = 1-100, one number = 1-N. + /// `/random [min] [max]`: bare is 1-100, one number is 1-N. Random { min: u32, max: u32 }, - /// `/played` β€” CMSG_PLAYED_TIME. + /// `/played`: `CMSG_PLAYED_TIME`. Played, - /// `/shot` β€” the director's framing instrument (decision 0600): dump the CURRENT world-camera - /// pose as a ready-to-paste capture `Scenario` (raw WoW eye/look + the rendered game minute) - /// into chat and `benilla-config/shots.txt`. Client-local, no wire traffic β€” how a chosen spot - /// travels from the director's eye to the golden set as exact numbers. + /// `/shot`: the camera pose as a capture `Scenario` line, to chat and `shots.txt`. Shot, - /// `/liquid` β€” the swim diagnostic (decision 0634 follow-up): dump the interior claim, the - /// resolved liquid verdict, and every candidate footprint over the player's feet. Client-local, - /// no wire traffic. Built when the canal-tunnel "swim in air" survived the first fix β€” the - /// question "which surface is claiming this spot, and from which file" had no instrument. + /// `/liquid`: the interior claim, liquid verdict and candidate footprints at the feet. Liquid, - /// `/reaction [name]` β€” the attackability diagnostic: every input the reaction ladder judges - /// the subject on, the rung that decided, and the resulting `can_attack` verdict. Bare uses - /// the current target; a name resolves a streamed player (what a scripted probe can reach). + /// `/reaction [name]`: every input and rung of the reaction ladder for one unit. Reaction { name: Option }, - /// `/convertraid` β€” convert the party to a raid (`CMSG_GROUP_RAID_CONVERT`, leader only, - /// server-judged). A benilla addition: 1.12's only trigger is the RaidFrame tab's Convert - /// button, not built yet β€” see the [`SlashIndex::ConvertRaid`] doc. + /// `/convertraid` (`CMSG_GROUP_RAID_CONVERT`), benilla's own: 1.12 converts only from the + /// RaidFrame's Convert button. ConvertRaid, - /// `/help` (aliases /h /?) β€” the command summary. + /// `/help`. Help, - /// A `/name` line that resolved in `EmotesText` (`/wave` β†’ text id 101) β€” sent as - /// `CMSG_TEXT_EMOTE` targeted at the current selection. + /// An `EmotesText` command (`/wave` is 101), sent as `CMSG_TEXT_EMOTE` at the selection. TextEmote(u32), - /// A channel verb the stock `ChatFrame.lua` handler already parsed and the VM queued - /// (`JoinChannelByName`, `ChannelKick`, … β€” `benilla_ui::script::ChannelCommand`). Never - /// produced by [`parse_line`]; it enters the executor from the engine's queue. + /// A channel verb the stock handler parsed and the VM queued; never from [`parse_line`]. Channel(benilla_ui::script::ChannelCommand), - /// The `/castvis` **dev instrument** (decision 0099 phase 2): synthesize a cast edge locally - /// β€” the exact [`crate::creature_anim::CastEvent`] the wire would produce β€” on the selection - /// (else self), no server round-trip. Runtime-available like every current instrument - /// (`ui_pass::demo_enabled`'s note: decision 0026's compile-time `dev` feature is the recorded - /// target seam, not yet built); it moves behind that feature when 0026 phase 1 lands. + /// `/castvis`: a locally synthesized [`crate::creature_anim::CastEvent`], at the selection + /// or self. CastVis { spell_id: u32, kind: crate::creature_anim::CastEventKind, - /// `ground` β€” send the GO as a **pure dest cast**: empty hit and miss lists, a point on - /// the wire. That is the only shape that reaches the location fallback (one projectile at - /// the point, the ground arrival), so without it the instrument cannot see Flare or a - /// bomb thrown at empty dirt at all. Ignored on the non-GO edges. + /// A pure dest cast (no hits, a point): the only shape that reaches the location fallback. ground: bool, }, - /// A command whose reference handler is a **one-line call** into FrameXML β€” `/trade` - /// (`InitiateTrade("target")`), `/inspect` (`InspectUnit("target")`), the loot-method trio - /// (`SetLootMethod(…)`), and `/script`/`/run` (the ref's `RunScript(msg)`, where the typed text - /// IS the chunk). Running the reference's own call is the 0668 posture: the panel-opening, - /// permission and error behaviour stays in the FrameXML that owns it instead of being - /// re-derived in Rust. + /// A reference handler that is one Lua call (`InitiateTrade("target")`, `RunScript(msg)`, …). Lua { body: String }, - /// `/quit` `/exit` β€” the reference's `Quit()`: leave the game entirely, through the same - /// session queue (and the same confirmation/countdown) the game menu's Exit button uses. + /// `/quit`: `Quit()`, through the game menu Exit button's queue and countdown. Quit, - /// `/logout` β€” leave the world back to character select (`CMSG_LOGOUT_REQUEST`, decision 0193); - /// the vanilla client's own command. The confirmed round-trip (`SMSG_LOGOUT_COMPLETE`) flips - /// [`crate::char_select::ClientState`] back to the roster screen. + /// `/logout`: `Logout()`, the game menu's route back to character select. Logout, - /// `/chattest` β€” the 0288 instrument: pump one synthetic line of every kind/notice/link form - /// through the REAL router+composer, so the whole surface is eyeballable in one screen with - /// no server choreography. Dev-runtime like `/castvis` (decision 0026's compile-time gate is - /// the recorded target seam). + /// `/chattest`: one synthetic line of every kind, notice and link form. ChatTest, - /// `/invite` `/inv` `/i` β€” party invite by name (`CMSG_GROUP_INVITE`). `None` = bare, which - /// falls back to the selected PLAYER's name (the ref's `GetSlashCmdTarget` law, - /// ChatFrame.lua:650-658 β€” no name and no player target is a silent no-op). + /// `/invite` (`CMSG_GROUP_INVITE`): bare falls back to a selected player's name, else does + /// nothing (`GetSlashCmdTarget`, `ChatFrame.lua:650-658`). Invite { name: Option }, - /// `/uninvite` `/un` `/u` `/kick` β€” kick by name (`CMSG_GROUP_UNINVITE`), same - /// name-or-target law. + /// `/uninvite` (`CMSG_GROUP_UNINVITE`), with the same fallback. Uninvite { name: Option }, - /// `/promote` `/pr` β€” hand leadership over (`CMSG_GROUP_SET_LEADER`), same name-or-target - /// law; the 1.12 wire takes a guid, resolved against the roster at dispatch. + /// `/promote` (`CMSG_GROUP_SET_LEADER`, a guid resolved from the roster), same fallback. Promote { name: Option }, - /// `/duel [name]` β€” challenge a player (the duel spell cast at them). `None` = bare, which - /// falls back to the selected PLAYER exactly like the party commands do β€” the ref's - /// `SlashCmdList["DUEL"]` is `if GetSlashCmdTarget(msg) then StartDuel(...)`, so no name and - /// no player target is a silent no-op. + /// `/duel [name]` (`StartDuel`), with the same fallback. Duel { name: Option }, - /// `/forfeit` `/concede` `/yield` β€” `CancelDuel()`. Takes no argument and needs no state - /// check: one opcode covers decline/cancel/forfeit and the server reads the intent. + /// `/forfeit`: `CancelDuel()`, one opcode for decline, cancel and forfeit. Forfeit, - /// `/pvp` β€” `TogglePVP()` (decision 0646 Β§3). Takes no argument: the binding has no state - /// form, and the server reads the toggle from our current preference. + /// `/pvp`: `TogglePVP()`. Pvp, - /// `/partytest [lead|raid|invite|mark|ping|off]` β€” the party-frame dev instrument (decision 0434, the - /// `/chattest` pattern): a synthetic roster through the real apply path (`lead` = the same - /// roster with US leading, for the leader-only popup rows), a fake pending invite for the - /// popup, a local skull on the current target (the mark renders without a server echo), or - /// clear. While the roster is synthetic the drain runs in SANDBOX mode - /// ([`crate::ui_party::GroupState::test`]): the popup's group-mutating rows β€” promote, - /// kick, leave, loot settings, raid marks β€” apply to the local mirror, so the whole menu - /// surface is exercisable serverless. `raid` is the same idea one level out (decision 1549): - /// a synthetic 25-member RAID with us leading, which is the only way the Raid tab's 8Γ—5 grid - /// is eyeballable without forty accounts β€” the drag, Ready Check and the kick all apply to - /// the local mirror there too. `ping` (decision 1596) seats a synthetic *group member's* - /// minimap ping 35 yd north-east β€” the one leg of the ping that otherwise needs a second - /// client. + /// `/partytest [lead|raid|invite|mark|ping|off]`: a synthetic party or 25-member raid, a fake + /// invite, a local skull on the target or a member's minimap ping. While the roster is + /// synthetic, the popup's group-mutating rows apply locally + /// ([`crate::ui_party::GroupState::test`]). PartyTest { arg: String }, - /// `/target [name]` β€” select by name (`TargetByName`, decision 0886). Resolves creatures AND - /// players, case-insensitively, by whole name or longest common prefix, with no range, cone, - /// liveness or hostility gate at all. `None` = bare, which the ref's `GetSlashCmdTarget` turns - /// into your current target's name when that target is a player (a no-op re-select), and into - /// nothing otherwise. + /// `/target [name]` (`TargetByName`): creatures and players, case-insensitive, whole name or + /// longest common prefix, with no range, cone, liveness or hostility gate. Target { name: Option }, - /// `/assist [name]` β€” select whatever the basis unit is targeting. A named basis resolves - /// **players only** (typemask 0x10); bare assists your current target, creature or player. + /// `/assist [name]`: a named basis is a player (typemask `0x10`); bare assists the target. Assist { name: Option }, - /// `/follow [name]` β€” auto-follow (decision 0890). A named subject resolves players only, and - /// only living assistable ones (the ref's filter mode 2); bare follows the current selection, - /// creature or player. Client-side movement β€” nothing goes on the wire. + /// `/follow [name]`: a named subject is a living, assistable player (filter mode 2); bare + /// follows the selection. Client-side, nothing on the wire. Follow { name: Option }, - /// `/macrohelp` β€” the reference's `ChatFrame_DisplayMacroHelpText`: `MACRO_HELP_TEXT_LINE1..5` - /// straight out of the shipped `GlobalStrings.lua` (decision 0983). Resolved in the drain, - /// which holds the VM, so the text can never go stale against the install. + /// `/macrohelp`: `ChatFrame_DisplayMacroHelpText`'s five `MACRO_HELP_TEXT_LINE`s. MacroHelp, - /// `/reload`, or `ReloadUI()` typed through `/script` β€” tear the UI session down and build a - /// new one without leaving the world (decision 1291). Queued on the session seam like - /// [`ParsedChat::Logout`]; [`crate::ui_script::run_pending_reload`] runs it at the top of - /// the next frame. (`/console reloadUI` reaches the same request through the command - /// registry's `reloadUI`.) + /// `/reload`, benilla's own: the in-world rebuild `ReloadUI()` queues. ReloadUi, - /// A `/console` line the engine's CVar store did not consume β€” a command name, or a bare - /// CVar name to print β€” for the command registry ([`crate::console::execute`], decision - /// 2303): the reference's `ConsoleCommand` table, which every subsystem registers into and - /// which answers an unknown line by saying so. + /// A `/console` line the CVar store did not consume, for the `ConsoleCommand` registry + /// ([`crate::console::execute`]). Console { line: String }, - /// A slash line matching neither a chat command nor an `EmotesText` name β€” dropped. + /// A slash line matching neither a command nor an `EmotesText` name. Unknown, } -/// The name half of the ref's `GetSlashCmdTarget` (ChatFrame.lua:650-658) β€” the argument grammar -/// **thirteen** shipped commands share, `/target` and `/assist` among them. -/// -/// Its first line is a trim, not a tokenizer: `gsub(msg, "(%s*)(.*[^%s]+)(%s*)", "%2", 1)` keeps -/// **everything between the outer whitespace**, internal spaces included. This used to take the -/// first word instead, on the reasoning that 1.12 player names carry no spaces β€” true, but -/// `/target` resolves *creature* names too, and "Kobold Vermin" is the common case. `args` reaches -/// us already trimmed ([`parse_line`]), which is exactly what that gsub computes. -/// -/// Empty defers to the caller's fallback (a bare party command acts on the selected PLAYER β€” -/// `target_player_name` in [`super`], the ref's `UnitIsPlayer("target")` branch). -/// -/// **Not carried: the unit-token expansion.** The ref's tail expands `player`, `target`, -/// `^party[1-4]` and `^raid[0-9]` through `UnitName` before returning, so `/assist party1` means -/// "that member's name". benilla feeds only the `player` and `target` tokens to the VM's unit -/// store, so the roster half has nothing to resolve against yet; the tokens would pass through as -/// literal names. Named in decision 0886 as a gap rather than half-built here. +/// The name half of `GetSlashCmdTarget` (`ChatFrame.lua:650-665`), shared by the stock commands +/// that take a name. Its gsub is a trim, not a tokenizer, so "Kobold Vermin" is one name; `args` +/// arrives trimmed. Empty defers to the caller's fallback (`target_player_name`). The reference +/// also expands `player`, `target`, `^party[1-4]` and `^raid[0-9]` through `UnitName`; this +/// does not, so a unit token passes through as a literal name. fn slash_target_name(args: &str) -> Option { (!args.is_empty()).then(|| args.to_string()) } -/// Parse a trimmed slash line into an ACTION (`/join`, `/afk`, `/random`, `/wave`, the dev -/// commands…) through the boot-built command table ([`super::commands`], decision 0881). The -/// chat-TYPE commands (`/say`-family, `/w`, `/r`) never reach here on the send path β€” -/// [`parse_enter_type_switch`] and the reply arm consume them first; a plain no-slash line sends as -/// the box's current type ([`drain_chat_input`]). A non-slash line reaching this fn (unit tests -/// only) is [`ParsedChat::Unknown`]. -/// -/// The command/argument split is `ChatEdit_ParseText`'s (ChatFrame.lua l.2161-2168): the command is -/// the FIRST word, the argument is everything after that first space. That is why `/wave Bob` is a -/// `/wave` carrying an argument β€” before 0881 the emote arm matched the *whole* line against -/// `EmotesText`, so every emote typed with anything after it was an unknown command. +/// Parse a trimmed slash line through the boot-built command table; a line with no slash is +/// [`ParsedChat::Unknown`]. The split is `ChatEdit_ParseText`'s (`ChatFrame.lua:2099-2105`): the +/// command is the first word and the argument the rest, so `/wave Bob` is `/wave` with an argument. pub(in crate::ui_chat) fn parse_line(table: &SlashCommands, line: &str) -> ParsedChat { let Some(rest) = line.strip_prefix('/') else { return ParsedChat::Unknown; @@ -222,34 +135,16 @@ pub(in crate::ui_chat) fn parse_line(table: &SlashCommands, line: &str) -> Parse // The alias table did the `DoEmote(token)` resolve at boot: `/lol` arrives as LAUGH's id. Some(Command::Emote { text_id }) => ParsedChat::TextEmote(text_id), Some(Command::Dev(dev)) => dev_command(dev, args), - // HELP_TEXT_SIMPLE's case β€” a command benilla registers no handler for is indistinguishable - // from one the reference never had, which is the honest answer for both. + // `HELP_TEXT_SIMPLE`'s case, after the drain offers the line to `SlashCmdList`. None => ParsedChat::Unknown, } } -/// `s` as a Lua **short** string literal, escaped β€” the only quoting 1.12's lexer can read for -/// arbitrary text (decision 2136). -/// -/// This used to build a long-bracket literal and step its `=` level past anything the payload -/// could close early: `[=[a]]b]=]`. That works on a Lua 5.1-family lexer and **is a syntax error -/// on the 1.12 client**, whose lexer has no long-string levels at all β€” `read_long_string -/// 0x700010` routes `=` to its default arm as ordinary content, the `[` arm at `0x6ff771` makes -/// exactly one comparison (`cmp eax,0x5b`), and a `[=` therefore leaves the lexer holding the -/// single-character token `[`, which `prefixexp 0x6fde40` refuses with ``unexpected symbol near -/// `['`` (measured on the reference client's own Lua loader). -/// -/// 2101 removed the three 5.1 grammar additions the corpus probes and left the levelled long -/// bracket standing, on a measurement of **zero occurrences** across FrameXML, GlueXML, -/// Blizzard's addons, the director's own folder and the 219-addon corpus. That measurement was -/// right and incomplete in one specific way: it scanned Lua *trees*, and this is Lua *generated -/// by Rust at runtime*, which no source scan could see and the fork's parser cannot reject at -/// compile time either. It is the one live producer of a construct the reference cannot parse, -/// and it fires whenever a `/console` argument contains `]]`. -/// -/// Escaping is what 5.0 leaves: `\` and `"` are self-escaped, the three whitespace controls take -/// their named forms, and every other control byte takes a **three-digit** `\ddd` β€” padded so a -/// following digit cannot be swallowed into the escape. +/// `s` as an escaped Lua short string literal. A long bracket cannot quote it: a `/console` +/// argument may hold `]]`, and the 1.12 lexer has no bracket levels (the `[` arm at `0x6ff771` +/// compares once, `read_long_string` `0x700010` reads `=` as content), so `[=[` fails in +/// `prefixexp` (`0x6fde40`). `\` and `"` escape themselves, `\n` `\r` `\t` take their names, and +/// any other control byte a three-digit `\ddd`, so a following digit is not swallowed. pub(in crate::ui_chat) fn lua_quoted_string(s: &str) -> String { let mut out = String::with_capacity(s.len() + 2); out.push('"'); @@ -270,8 +165,8 @@ pub(in crate::ui_chat) fn lua_quoted_string(s: &str) -> String { out } -/// The per-command argument grammar. Each arm is the reference handler's own body reduced to what -/// it does with `msg` β€” the aliases that reach it are the table's business, never this function's. +/// The per-command argument grammar: each arm is the reference handler's body reduced to what it +/// does with `msg`. fn slash_command(index: SlashIndex, args: &str) -> ParsedChat { use SlashIndex as S; match index { @@ -281,7 +176,7 @@ fn slash_command(index: SlashIndex, args: &str) -> ParsedChat { S::Join => { let (name, password) = args.split_once(char::is_whitespace).unwrap_or((args, "")); if name.is_empty() { - // CHAT_JOIN_HELP (the ref's bare-/join reply) rides the Unknown help line. + // Unknown hands the line to the stock handler, which prints `CHAT_JOIN_HELP`. ParsedChat::Unknown } else { ParsedChat::Join { @@ -300,18 +195,11 @@ fn slash_command(index: SlashIndex, args: &str) -> ParsedChat { Some(name) => ParsedChat::ChatList { name: name.to_string(), }, - // The bare joined-channel listing is P6's (it needs the list). + // Unknown hands a bare line to the stock handler, which runs `ListChannels()`. None => ParsedChat::Unknown, }, - // **The reference's own bodies, not a second implementation** (2088). These used to build - // a bare `ClientCommand::Chat` β€” which was right until the away law landed, and would now - // be a SECOND `/afk` that sends the packet with no echo, no default substitution and no - // mirror write. The stock parser claims a typed `/afk` before this table ever sees it, so - // the only lines still arriving here are the ones that never touched the edit box (a - // `WOW_PROBE_CHAT` rig's), and routing them through `SlashCmdList` funnels them into the - // same `SendChatMessage` seam the player's own keystrokes take β€” one implementation, and - // the probe exercises the real path rather than a shadow of it. Same posture, and the same - // reasoning, as the social verbs below. + // The stock bodies: a probe's `/afk` takes the same `SendChatMessage` path, echo, default + // text and mirror included, as a typed one. S::ChatAfk => social_body("CHAT_AFK", args), S::ChatDnd => social_body("CHAT_DND", args), S::Random => { @@ -330,8 +218,7 @@ fn slash_command(index: SlashIndex, args: &str) -> ParsedChat { S::Help => ParsedChat::Help, S::Logout => ParsedChat::Logout, S::Quit => ParsedChat::Quit, - // The party membership commands (decision 0434) and the duel verbs (0633): a bare command - // falls back to the selected PLAYER (`GetSlashCmdTarget`). + // A bare command falls back to the selected player (`GetSlashCmdTarget`). S::Invite => ParsedChat::Invite { name: slash_target_name(args), }, @@ -344,8 +231,7 @@ fn slash_command(index: SlashIndex, args: &str) -> ParsedChat { S::Duel => ParsedChat::Duel { name: slash_target_name(args), }, - // The by-name selection pair (decision 0886). Both take the whole trimmed argument β€” - // `/target Kobold Vermin` is one name, not a name plus junk. + // The whole trimmed argument is the name: `/target Kobold Vermin`. S::Target => ParsedChat::Target { name: slash_target_name(args), }, @@ -357,19 +243,13 @@ fn slash_command(index: SlashIndex, args: &str) -> ParsedChat { }, S::DuelCancel => ParsedChat::Forfeit, S::Pvp => ParsedChat::Pvp, - // The social verbs (decision 0668): the reference's own `SlashCmdList` bodies, which the - // stock ChatFrame.lua carries since 1948 β€” and whose parser claims these lines before - // they ever reach here, so these rows are the shape kept for the day the arm is pruned - // (1948's follow-on). The argument is passed WHOLE: `/who`'s is a filter expression. + // The stock bodies, with the whole argument: `/who`'s is a filter expression. S::Who => social_body("WHO", args), S::Friends => social_body("FRIENDS", args), S::RemoveFriend => social_body("REMOVEFRIEND", args), S::Ignore => social_body("IGNORE", args), S::Unignore => social_body("UNIGNORE", args), - // The one-line reference bodies over globals benilla already implements (decision 0881, - // the 0668 posture): `InitiateTrade("target")`, `InspectUnit("target")`, - // `SetLootMethod(...)`, `RunScript(msg)`. Running the reference's own call keeps the - // panel-opening/permission behaviour in the FrameXML that owns it. + // The reference's one-line bodies, run as Lua. S::Trade => ParsedChat::Lua { body: "InitiateTrade(\"target\")".into(), }, @@ -382,7 +262,7 @@ fn slash_command(index: SlashIndex, args: &str) -> ParsedChat { S::LootRoundRobin => ParsedChat::Lua { body: "SetLootMethod(\"roundrobin\")".into(), }, - // The ref's LOOT_MASTER is a no-op without a name/target (`if GetSlashCmdTarget(msg)`). + // `LOOT_MASTER` does nothing without a name or player target (`if GetSlashCmdTarget(msg)`). S::LootMaster => match slash_target_name(args) { Some(name) => ParsedChat::Lua { body: format!( @@ -392,10 +272,7 @@ fn slash_command(index: SlashIndex, args: &str) -> ParsedChat { }, None => ParsedChat::Unknown, }, - // `/cast ` = the ref's `SlashCmdList["CAST"]` verbatim: `if msg ~= "" then - // CastSpellByName(msg) end` (ChatFrame.lua:1120). The binding does the book lookup - // (`benilla_ui::script::spellbook::resolve_spell_by_name`) and queues onto the ONE cast - // path, so a typed `/cast` and a macro's `/cast` line are the same code β€” decision 0983. + // `SlashCmdList["CAST"]` (`ChatFrame.lua:1120`): `CastSpellByName(msg)` unless empty. S::Cast => { if args.is_empty() { ParsedChat::Unknown @@ -405,23 +282,17 @@ fn slash_command(index: SlashIndex, args: &str) -> ParsedChat { } } } - // `/macro` `/m` = the ref's own body: `ShowMacroFrame()` (the `RunMacro(msg)` branch beside - // it is commented out in the shipped 1.12 ChatFrame.lua β€” there is no `RunMacro` binding in - // the client at all, confirmed against the registered-binding scan). + // `SlashCmdList["MACRO"]` (`ChatFrame.lua:1112`): `ShowMacroFrame()`. Its `RunMacro` + // branch is commented out, and the 1.12 client registers no `RunMacro`. S::MacroUi => ParsedChat::Lua { body: "ShowMacroFrame()".into(), }, S::MacroHelp => ParsedChat::MacroHelp, - // `/reload` (ours, 1291) and the reference's `/console reloadUI` are the same rebuild. - // The console command match is case-insensitive like the engine's console; `0x4035f0` - // reads no arguments, so anything after `reloadUI` is ignored rather than an error. + // `/reload` is the rebuild `/console reloadUI` runs (`0x4035f0`, which reads no arguments). S::ReloadUi => ParsedChat::ReloadUi, S::ConvertRaid => ParsedChat::ConvertRaid, - // `/errors` (ours, 1495) β†’ the script error log. Routed as Lua rather than a new - // `ParsedChat` variant for the same reason `/macro` is: the window is FrameXML, the - // toggle is a FrameXML function, and a Rust arm would only forward to it. - // `/errors clear` empties the log β€” the reporter's workflow is clear, reproduce, - // screenshot, and without it the shot carries a session's worth of unrelated rows. + // `/errors`, benilla's own: toggles the script error log, a FrameXML window; + // `/errors clear` empties it. S::ScriptErrors => ParsedChat::Lua { body: if args.trim().eq_ignore_ascii_case("clear") { "BenillaScriptLog_Clear()".into() @@ -429,20 +300,13 @@ fn slash_command(index: SlashIndex, args: &str) -> ParsedChat { "BenillaScriptLog_Toggle()".into() }, }, - // `/console ` β€” the stock ChatFrame.lua's handler is one line, `ConsoleExec(msg)`, - // and a TYPED `/console` never reaches this arm: the reference's `ChatEdit_ParseText` - // finds its own `SlashCmdList["CONSOLE"]` first (1948). What does reach it is a line - // that skipped the edit box β€” a `WOW_PROBE_CHAT` rig, or a chain whose ChatFrame.lua - // lacks the handler β€” and 0637's contract is that a probe line is "what the director - // would type". So this forwards to the same verb the stock handler calls: a registered - // CVar name writes the CVar (`fpsJournal 1`, 2008), and `reloadUI` or anything else - // comes back through `engine_verbs` exactly as it does from the Lua route. (Before 2008 - // this arm knew `reloadui` and answered everything else "not implemented" β€” which is - // what a probe's `/console fpsJournal 1` got, while the same line typed worked.) + // `SlashCmdList["CONSOLE"]` (`ChatFrame.lua:671`) is `ConsoleExec(msg)`. A typed line runs + // it; one that skipped the edit box calls the same verb, so a CVar line writes the CVar + // and anything else comes back through `engine_verbs`. S::Console => ParsedChat::Lua { body: format!("ConsoleExec({})", lua_quoted_string(args)), }, - // `/script` = the ref's `RunScript(msg)`: the typed text IS the chunk, un-escaped. + // `RunScript(msg)`: the typed text is the chunk, unescaped. S::Script => { if args.is_empty() { ParsedChat::Unknown @@ -455,13 +319,11 @@ fn slash_command(index: SlashIndex, args: &str) -> ParsedChat { } } -/// Benilla's own instruments' grammar ([`DevCmd`] β€” no reference strings behind these). +/// benilla's own instruments' grammar ([`DevCmd`]), in the command table only in a dev build. fn dev_command(dev: DevCmd, args: &str) -> ParsedChat { match dev { - // `/castvis [go|ground|fail]` β€” bare = Start (the precast hold begins), `go` = - // the release at the selection, `ground` = the release as a pure DEST cast (a point in - // front of the player, no unit targets β€” the ground-missile lane), `fail` = the reap. - // Malformed β†’ Unknown (dropped loudly). + // `/castvis [go|ground|fail]`: bare starts the precast, `go` releases at the + // selection, `ground` releases as a pure dest cast ahead of the player, `fail` reaps. DevCmd::CastVis => { use crate::creature_anim::CastEventKind; let mut words = args.split_whitespace(); diff --git a/crates/benilla-app/src/ui_chat/language.rs b/crates/benilla-app/src/ui_chat/language.rs index 82621c0e..93009f4a 100644 --- a/crates/benilla-app/src/ui_chat/language.rs +++ b/crates/benilla-app/src/ui_chat/language.rs @@ -1,26 +1,10 @@ -//! **The language gate** β€” everything that decides *whether* a chat line is garbled, and by how -//! much. The substitution itself is [`benilla_formats::garble`] (the reference's `0x49b560`); this -//! module is `0x49a870`'s share of the work: the exemptions, and the "how well does this character -//! know that language" answer the routine takes as its second argument. -//! -//! That answer is `0x5ec720`'s. The chain is entirely -//! client-side over server-synced skill data β€” **nothing is pushed as a "language list"**: -//! -//! ```text -//! language id ──(the spell whose Effect_1 == 39 declares it, over the KNOWN spells)──▢ spell id -//! ──(that spell's SkillLineAbility row)──▢ SkillLine.dbc id -//! ──(the player's PLAYER_SKILL_INFO block)──▢ skill value -//! ``` -//! -//! Then `skill >= 300` is fluent and the line passes through untouched; below that every *word* -//! runs its own `hash % 300 < skill` test, so partial skill is graduated rather than a switch. -//! -//! **Why the first hop reads spell β†’ language and not the reverse** is the shipped data, and it is -//! recorded on [`benilla_formats::SpellCatalog::declared_language`]: five spells declare Common -//! rather than their own language, so a whole-DBC map inverted the other way makes *Common* resolve -//! to Old Tongue and garbles everyone's own faction chat. Folding spell β†’ language over the -//! character's own known-spell set is what the reference does (`0x4b2656` runs on spell *add*) and -//! it is the only shape that survives that data. +//! The language gate, `0x49a870`'s share of garbling: the exemptions, and how well the character +//! knows the language, the second argument of the substitution [`benilla_formats::garble`] +//! (`0x49b560`). Fluency is `0x5ec720`'s client-side chain; the server sends no language list: +//! language β†’ the known spell whose effect 39 declares it β†’ its `SkillLineAbility` row β†’ the +//! `PLAYER_SKILL_INFO` value. At 300 a line passes whole; below, each word passes when +//! `hash % 300 < skill`. The fold runs spell β†’ language over known spells only (`0x4b2656`, on +//! spell add): five spells declare Common, so the inverse would map Common to Old Tongue. use std::collections::HashMap; @@ -31,15 +15,11 @@ use crate::net::{ObjectStore, SelfPlayer}; use crate::ui_action::{PlayerActions, Spells}; use crate::ui_spellbook::SkillLines; -/// `PLAYER_FLAGS_GM` β€” descriptor index 190, bit 3 (`0x49a9cc` reads `[[player+0xe68]+8] >> 3 & 1`). +/// The GM bit of `PLAYER_FLAGS` (descriptor 190), as `0x49a9cc` reads it. const PLAYER_FLAGS_GM: u32 = 0x8; -/// The chat types that **force the language to 0** regardless of what the wire said -/// (`0x49a970`–`0x49a986`): EMOTE, SYSTEM, MONSTER_EMOTE and RAID_BOSS_EMOTE. -/// -/// They are narration, not speech β€” "Thrall glares at you" is not spoken in Orcish β€” and forcing 0 -/// suppresses both the garble and the `[Language]` header in one move, since the header keys off the -/// same field. +/// Chat types forced to language 0 whatever the wire says (`0x49a970`-`0x49a986`), which drops +/// both the garble and the `[Language]` header. const ALWAYS_UNIVERSAL: [u8; 4] = [ benilla_protocol::messages::CHAT_MSG_EMOTE, benilla_protocol::messages::CHAT_MSG_SYSTEM, @@ -47,51 +27,31 @@ const ALWAYS_UNIVERSAL: [u8; 4] = [ benilla_protocol::messages::CHAT_MSG_RAID_BOSS_EMOTE, ]; -/// The garble word pool plus the local character's fluency in each language β€” everything -/// [`effective_language`] and [`garble`] need, in one resource so the chat feed spends one system -/// param on the whole gate. +/// The garble word pool and the character's fluency per language. #[derive(Resource, Default)] pub(crate) struct ChatLanguages { - /// `LanguageWords.dbc`, loaded once. `None` while the chain has not been read (or if it could - /// not be), in which case nothing garbles β€” a broken install renders chat plainly rather than - /// blanking it. + /// `LanguageWords.dbc`; while `None`, nothing garbles. words: Option, /// language id β†’ this character's skill in it. Absent = 0 = never understood. skill: HashMap, - /// `PLAYER_FLAGS & 0x8`. A GM sees **everything** plain, and with an empty `[Language]` header - /// β€” the same bit also skips the profanity filter in the reference. + /// `PLAYER_FLAGS & 0x8`: a GM reads every line plain, with no `[Language]` header. gm: bool, - /// Is there a local player object at all? - /// - /// `0x49b560`'s **second** early-out (`0x49b590` β†’ `0x49b597 test eax,eax; je`) is exactly this: - /// with no local player the line is copied verbatim. It matters because the alternative is - /// worse than a divergence β€” with no body we would find no skill row, read 0, and garble - /// *everything* rather than nothing. A line arriving before the self entity exists must render - /// plainly, not as gibberish. + /// With no local player a line is copied verbatim (`0x49b597`), not garbled for want of skill. have_player: bool, } impl ChatLanguages { - /// This character's skill in `language` β€” the second argument of `0x49b560`. + /// This character's skill in `language`, the second argument of `0x49b560`. pub(crate) fn skill(&self, language: u32) -> u32 { self.skill.get(&language).copied().unwrap_or(0) } - /// The language this line is **rendered** as, after the exemptions `0x49a870` applies before it - /// ever reaches the garble routine. `0` means "render plain, and show no `[Language]` header" β€” - /// which is one decision in the reference, not two, because the header reads the same field. - /// - /// The addon sentinel is *not* handled here: `language == -1` never reaches this path at all - /// (it is dropped upstream as addon traffic, decision 1029), and it is a `u32` by the time we - /// see it. - /// **Is the viewer a GM?** β€” `PLAYER_FLAGS & 0x8`, read at the same `0x49a9cc` site the garble - /// gate reads it at. The chat chokepoint's spam arm needs the identical bit (`0x49ab03`), and - /// it is already here, so it is answered from here rather than re-derived from a second - /// descriptor query (decision 2077). + /// Whether the viewer is a GM (`0x49a9cc`); the spam filter reads the same bit (`0x49ab03`). pub(crate) fn is_gm(&self) -> bool { self.gm } + /// The language a line renders as after `0x49a870`'s exemptions; 0 renders plain, no header. pub(crate) fn effective_language(&self, chat_type: u8, language: u32) -> u32 { if self.gm || !self.have_player || ALWAYS_UNIVERSAL.contains(&chat_type) { return 0; @@ -99,8 +59,7 @@ impl ChatLanguages { language } - /// Rewrite a line as this character hears it. Returns `text` unchanged for language 0, for a - /// fluent listener, and when the pool is unavailable. + /// The line as this character hears it: unchanged for language 0, fluency, or no word pool. pub(crate) fn garble(&self, language: u32, text: &str) -> String { let Some(words) = self.words.as_ref() else { return text.to_string(); @@ -109,7 +68,7 @@ impl ChatLanguages { } } -/// Load `LanguageWords.dbc` once at startup ([`crate::ui_unit`]'s `load_default_languages` shape). +/// Load `LanguageWords.dbc` once at startup. pub(super) fn load_language_words( mut langs: ResMut, assets: Option>, @@ -128,18 +87,13 @@ pub(super) fn load_language_words( info!("ui_chat: {} language word pools", words.len()); langs.words = Some(words); } - // Not fatal: with no pool every line renders plain, which is what benilla did before B262. + // Not fatal: with no pool every line renders plain. Err(e) => warn!("ui_chat: language word pools unavailable β€” {e:#}"), } } -/// Recompute the per-language skill map and the GM bit. -/// -/// Cheap and unconditional: the walk is over the character's own language spells (at most a -/// handful) and one pass of the 128-slot skill block per language, and it only runs when the spell -/// book or the self descriptors actually changed. The reference maintains the same information -/// incrementally, at spell-add time; a rebuild is the same answer without a hook, and a language -/// cannot change often enough for the difference to matter. +/// Recompute the per-language skill map and the GM bit when the spell book, the catalogs or our +/// descriptor change; the reference keeps the same map at spell add. pub(super) fn feed_language_skills( mut langs: ResMut, actions: Option>, @@ -148,7 +102,7 @@ pub(super) fn feed_language_skills( self_q: Query, With>, ) { let Ok(store) = self_q.single() else { - // No body β€” the reference's "no local player" edge, which copies verbatim. + // No body: every line renders plain. if !langs.skill.is_empty() || langs.gm || langs.have_player { langs.skill.clear(); langs.gm = false; @@ -156,9 +110,6 @@ pub(super) fn feed_language_skills( } return; }; - // The gate the doc above promises: the map is a function of the spell book, the two - // catalogs and our own descriptor, so a frame with all four still β€” and a player already - // folded in β€” has nothing to rebuild. let inputs_moved = store.is_changed() || actions.as_ref().is_some_and(|a| a.is_changed()) || spells.as_ref().is_some_and(|s| s.is_changed()) @@ -175,25 +126,19 @@ pub(super) fn feed_language_skills( continue; }; let Some(line) = lines.catalog.spell_to_line(known) else { - // The reference's own dead end: 25674 declares Draconic and has no - // `SkillLineAbility` row, so `0x6de040` finds nothing and the language stays - // unknown. Draconic is never understood in 1.12.1. + // Spell 25674 declares Draconic but has no `SkillLineAbility` row (`0x6de040`), + // so Draconic is never understood. continue; }; - // Ascending spell id, and the later write wins β€” the reference's `[0xb700ac][lang] = - // spellId` is a plain store, and the initial spell batch arrives sorted. + // The later spell id wins, as the reference's plain store `[0xb700ac][lang] = spellId` + // over the sorted initial spell batch. skill.insert(language, skill_value(&store, line)); } } if skill != langs.skill || gm != langs.gm || !langs.have_player { - // The instrument for this whole surface, and it earns its line: the gate is invisible from - // the outside β€” a wrong answer here looks like "chat renders fine", which is precisely the - // bug B262 reported. On change only, so it is one line per login, not per frame. - // - // **`gm=true` means nothing will EVER garble**, and GM mode is on by default on `probeN` - // accounts (0679), so a probe hunting this surface reads its own answer here rather than - // concluding the fix did not land. + // Logged on change: a wrong gate looks like plain chat, and `gm=true` means nothing + // garbles. let mut named: Vec = skill.iter().map(|(l, v)| format!("{l}:{v}")).collect(); named.sort(); info!("ui_chat: language fluency {{{}}} gm={gm}", named.join(" ")); @@ -203,12 +148,8 @@ pub(super) fn feed_language_skills( } } -/// Keep the chat frame's own `this.defaultLanguage` in step with the body β€” the **faction** tongue -/// `GetDefaultLanguage()` answers, which is what suppresses the `[Language]` header on your own -/// side's chat ([`super::frames::compose`]). -/// -/// The reference resolves it per call from the live player object; we resolve it on change, which -/// is the same answer without the per-line churn β€” a race cannot change without a new world entry. +/// Keep `this.defaultLanguage` at the faction tongue `GetDefaultLanguage()` answers, which the +/// `[Language]` header test in [`super::frames::compose`] reads. pub(super) fn feed_default_language( mut windows: ResMut, langs: Option>, @@ -226,9 +167,8 @@ pub(super) fn feed_default_language( } } -/// A skill line's effective value out of `PLAYER_SKILL_INFO`, exactly as `0x5ec720` sums it: -/// the base, plus the permanent bonus **only when the base is non-zero**, plus the signed temporary -/// bonus. `0` when the character has no row for the line. +/// A skill's `PLAYER_SKILL_INFO` value as `0x5ec720` sums it: base, plus the permanent bonus only +/// when base is non-zero, plus the signed temporary bonus; 0 with no row. fn skill_value(store: &ObjectStore, line: u32) -> u32 { for i in 0..benilla_protocol::messages::PLAYER_SKILL_SLOTS { let Some(s) = store.0.player_skill(i) else { @@ -263,9 +203,6 @@ mod tests { } } - /// With no body, everything is Universal β€” the reference's second early-out. The failure this - /// prevents is the loud one: no body means no skill rows, so a naive gate would read 0 for every - /// language and garble the whole world instead of none of it. #[test] fn no_local_player_renders_every_line_plainly() { let mut l = langs(&[(7, 300)], false); @@ -291,8 +228,6 @@ mod tests { } } - /// GM mode turns the whole thing off β€” the line renders plain **and** loses its header, because - /// the reference expresses both by forcing the language field to 0. #[test] fn gm_mode_makes_every_line_universal() { let l = langs(&[], true); @@ -308,8 +243,6 @@ mod tests { assert_eq!(l.skill(8), 0, "Demonic, which nobody can learn in 5875"); } - /// With no word pool nothing garbles β€” the pre-B262 behaviour, which is the right failure for a - /// broken install (readable chat beats blank chat). #[test] fn a_missing_pool_renders_plainly_rather_than_blanking_chat() { let l = langs(&[], false); diff --git a/crates/benilla-app/src/ui_chat/logging.rs b/crates/benilla-app/src/ui_chat/logging.rs index 9d1d830f..1cfe12b8 100644 --- a/crates/benilla-app/src/ui_chat/logging.rs +++ b/crates/benilla-app/src/ui_chat/logging.rs @@ -1,17 +1,9 @@ -//! `LoggingChat` / `LoggingCombat` β€” the two log files the `/chatlog` and `/combatlog` handlers -//! toggle (stock `ChatFrame.lua` l.677-693: the handler reads the flag, flips it, and prints -//! `CHATLOGENABLED`/`COMBATLOGENABLED` itself, so the engine's whole job is the flag and the -//! file). -//! -//! The flags live in the VM (`chat_misc`); this is the file end. The reference writes -//! `Logs\WoWChatLog.txt` and `Logs\WoWCombatLog.txt` beside `WTF`; ours are the same two names -//! under `benilla-config/Logs/` ([`crate::local_state::logs_dir`]) β€” the install is read-only -//! (decision 1486), so the folder is ours, not the game's. Lines are appended as the chat window -//! shows them, each stamped `M/D HH:MM:SS.mmm` the way the reference's log reads, in UTC (this -//! process has no local-zone source and would rather be honest than guess an offset). -//! -//! A file that cannot be opened logs once and the flag stays set β€” the Lua printed "enabled", -//! and a silent flip back would make the next `/chatlog` do the opposite of what it says. +//! The `LoggingChat`/`LoggingCombat` files, `WoWChatLog.txt` and `WoWCombatLog.txt`: the stock +//! `/chatlog` and `/combatlog` flip the VM's flag and print the notice (`ChatFrame.lua:675-695`); +//! this appends each line as the window shows it, stamped `M/D HH:MM:SS.mmm` in UTC, as the +//! process has no local time zone. Deviation: the files live in `benilla-config/Logs/`, not the +//! install's `Logs`, because the install is read-only. A file that fails to open leaves the flag +//! set: Lua already printed "enabled". use std::io::Write as _; @@ -19,8 +11,7 @@ use bevy::prelude::*; use benilla_ui::script::UiScript; -/// The open log files, held by the chat windows because that is where every rendered line -/// passes ([`super::frames::route`]). +/// The open log files, kept on the chat windows since every line passes [`super::frames::route`]. #[derive(Default)] pub(crate) struct ChatLogFiles { chat: Option, @@ -28,8 +19,7 @@ pub(crate) struct ChatLogFiles { } impl ChatLogFiles { - /// One rendered line β€” to the combat log when the kind is a combat-log kind, else the chat - /// log; either only when that file is open. + /// Append a rendered line to the combat or the chat log, when that file is open. pub(super) fn record(&mut self, combat: bool, line: &str) { let slot = if combat { &mut self.combat @@ -98,8 +88,8 @@ fn stamp() -> String { ) } -/// Days since 1970-01-01 β†’ `(year, month, day)`, the proleptic Gregorian calendar (Howard -/// Hinnant's `civil_from_days`). +/// Days since 1970-01-01 β†’ `(year, month, day)`, proleptic Gregorian: the `civil_from_days` +/// algorithm. fn civil_from_days(z: i64) -> (i64, u32, u32) { let z = z + 719_468; let era = z.div_euclid(146_097); @@ -130,8 +120,7 @@ pub(super) fn sync_chat_logging( pub(super) fn plugin(app: &mut App) { app.add_systems( Update, - // After the tick: the flags are Lua's (`LoggingChat`/`LoggingCombat`), read the frame - // they move. + // After the UI tick, so a flag Lua moved is read the same frame. sync_chat_logging .after(crate::ui_script::UiInput) .in_set(crate::char_select::InWorldGated), diff --git a/crates/benilla-app/src/ui_chat/net.rs b/crates/benilla-app/src/ui_chat/net.rs index 888866e0..04ed774a 100644 --- a/crates/benilla-app/src/ui_chat/net.rs +++ b/crates/benilla-app/src/ui_chat/net.rs @@ -1,10 +1,6 @@ -//! The chat window's packet handlers (in the net handler table since 2323, moved out of the -//! drain's chat arm file) β€” the spoken line itself, plus the server notices and query answers -//! that render as chat lines (the channel roster, whisper refusals, `/played`). -//! -//! Two handlers here do NOT render as chat: `SMSG_NOTIFICATION` and `SMSG_AREA_TRIGGER_MESSAGE` -//! are the reference's UIErrorsFrame toasts, and queue onto [`UiErrorTexts`]. They live in this -//! file because they are text-carrying server notices, not because they share a sink. +//! The chat window's packet handlers: the spoken line, and the notices and answers that print as +//! chat. `SMSG_NOTIFICATION` and `SMSG_AREA_TRIGGER_MESSAGE` are `UIErrorsFrame` toasts instead, +//! queued on [`UiErrorTexts`]. use benilla_protocol::messages::{ChatMessage, CHAT_MSG_WHISPER}; use benilla_protocol::{SessionEvent, SessionEventKind}; @@ -15,8 +11,7 @@ use crate::net::{ClientCommand, NetCommands, NetHandlerApp, PlayedTimeAnswer, Se use crate::ui_action::{UiErrorKeys, UiErrorTexts}; use crate::ui_social::SocialState; -/// Register the chat window's handlers β€” called from [`super::UiChatPlugin`]. One per kind -/// (the four world broadcasts share one), plus the session-end listener. +/// Register the chat window's handlers and its session-end listener. pub(super) fn register(app: &mut App) { use SessionEventKind as K; app.net_handler(K::Chat, on_chat) @@ -64,8 +59,7 @@ fn on_whisper_refusal(In(ev): In, mut errors: ResMut) } } -/// The four world broadcasts β€” parked for [`super::broadcast`]'s resolve pass, which owns the -/// AreaTable/ServerMessages lookups and the joined-defense-channel walk. +/// The four world broadcasts, parked for [`super::broadcast`]'s resolve pass. fn on_broadcast(In(ev): In, mut chat_log: ResMut) { let b = match ev { SessionEvent::ZoneUnderAttack { area_id } => Broadcast::ZoneUnderAttack { area_id }, @@ -87,9 +81,8 @@ fn on_toast(In(ev): In, mut errors: ResMut) { } } -/// BOTH halves, and they are not redundant. The chat breakdown is our stand-in for the -/// reference's `ChatFrame_DisplayTimePlayed`, which we do not ship; the mailbox is what becomes -/// `TIME_PLAYED_MSG(total, level)` for an addon that asked. +/// Print the `/played` breakdown, and fill the mailbox `feed::played_time_bridge` fires as +/// `TIME_PLAYED_MSG`. fn on_played_time( In(ev): In, mut answer: ResMut, @@ -124,23 +117,13 @@ fn on_random_roll(In(ev): In, mut chat_log: ResMut) { } } -/// The chat log's session state dies with the socket. A listener on the session end -/// (a second handler on the kind, after the bridge's own teardown). +/// Clear the chat log's session state when the session ends. fn on_session_end(In(_): In, mut chat_log: ResMut) { chat_log.clear_session(); } -/// A spoken line (`SMSG_MESSAGECHAT`) β€” the chat window's own feed (decision 0084): -/// [`super`] formats + colors per type, resolves the sender name ask-once, and -/// AddMessages it into ChatFrame1. -/// -/// System lines (`CHAT_MSG_SYSTEM` 0x0A, vmangos `SharedDefines.h`) are the SERVER'S ANSWER to a GM -/// dot-command β€” "Premade gear template N applied", "No matching premade player template found", -/// "There is no such command". For a headless probe that is the only channel the server has to say -/// *why* something did not happen, so it rides at `info!`: at `debug!` it was in the log but -/// invisible at the default level, and a refused command read exactly like an applied one -/// (decision 0651 β€” the rig's whole batch silently no-op'd on a too-low GM level and nothing said -/// so). Ordinary chat stays at `debug!`: conversation, not diagnosis, and high volume. +/// A spoken line (`SMSG_MESSAGECHAT`). System lines (`0x0A`), the server's answers to GM +/// commands, log at `info!` and go out as [`ServerSaidMessage`]; other chat logs at `debug!`. fn chat( m: ChatMessage, chat_log: &mut ChatLog, @@ -148,54 +131,12 @@ fn chat( net_commands: &NetCommands, server_said: &mut MessageWriter, ) { - // The ADDON gate (decision 1029, B215): a line whose `language` is `LANG_ADDON` is not speech - // at all β€” it is one addon talking to another over the party/raid/guild/channel lane, because - // 1.12.1 has no addon opcode and no addon `ChatMsg` type. The real client never renders it; it - // fires `CHAT_MSG_ADDON` instead. Rendering it printed a partied real-client player's - // `[Party] [Soreen]: Quiver VERSION:3.1.4` version ping as ordinary party chat, and a - // mixed-client party makes that constant background noise. - // - // In `WoW.exe` (5875) the test is - // `0x49a89b`/`0x49a89e cmp edi,-0x1`, in the DISPLAY function `0x49a870`, and every branch of - // the addon arm `[0x49a8a7, 0x49a96d)` jumps below the normal render path at `0x49a970` β€” the - // chat frame is unreachable for such a line by construction. The **language field is the whole - // discriminator**: the type byte takes no part in the comparison, and the wire parser - // `0x49d560` never inspects language at all. - // - // **Ignore beats addon**, so that check comes first even though it costs a duplicated call: - // the reference drops an ignored sender's line at `0x49d72d`, *upstream* of the language test, - // so an ignored player's addon traffic fires nothing at all. Unobservable while both paths - // merely drop, but it is the precedence that has to already be right the day there is - // something to fire. (`CMSG_CHAT_IGNORED` is not owed here β€” the server refuses `LANG_ADDON` - // on WHISPER, the only type that answers on the wire.) - // - // **One divergence, named:** the reference suppresses *after* name resolution β€” an uncached - // sender parks the line in `__AUPENDINGCHAT__` (`0x49d7ba`) behind `CMSG_NAME_QUERY`, and - // `CHAT_MSG_ADDON` fires late from the callback `0x49ccc0`. We drop here instead, ahead of the - // name query [`ChatLog::push_wire`] would queue, which saves a name query per addon sender. - // - // **THE CHANNEL IS NOW HALF-OPEN, and that is the state of it β€” not a claim that it works.** - // Decision 1235 landed the SEND half: `SendAddonMessage` composes, validates against the - // client's four-lane whitelist and puts a real `CMSG_MESSAGECHAT`/`LANG_ADDON` on the wire. - // Nothing on THIS side changed, so an addon can broadcast and **cannot hear** β€” including its - // own echo (vmangos's `Group::BroadcastPacket` takes no ignore-guid, so the party lane returns - // a sender's own line, and an addon that sequences on that echo sees nothing). - // - // Opening the receive half is NOT deleting this `return`, which is why it is separate work: - // - // - `CHAT_MSG_ADDON` (event 227, `0x49a95f push 0xe3`) is **not a `ChatTypeInfo` key** and - // cannot ride [`crate::ui_chat::event`]'s pipeline β€” it has its own format string and its - // own 4-argument shape `(prefix, message, distribution, sender)`, against the chat - // family's ten. `every_fired_event_name_is_a_chat_type_info_key` would rightly fail it. - // - `prefix`/`message` come from splitting the text on its **FIRST** tab (`0x49a8d0`); with - // no tab the whole text is the *prefix* and the message is `""`, not the other way round. - // - `distribution` is the remap table `0x49aff4` β†’ jump table `0x49afe0`: only - // PARTY/RAID/GUILD/BATTLEGROUND get names, every other type byte reports `"UNKNOWN"`. - // - the *fire* must move downstream of the name resolve (`0x49ccc0`) so `sender` is a name, - // not a guid. The drop can stay here; the fire cannot. - // - // The payload rides `debug!` and its own `addon` trace tag rather than vanishing, so - // mixed-client traffic stays diagnosable: invisible in chat, never invisible to us. + // Addon traffic: 1.12 has no addon opcode or chat type, so `LANG_ADDON` on any lane is the + // whole test (`0x49a89e`, in the display function `0x49a870`). The line never renders + // (`0x49a970`); it fires `CHAT_MSG_ADDON` once the sender's name resolves (`0x49d7ba`, + // `0x49ccc0`). An ignored sender's fires nothing, as the reference drops ignored lines first + // (`0x49d72d`), and needs no `CMSG_CHAT_IGNORED`: vmangos refuses addon whispers + // (`ChatHandler.cpp:84-103`). if m.is_addon() { if !social.is_ignored(m.sender_guid) { debug!( @@ -203,53 +144,34 @@ fn chat( m.chat_type, m.sender_guid, m.text ); if benilla_assets::trace::enabled() { - // `<-` inbound, matching the `->` the outbound drain writes to this same tag - // (`ui_chat::input::drain_addon_message_sends`): one tag, both directions, so a - // live run's trace reads as the conversation it is. + // `<-` inbound; the outbound drain writes `->` under the same tag. benilla_assets::trace::line( "addon", &format!("<- [{:#04x}] {:?}", m.chat_type, m.text), ); } - // **The channel is whole now.** Parked for the sender-name resolve rather than fired - // here, because the reference fires `CHAT_MSG_ADDON` DOWNSTREAM of the name query - // (`0x49ccc0`) so `sender` is a name and never a guid β€” the one requirement of the - // four that is about WHERE rather than what. `push_addon` carries the other three: the - // first-tab split, the no-tab-means-all-prefix direction, and the four-lane - // distribution remap. chat_log.push_addon(&m.text, m.chat_type, m.sender_guid); } - // The line still never reaches a chat window: an addon message is not speech, and the - // reference's addon arm jumps below the render path (`0x49a970`). An IGNORED sender fires - // nothing at all β€” ignore beats addon, which is why that check still wraps this. return; } if m.chat_type == 0x0A { info!("net: server says β€” {}", m.text); - // …and as a message, for the senders that must know whether their command landed. Server - // state with no descriptor field (god mode) has no other tell. + // Also as a message: server state with no descriptor field (god mode) has no other tell. server_said.write(ServerSaidMessage { text: m.text.clone(), }); } else { debug!("net: chat [{:#04x}] {}", m.chat_type, m.text); } - // …and on the trace clock too (decision 0624). A GM dot-command is the only way to ask the - // SERVER what it believes β€” `.gps` reads back the server-side position of a mover whose packets - // may or may not be reaching it β€” and its answer is only usable if it lands on the same - // timeline as the `snd`/`rly`/`run` lines it must be read against. `debug!` timestamps are - // wall-clock in a different format; this is one clock, one file. + // On the trace clock too, so a `.gps` answer lines up with the movement trace. if benilla_assets::trace::enabled() { benilla_assets::trace::line( "sys", &format!("[{:#04x}] {}", m.chat_type, m.text.replace('\n', " ⏎ ")), ); } - // The ignore gate (decision 0668): an ignored speaker is dropped SILENTLY β€” no line at all β€” - // which is the client's own `FriendList::IsIgnored 0x5ae5a0` check, read on the sibling - // text-emote path (`0x49dbe0`). A dropped WHISPER - // additionally tells the server, so the sender gets the "is ignoring you" answer: that is what - // `CMSG_CHAT_IGNORED` is for, and only the client can send it. + // An ignored speaker is dropped silently (`FriendList::IsIgnored`, `0x5ae5a0`); a dropped + // whisper also sends `CMSG_CHAT_IGNORED`, so the sender hears "is ignoring you". if social.is_ignored(m.sender_guid) { if m.chat_type == CHAT_MSG_WHISPER { let _ = net_commands.0.send(ClientCommand::ChatIgnored { @@ -261,9 +183,8 @@ fn chat( chat_log.push_wire(m); } -/// The `/chatlist` roster (`SMSG_CHANNEL_LIST`) β€” CHAT_CHANNEL_LIST_GET "[%s] " + the roster. -/// Names arrive as guids; v1 renders the count (the per-member resolve fan-out lands with the -/// P6 channel wiring β€” /chatlist is rare enough that a count is honest, never wrong). +/// The `/chatlist` roster (`SMSG_CHANNEL_LIST`). Members arrive as guids and are not resolved, +/// so the line gives their count where the reference lists their names (`0x49c690`). fn channel_list(channel: String, members: &[(u64, u8)], chat_log: &mut ChatLog) { let mut ev = ChatEvent::text_only( ChatEventKind::ChannelList, @@ -273,13 +194,8 @@ fn channel_list(channel: String, members: &[(u64, u8)], chat_log: &mut ChatLog) chat_log.push_event(ev); } -/// A whisper target wasn't online β€” `ERR_CHAT_PLAYER_NOT_FOUND_S`, catalog row 241, whose `%s` -/// the server's own name fills. -/// -/// The KEY travels rather than a composed sentence (decision 2045): this is the net-apply pass and -/// there is no VM here, so `ui_action`'s drain is what resolves it against the player's own -/// `GlobalStrings.lua` β€” and the row, not this call site, is what says the line goes to chat -/// (`kind 0`) and makes no sound. +/// A whisper target is offline: message 241, `ERR_CHAT_PLAYER_NOT_FOUND_S`, with the server's +/// name. The key travels, since there is no VM here; its row routes it to chat. fn chat_player_not_found(name: &str, errors: &mut crate::ui_action::UiErrorKeys) { errors.0.push(crate::ui_action::UiError::s( "ERR_CHAT_PLAYER_NOT_FOUND_S", @@ -287,70 +203,38 @@ fn chat_player_not_found(name: &str, errors: &mut crate::ui_action::UiErrorKeys) )); } -/// A cross-faction whisper was refused β€” `ERR_CHAT_WRONG_FACTION`, catalog row 240. Same route and -/// the same reason as [`chat_player_not_found`], with no argument to fill. +/// A cross-faction whisper was refused: message 240, `ERR_CHAT_WRONG_FACTION`. fn chat_wrong_faction(errors: &mut crate::ui_action::UiErrorKeys) { errors .0 .push(crate::ui_action::UiError::key("ERR_CHAT_WRONG_FACTION")); } -/// A server notice (`SMSG_NOTIFICATION`) β€” the **red UIErrorsFrame line**, never a chat line. -/// -/// Byte-verified in the reference: opcode `0x1cb`'s handler is `0x401800` (registered at -/// `0x40172f`, torn down at `0x401f09`), whose whole body is "read the cstring, then -/// `mov edx,1; lea ecx,[buf]; call 0x4945b0`" plus a console log (`0x63cd00(…, 3, buf)` β€” our -/// `info!` below). `0x4945b0(text, 1)` fires FrameScript event `0xe0` = `UI_ERROR_MESSAGE`. -/// Nothing on this path touches the chat composer. -/// -/// **This used to push into the chat feed**, as a stand-in from before benilla had an errors -/// frame. It has had a real one for a long time β€” the ref `UIErrorsFrame`, a genuine -/// `MessageFrame` (ours until 1751's fourteenth window, the chain's own -/// `Interface\FrameXML\UIErrorsFrame.xml` since) β€” and the stand-in outlived its reason: vmangos -/// `Player::SetGameMaster` answers `.gm on|off` with **both** `SendSysMessage` and -/// `SendNotification` (`Objects/Player.cpp:2676-2677`/`2701-2702`), so a client that sinks the -/// notification into chat prints "GM mode is ON" **twice** where the reference prints it once. +/// `SMSG_NOTIFICATION` (`0x1cb`, handler `0x401800`): the red `UIErrorsFrame` line, +/// `UI_ERROR_MESSAGE` from `0x4945b0(text, 1)`, never a chat line. vmangos sends `.gm on` as both +/// a system line and a notification (`Objects/Player.cpp:2676-2677`). fn notification(text: String, errors: &mut UiErrorTexts) { - // The handler's own console leg, and the same reason 0651 logs the dot-command answers: a - // toast that flashed for five seconds and one that never arrived look identical afterwards. + // The reference also logs it to the console (`0x63cd00`). info!("net: notification β€” {text}"); errors.error(text); } -/// An area trigger's refusal (`SMSG_AREA_TRIGGER_MESSAGE`) β€” "You must be at least level 58 to -/// enter…", "You cannot enter … while in ghost form." The same frame as [`notification`], the -/// **yellow** arm: `0x2b8` shares the multi-opcode handler `0x48f690`, whose arm at `0x48f8ff` -/// ends `xor edx,edx; call 0x4945b0` β€” flag 0, so event `0xe1` = `UI_INFO_MESSAGE`. (The older -/// comment here called it the same sink as the notification and left it at that; the sink is the -/// same, the arm is not.) Without it, a refused portal is silent, which reads exactly like a -/// portal that is still broken. +/// An area trigger's refusal (`SMSG_AREA_TRIGGER_MESSAGE`, `0x2b8`, arm `0x48f8ff` of +/// `0x48f690`): the yellow `UIErrorsFrame` line, `UI_INFO_MESSAGE` from `0x4945b0(text, 0)`. fn area_trigger_message(text: String, errors: &mut UiErrorTexts) { - // Logged for the same reason 0651 logs the server's dot-command answers: a trigger that - // refused and a trigger the client never noticed look identical from outside. info!("net: area-trigger message β€” {text}"); errors.info(text); } -/// The four **world broadcasts** β€” parked on [`ChatLog`]'s broadcast queue for -/// [`super`]'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. +/// Park a world broadcast for `ui_chat::broadcast`'s resolve pass, logged: a defense broadcast +/// outside the defense channels prints nothing, so the log is its only trace. 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). +/// The `/played` answer as two system lines, in English, shaped like `TIME_PLAYED_TOTAL` and +/// `TIME_PLAYED_LEVEL` over `TIME_DAYHOURMINUTESECOND` (`GlobalStrings.lua:4243-4247`). fn played_time(total: u32, level: u32, chat_log: &mut ChatLog) { for (label, secs) in [ ("Total time played", total), @@ -418,29 +302,22 @@ mod tests { world.resource::().pending_len() } - /// B215 / decision 1029: an addon broadcast never reaches the chat window. It arrives as an - /// ordinary `CHAT_MSG_PARTY` β€” the lane a mixed-client party's hunter addon talks over β€” so the - /// `language` sentinel is the only thing standing between it and a rendered `[Party]` line. + /// An addon line rides an ordinary lane; only its `language` keeps it out of the window. #[test] fn addon_chat_never_reaches_the_chat_window() { assert!( !reaches_the_chat_window(a_party_line(LANGUAGE_ADDON, "Quiver\tVERSION:3.1.4")), "an addon broadcast must be dropped before the chat log" ); - // A payload with no tab is still addon traffic β€” the gate reads the language field, never - // the shape of the text. + // No tab, or no text: still addon traffic, since the gate reads the language alone. assert!(!reaches_the_chat_window(a_party_line( LANGUAGE_ADDON, "nopayloadseparator" ))); - // An empty addon payload likewise: nothing about the text can un-addon a line. assert!(!reaches_the_chat_window(a_party_line(LANGUAGE_ADDON, ""))); } - /// ...and since the receive half opened, "never reaches the chat window" must not be allowed to - /// mean "vanished". The same line is PARKED for `CHAT_MSG_ADDON`, which is the distinction that - /// stopped being free the moment `Pending::Addon` existed β€” `pending_len` excludes addon items - /// precisely so the test above keeps asserting what it says, and this one covers the other half. + /// `pending_len` excludes parked addon items; this checks the line is parked, not lost. #[test] fn an_addon_line_is_parked_for_the_addon_event_not_discarded() { let mut world = World::new(); @@ -479,9 +356,6 @@ mod tests { assert_eq!(log.pending_len(), 0, "and still never headed for a window"); } - /// The control the gate must not swallow: real speech on the same lane, in every tongue a - /// character can speak. A gate that keyed on "unknown language" instead of the sentinel would - /// silently eat Demonic and Gutterspeak. #[test] fn speech_on_the_addon_lane_still_renders() { for language in [0, 1, 2, 3, 6, 7, 8, 9, 10, 11, 12, 13, 14, 33] { @@ -492,19 +366,8 @@ mod tests { } } - /// **The GM-mode double line** β€” the director's report, pinned end to end. - /// - /// vmangos answers `.gm on|off` with **two** packets, not one: `Player::SetGameMaster` calls - /// `SendSysMessage(LANG_GM_ON)` *and* `GetSession()->SendNotification(LANG_GM_ON)` - /// (`src/game/Objects/Player.cpp:2676-2677` and `2701-2702`). They carry the same words down - /// different roads, and only the first is a chat line: the reference's `SMSG_NOTIFICATION` - /// handler `0x401800` is "read the cstring, `mov edx,1`, `call 0x4945b0`" β€” event `0xe0` - /// `UI_ERROR_MESSAGE`, the UIErrorsFrame toast β€” plus a console log, and it never reaches the - /// chat composer. Benilla used to park the notice in the chat feed for want of an errors - /// frame; it has had one since our own `ErrorsFrame.xml` (the chain's `UIErrorsFrame.xml` - /// since 1751), and the stand-in was what printed "GM mode is ON" twice. - /// - /// The pair is the test: BOTH halves of one toggle, one chat line, one toast. + /// `.gm on` arrives as a system line and a notification (`Objects/Player.cpp:2676-2677`): one + /// chat line, one red toast. #[test] fn toggling_gm_mode_prints_one_chat_line_and_one_toast() { let sys_line = ChatMessage { @@ -529,10 +392,6 @@ mod tests { ); } - /// The notification's sibling arm shares the frame and **not** the colour: `0x2b8` - /// `SMSG_AREA_TRIGGER_MESSAGE` runs the multi-opcode handler `0x48f690`, whose arm at - /// `0x48f8ff` ends `xor edx,edx; call 0x4945b0` β€” flag 0, so event `0xe1` `UI_INFO_MESSAGE`, - /// the yellow line. This is the half the old "same sink as the notification" comment missed. #[test] fn an_area_trigger_refusal_takes_the_yellow_arm() { let mut errors = UiErrorTexts::default(); diff --git a/crates/benilla-app/src/ui_chat/recruitment.rs b/crates/benilla-app/src/ui_chat/recruitment.rs index 647ac81c..3d473cd1 100644 --- a/crates/benilla-app/src/ui_chat/recruitment.rs +++ b/crates/benilla-app/src/ui_chat/recruitment.rs @@ -1,44 +1,11 @@ -//! The `AUTO_JOIN_GUILD_CHANNEL` cascade β€” the reference's `0x49ea90`, the thing -//! `SetGuildRecruitmentMode(1)` tail-jumps into and the one place the client joins or leaves -//! `GuildRecruitment - City` on its own (decision 2144). -//! -//! **What it is for.** `GuildRecruitment` is the channel *unguilded* players sit in to be found. -//! Its `ChatChannels.dbc` row carries no `INITIAL` bit, so the zone walk never seeds it; instead -//! the option's latch (`[0x843608]`, `1` = AUTO, the shipped default) drives a cascade that reads -//! the local player's own `PLAYER_GUILDID` and acts on the wire: -//! -//! - **guilded** β‡’ leave-by-name `"GuildRecruitment - City"` (`0x49eb17` β†’ `0x49ee70`): -//! `CMSG_LEAVE_CHANNEL` and its `ZONECHANNELS` bit cleared, then `UPDATE_CHAT_WINDOWS`. **No -//! already-a-member check.** The window entry the join registered **stays**: the leave's strip -//! key is the full name, which no window carries (`0x49f017`). -//! - **unguilded, in a capital** (`AreaTable Flags & 0x100`) β‡’ join `"GuildRecruitment"` -//! (`0x49eb55` β†’ `0x49eb70`): a slot, the composed name into chat window 1's list, -//! `CMSG_JOIN_CHANNEL`, then `UPDATE_CHAT_WINDOWS`. **No already-joined check.** -//! - **otherwise** β€” no resolvable zone row, or unguilded outside a capital β‡’ the one-shot -//! `[0xb6e5e4]` is armed and `ZoneChannelRefresh`'s tail consumes it on the next walk -//! (`0x49a6a4`–`0x49a6b2`). -//! -//! Once the join confirms, the server's `YOU_JOINED` ORs bit 24 into the mask (`0x49bbaf`), and -//! from then on **the zone walk owns the channel exactly like Trade** β€” registered before the -//! gate, suspended outside a capital, re-joined on the way back in through the state-3 bypass -//! ([`super::channels::plan_walk`]). The cascade is the *entry*, not the upkeep. -//! -//! **Triggers** (`0x49ea90`'s closed caller census): `SetGuildRecruitmentMode(1)`; the -//! chat-cache loader seating `AUTO`; the local player's `PLAYER_GUILDID` field-change watcher -//! (`0x5e2770`); and every `CGPlayer` create (`0x5dec1e`) β€” which includes the local player's own -//! at login. Ours: the Lua verb's ask, and a watcher over the local player's guild id whose first -//! sight of it *is* the create. The loader's call is subsumed (the watcher fires after the seat, -//! never before, because the walk's zone β€” which the cascade waits on β€” resolves only after the -//! world-entry load). **The per-*remote*-player re-fire is deliberately not modelled**: on the -//! bytes a guilded player re-sends the LEAVE every time another player streams in, which vmangos -//! answers "Not on channel" β€” a notice the stock `ChatFrame_OnEvent` then drops, because no slot -//! and no window carries the channel (`found == 0`). Invisible on screen, a packet per player on -//! the wire, and a quirk of where the hook sits rather than a behaviour; recorded in 2144. -//! -//! **Retry, generalised.** The reference arms a one-shot and the walk's tail consumes it on the -//! next zone change. Ours keeps the request pending and re-evaluates on every frame the zone -//! resolves; the observable is the same β€” it acts on the first zone change into a capital β€” and -//! it does not depend on the walk happening to run. +//! The `AUTO_JOIN_GUILD_CHANNEL` cascade (`0x49ea90`), the one place the client joins or leaves +//! `GuildRecruitment - City` on its own. On AUTO (`[0x843608]`, the default) a guilded player +//! leaves it (`0x49ee70`) and an unguilded one in a capital joins it (`0x49eb70`), with no +//! membership check; otherwise a retry waits for the next zone change. Once joined, the zone walk +//! owns it as it owns Trade. Triggers: `SetGuildRecruitmentMode(1)` and the `PLAYER_GUILDID` +//! watcher (`0x5e2770`), whose first sight is the player create (`0x5dec1e`). Deviation: no re-run +//! when a remote player streams in, because the reference's re-run only repeats the join, which +//! vmangos swallows, or the leave, whose "Not on channel" notice nothing prints. use bevy::prelude::*; @@ -50,23 +17,17 @@ use crate::net::{ClientCommand, NetCommands, ObjectStore, SelfPlayer}; use super::channels::{city_word, ZoneChannelWalk}; use super::edit::ChannelState; -/// `AreaTable.dbc` `Flags & 0x100` β€” vmangos `AREA_FLAG_CAPITAL`. The cascade's own capital test -/// (`0x49eb2e test ch,1`): a *different* bit from the walk's eligibility gate (`0x8`), read here -/// as its own flag because the client reads it as one. In the 1.12.1 data both sit on exactly the -/// same six rows. +/// vmangos `AREA_FLAG_CAPITAL`, the cascade's capital test (`0x49eb2e`); the walk tests `0x8`, +/// which marks the same six rows in 1.12 data. const AREA_FLAG_CAPITAL: u32 = 0x100; /// The cascade's own state: whether a run is owed, and the guild id its watcher last saw. #[derive(Resource, Default)] pub(crate) struct GuildRecruitmentCascade { - /// A run is owed β€” the reference's `[0xb6e5e4]` one-shot and the tail-jump out of `0x49ea70`, - /// folded into one flag: set by every trigger, cleared when the cascade acts or finds the latch - /// off, **kept** while it cannot act (module doc, "Retry, generalised"). + /// A run is owed: the one-shot `[0xb6e5e4]` and the tail-jump from `0x49ea70` in one flag, + /// kept while the cascade cannot act. pending: bool, - /// The local player's `PLAYER_GUILDID` as last observed β€” the field-change watcher - /// `0x5e2770`. `None` until the player streams in, so the first observation is a change: that - /// is the reference's player-create trigger (`0x5dec1e`), which is what runs the cascade at - /// login. + /// The last `PLAYER_GUILDID` seen; `None` makes the first sight a change, the create trigger. last_guild_id: Option, } @@ -76,8 +37,7 @@ impl GuildRecruitmentCascade { self.pending = true; } - /// The watcher: note the local player's guild id, answering whether it moved (the first sight - /// counts). A move requests a run. + /// The watcher: note the guild id, and request a run when it moved or is first seen. pub(super) fn observe_guild_id(&mut self, guild_id: u32) -> bool { if self.last_guild_id == Some(guild_id) { return false; @@ -87,8 +47,7 @@ impl GuildRecruitmentCascade { true } - /// Session end: the next login's first sight of the guild id must be a change again, and a - /// run owed to a character who logged out is not owed to the next one. + /// Session end: forget the owed run and the last guild id. pub(super) fn clear_session(&mut self) { self.pending = false; self.last_guild_id = None; @@ -98,17 +57,16 @@ impl GuildRecruitmentCascade { /// What `0x49ea90` does once the player and the zone row have resolved. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum Cascade { - /// Guilded: leave `GuildRecruitment - City` (`0x49eb0e`–`0x49eb2b`). + /// Guilded: leave `GuildRecruitment - City` (`0x49eb0e`-`0x49eb2b`). Leave, - /// Unguilded, in a capital: join `GuildRecruitment` (`0x49eb2b`–`0x49eb64`). + /// Unguilded, in a capital: join `GuildRecruitment` (`0x49eb2b`-`0x49eb64`). Join, /// Unguilded, not in a capital: arm the retry (`0x49eb33`), no packet. Deferred, } -/// `0x49ea90`'s verdict, pure. `None` = the latch is not AUTO (`0x49ea96`): nothing at all, the -/// retry not even armed. The guild test comes **before** the capital test (`0x49eb0c`), so a -/// guilded player leaves from anywhere. +/// `0x49ea90`'s verdict: `None` when the latch is not AUTO (`0x49ea96`), not even a retry. The +/// guild test comes first (`0x49eb0c`), so a guilded player leaves from anywhere. pub(crate) fn cascade(auto: bool, guild_id: u32, in_capital: bool) -> Option { if !auto { return None; @@ -123,8 +81,7 @@ pub(crate) fn cascade(auto: bool, guild_id: u32, in_capital: bool) -> Option>, mut state: ResMut, @@ -135,14 +92,11 @@ pub(super) fn guild_recruitment_cascade( commands: Res, ) { let Some(mut script) = script else { return }; - // Trigger: `SetGuildRecruitmentMode(1)` β€” `0x49ea70`'s tail-jump, whether or not the value - // moved (the reference's store is unconditional and the jump keys on the new value alone). + // `SetGuildRecruitmentMode(1)` runs it even when the value did not move (`0x49ea70`). if script.take_guild_recruitment_cascade() { state.request(); } - // Trigger: the local player's guild id, first sight included. Only while the avatar is - // streamed β€” a frame between the logout despawn and the teardown must not read as "left the - // guild" (`ui_guild::feed` keeps the same rule). + // Only while the avatar is streamed: a despawned body has not "left the guild". let Some(guild_id) = self_q.iter().next().map(|s| s.0.player_guild_id()) else { return; }; @@ -150,8 +104,7 @@ pub(super) fn guild_recruitment_cascade( if !state.pending { return; } - // The latch and the mask are seated by the same chat-cache restore; before it, the latch is - // the boot value and not the character's. The walk holds on the same condition. + // Wait for the chat-cache restore, which seats the latch and the mask. if channels.zone_mask.is_none() { return; } @@ -171,10 +124,8 @@ pub(super) fn guild_recruitment_cascade( if action == Cascade::Deferred { return; // `0x49eb33`: the retry stays armed } - // The row and its composed name: `0x49f140` composes `Name_lang` against the `"City"` row - // regardless of the zone (the join side is handed the bare shortcut and `0x49eb70` composes - // it again the same way). No such row, or no city word (the walk's own guard against a - // half-formed name): nothing to name, consumed rather than retried. + // The name is composed against the "City" row whatever the zone (`0x49f140`); with no row or + // city word the run is consumed, not retried. let target = channels .channels .rows() @@ -196,8 +147,7 @@ pub(super) fn guild_recruitment_cascade( } Cascade::Join => { info!("chat: guild recruitment cascade β€” unguilded in a capital, joining {name:?}"); - // `0x49eb70`: the slot (`0x49b980`, de-duplicated by name), window 1's list - // (`frameIdx = 0`), then the send β€” unconditional, so a repeat re-sends. + // `0x49eb70`: the slot (`0x49b980`, by name), window 1's list, then the send, always. match channels.claim_slot(&name) { Some(n) => { debug!("chat: {name:?} registered as slot {n}"); @@ -216,20 +166,13 @@ pub(super) fn guild_recruitment_cascade( } Cascade::Deferred => {} } - // Both acting arms fire it (`0x49eb21`, `0x49eb5f`): the stock `ChatFrame_OnEvent` re-reads - // every window's channel list on this event and nothing else does. + // Both acting arms fire it (`0x49eb21`, `0x49eb5f`); chat frames re-read channels on it. script.fire_event("UPDATE_CHAT_WINDOWS", vec![]); state.pending = false; } -/// The cascade's LEAVE arm β€” leave-by-name `0x49ee70` over the composed name -/// `"GuildRecruitment - City"`: the packet and the mask bit (`0x49f10a`/`0x49f11a`). -/// -/// **No window strip.** `0x49ee70` does strip every window's list, but keyed on the DBC Shortcut -/// only when its *argument* matched a shortcut β€” else on the argument verbatim (`0x49eff6` / -/// `0x49f017`). The full name matches no shortcut, and the join arm registered window 1's entry -/// under the Shortcut `"GuildRecruitment"`, so the scan misses in every window and the entry -/// survives. Stripping by the Shortcut here removed an entry the reference keeps. +/// Leave by the full name (`0x49ee70`): the packet and the mask bit (`0x49f10a`/`0x49f11a`). No +/// window loses its entry: the strip matches that name verbatim (`0x49f017`), which none holds. fn cascade_leave(channels: &mut ChannelState, commands: &NetCommands, name: String) { let _ = commands .0 @@ -260,11 +203,6 @@ mod tests { assert_eq!(cascade(true, 0, false), Some(Cascade::Deferred)); } - /// **The cascade's own leave cannot strip its window entry** (`0x49f017`). - /// The join arm registered window 1's entry - /// under the Shortcut `"GuildRecruitment"`; the leave's strip key is its argument, the full - /// `"GuildRecruitment - City"`, which matches no DBC shortcut and so is used verbatim β€” and - /// equals no window entry. The packet goes out and the mask bit clears; the entry stays. #[test] fn the_cascades_leave_keeps_the_windows_entry() { const GUILD_RECRUITMENT: u32 = 25; @@ -303,9 +241,6 @@ mod tests { ); } - /// The watcher: the first sight of the guild id is a change (the player-create trigger), a - /// repeat is not, a move is, and the session end forgets β€” so the next login's first sight is - /// a change again. #[test] fn the_watcher_fires_on_first_sight_and_on_change() { let mut c = GuildRecruitmentCascade::default(); diff --git a/crates/benilla-app/src/ui_chat/settings.rs b/crates/benilla-app/src/ui_chat/settings.rs index b148c135..de620ed4 100644 --- a/crates/benilla-app/src/ui_chat/settings.rs +++ b/crates/benilla-app/src/ui_chat/settings.rs @@ -1,96 +1,43 @@ -//! The chat windows' **saved state** β€” the whole `chat-cache.txt` record: the per-type colour -//! table and, per window, the tint, alpha, font size, lock, dock, shown flag, name, the message -//! types it shows and the channels it carries (decisions 1589 β†’ 1714 β†’ 1948). -//! -//! The state itself lives in the VM ([`benilla_ui::script::ChatWindowLook`] and the chat-type -//! registry, written by the reference's own `SetChatWindow*`, `Add/RemoveChatWindowMessages`, -//! `Add/RemoveChatWindowChannel`, `ChangeChatColor`, and read straight back out of -//! `GetChatWindowInfo`/`GetChatWindowMessages`/`GetChatWindowChannels`/`GetChatTypeIndex`); this -//! module is the two ends the VM cannot own β€” **where it comes from at login and where it goes at -//! logout**. -//! -//! ## The grammar is the reference's -//! -//! 1.12 keeps these in `WTF/Account////chat-cache.txt`, written whole by -//! `0x499a80` and read by `0x498a60` (every line below is the reference's): +//! The chat windows' saved state, the whole `chat-cache.txt` record: the per-type colours and each +//! window's tint, alpha, font size, lock, dock, shown flag, name, message groups and channels. It +//! lives in the VM, written and read by the stock verbs (`SetChatWindow*`, `ChangeChatColor`, +//! `GetChatWindowInfo` and kin); this module loads it at login and saves it at logout, in the +//! grammar of the reference's `WTF/Account////chat-cache.txt` (written whole by +//! `0x499a80`, read by `0x498a60`): //! //! ```text //! VERSION 2 //! ADDEDVERSION 2 //! OPTION_GUILD_RECRUITMENT_CHANNEL AUTO -//! CHANNELS ← the custom channels the client is in, re-joined at login -//! MyChannel -//! END +//! CHANNELS … END ← the custom channels, one per line, re-joined at login //! ZONECHANNELS 18874371 ← the joined zone channels, as bits 1<<(ChannelID-1) -//! COLORS ← the chat-type registry, R G B bytes -//! SAY 255 255 255 -//! … -//! END -//! -//! WINDOW 1 -//! NAME General ← only when a name was stored (name[0] != 0) +//! COLORS … END ← the chat-type registry, one SAY 255 255 255 row per type +//! WINDOW 1 ← then per window: +//! NAME General ← only when a name was stored //! SIZE 0 //! COLOR 0 0 0 0 ← R G B A bytes, from the record's packed BGRA quad //! LOCKED 1 //! DOCKED 1 //! SHOWN 1 -//! -//! MESSAGES ← the enabled groups of the 68-entry CHATMSGGROUP table, table order -//! SYSTEM -//! … -//! END -//! CHANNELS ← this window's CUSTOM channels only β€” a zone channel is never a name -//! END -//! ZONECHANNELS 18874371 ← this window's zone channels, as bits, masked by the joined set +//! MESSAGES … END ← the enabled groups of the 68-entry CHATMSGGROUP table, in order +//! CHANNELS … END ← this window's custom channels; its zone channels are bits +//! ZONECHANNELS 18874371 ← this window's zone channels, masked by the joined set //! END //! ``` //! -//! Two consequences worth stating. **The file is the sole source**: the loader zeroes every -//! window's flags before parsing, so a `MESSAGES` block is the set, not an addition β€” and a -//! window the file does not mention keeps the boot init ([`ChatWindowLook::stock`]). **Zone -//! channels are bits, not names**: the record stores the DBC Shortcut (`General`) with its id, -//! the file stores the bit, and the loader's in-window `ZONECHANNELS` arm turns bits back into -//! `(Shortcut, id)` rows. A file older than `ADDEDVERSION 2` gets the groups added since -//! back-filled (`COMBAT_FACTION_CHANGE`, `MONEY` β€” into window 2), exactly as the loader does. +//! The loader zeroes every window's flags first, so a `MESSAGES` block is the whole set; a window +//! the file omits keeps its boot init ([`ChatWindowLook::stock`]), and a file older than +//! `ADDEDVERSION 2` gets the groups added since. `LOCKED` is an `i32` (`CHATWINDOW+0x8c`) written +//! through `setne`, so only 0 and 1 round-trip. Deviation: `set_chat_window_looks` drops an +//! out-of-range window, where the reference's `WINDOW` bound is off by one (`0x498d1c`, `ja` where +//! the array needs `jae`), because that write would land past the array. //! //! Ours is `benilla-config/chat/-.txt` -//! ([`crate::local_state::chat_character_path`]) in that grammar, so a stock file reads here and -//! ours reads there. The reader is as lenient as the reference's β€” case-insensitive keys, an -//! unknown key skipped rather than failing the line, blank lines nothing β€” and still accepts the -//! one-line `WINDOW 1 SIZE 0 COLOR …` rows the files written before 1948 hold, so no player's -//! saved looks are lost to the grammar change. -//! -//! `LOCKED` is an `i32` in the record (`CHATWINDOW+0x8c`) but the cache writer booleanises it -//! through `setne`, so only `{0,1}` round-trip there β€” which is why ours is a `bool`. -//! -//! **The loader's `WINDOW` bound is off by one in the reference** β€” `0x498d1c` uses `ja` where the -//! array wants `jae`. Ours cannot: the index is bounds-checked at the seam that consumes it -//! (`set_chat_window_looks` uses `get_mut`), and an out-of-range window is simply dropped. -//! -//! ## The login events -//! -//! The loader fires `UPDATE_CHAT_WINDOWS` once and then `UPDATE_CHAT_COLOR` for **every** registry -//! entry β€” file or no file (`0x4996b9`, `0x499934`). The first is what `FloatingChatFrame_Update` -//! docks, hides and colours the windows from; the second is how a saved colour reaches -//! `ChatTypeInfo` and repaints the lines already in the window (`ChatFrame_OnEvent`'s arm). So does -//! ours, once per character per VM. -//! -//! ## Why per character, and not a CVar -//! -//! Both halves matter. *Per character*, because it is where the reference puts it and because it -//! is what the setting means β€” a raid alt reading a 40-man combat log wants a solid box where a -//! questing alt wants glass. And *not a CVar*, because `SetChatWindowAlpha` is the API 1.12 addons -//! are written against: an addon that reads a window's alpha calls `GetChatWindowInfo`, and -//! routing benilla's store through `config.toml` instead would have given the same player setting -//! two different names depending on who asked. -//! -//! ## The write posture -//! -//! **Debounced by one quiet second, plus both session edges** β€” the colour picker's opacity slider -//! drives `FCF_SetChatWindowOpacity` on *every drag step*, so this is a slider, not a discrete -//! edit: [`crate::cvars`]'s `SAVE_QUIET` reasoning applies verbatim ("long enough to coalesce a -//! slider drag, short enough that a crash loses one gesture, not a session"). The edges are -//! `OnExit(InWorld)` and `AppExit`, the same two the camera pose and the saved variables use. +//! ([`crate::local_state::chat_character_path`]); its reader is as lenient as the reference's +//! (keys case-insensitive, unknown keys and blank lines skipped) and also reads benilla's older +//! one-line `WINDOW 1 SIZE 0 COLOR …` rows. `UPDATE_CHAT_WINDOWS`, then `UPDATE_CHAT_COLOR` per +//! registry entry, fire file or no file (`0x4996b9`, `0x499934`). Saves wait one quiet second, as +//! the opacity slider writes on every drag step, and flush at the session end and at `AppExit`. use std::path::PathBuf; @@ -102,12 +49,10 @@ use super::edit::zone_bit; use crate::net::{ClientCommand, NetCommands}; use crate::ui_script::VmMemo; -/// How long a dirty look sits before the save fires. [`crate::cvars`]'s own constant and its own -/// reasoning β€” an opacity drag is exactly the gesture it was sized for. +/// How long a dirty look sits before the save fires, as in `crate::cvars`. const SAVE_QUIET: std::time::Duration = std::time::Duration::from_secs(1); -/// The file's header β€” where these values come from and where the law lives. Comment lines; the -/// reference's reader has none, ours skips them. +/// The file's header, as comment lines: the reference's reader has no comments, ours skips them. const HEADER: &str = "\ # benilla chat cache (decisions 1589, 1948) β€” the reference's chat-cache.txt grammar (written by # 0x499a80, read by 0x498a60): the custom channels to re-join, the joined zone channels as bits, the @@ -117,59 +62,33 @@ const HEADER: &str = "\ # Written whole; the tab menu, /join and ChangeChatColor are what move it. "; -/// **The writer generation, carried in the header comment** (decision 2120) β€” the one thing in -/// this file that is ours and not the reference's grammar, and it is a repair marker, not a -/// format version. -/// -/// Every file this client wrote before 2120 composed its `ZONECHANNELS` words from the LIVE -/// channel roster. A save taken while that roster was momentarily empty β€” the session-end flush -/// racing `end_session_channels` on the same unordered `OnExit(InWorld)` edge β€” wrote -/// `ZONECHANNELS 0` in the header *and*, through `window bits AND header mask`, in every window -/// block. The next login then rebuilt window 1 with no channels, and the stock -/// `ChatFrame_OnEvent` silently drops every `CHANNEL*` line a window does not carry β€” so the -/// character lost its `Joined Channel:` notices and all General/Trade speech, permanently, because -/// nothing but the loader's no-file path ever seeds a window's channel list. -/// -/// A file without this line is one of those. [`restore_chat_looks`] re-seeds window 1 from -/// `ChatChannels.dbc`'s `INITIAL` rows exactly as the no-file path does, and the next save stamps -/// the marker, so the repair fires once per character and then never again. Bump it only for -/// another repair of our own making; it is a comment line, so a reference client reading this file -/// skips it like the rest of the header. +/// benilla's repair marker, a header comment line, not the reference's grammar. A file without it +/// may hold `ZONECHANNELS 0` words from an empty roster, which strip window 1 of its channels; +/// [`restore_chat_looks`] re-seeds such a file once, and the next save stamps the marker. const WRITER_GENERATION: &str = "# benilla-writer 2 (decision 2120)"; /// Which character's file we are on, where it lives, and whether it is owed a write. #[derive(Resource, Default)] pub(super) struct ChatWindowFile { path: Option, - /// The `(realm, character)` [`Self::path`] was built for. Session-keyed (1290) like the macro - /// and binding loads: the *same* character coming back still meets a fresh VM whose look table - /// is back at the stock row. + /// The `(realm, character)` [`Self::path`] was built for, per VM: a fresh VM restores again. identity: VmMemo>, - /// Whether **this VM** has unsaved writes. Session-keyed for a reason that is one-way and - /// therefore worth the wrapper: the values live in the VM, so a plain `bool` surviving a VM - /// replacement would let a save compose the player's file out of a table that is back at the - /// stock row β€” the "refusing to compose the file from nothing" hazard `crate::cvars` guards - /// against, one store over. A fresh VM starts undirty and cannot write until Lua writes. + /// Whether this VM has unsaved Lua writes, per VM: a replaced VM's stock table must not save. dirty: VmMemo, last_change: Option, } -/// What a file parses to: the windows it names (0-based index, the record), the `COLORS` rows -/// it carries in file order, and the custom channels its header lists for re-joining. +/// A parsed file: its windows (0-based), its `COLORS` rows in file order, its custom channels. #[derive(Debug, PartialEq, Eq)] pub(super) struct Parsed { pub(super) looks: Vec<(usize, ChatWindowLook)>, pub(super) colors: Vec<(String, [u8; 3])>, pub(super) joined: Vec, - /// The header's `ZONECHANNELS` word β€” the reference's `ds:0xb6e5e0`, loaded from the file as - /// an overwrite (`0x498d83`). `None` when the file carried no such line, which is what makes - /// the loader fall back to the DBC seed rather than to a mask of 0 (decision 2120). + /// The header's `ZONECHANNELS` word, the reference's `[0xb6e5e0]`, overwritten from the file + /// (`0x498d83`); `None` without the line, so the loader seeds from the DBC rather than 0. pub(super) zone_mask: Option, - /// `OPTION_GUILD_RECRUITMENT_CHANNEL` β€” the auto-join latch `GetGuildRecruitmentMode` returns - /// (decision 2115). `STANDARD` is 0 and **anything else, a missing word included, is 1**: that - /// is the reference's own reading (`"STANDARD"` takes the - /// `0x49ea70(ecx=0)` leg and every other word takes `ecx=1`), and it is why a file with no such - /// line at all reads as AUTO here, exactly as it does there. + /// `OPTION_GUILD_RECRUITMENT_CHANNEL`, the latch `GetGuildRecruitmentMode` returns: `STANDARD` + /// is 0 and any other word, or none, is 1 (`0x49ea70`, `ecx=0` only for `STANDARD`). pub(super) guild_recruitment_auto: bool, } @@ -180,27 +99,16 @@ impl Default for Parsed { colors: Vec::new(), joined: Vec::new(), zone_mask: None, - // The boot value, and it is evidence rather than a guess: all 33 `chat-cache.txt` - // files the reference client itself wrote in this repo's install say `AUTO`, on - // characters that never opened the option. + // The boot value: the reference writes `AUTO` for characters that never set it. guild_recruitment_auto: true, } } } -/// Render the file exactly as the writer does (`0x499a80`), window order. -/// -/// `custom` is [`super::edit::ChannelState::custom`] and supplies the header's `CHANNELS` names β€” -/// **the custom channels only**: a zone channel is never written as a name, it travels as its bit -/// (`0x499ba1`, which is why all 34 stock files have an empty per-window -/// `CHANNELS` block beside a non-zero `ZONECHANNELS`). Durable, not the live roster, for the -/// mask's own reason: the session end clears the roster before the flush reads it. -/// -/// `zone_mask` is [`super::edit::ChannelState::zone_mask`], written raw into the header -/// (`0x499c19`) and ANDed with each window's own bits for its block (`0x49a133`/`0x49a138`). It is -/// **passed in rather than derived from the live roster** β€” decision 2120, and the whole bug: derived, it -/// was 0 on any save taken while the roster was empty, and `window AND 0` erased the window's -/// channel list for good. +/// Render the file as the writer does (`0x499a80`). `custom` fills the header's `CHANNELS`, custom +/// channels only: a zone channel travels as its bit (`0x499ba1`). `zone_mask` goes raw into the +/// header (`0x499c19`) and is ANDed with each window's bits (`0x49a133`, `0x49a138`). Both are the +/// durable lists, never the live roster, which the session end clears before the flush reads it. fn render( looks: &[ChatWindowLook], colors: &[ChatTypeColor], @@ -209,11 +117,10 @@ fn render( guild_recruitment_auto: bool, ) -> String { let mut out = String::from(HEADER); - // The repair marker (decision 2120) β€” a comment line, so the reference's own reader skips it. + // The repair marker, a comment line. out.push_str(WRITER_GENERATION); out.push('\n'); - // The latch is the live one now, not a literal (decision 2115): `SetGuildRecruitmentMode` - // writes it and this is the one place it persists. + // The latch `SetGuildRecruitmentMode` writes; this is the one place it persists. let recruitment = if guild_recruitment_auto { "AUTO" } else { @@ -276,7 +183,7 @@ fn render( enum Block { /// Top level, or inside a `WINDOW` whose keys arrive one per line. Top, - /// The header's `CHANNELS … END` β€” the custom channels to re-join. + /// The header's `CHANNELS … END`: the custom channels to re-join. Joined, Colors, Messages, @@ -334,19 +241,9 @@ fn parse(text: &str, rows: &[(u32, String)]) -> Parsed { if is_end { block = Block::Top; } else if let Some((_, look)) = current.as_mut() { - // The loader takes the first word, id 0 β€” **unless the word is a - // `ChatChannels.dbc` Shortcut**, in which case it is a zone channel that lost - // its id and this restores it (decision 2130). - // - // Not a heuristic: the reference's engine resolves every name through the - // shortcut walk before it stores one (`AddChatWindowChannel 0x4a1000`), - // so a *custom* entry can never be spelled exactly - // like a built-in shortcut β€” all 34 stock files carry an empty per-window - // `CHANNELS` block beside a non-zero `ZONECHANNELS`. Ours could, because the - // VM's catalog was still empty when the window was written, and `General` came - // back as a custom channel with the id the id-match at `ChatFrame.lua:1379` - // needs. The write side is fixed; this is what heals the files it already - // damaged, and it is idempotent β€” a healed file has no such name to match. + // A word that is a `ChatChannels.dbc` Shortcut is a zone channel that lost its + // id: the reference never stores one by name (`AddChatWindowChannel` + // `0x4a1000` resolves shortcuts first), and `ChatFrame.lua:1379` matches ids. let id = rows .iter() .find(|(_, shortcut)| shortcut.eq_ignore_ascii_case(head)) @@ -371,7 +268,7 @@ fn parse(text: &str, rows: &[(u32, String)]) -> Parsed { continue; } let mut look = ChatWindowLook::stock(index - 1); - // The pre-1948 one-line row: the keys follow on the same line. + // benilla's older one-line row: the keys follow on the same line. while let Some(key) = it.next() { apply_key(&mut look, key, &mut it, rows); } @@ -384,18 +281,15 @@ fn parse(text: &str, rows: &[(u32, String)]) -> Parsed { } else if head.eq_ignore_ascii_case("ADDEDVERSION") { added_version = it.next().and_then(|v| v.parse().ok()).unwrap_or(0); } else if head.eq_ignore_ascii_case("ZONECHANNELS") { - // The reference's top-level arm is an OVERWRITE of `ds:0xb6e5e0` (`0x498d83`), not - // an OR β€” the file is the whole truth about which zone channels this character - // holds (decision 2120). + // Overwrites `[0xb6e5e0]` (`0x498d83`), never ORs: the file holds the whole mask. out.zone_mask = it.next().and_then(|v| v.trim().parse::().ok()); } else if head.eq_ignore_ascii_case("OPTION_GUILD_RECRUITMENT_CHANNEL") { - // The reference's own test, whole and case-folded: `STANDARD` is the only word - // that means 0; every other word β€” and no word at all β€” means 1 (decision 2115). + // The reference's test, whole-word and case-folded: only `STANDARD` means 0. out.guild_recruitment_auto = !it .next() .is_some_and(|w| w.eq_ignore_ascii_case("STANDARD")); } - // VERSION is the reference's and not read here. + // `VERSION` is not read. continue; }; if is_end { @@ -413,9 +307,8 @@ fn parse(text: &str, rows: &[(u32, String)]) -> Parsed { } } flush(&mut current, &mut out); - // The loader's EOF back-fill (`0x49967c`): a file older than the groups added since gets them, - // into window 1 for the first ten groups and window 2 otherwise β€” the two `addedVersion` rows - // are both window 2's. + // The loader's EOF back-fill (`0x49967c`): an older file gets the groups added since, the + // first ten into window 1 and the rest, both `addedVersion` rows among them, into window 2. if added_version < 2 { for (i, (name, on, ver)) in MESSAGE_GROUPS.iter().enumerate() { if *on && *ver > added_version { @@ -492,31 +385,13 @@ fn shortcut_rows(channels: &super::edit::ChannelState) -> Vec<(u32, String)> { .collect() } -/// Restore the file into a fresh VM β€” once per character per VM β€” and fire the loader's two -/// events, file or no file. -/// -/// **This runs INSIDE the world-entry UI load, not from `Update`** (decision 2119), and the two -/// events are why. It is the only caller of `UPDATE_CHAT_WINDOWS`, and that event is the only -/// thing that registers a chat frame for any `CHAT_MSG_*` β€” `ChatFrame_OnEvent`'s arm (ref -/// `ChatFrame.lua` l.1261-1273) is what calls -/// `ChatFrame_RegisterForMessages(GetChatWindowMessages(this:GetID()))`. Anything fired before it -/// reaches no window and is gone with no trace (1784: a rejected `AddMessage` is a silent skip). -/// As an `Update` system this landed on the same frame the parked VM came back and the whole -/// queued login burst drained, with no ordering between them β€” so the server's MOTD, sent as -/// `CHAT_MSG_SYSTEM` right after `SMSG_LOGIN_VERIFY_WORLD`, was routed at a window registered for -/// nothing. Measured on a live login: `net: server says β€” Welcome to World of Warcraft!` at -/// `…45.155929`, this restore at `…45.157625`. -/// -/// It is also the only caller of the `UPDATE_CHAT_COLOR` burst, and that one has to precede -/// `PLAYER_LOGIN`. The burst's `WHISPER` row mirrors itself into `ChatTypeInfo["REPLY"]` -/// (`ChatFrame.lua` l.1357-1365) β€” an entry the engine's 94-row registry does not carry, so its -/// `.id` is **0** (`ui_script::chat_tests` asserts exactly that) β€” and `UpdateColorByID(0, …)` -/// repaints every line already in the window whose id is 0, which is every line printed with no -/// explicit colour. `AceConsole-2.0`'s `Print` is exactly that call -/// (`AddMessage(text, nil, nil, nil, nil, 5)`), so running the burst after the addons had printed -/// repainted their login lines whisper-pink: measured `(255,128,255)` on Bartender2's login line -/// against the reference's `(255,255,255)`, in a run where the same `AddMessage` a second later -/// came out white. +/// Restore the file into a fresh VM, once per character per VM, and fire the loader's two events, +/// file or no file. Called from the world-entry UI load, not `Update`, for those events: +/// `UPDATE_CHAT_WINDOWS` registers a window for any `CHAT_MSG_*` (`ChatFrame.lua:1261-1273`), so +/// it must precede the server's MOTD after `SMSG_LOGIN_VERIFY_WORLD`, and the `UPDATE_CHAT_COLOR` +/// burst must precede `PLAYER_LOGIN`: its `WHISPER` row also repaints `ChatTypeInfo["REPLY"]` +/// (`ChatFrame.lua:1357-1365`), whose `.id` is 0, and `UpdateColorByID(0, …)` recolours every line +/// printed with no explicit colour, an addon's login `Print` among them. pub(crate) fn restore_chat_looks(world: &mut World, script: &mut UiScript) { let Some(id) = world .get_resource::() @@ -524,9 +399,7 @@ pub(crate) fn restore_chat_looks(world: &mut World, script: &mut UiScript) { else { return; }; - // The `ChannelState` reads are taken as owned rows up front: the restore needs the DBC - // shortcut table and the auto-join rows while it also holds `ChatWindowFile` mutably, and a - // `&mut World` hands out one resource borrow at a time. + // Owned rows up front: `ChatWindowFile` is borrowed mutably below. let Some(channels) = world.get_resource::() else { return; }; @@ -537,17 +410,16 @@ pub(crate) fn restore_chat_looks(world: &mut World, script: &mut UiScript) { .map(|r| (r.shortcut.clone(), r.id)) .collect(); let commands = world.get_resource::().map(|c| c.0.clone()); - // The DBC seed β€” every `ChatChannels.dbc` row the client joins by itself (`flags & 1`). + // The DBC seed: every `ChatChannels.dbc` row the client joins by itself (`flags & 1`). let seed_mask = auto_rows.iter().fold(0, |m, (_, id)| m | zone_bit(*id)); - // `ChatWindowFile`'s borrow is scoped, because the mask has to go home to `ChannelState` - // afterwards and a `&mut World` hands out one resource borrow at a time. + // Scoped, so `ChannelState` can take the mask afterwards. let mut parsed; { let Some(mut file) = world.get_resource_mut::() else { return; }; if file.identity.get(script).as_ref() == Some(&id) { - return; // already restored for this character, into the VM that is live now + return; // already restored for this character into this VM } let who = format!("{} on {}", id.1, id.0); file.path = crate::local_state::chat_character_path(&id.0, &id.1); @@ -566,18 +438,14 @@ pub(crate) fn restore_chat_looks(world: &mut World, script: &mut UiScript) { } }); let had_file = text.is_some(); - // A file our own pre-2120 writer damaged: no marker, so its `ZONECHANNELS` words may have been - // composed from an empty roster and its window channel lists erased with them. Repaired below, - // once β€” the next save stamps the marker. + // No marker: its `ZONECHANNELS` words may come from an empty roster. Repaired below, once. let damaged = text .as_deref() .is_some_and(|t| !t.contains(WRITER_GENERATION)); parsed = text.map(|t| parse(&t, &rows)).unwrap_or_default(); if !had_file { - // The loader's no-file path (`0x4997ad`): the mask is seeded from those rows, and - // window 1's channel slots get each of them as `(Shortcut, id)` β€” the rows - // `ChatFrame_RegisterForChannels` will match zone speech against by id. The rest of the - // record is the boot init the VM already holds. + // The loader's no-file path (`0x4997ad`): window 1 gets each seed row as + // `(Shortcut, id)`, which `ChatFrame_RegisterForChannels` matches zone speech by. let mut general = ChatWindowLook::stock(0); general.channels = auto_rows.clone(); parsed.looks.push((0, general)); @@ -591,10 +459,8 @@ pub(crate) fn restore_chat_looks(world: &mut World, script: &mut UiScript) { ); } if damaged { - // **The one-time repair** (decision 2120, [`WRITER_GENERATION`]). Re-seed window 1 the way - // the loader's no-file path seeds it, and OR the DBC bits back into the mask. Additive and - // deduplicated, exactly like the in-window `ZONECHANNELS` arm (`0x499332`-`0x4994e7`), so a - // file that survived intact is left alone and one that was zeroed gets its channels back. + // The one-time repair: re-seed window 1 and OR the seed into the mask, additive and + // deduplicated like the in-window `ZONECHANNELS` arm (`0x499332`-`0x4994e7`). if let Some((_, general)) = parsed.looks.iter_mut().find(|(w, _)| *w == 0) { for (shortcut, id) in &auto_rows { if !general @@ -607,29 +473,25 @@ pub(crate) fn restore_chat_looks(world: &mut World, script: &mut UiScript) { } } parsed.zone_mask = Some(parsed.zone_mask.unwrap_or(0) | seed_mask); - // Owed a write, so the repair is genuinely ONCE: the next save composes the repaired - // record and stamps [`WRITER_GENERATION`], and this branch never runs for the character - // again. `last_change` is `None` here, so that save is the very next frame's. + // Owed a write, which stamps the marker; it saves next frame (`last_change` is `None`). *file.dirty.get(script) = true; info!("chat cache: repaired a pre-2120 file's zone channels for {who}"); } - } // …and `ChatWindowFile`'s borrow ends here. - // The mask is durable state from here on (decision 2120): the file's word when it carried one, - // the DBC seed when it did not, and from then on the confirmed joins' own OR. + } + // The durable mask: the file's word, else the DBC seed; confirmed joins OR into it after. let mask = parsed.zone_mask.unwrap_or(seed_mask); if let Some(mut channels) = world.get_resource_mut::() { - // `Some` is the reference's "chat system ready" flag (`0x499a18`): the walk and the - // guild-recruitment cascade both hold until this line has run (decision 2144). + // `Some` is the reference's "chat system ready" flag (`0x499a18`): the zone walk and the + // guild-recruitment cascade wait for it. channels.zone_mask = Some(mask); - // The custom re-join list is durable on the same terms: seated from the file here, then - // moved only by a confirmed join and an explicit leave (`ChannelState::custom`). + // The custom re-join list, seated here, then moved only by a confirmed join or a leave. channels.custom = parsed.joined.clone(); } script.set_guild_recruitment_mode(u8::from(parsed.guild_recruitment_auto)); script.set_chat_colors(parsed.colors); script.set_chat_window_looks(parsed.looks); - // UPDATE_CHAT_WINDOWS once, then UPDATE_CHAT_COLOR for every registry entry, on the file - // path and the no-file path alike (`0x4996b9`, `0x499934`). + // `UPDATE_CHAT_WINDOWS` once, then `UPDATE_CHAT_COLOR` for every registry entry, file or no + // file (`0x4996b9`, `0x499934`). script.fire_event("UPDATE_CHAT_WINDOWS", vec![]); let renorm = |b: u8| f64::from(b as f32 * (1.0f32 / 255.0f32)); for entry in script.chat_colors() { @@ -643,8 +505,7 @@ pub(crate) fn restore_chat_looks(world: &mut World, script: &mut UiScript) { ], ); } - // The header's CHANNELS: the custom channels the character was in, re-joined the way the - // reference re-joins them at login (the zone channels are the zone walk's, not the file's). + // The header's `CHANNELS` re-join at login, as in the reference; zone channels are the walk's. if let Some(commands) = commands { for name in parsed.joined { let _ = commands.send(ClientCommand::JoinChannel { @@ -655,13 +516,12 @@ pub(crate) fn restore_chat_looks(world: &mut World, script: &mut UiScript) { } } -/// A Lua-side write landed β€” arm the debounce. +/// Arm the debounce when Lua writes the chat state. fn watch_chat_looks(script: Option>, mut file: ResMut) { let Some(mut script) = script else { return }; let moved = !script.take_chat_window_changes().is_empty(); let coloured = script.take_chat_color_changes(); - // `SetGuildRecruitmentMode` is a Lua write into the same file (decision 2115) β€” a host seat at - // login does not arm this, only a script call does. + // `SetGuildRecruitmentMode` persists here too; a Lua call arms this, the login seat never. let recruitment = script.take_guild_recruitment_change(); if !(moved || coloured || recruitment) { return; @@ -671,8 +531,8 @@ fn watch_chat_looks(script: Option>, mut file: ResMut bool { if exiting { restored @@ -747,39 +599,16 @@ fn save_chat_looks( flush(&script, &channels, &mut file, exiting); } -/// The session-end flush β€” the reference's chat teardown (`0x499a80` from `0x490c55`), called -/// from [`crate::ui_script::end_ui_session`] beside [`crate::cvars::fold_dying_vm_cvars`]: -/// **after** the shutdown events, **before** the VM is replaced. -/// -/// **It is not an `OnExit(InWorld)` system, and that is the whole point.** `/reload` rebuilds the -/// VM without ever leaving the world (1291), so that edge does not fire for it β€” and nothing else -/// covers the gap, because both halves of the debounce beside it are VM-keyed: [`ChatWindowFile::dirty`] is -/// a [`VmMemo`] that resets to `false` with the new VM, and [`restore_chat_looks`] then re-seats -/// the look table from the file **on disk**. So a window dragged, resized, recoloured or renamed -/// within [`SAVE_QUIET`] of a `/reload` was written nowhere and read straight back stale: the -/// player's change was discarded with no error and no trace. -/// -/// `end_ui_session` is the one place every root meets β€” it is what the `OnExit(InWorld)` -/// registration runs, and what `run_pending_reload` calls β€” so one call here covers the logout, -/// the character switch and the reload. It also retires the hand-stated ordering this flush used -/// to need: two unconstrained systems on one `OnExit` edge are placed by the executor, and that -/// placement was measured (bevy 0.18) to move with nothing but their registration positions. -/// Being *inside* the ender is an ordering no arrangement can lose. -/// -/// A session whose UI never loaded writes nothing, by construction rather than by a guard: -/// `restored` is false for a VM that never read the character's file, and [`owes_write`] refuses -/// on it β€” which is the same protection against composing the player's file out of a stock table -/// that [`ChatWindowFile::dirty`] exists for. +/// The session-end flush, the reference's chat teardown (`0x499a80` from `0x490c55`), called from +/// [`crate::ui_script::end_ui_session`] after the shutdown events and before the VM is replaced, +/// on logout, character switch and `/reload` alike. Not an `OnExit(InWorld)` system: `/reload` +/// never crosses it, and the debounce's state dies with the VM. pub(crate) fn fold_dying_vm_chat_cache(world: &mut World) { - // A world with no chat-cache state never added the plugin β€” a test world or a stripped - // scenario. Checked up front so the fetch below can be non-optional, exactly as - // `fold_dying_vm_cvars` checks its own. + // No plugin in this world (a test world): nothing to flush. if !world.contains_resource::() { return; } - // `resource_scope` rather than a `SystemState`: it lifts the file out for the call, which - // leaves the VM and the channel roster as two plain shared borrows of the world β€” the whole - // fetch, with no system machinery and nothing to keep in step with the signature. + // Lift the file out, leaving the VM and the roster as shared borrows of the world. world.resource_scope(|world, mut file: Mut| { let (Some(script), Some(channels)) = ( world.get_non_send_resource::(), @@ -793,12 +622,8 @@ pub(crate) fn fold_dying_vm_chat_cache(world: &mut World) { pub(super) fn plugin(app: &mut App) { app.init_resource::() - // **The restore is not here.** `UPDATE_CHAT_WINDOWS`/`UPDATE_CHAT_COLOR` have to precede - // the session's first chat line and `PLAYER_LOGIN` respectively, and no `run_if` can buy - // that: 1978's UI-is-up gate (`ingame_ui_up`) puts the restore on exactly the frame the - // parked VM comes back and `feed_chat` drains the whole queued login burst, with nothing - // ordering the two. It is called from `ui_script::lifecycle`'s world-entry load instead β€” - // [`restore_chat_looks`], decision 2119. Only the watcher belongs in `Update`. + // The restore runs from the world-entry UI load, not here: its events must precede the + // first chat line and `PLAYER_LOGIN`, which no `Update` ordering guarantees. .add_systems( Update, // After the tick: every write it watches is Lua's. @@ -812,13 +637,8 @@ pub(super) fn plugin(app: &mut App) { .after(crate::ui_script::UiInput) .in_set(crate::char_select::InWorldGated), ); - // **The session-end flush is not registered here.** It is called from inside - // `ui_script::end_ui_session` ([`fold_dying_vm_chat_cache`]) so that it covers `/reload` β€” a - // VM rebuild that never crosses `OnExit(InWorld)` β€” as well as the logout edge, and so that - // its position relative to the ender is structural instead of a registration-order bet. - // The quit flush rides the exit edge rather than `Update` for decision 1528's reason: the - // close button's `AppExit` is not written until `PostUpdate`, so a save chained beside the - // watcher would lose the last second of drags to the process ending. + // The session-end flush is `fold_dying_vm_chat_cache`; the quit flush rides the exit edge, + // as the close button's `AppExit` is written in `PostUpdate`, after an `Update` save. crate::shutdown::on_app_exit(app, save_chat_looks.into_configs()); } @@ -826,10 +646,7 @@ pub(super) fn plugin(app: &mut App) { mod tests { use super::*; - /// A record for WINDOW `n` (1-based) β€” the window's own boot-init row with the look fields - /// set. The window number is a parameter because `DOCKED`, `SHOWN` and `MESSAGES` differ per - /// window at init, so an expectation that does not say which window it is cannot be right - /// for all of them. + /// Window `n`'s (1-based) boot-init record, which differs per window, with the look fields set. fn look(n: usize, r: u8, g: u8, b: u8, a: u8, font_size: i32) -> ChatWindowLook { ChatWindowLook { r, @@ -842,9 +659,7 @@ mod tests { } } - /// `ChatChannels.dbc` as the loader hands it to [`parse`] β€” a subset of the shipped six, but - /// carrying all three `INITIAL` rows, because the ids 1 / 2 / 22 are what the window blocks are - /// actually about. + /// A subset of `ChatChannels.dbc` as [`parse`] takes it, with the three `INITIAL` rows. fn rows() -> Vec<(u32, String)> { vec![ (1, "General".to_string()), @@ -863,8 +678,6 @@ mod tests { .collect() } - /// The file round-trips β€” every field of the record, the colour rows, the header's custom - /// channels, the zone bits back into `(Shortcut, id)` rows β€” indices included. #[test] fn the_file_round_trips() { let mut named = look(3, 9, 8, 7, 6, 12); @@ -878,8 +691,7 @@ mod tests { named, ]; let colors = colors(&[("SAY", [1, 2, 3]), ("CHANNEL7", [4, 5, 6])]); - // The mask and the custom list are the character's durable state (2120), passed in rather - // than derived from the live roster. + // The mask and the custom list are passed in, not derived from the live roster. let text = render(&looks, &colors, &["MyChan".to_string()], 0b11, true); assert!( text.contains("\nCHANNELS\nMyChan\nEND\n\nZONECHANNELS 3\n"), @@ -908,19 +720,8 @@ mod tests { assert_eq!(parsed.joined, vec!["MyChan".to_string()]); } - /// **A window `CHANNELS` name that IS a DBC shortcut is a zone channel, not a custom one** - /// (decision 2130) β€” the repair for the files our own writer damaged. - /// - /// The director's `Onewarrior` file carried this verbatim: `CHANNELS / General / LocalDefense - /// / END` beside `ZONECHANNELS 2`, so window 1 held General and LocalDefense with id **0** and - /// only Trade kept its bit. The stock `ChatFrame_OnEvent` matches a channel line by - /// `zoneChannelList[index] == arg7` (`ChatFrame.lua:1379`), so that character silently lost - /// every General and LocalDefense line β€” join notices and speech alike β€” while Trade still - /// worked. - /// - /// The write side no longer produces it (the VM's catalog is fed before any verb can ask), and - /// this heals what it already wrote. Idempotent by construction: a healed file writes the name - /// as a bit, so there is no name left here to match. + /// The stock `ChatFrame_OnEvent` matches channel lines by id (`ChatFrame.lua:1379`), so a + /// shortcut stored with id 0 would lose its channel's lines. #[test] fn a_window_channel_named_like_a_dbc_shortcut_regains_its_id() { let text = "WINDOW 1\nSIZE 0\n\nMESSAGES\nEND\n\nCHANNELS\nGeneral\nMyChan\n LocalDefense\nEND\n\nZONECHANNELS 2\n\nEND\n"; @@ -937,44 +738,29 @@ mod tests { and the ZONECHANNELS bit still contributes Trade" ); - // …and the next save writes them as bits again rather than as names, which is what makes - // the repair one-way. + // The next save writes them as bits, not names. let out = render(&[parsed.looks[0].1.clone()], &[], &[], 0x0020_0003, false); let window = out.split("WINDOW 1").nth(1).unwrap(); assert!(window.contains("CHANNELS\nMyChan\nEND"), "{out}"); assert!(window.contains("ZONECHANNELS 2097155\n"), "{out}"); } - /// A window's zone bits are masked by the joined set β€” a zone channel the client has left - /// is not written for the window either, the way the writer ANDs the two words. #[test] fn a_windows_zone_bits_are_masked_by_the_joined_set() { let mut w = look(1, 0, 0, 0, 0, 0); w.channels = vec![("General".into(), 1), ("Trade".into(), 2)]; - // The mask holds General alone β€” Trade was explicitly left, so the window's own Trade - // bit is ANDed away on the way out. + // The mask holds General alone: Trade was left, so its window bit is ANDed away. let text = render(&[w], &[], &[], 1, true); let windows: Vec<&str> = text.split("WINDOW 1").collect(); assert!(windows[1].contains("ZONECHANNELS 1\n"), "{text}"); } - /// **The bug 2120 fixes, in one assertion: an empty roster must not erase a window's - /// channels.** - /// - /// The mask used to be `joined.fold(|m, (_, id)| m | zone_bit(id))`, so a save taken while - /// `ChannelState::joined` was momentarily empty β€” the session-end flush racing - /// `end_session_channels` on the same unordered `OnExit(InWorld)` edge β€” wrote `ZONECHANNELS - /// 0` in the header AND, through `window bits AND header mask`, in every window block. Nothing - /// but the loader's no-file path ever seeds a window's channel list, so the next login rebuilt - /// window 1 with none, and the stock `ChatFrame_OnEvent` silently drops every `CHANNEL*` line - /// a window does not carry: no `Joined Channel:` notices and no General/Trade speech, for - /// good. Ten of the twenty files in this repo's own config folder had reached that state. + /// The header and window words come from the durable mask, not the live roster. #[test] fn an_empty_roster_does_not_erase_a_windows_zone_channels() { let mut w = look(1, 0, 0, 0, 0, 0); w.channels = vec![("General".into(), 1), ("Trade".into(), 2)]; - // The character IS in both channels β€” the mask says so β€” but the live roster is empty, - // which is exactly the state a session-end save can be taken in. + // In both channels by the mask, with the live roster empty, as at a session-end save. let text = render(&[w], &[], &[], 0b11, true); assert!( text.contains("\nZONECHANNELS 3\n"), @@ -989,9 +775,7 @@ mod tests { ); } - /// A file our own pre-2120 writer zeroed carries no [`WRITER_GENERATION`] line, and that is - /// the whole discriminator: the repair is keyed on the marker, not on guessing whether a - /// player meant to leave every zone channel. + /// The repair is keyed on the [`WRITER_GENERATION`] marker, not on the zone words. #[test] fn a_written_file_carries_the_writer_generation_and_a_damaged_one_does_not() { let text = render(&[ChatWindowLook::stock(0)], &[], &[], 0b11, true); @@ -999,7 +783,7 @@ mod tests { text.contains(WRITER_GENERATION), "every file we write is stamped, so the repair fires once: {text}" ); - // The shape the damage takes: a header the reference's own reader accepts, with no marker. + // A zeroed file: a header the reference's reader accepts, with no marker. let damaged = "VERSION 2\n\nCHANNELS\nEND\n\nZONECHANNELS 0\n\n\ WINDOW 1\nSIZE 0\nSHOWN 1\n\nMESSAGES\nSYSTEM\nEND\n\n\ CHANNELS\nEND\n\nZONECHANNELS 0\n\nEND\n"; @@ -1012,8 +796,6 @@ mod tests { ); } - /// The mask is durable state: a confirmed join sets a bit, an explicit leave clears one, and - /// a custom channel (no DBC id) has no bit at all. #[test] fn the_zone_mask_moves_on_join_and_explicit_leave_only() { let mut state = super::super::edit::ChannelState { @@ -1033,7 +815,7 @@ mod tests { ]), ..Default::default() }; - // Unseated, a join is dropped rather than absorbed into a word nobody has read yet. + // Unseated, a join is dropped. state.note_zone_channel_joined("General - Elwynn Forest"); assert_eq!(state.zone_mask, None, "not seated: nothing to OR into"); state.zone_mask = Some(0); @@ -1042,8 +824,8 @@ mod tests { assert_eq!(state.zone_mask, Some(0b11)); state.note_zone_channel_joined("MyChan"); assert_eq!(state.zone_mask, Some(0b11), "a custom channel has no bit"); - // The clear is keyed on the SLOT the wire name finds, and its own id (decision 2144, - // `0x49f0f4`–`0x49f11a`) β€” a name we hold no slot for clears nothing. + // The clear is keyed on the slot the wire name finds and its id (`0x49f0f4`-`0x49f11a`); + // a name with no slot clears nothing. state.note_zone_channel_left("Trade - City"); assert_eq!(state.zone_mask, Some(0b11), "no slot carries it yet"); state.claim_slot("Trade - City"); @@ -1055,8 +837,6 @@ mod tests { ); } - /// The custom re-join list moves on the same two edges as the mask β€” a confirmed join of a - /// channel with no DBC id, and an explicit leave β€” and a zone channel never enters it. #[test] fn the_custom_list_moves_on_join_and_explicit_leave_only() { let mut state = super::super::edit::ChannelState { @@ -1079,10 +859,7 @@ mod tests { assert!(state.custom.is_empty(), "an explicit leave drops it"); } - /// **The teardown write is unconditional; only the debounce reads the dirty flag** (decision - /// 2144). A `/leave General` moves host state alone β€” the mask β€” and the reference persists - /// it because its teardown saver has no dirty flag; ours gated the same write on a Lua-side - /// flag, so the leave came back on the next login unless a window had also been dragged. + /// The teardown write ignores the dirty flag, as the reference's does, so a `/leave` persists. #[test] fn a_flush_writes_whatever_was_restored_and_the_debounce_writes_only_lua_moves() { // The flush: restored is the whole condition. @@ -1092,24 +869,19 @@ mod tests { !owes_write(true, false, true, true), "a VM that never read the file has nothing of the player's to write" ); - // The debounce: dirty AND quiet, never on host-only state. + // The debounce: dirty and quiet. assert!(owes_write(false, true, true, true)); assert!(!owes_write(false, true, true, false), "still being dragged"); assert!(!owes_write(false, true, false, true), "nothing Lua moved"); } - /// The header is a comment block and survives the round trip as one β€” a reader that choked on - /// its own header would lose the player's settings on the second launch. #[test] fn the_header_is_skipped_not_parsed() { assert!(render(&[ChatWindowLook::default()], &[], &[], 0, true).starts_with('#')); assert_eq!(parse(HEADER, &rows()), Parsed::default()); } - /// The rows the files written before 1948 hold β€” keys on the `WINDOW` line, no - /// `ADDEDVERSION` β€” still read, case-insensitively, an unknown key skipped and a missing - /// field left at its init value; and the loader's back-fill lands `COMBAT_FACTION_CHANGE` and - /// `MONEY` in window 2 as it would for any pre-`ADDEDVERSION 2` file. + /// benilla's older one-line rows (no `ADDEDVERSION`), with the back-fill into window 2. #[test] fn the_pre_1948_one_line_rows_still_parse() { let got = parse( @@ -1132,9 +904,6 @@ mod tests { assert!(got.looks[1].1.messages.iter().any(|m| m == "MONEY")); } - /// A file in the reference's own layout β€” the one a stock client writes β€” parses: the global - /// blocks are read for what they are, the colours land, both dock windows carry their sets, - /// and the window's `ZONECHANNELS` word comes back as `(Shortcut, id)` rows. #[test] fn a_stock_reference_file_parses() { let text = "VERSION 2\n\nADDEDVERSION 2\n\nOPTION_GUILD_RECRUITMENT_CHANNEL AUTO\n\n\ @@ -1176,9 +945,6 @@ mod tests { assert_eq!(w2.messages, vec!["COMBAT_XP_GAIN".to_string()]); } - /// `LOCKED` round-trips, and a file that never mentions it keeps the init's **locked** row β€” - /// the lenient default that matters, because reading it as "unlocked" would hand every - /// pre-existing player's chat window to a stray drag on their next login. #[test] fn the_lock_round_trips_and_an_absent_key_stays_locked() { let unlocked = ChatWindowLook { @@ -1195,9 +961,6 @@ mod tests { ); } - /// **`DOCKED` round-trips, and an absent key keeps the window's own init position** β€” the - /// leniency `LOCKED` gets, in the one field where a single flat default could not express it - /// (1714). `SHOWN` and the message sets get the same treatment for the same reason. #[test] fn dock_positions_round_trip_and_an_absent_key_keeps_the_init() { let moved = ChatWindowLook { @@ -1207,8 +970,8 @@ mod tests { let text = render(std::slice::from_ref(&moved), &[], &[], 0, true); assert!(text.contains("DOCKED 3")); assert_eq!(parse(&text, &rows()).looks, vec![(0, moved.clone())]); - // No DOCKED/SHOWN/MESSAGES at all: the boot init stands β€” window 1 shown and undocked - // with the ten General groups, window 2 shown at dock index 1 with the 34, window 3 out. + // The boot init: window 1 shown and undocked with ten groups, window 2 shown at dock + // index 1 with 34, window 3 hidden. let got = parse( "WINDOW 1 SIZE 0\nWINDOW 2 SIZE 0\nWINDOW 3 SIZE 0\n", &rows(), @@ -1222,7 +985,6 @@ mod tests { ); } - /// Junk costs the line it is on and nothing more. #[test] fn junk_costs_only_its_own_line() { let got = parse( @@ -1232,26 +994,15 @@ mod tests { assert_eq!(got.looks, vec![(1, look(2, 0, 0, 0, 0, 18))]); } - /// A `MESSAGES` block replaces the init set rather than adding to it β€” an empty block is a - /// window that shows nothing, which is what the writer means by it (the loader zeroes every - /// flag before it reads). + /// A `MESSAGES` block replaces the init set: the loader zeroes every flag before it reads. #[test] fn an_empty_messages_block_is_an_empty_set() { let got = parse("ADDEDVERSION 2\nWINDOW 1\nMESSAGES\nEND\nEND\n", &rows()); assert!(got.looks[0].1.messages.is_empty()); } - /// **The `/reload` flush** (the reload half of decision 1290's class): a window renamed in the - /// last second before a `ReloadUI()` must reach the player's file. - /// - /// Against the pre-fix shape this fails, and it is worth saying exactly why, because all three - /// of the mechanisms that look like they should cover it are VM-keyed and reset together: - /// `save_on_session_end` hung on `OnExit(InWorld)`, which `run_pending_reload` never crosses - /// (1291); the debounce beside it had not fired, because [`SAVE_QUIET`] had not elapsed; and - /// the new VM's [`ChatWindowFile::dirty`] memo came up `false` while [`restore_chat_looks`] - /// re-seated the look table from the file **on disk**. So the rename was discarded with no - /// error β€” and the assertion on the rebuilt VM below is the half the player would actually - /// see: the window comes back under its old name. + /// A rename inside [`SAVE_QUIET`] of a `/reload`, which never crosses `OnExit(InWorld)`, + /// reaches the file through the flush in `end_ui_session` and comes back in the rebuilt VM. #[test] fn a_window_renamed_just_before_a_reload_reaches_the_file() { use bevy::ecs::system::RunSystemOnce; @@ -1286,7 +1037,7 @@ mod tests { .clone() .expect("the character's chat cache path is seated by the entry load"); - // The player renames window 1 β€” a Lua write, exactly what the debounce exists to coalesce. + // A Lua rename of window 1, which arms the debounce. world .non_send_resource_mut::() .run(r#"SetChatWindowName(1, "Reloaded")"#) @@ -1299,7 +1050,7 @@ mod tests { "precondition: the rename armed the debounce, so the write is owed but not yet due" ); - // `/reload`, immediately β€” inside `SAVE_QUIET`, which is the whole window of the bug. + // `/reload` at once, inside `SAVE_QUIET`. world.insert_resource(State::new(crate::char_select::ClientState::InWorld)); world.resource_mut::().0 = true; crate::ui_script::run_pending_reload(&mut world); @@ -1311,7 +1062,7 @@ mod tests { `fold_dying_vm_chat_cache`; file was:\n{on_disk}" ); - // And the half the player sees: the rebuilt VM read it back. + // The rebuilt VM reads it back. let name = world .non_send_resource_mut::() .eval::("GetChatWindowInfo(1)") @@ -1325,8 +1076,7 @@ mod tests { let _ = std::fs::remove_dir_all(&tmp); } - /// A roster with one character picked β€” what `load_ingame_ui_on_world_entry` keys the - /// character's chat cache path on. + /// A roster with one character picked, which keys the chat cache path. fn probe_roster(name: &str) -> crate::char_select::Roster { crate::char_select::Roster::with_pending_pick( vec![benilla_protocol::Character { @@ -1358,13 +1108,8 @@ mod tests { ) } - /// **A custom channel survives `/logout`** (2184 Β§6, the question it left open). The header's - /// `CHANNELS` list is the character's custom channels to re-join at the next login. It was - /// composed from the live roster, and the logout clears that roster first: the - /// `Disconnected{LoggedOut}` that arrives in the same net drain as the logout runs - /// `end_session_channels_on_disconnect` in `Update`, a frame before `OnExit(InWorld)` reaches - /// `end_ui_session`'s flush. So the file was written with an empty list and the channel was - /// never re-joined. The same order is driven here, by the same functions. + /// The disconnect clears the live roster a frame before `end_ui_session` flushes, so the + /// header's re-join list must come from the durable custom list; the same functions run here. #[test] fn a_custom_channel_survives_a_logout() { use bevy::ecs::system::RunSystemOnce; @@ -1408,7 +1153,7 @@ mod tests { world.insert_resource(roster); crate::ui_script::load_ingame_ui_on_world_entry(&mut world); - // The login's re-join is confirmed: the server's YOU_JOINED, through the real feed arm. + // The server confirms the re-join (`YOU_JOINED`) through the real feed arm. world.resource_scope( |world, mut channels: Mut| { let mut script = world.non_send_resource_mut::(); diff --git a/crates/benilla-app/src/ui_chat/tests.rs b/crates/benilla-app/src/ui_chat/tests.rs index 573dafc2..df7fcf6e 100644 --- a/crates/benilla-app/src/ui_chat/tests.rs +++ b/crates/benilla-app/src/ui_chat/tests.rs @@ -2,17 +2,8 @@ use super::event::{default_color, ChatEvent, ChatEventKind as K}; use super::input::{emote_send_eligible, emote_target, EmoteGate, ParsedChat}; thread_local! { - /// The **shipped** string table, in a VM, once per test thread β€” `GlobalStrings.lua` run as - /// the chunk it is, so `\32` and every other escape is Lua's own doing rather than a parser - /// of ours. - /// - /// The composer resolves `CHAT__GET` / `CHAT__NOTICE` off the player's chain now - /// (decision 2045), so an assertion below is only worth making against the real table: a test - /// that graded a rendered line against a stub would pass on sentences the running client - /// never shows, which is the trap decision 2052 named when it moved the glue tests onto the - /// loader's own assembly. - /// - /// Built lazily, so a caller's `wow_data_or_skip!()` runs first. + /// The shipped `GlobalStrings.lua`, run in a VM once per test thread; built lazily, so a + /// caller's `wow_data_or_skip!()` runs first. static GLOBAL_STRINGS: benilla_ui::script::UiScript = { let s = benilla_ui::script::UiScript::new().expect("VM"); crate::ui_script::test_ui::load_ui(&s, "Interface\\FrameXML\\GlobalStrings.lua"); @@ -20,15 +11,12 @@ thread_local! { }; } -/// Run something that needs the shipped string table β€” the feed's line producers resolve their -/// keys through one of these now (decision 2045), and every assertion below wants the REAL table -/// under it rather than a stub with our own idea of the wording in it. +/// Run `f` with a key lookup into the shipped string table. fn with_strings(f: impl FnOnce(&dyn Fn(&str) -> Option) -> T) -> T { GLOBAL_STRINGS.with(|s| f(&|key: &str| s.lua().globals().get::(key).ok())) } -/// [`super::frames::compose`] against the shipped table β€” the shim that keeps every assertion in -/// this file (and [`super::broadcast`]'s own) reading as a rendered line rather than as a lookup. +/// [`super::frames::compose`] against the shipped table, for this file and [`super::broadcast`]. pub(super) fn compose(event: &ChatEvent, kind: K, default_language: &str) -> Option { GLOBAL_STRINGS.with(|s| { super::frames::compose(event, kind, default_language, &|key| { @@ -37,7 +25,7 @@ pub(super) fn compose(event: &ChatEvent, kind: K, default_language: &str) -> Opt }) } -/// A player-line event (the wire bridge's output shape) β€” sender resolved, optional flag. +/// A player-line event, as the wire bridge builds it. fn ev(kind: K, text: &str, sender: &str) -> ChatEvent { ChatEvent { kind: Some(kind), @@ -50,8 +38,7 @@ fn ev(kind: K, text: &str, sender: &str) -> ChatEvent { #[test] fn player_lines_link_the_name_except_emote() { let _data = benilla_formats::wow_data_or_skip!(); - // The composer emits the REAL |Hplayer link now (ref ChatFrame.lua l.1451); the renderer - // strips the markers and spans the [Name] (the P2 markup law). + // The `|Hplayer` link of `ChatFrame.lua:1451`. assert_eq!( compose(&ev(K::Say, "hi there", "Bob"), K::Say, "Common").unwrap(), "|Hplayer:Bob|h[Bob]|h says: hi there" @@ -65,7 +52,7 @@ fn player_lines_link_the_name_except_emote() { .unwrap(), "To |Hplayer:Bob|h[Bob]|h: hey" ); - // EMOTE uses the bare name (l.1450 `type ~= "EMOTE"`). + // EMOTE takes the bare name (`ChatFrame.lua:1450`). assert_eq!( compose(&ev(K::Emote, "dances.", "Bob"), K::Emote, "Common").unwrap(), "Bob dances." @@ -98,7 +85,6 @@ fn flags_prefix_the_name_and_afk_uses_its_get() { compose(&e, K::Say, "Common").unwrap(), "|Hplayer:Bob|h[Bob]|h says: brb" ); - // A received AFK auto-reply: CHAT_AFK_GET (whisper-pink family). assert_eq!( compose(&ev(K::Afk, "farming", "Bob"), K::Afk, "Common").unwrap(), "|Hplayer:Bob|h[Bob]|h is Away From Keyboard: farming" @@ -114,7 +100,7 @@ fn language_header_rides_non_default_tongues() { compose(&e, K::Say, "Common").unwrap(), "|Hplayer:Grunk|h[Grunk]|h says: [Orcish] throm-ka" ); - // Common (our default) and Universal (empty) render no header. + // The frame's default tongue renders no header. e.language = "Common".into(); assert_eq!( compose(&e, K::Say, "Common").unwrap(), @@ -134,8 +120,7 @@ fn system_and_loot_lines_are_verbatim() { .unwrap(), "Additem: Wool Cloth added." ); - // A LOOT line arrives already composed by `ui_loot::receive_line`, item link and all; compose - // must pass the escapes through untouched (the quality colour is the link's, not the line's). + // A LOOT line arrives composed, item link and all; its escapes pass through untouched. assert_eq!( compose( &ChatEvent::text_only( @@ -150,10 +135,7 @@ fn system_and_loot_lines_are_verbatim() { ); } -/// B156's visible half, in one assertion: a TEXT_EMOTE line renders **verbatim**, and setting the -/// performer in `sender` (arg2, for addons) must not make the composer bracket a name onto it the -/// way it does for SAY. If this ever starts reading "[Bob] Bob waves.", the sender slot has leaked -/// into the render. +/// The performer rides in `sender` (arg2, for addons) without the composer bracketing it on. #[test] fn text_emote_lines_are_verbatim_and_never_wear_the_senders_name() { let _data = benilla_formats::wow_data_or_skip!(); @@ -162,20 +144,13 @@ fn text_emote_lines_are_verbatim_and_never_wear_the_senders_name() { compose(&e, K::TextEmote, "Common").unwrap(), "Bob waves at you." ); - // The control: the same event as a SAY *does* get the bracketed link, so the assertion above - // is about the TEXT_EMOTE arm and not about `compose` having stopped decorating anything. + // The control: a SAY does get the bracketed link. assert!(compose(&ev(K::Say, "hi", "Bob"), K::Say, "Common") .unwrap() .contains("[Bob]")); } -/// **A self-target goes out as guid 0** β€” `DoEmote`'s last act before it builds the packet -/// (`0x5ef611`), and the reason vanilla has no self-emote sentence (decision 1282, correcting -/// 1274's claim that you would read "You wave at ⟨YourName⟩."). -/// -/// Without this the server echoes your own name back as the emote's target and the *whole zone* -/// reads "Sam waves at Sam." β€” so the control below (a selection that is someone else survives -/// intact) is what makes this a gate and not a mute button. +/// `DoEmote` sends a self-target as guid 0 (`0x5ef611`), so 1.12 has no self-emote sentence. #[test] fn emoting_at_your_own_selection_sends_an_untargeted_emote() { let _data = benilla_formats::wow_data_or_skip!(); @@ -185,22 +160,19 @@ fn emoting_at_your_own_selection_sends_an_untargeted_emote() { let me = Entity::from_raw_u32(7).unwrap(); let them = Entity::from_raw_u32(9).unwrap(); - // Myself selected: the guid is dropped on the floor, exactly as `mov [ebp+0xc],ebx` does. let sel = Selection { target: Some(me), guid: Some(0xdead_beef), }; assert_eq!(emote_target(&sel, Some(me)), 0); - // The control β€” someone else selected: the guid goes out untouched. let sel = Selection { target: Some(them), guid: Some(0xdead_beef), }; assert_eq!(emote_target(&sel, Some(me)), 0xdead_beef); - // No selection at all is already untargeted, and a not-yet-streamed self entity must not make - // an empty selection look like a self-target (the `me.is_some()` guard). + // No selection is untargeted, and with no self entity yet a selection goes out untouched. assert_eq!(emote_target(&Selection::default(), Some(me)), 0); let sel = Selection { target: Some(them), @@ -209,10 +181,6 @@ fn emoting_at_your_own_selection_sends_an_untargeted_emote() { assert_eq!(emote_target(&sel, None), 0xdead_beef); } -/// The receive half of B156 on the real tables (decision 1274): the five reachable sentence forms, -/// the performer in arg2, and the three silent rows. The composition law itself is pinned in -/// `benilla_formats::emote_text`; what this covers is the seam β€” that the app hands the composer -/// the right facts and puts the result in the right slots. Skips without client data. #[test] fn a_received_text_emote_composes_its_sentence_and_names_the_performer() { let data = benilla_formats::wow_data_or_skip!(); @@ -247,8 +215,7 @@ fn a_received_text_emote_composes_its_sentence_and_names_the_performer() { let e = line(WAVE, l).expect("a sentence"); assert_eq!(e.text, expected); assert_eq!(e.kind, Some(K::TextEmote)); - // arg2 is the performer, not the target β€” the reference pushes the performer's NameCache - // record (`0x49b47c`). + // arg2 is the performer, not the target (`0x49b47c`). assert_eq!(e.sender, "Bob"); } for (l, expected) in [ @@ -259,16 +226,12 @@ fn a_received_text_emote_composes_its_sentence_and_names_the_performer() { assert_eq!(e.text, expected); assert_eq!(e.sender, "Me"); } - // SIT's columns point at EmotesTextData rows that ship blank: no line, not an empty one. + // SIT's EmotesTextData rows ship blank: no line, not an empty one. assert!(line(SIT, them("-")).is_none(), "/sit prints nothing"); } -/// The three honor forms (COMBATLOG_HONORAWARD / COMBATLOG_HONORGAIN / COMBATLOG_DISHONORGAIN, -/// GlobalStrings :786/:787/:785) and the fork between them, decision 1512. -/// -/// The empty-rank case is asserted deliberately: it is what the server's floor-at-5 exists to -/// prevent, so a change that silently starts hiding the clause instead would pass unnoticed here -/// without it. +/// `COMBATLOG_HONORAWARD`, `COMBATLOG_HONORGAIN` and `COMBATLOG_DISHONORGAIN` +/// (`GlobalStrings.lua:785-787`). #[test] fn honor_gain_lines_pick_the_reference_form() { let _data = benilla_formats::wow_data_or_skip!(); @@ -281,22 +244,19 @@ fn honor_gain_lines_pick_the_reference_form() { .as_deref(), Some("Grimtusk dies, honorable kill Rank: Sergeant (Estimated Honor Points: 137)") ); - // A dishonorable kill: vmangos sends the same packet with a negative honor - // (`HonorMgr.cpp:807`), and the client's fork is on the sign. + // A dishonorable kill: vmangos sends a negative honor (`HonorMgr.cpp:807`). assert_eq!( with_strings(|g| super::feed::honor_gain_line(Some("Innkeeper Renee"), None, -37, g)) .as_deref(), Some("Innkeeper Renee dies, dishonorable kill.") ); - // The BOUNDARY, byte-verified at `0x625270`: the test is `honor <= 0`, so a zero-honor kill - // takes the dishonorable arm. The pre-verdict reading had `< 0` and put this one on the - // honorable side, where it would have printed "Rank: (Estimated Honor Points: 0)". + // The fork is `honor <= 0` (`0x625270`): zero honor is dishonorable. assert_eq!( with_strings(|g| super::feed::honor_gain_line(Some("Grimtusk"), Some("Sergeant"), 0, g)) .as_deref(), Some("Grimtusk dies, dishonorable kill.") ); - // No rank title: the clause stays, empty β€” the reference's own shape. + // No rank title: the clause stays, empty. assert_eq!( with_strings(|g| super::feed::honor_gain_line(Some("Grimtusk"), None, 5, g)).as_deref(), Some("Grimtusk dies, honorable kill Rank: (Estimated Honor Points: 5)") @@ -306,8 +266,8 @@ fn honor_gain_lines_pick_the_reference_form() { #[test] fn xp_gain_lines_pick_the_reference_form() { let _data = benilla_formats::wow_data_or_skip!(); - // COMBATLOG_XPGAIN_FIRSTPERSON / its EXHAUSTION1 rested form / _UNNAMED (GlobalStrings - // :801/:789/:804). + // `COMBATLOG_XPGAIN_FIRSTPERSON`, `COMBATLOG_XPGAIN_EXHAUSTION1` (rested) and + // `COMBATLOG_XPGAIN_FIRSTPERSON_UNNAMED` (`GlobalStrings.lua:801`, `:789`, `:804`). assert_eq!( with_strings(|g| super::feed::xp_gain_line(Some("Kobold Vermin"), 35, 0, g)).as_deref(), Some("Kobold Vermin dies, you gain 35 experience.") @@ -320,16 +280,15 @@ fn xp_gain_lines_pick_the_reference_form() { with_strings(|g| super::feed::xp_gain_line(None, 120, 0, g)).as_deref(), Some("You gain 120 experience.") ); - // The XP kind wears the shipped lavender (chat-cache row 46, 0x6F6FFF). + // The shipped lavender, 0x6F6FFF, chat-cache row 46. assert_eq!(default_color(K::CombatXpGain), [111, 111, 255]); } #[test] fn exploration_lines_pick_the_reference_form() { let _data = benilla_formats::wow_data_or_skip!(); - // ERR_ZONE_EXPLORED (GlobalStrings :1925) β€” the toast, fired on EVERY exploration packet - // (UIErrorsFrame); ERR_ZONE_EXPLORED_XP (:1926) β€” the chat system line that rides - // additionally iff xp > 0 (byte-verified branch `0x5e422f`; decisions 0828/0829). + // `ERR_ZONE_EXPLORED` (`GlobalStrings.lua:1925`) is the toast on every exploration packet; + // `ERR_ZONE_EXPLORED_XP` (`:1926`) is the chat line, only when xp > 0 (`0x5e422f`). assert_eq!( with_strings(|g| super::feed::exploration_toast("Westfall", g)).as_deref(), Some("Discovered: Westfall") @@ -375,19 +334,8 @@ fn channel_line_prefixes_the_stripped_channel() { ); } -/// The notice arms print arg4 **whole** β€” zone tail and all (1275). -/// -/// The pair to hold in view is [`channel_line_prefixes_the_stripped_channel`] directly above: the -/// same channel, the same arg4, and the reference renders them differently. `gsub(arg4, -/// "%s%-%s.*", "")` lives at l.1463, inside the speech `else` arm, *after* every notice arm has -/// returned β€” so speech says "[General]" and the join notice says "[General - Elwynn Forest]". -/// -/// **The fixtures are built by the bridge**, not hand-stamped with a kind. CHANNEL_NOTICE and -/// CHANNEL_NOTICE_USER are two different arms of `ChatFrame_OnEvent` β€” one passes arg4 alone, the -/// other arg4/arg2/arg5 (l.1416/1424) β€” and which one a notice byte takes is -/// [`super::feed::notice_event`]'s call, not the test author's. Stamping it by hand is how this -/// test came to describe PLAYER_KICKED as a plain CHANNEL_NOTICE, which the bridge has never -/// produced; nothing could see it while the composer ignored the distinction. +/// The notice arms print arg4 whole, zone tail and all: the strip at `ChatFrame.lua:1463` is the +/// speech arm's alone. The bridge builds each fixture, so each takes the arm its byte selects. #[test] fn channel_notices_compose_by_the_notice_law() { let _data = benilla_formats::wow_data_or_skip!(); @@ -410,7 +358,7 @@ fn channel_notices_compose_by_the_notice_law() { notice(0x12, "World", Some("Ann"), Some("Mod")).unwrap(), // PLAYER_KICKED "[World] Player Ann kicked by Mod." ); - // A member join line is a CHANNEL_JOIN event, hyperlinked like any player line. + // A member join is a CHANNEL_JOIN event, linked like a player line. let mut join = ev(K::ChannelJoin, "", "Ann"); join.channel = "World".into(); assert_eq!( @@ -419,31 +367,19 @@ fn channel_notices_compose_by_the_notice_law() { ); } -// ── the Lua face: the real CHAT_MSG_* fire (0288 Β§1's addon-API phase) ──────────────────────── -// -// These drive the REAL router into a REAL VM with our shipped ChatFrame.xml under it, because the -// question they exist to answer β€” "does anything print twice now?" β€” cannot be answered by -// reasoning about the composer in isolation. `route` both renders and fires; only a VM holding -// our actual window can show that the two do not add up to two lines. +// ── the Lua face: the CHAT_MSG_* fire ─────────────────────────────────────────────────────────── -/// A fresh VM with the shipped chat stack under it β€” the same files the app loads, so `ChatFrame1` -/// here is the real window carrying its real ``. +/// A fresh VM with the chat stack the app loads, so `ChatFrame1` is the real window. fn chat_vm() -> benilla_ui::script::UiScript { let mut s = benilla_ui::script::UiScript::new().unwrap(); - // GameTooltip.xml + UIDropDownMenu.xml are real RUNTIME dependencies of the chat tabs since - // decision 1589: a left click closes any open menu (`CloseDropDownMenus`, the reference's own - // first move) and a right click opens the window's options menu. `benilla.toc` already orders - // both ahead of ChatFrame.xml (l.60/64 vs l.399); the harness says so too, rather than a guard - // that would hide a real ordering fault. (The tooltip file is the dropdown kit's own - // dependency β€” its MenuBackdrop reads `TOOLTIP_DEFAULT_COLOR`.) + // The chat tabs call the dropdown kit (`CloseDropDownMenus` on a click), which reads + // `TOOLTIP_DEFAULT_COLOR`: both load ahead of ChatFrame.xml, as in `benilla.toc`. for file in [ "Interface\\FrameXML\\Fonts.xml", r"Interface\FrameXML\MoneyFrame.lua", r"Interface\FrameXML\MoneyFrame.xml", "Interface\\FrameXML\\GameTooltip.xml", "Interface\\FrameXML\\UIDropDownMenu.xml", - // The UIMenu kit is the reference's own file since 1751 window 21, so this reads both - // stores through the one loader that speaks them. "Interface\\FrameXML\\UIMenu.xml", "Interface\\FrameXML\\GlobalStrings.lua", "Interface\\FrameXML\\BasicControls.xml", @@ -463,9 +399,8 @@ fn chat_vm() -> benilla_ui::script::UiScript { s } -/// An "addon" that records what a `CHAT_MSG_*` fire actually delivered β€” the count, the event -/// name, and `arg1..arg10` joined with `|`. The concatenation is the point: a `nil` in any slot -/// raises in Lua, so a passing read is itself the proof that all ten args arrived. +/// An addon that records a `CHAT_MSG_*` fire: the count, the event and `arg1..arg10` joined with +/// `|`, which raises on a `nil` in any slot. const SPY: &str = r#" SpyN, SpyEvent, SpyLine = 0, "", "" Spy = CreateFrame("Frame", "BenillaChatSpy") @@ -482,15 +417,7 @@ fn lines_in_window(s: &benilla_ui::script::UiScript) -> i64 { s.eval::("return ChatFrame1:GetNumMessages()").unwrap() } -/// **The double-print answer, proved rather than argued.** -/// -/// In the reference, `CHAT_MSG_SAY` is what MAKES the line: C fires it, `ChatFrame_OnEvent` calls -/// `AddMessage`. benilla composes and adds in Rust instead (0288 Β§1) and now fires the event as -/// well β€” so the honest worry is that an addon registering the event on *our own* ChatFrame1 makes -/// the line land twice. It does not, and the mechanism is that our shipped `ChatFrame.xml` -/// `` handles exactly one event (`EXECUTE_CHAT_LINE`) and ignores everything else. -/// -/// The spy is a control, not decoration: without it a broken fire would pass this test. +/// A routed line prints once, even after an addon registers `ChatFrame1` for its event again. #[test] fn an_addon_registering_our_own_chat_frame_does_not_double_print() { let _data = benilla_formats::wow_data_or_skip!(); @@ -509,8 +436,7 @@ fn an_addon_registering_our_own_chat_frame_does_not_double_print() { "the addon saw the fire β€” otherwise the count above proves nothing" ); - // Now the addon registers OUR window for the event, exactly as the reference's own - // FloatingChatFrame does. This is the double-print case if there is one. + // An addon registers ChatFrame1 for the event again: the double-print case, if there is one. s.run(r#"ChatFrame1:RegisterEvent("CHAT_MSG_SAY")"#) .unwrap(); super::frames::route(&mut s, &mut windows, &ev(K::Say, "hi again", "Bob")); @@ -523,11 +449,7 @@ fn an_addon_registering_our_own_chat_frame_does_not_double_print() { assert!(s.errors().is_empty(), "handler errors: {:?}", s.errors()); } -/// **The line is already in the window when an addon's handler runs.** The reference dispatches to -/// listeners in registration order and ChatFrame1 registers at FrameXML load, before any addon -/// exists β€” so an addon that reads `GetNumMessages()` (or re-reads the last line to recolour it) -/// from its own `CHAT_MSG_*` handler sees the line, not the gap before it. Our Rust composer stands -/// in for ChatFrame1's handler, so it has to run first for the same reason. +/// Listeners run in registration order, as the reference's do, and `ChatFrame1` registered first. #[test] fn an_addons_handler_sees_the_line_already_in_the_window() { let _data = benilla_formats::wow_data_or_skip!(); @@ -553,9 +475,7 @@ fn an_addons_handler_sees_the_line_already_in_the_window() { ); } -/// A player line fires `CHAT_MSG_SAY` with the reference's own arg positions β€” including the two -/// slots our doc comment used to omit (arg7, arg10), both numbers, both zero for a non-channel -/// line. +/// arg7 and arg10 are numbers, zero for a non-channel line. #[test] fn a_say_line_fires_chat_msg_say_in_the_references_arg_positions() { let _data = benilla_formats::wow_data_or_skip!(); @@ -571,20 +491,15 @@ fn a_say_line_fires_chat_msg_say_in_the_references_arg_positions() { super::frames::route(&mut s, &mut windows, &e); assert_eq!(s.eval::("return SpyEvent").unwrap(), "CHAT_MSG_SAY"); - // arg1 is the RAW body, not the composed line β€” the reference's Lua is what adds - // "%s says: " and the |Hplayer link, so an addon must see what the wire sent. + // arg1 is the raw body: the reference's Lua adds "%s says: " and the link. assert_eq!( s.eval::("return SpyLine").unwrap(), "throm-ka|Grunk|Orcish|||GM|0|0||0" ); } -/// A channel notice fires the **token** in arg1 (not the rendered line), the numbered display form -/// in arg4, and the three numeric slots the reference reads bare. -/// -/// The last assertion runs `ChatFrame_OnEvent`'s own two comparisons β€” `arg7 > 0` and -/// `arg10 > 0` β€” inside the handler. Under Lua 5.0 a `nil` there raises, so this is the test that -/// would have caught passing nine args instead of ten. +/// A channel notice fires its token in arg1 and the numbered name in arg4; the handler repeats +/// `ChatFrame_OnEvent`'s bare `arg7 > 0` and `arg10 > 0`, which raise on a `nil`. #[test] fn a_channel_notice_fires_its_token_and_the_reference_reads_arg7_and_arg10_bare() { let _data = benilla_formats::wow_data_or_skip!(); @@ -607,9 +522,7 @@ fn a_channel_notice_fires_its_token_and_the_reference_reads_arg7_and_arg10_bare( "#, ) .unwrap(); - // The reference prints a channel-family line only for a channel the WINDOW carries - // (`ChatFrame_OnEvent` l.1374-1391 walks `this.channelList`; a miss returns) β€” the list - // `/join`'s handler fills through `ChatFrame_AddChannel`. + // The window prints only a channel it carries (`ChatFrame.lua:1374-1391`); `/join` adds it. s.run("ChatFrame_AddChannel(ChatFrame1, 'General - Elwynn Forest')") .unwrap(); @@ -632,18 +545,10 @@ fn a_channel_notice_fires_its_token_and_the_reference_reads_arg7_and_arg10_bare( ); assert_eq!(s.eval::("return SpyZone").unwrap(), 1); assert!(s.errors().is_empty(), "handler errors: {:?}", s.errors()); - // ...and the window still shows the one composed line it always did. assert_eq!(lines_in_window(&s), 1); } -/// **MODE_CHANGE produces no chat event at all** β€” not a silent one. -/// -/// This test replaced an earlier one that asserted the opposite (that a notice the UI renders -/// silently still reaches Lua, using MODE_CHANGE as the example). The bytes settle it -/// the other way: `0x49c24d`, the `0x0C` arm of the notice jump table, calls `0x49e910` and -/// **returns** β€” it never reaches the fire. So the right -/// behaviour is what our feed already does: drop it before it becomes an event, which is what this -/// now asserts. +/// The notice switch's MODE_CHANGE arm (`0x49c24d`) calls `0x49e910` and returns before the fire. #[test] fn a_mode_change_notice_never_becomes_an_event() { let _data = benilla_formats::wow_data_or_skip!(); @@ -665,8 +570,7 @@ fn a_mode_change_notice_never_becomes_an_event() { "MODE_CHANGE is dropped at the feed β€” the reference's 0x0C arm fires nothing" ); - // The control: a notice that DOES fire still gets queued, so the assertion above is about - // MODE_CHANGE and not about `push_channel_notice` being broken. + // The control: a notice that fires is queued. log.push_channel_notice( channel_notice::YOU_JOINED, "World".into(), @@ -675,9 +579,8 @@ fn a_mode_change_notice_never_becomes_an_event() { assert_eq!(log.pending_len(), 1); } -/// A channel line whose channel we are **not** in leaves all four channel slots empty β€” arg4 falls -/// back to the bare name and arg7/arg8/arg9 stay `0/0/""`. They are one record in the reference -/// (`slot+0x00/+0x04/+0x94/+0x98`, defaulted together at `0x49b12f`), so they are one record here. +/// arg4, arg7, arg8 and arg9 are one channel record in the reference, defaulted together +/// (`0x49b12f`). #[test] fn a_channel_we_are_not_in_fires_the_bare_name_and_zeroes() { let _data = benilla_formats::wow_data_or_skip!(); @@ -687,7 +590,6 @@ fn a_channel_we_are_not_in_fires_the_bare_name_and_zeroes() { s.run(r#"BenillaChatSpy:RegisterEvent("CHAT_MSG_CHANNEL")"#) .unwrap(); - // An empty joined list: nothing is in the local channel record array. let channels = super::edit::ChannelState::default(); let mut e = ev(K::Channel, "wts boar livers", "Bob"); e.channel = "SomeoneElsesChannel".into(); @@ -702,8 +604,6 @@ fn a_channel_we_are_not_in_fires_the_bare_name_and_zeroes() { ); } -/// `stamp_channel` splits the wire's bare name into the reference's arg4/arg8/arg9 trio: the -/// display form gets the number prefix, arg9 never does. #[test] fn stamping_a_channel_splits_the_display_form_from_the_base_name() { let _data = benilla_formats::wow_data_or_skip!(); @@ -716,28 +616,19 @@ fn stamping_a_channel_splits_the_display_form_from_the_base_name() { channels.stamp_channel(&mut e); assert_eq!(e.channel, "2. General - Elwynn Forest"); // arg4 assert_eq!(e.channel_number, 2); // arg8 - assert_eq!(e.channel_base, "General - Elwynn Forest"); // arg9, " - Zone" tail intact + assert_eq!(e.channel_base, "General - Elwynn Forest"); // arg9, zone tail intact - // A channel we are not in keeps its bare name in arg4 and leaves the whole rest of the record - // empty β€” the reference's miss leg `0x49aa86`, where there is no local record to read - // `slot+0x04/+0x94/+0x98` out of at all. + // A channel we are not in: its bare name in arg4, the rest empty (the miss leg `0x49aa86`). let mut other = ev(K::Channel, "hi", "Bob"); other.channel = "SomeoneElsesChannel".into(); channels.stamp_channel(&mut other); assert_eq!(other.channel, "SomeoneElsesChannel"); // arg4: the bare incoming name assert_eq!(other.channel_number, 0); // arg8 - assert_eq!(other.channel_base, ""); // arg9 β€” NOT the name + assert_eq!(other.channel_base, ""); // arg9, not the name assert_eq!(other.zone_channel_id, 0); // arg7 } -/// **A channel notice renders in the CHANNEL row, not the CHANNEL_NOTICE row** (1275). -/// -/// `ChatFrame_OnEvent` looks up `ChatTypeInfo[type]` and then overwrites it for the whole channel -/// family: `info = ChatTypeInfo["CHANNEL"..arg8]` (l.1381). So the grey C0C0C0 the CHANNEL_NOTICE -/// row carries is looked up and thrown away, and the join line comes out the channel's FFC0C0 β€” -/// which is what the director's eye caught: our notices read white-grey where the client's read -/// warm. Driven through the real router into the real window and read back off the extracted -/// quad, because the color that matters is the one that reaches the screen. +/// The channel family's colour is `ChatTypeInfo["CHANNEL"..arg8]` (`ChatFrame.lua:1381`). #[test] fn a_channel_notice_renders_in_the_channels_color_not_the_notice_row() { let _data = benilla_formats::wow_data_or_skip!(); @@ -748,8 +639,7 @@ fn a_channel_notice_renders_in_the_channels_color_not_the_notice_row() { let mut channels = super::edit::ChannelState::default(); channels.claim_slot("General - Elwynn Forest"); - // The window has to carry the channel for the reference's handler to print the line at all - // (`ChatFrame_OnEvent` l.1374-1391) β€” `/join`'s own bookkeeping, done here by hand. + // The window prints only a channel it carries (`ChatFrame.lua:1374-1391`). s.run("ChatFrame_AddChannel(ChatFrame1, 'General - Elwynn Forest')") .unwrap(); let mut e = ChatEvent::text_only(K::ChannelNotice, String::new()); @@ -778,29 +668,9 @@ fn a_channel_notice_renders_in_the_channels_color_not_the_notice_row() { ); } -/// **Crossing a zone border must not deregister the channel it renames** (decision 2130). -/// -/// The walk sends `LEAVE(General - Elwynn Forest)` then `JOIN(General - Westfall)` β€” one DBC row, -/// renamed, and the retail 1.8.1 Winterspring sniff shows exactly that -/// pair on the wire. The server answers each with a notice, and the stock `ChatFrame_OnEvent`'s -/// `YOU_LEFT` arm **deletes the window's registration for whatever it matched** -/// (`ChatFrame.lua` l.1382-1384): -/// -/// ```lua -/// this.channelList[index] = nil; -/// this.zoneChannelList[index] = nil; -/// ``` -/// -/// Nothing in stock FrameXML ever re-adds one on `YOU_JOINED` β€” `ChatFrame_AddChannel` is reachable -/// only from the `/join` popup and the chat-tab dropdown. So if our `YOU_LEFT` still resolves to a -/// slot, the window loses General for the rest of the session: the replacement join notice is -/// dropped unprinted, and so is every General line spoken in the new zone. That is the director's -/// *"sometimes I get no channel stuff"*, and this test is the observable. -/// -/// The window is registered here the way the chat cache registers it at login β€” the row's -/// **Shortcut** against its **ChannelID**, which is the id-match at `ChatFrame.lua:1379` β€” because -/// that is the registration the reference's own `chat-cache.txt` produces (`ZONECHANNELS` bits, not -/// names). +/// The stock `YOU_LEFT` arm deletes the window's registration for the channel it matches +/// (`ChatFrame.lua:1382-1384`) and no join re-adds it, so the leave of a renamed slot must match +/// nothing. The window registers by `ChannelID`, as the chat cache does (`ChatFrame.lua:1379`). #[test] fn a_zone_change_must_not_deregister_the_channel_it_renames() { let _data = benilla_formats::wow_data_or_skip!(); @@ -819,8 +689,7 @@ fn a_zone_change_must_not_deregister_the_channel_it_renames() { }; channels.claim_slot("General - Elwynn Forest"); - // `ChatFrame_RegisterForChannels(GetChatWindowChannels(1))`, by hand: it does exactly this pair - // of writes, and calling it needs the `this` the event dispatch supplies. + // `ChatFrame_RegisterForChannels(GetChatWindowChannels(1))`'s two writes, by hand. s.run("ChatFrame1.channelList[1] = 'General' ChatFrame1.zoneChannelList[1] = 1") .unwrap(); @@ -851,10 +720,8 @@ fn a_zone_change_must_not_deregister_the_channel_it_renames() { "the control must print β€” otherwise the assertion below proves nothing" ); - // The border crossing, in the order the walk actually produces it: `CMSG_LEAVE_CHANNEL(old)` - // goes out, **the slot is renamed in place before the answer can arrive** - // ([`super::edit::ChannelState::rename_slot`], the reference's `0x49bc50` at pass 1 step 6), - // `CMSG_JOIN_CHANNEL(new)` goes out, and only then do the two notices land. + // The border crossing: the leave goes out, the slot is renamed in place (`0x49bc50`), the join + // goes out, and only then do the two notices land. let renamed = channels.rename_slot("General - Elwynn Forest", "General - Westfall"); assert_eq!( renamed, @@ -892,7 +759,7 @@ fn a_zone_change_must_not_deregister_the_channel_it_renames() { General going silent for the rest of the session" ); - // …and the observable that actually matters: speech from the NEW zone still lands. + // Speech from the new zone still lands. let before = lines_in_window(&s); super::feed::deliver( &mut s, @@ -907,17 +774,9 @@ fn a_zone_change_must_not_deregister_the_channel_it_renames() { ); } -/// **Walking out of a capital SUSPENDS Trade β€” it does not free it** (decision 2130). -/// -/// The zone walk's other leave: a row that stops applying entirely, which in the 1.12 data means -/// exactly `Trade` when you step out of a city (the city gate `0x49a3b8`). The -/// `CMSG_LEAVE_CHANNEL` still goes out, but the client marks its own slot state 3 (`0x49bcf0`) and -/// keeps the record β€” so the notice comes back as the `SUSPENDED` token, the stock handler's -/// `YOU_LEFT` arm never runs, and the window keeps its registration. Walking back in re-joins -/// through the state-3 bypass onto the same slot, with the same number. -/// -/// Freeing it β€” which is what we did β€” cost Trade its registration on the way out and left the -/// re-join notice unprintable on the way back in. Same bug as the border crossing, one row over. +/// Leaving a city (`0x49a3b8`) sends the leave but keeps Trade's slot in state 3 (`0x49bcf0`): the +/// notice comes back as `SUSPENDED`, the stock `YOU_LEFT` arm never runs, and a re-join takes the +/// same slot. #[test] fn leaving_a_capital_suspends_trade_rather_than_deregistering_it() { let _data = benilla_formats::wow_data_or_skip!(); @@ -956,10 +815,8 @@ fn leaving_a_capital_suspends_trade_rather_than_deregistering_it() { ); s.resolve(); - // The line still PRINTS β€” `CHAT_SUSPENDED_NOTICE` is "Left Channel: [%s]", the same text as - // `CHAT_YOU_LEFT_NOTICE`. Only arg1 differs, and arg1 is what the stock handler branches on. - // Asserted because a missing string would make `compose_notice` answer `None` and the line - // would vanish silently β€” the failure this whole area is prone to. + // `CHAT_SUSPENDED_NOTICE` reads as `CHAT_YOU_LEFT_NOTICE` does; only arg1, which the stock + // handler branches on, differs. assert_eq!(lines_in_window(&s), before + 1); assert!( s.extract().iter().any(|q| matches!( @@ -984,7 +841,7 @@ fn leaving_a_capital_suspends_trade_rather_than_deregistering_it() { the stock handler never reached the arm that deletes the registration" ); - // Walking back in: the same slot, the same number, and the notice prints again. + // Walking back in: the same slot and number, and the notice prints. let before = lines_in_window(&s); super::feed::deliver( &mut s, @@ -1005,13 +862,7 @@ fn leaving_a_capital_suspends_trade_rather_than_deregistering_it() { ); } -/// **A renamed row's confirming notice is `YOU_CHANGED`, and it renders "Changed Channel:"** -/// (decision 2130). -/// -/// `CHAT_YOU_CHANGED_NOTICE = "Changed Channel: [%s]"` is a string 1.12 ships and we had never -/// printed, because we modelled no per-slot state to select it with (`0x02`'s arm splits on -/// `rec+0x9c == 2`). It is what a zone-border crossing actually looks like in the reference: one -/// line, not a leave and a join. +/// A renamed slot's join notice reads as `YOU_CHANGED`: the `0x02` arm splits on `rec+0x9c == 2`. #[test] fn a_renamed_zone_channel_confirms_as_changed_not_joined() { let _data = benilla_formats::wow_data_or_skip!(); @@ -1055,13 +906,8 @@ fn a_renamed_zone_channel_confirms_as_changed_not_joined() { ); } -/// **The leave line still knows its number, because the record dies after the line** (1275). -/// -/// [`super::feed::deliver`] is the ordering under test: we used to drop the channel from the joined -/// list before composing, so `stamp_channel` missed and the line came out "Left Channel: [General]" -/// β€” unnumbered, and (with the color override above) resolved against arg8 = 0. The reference's -/// YOU_LEFT arm flags the teardown and runs it *after* the fire (`0x49c5b0` fire, `0x49c5c2 call -/// 0x49bbd0`), so the line is numbered and an addon's handler still sees the channel. +/// The `YOU_LEFT` arm tears the record down after the fire (`0x49c5b0`, then `0x49bbd0` at +/// `0x49c5c2`), so [`super::feed::deliver`] stamps the line before it frees the slot. #[test] fn a_leave_notice_keeps_its_number_because_the_record_dies_after_the_line() { let _data = benilla_formats::wow_data_or_skip!(); @@ -1092,13 +938,7 @@ fn a_leave_notice_keeps_its_number_because_the_record_dies_after_the_line() { ); } -/// **A channel that leaves does not renumber the ones that stay** (1286). -/// -/// The director's teleport tour: `Left Channel: [1. General - Teldrassil]` / -/// `Joined Channel: [2. General - The Barrens]` / `Left Channel: [1. LocalDefense - Teldrassil]`, -/// with Trade shuffling 1 β†’ 2 β†’ 3 across the same few seconds β€” every number in the window moving -/// because the list closed each hole. The reference frees the slot in place and refills the first -/// free one, so a zone hop *renames* a channel and leaves its number alone. +/// The reference frees a slot in place and refills the first free one, so no other number moves. #[test] fn a_freed_slot_is_reused_and_the_others_keep_their_numbers() { let _data = benilla_formats::wow_data_or_skip!(); @@ -1132,7 +972,7 @@ fn a_freed_slot_is_reused_and_the_others_keep_their_numbers() { "and back into slot 2" ); - // The ceiling is the reference's ten (`0x49b9c0: cmp ecx,0xa`), counted in SLOTS. + // The ceiling is ten slots (`0x49b9c0`). for i in 4..=super::edit::MAX_CHANNELS { assert_eq!(c.claim_slot(&format!("Custom{i}")), Some(i as u32)); } @@ -1145,12 +985,7 @@ fn a_freed_slot_is_reused_and_the_others_keep_their_numbers() { ); } -/// **The next character does not inherit this one's chat window** (1288). -/// -/// The reference ends a session by destroying its Lua state, so the window that comes back is -/// empty. We keep the VM (`ui_script::IngameUiLoaded` is the latch standing in for that teardown), -/// so the director saw the previous character's `Joined Channel:` lines still sitting under the -/// new character's. Everything the module remembers across a box open goes with the lines. +/// The reference destroys its Lua state at logout, so the next character's window is empty. #[test] fn a_session_end_empties_the_window_and_the_boxs_memory() { let _data = benilla_formats::wow_data_or_skip!(); @@ -1176,15 +1011,8 @@ fn a_session_end_empties_the_window_and_the_boxs_memory() { ); } -/// **Every notice token names a string the shipped client actually has.** The composer splices -/// the token into `CHAT__NOTICE` the way `ChatFrame_OnEvent` does (l.1416/1424), so the token -/// table IS the render table β€” there is nothing left to cross-check between two lists of ours. -/// What can still be wrong is a token that resolves to nothing, which would silently print no -/// line at all; that is what this asserts, against the player's own `GlobalStrings.lua`. -/// -/// The bytes with no token render nothing, and must keep rendering nothing: MODE_CHANGE (`0x0C`) -/// fires no chat event in the reference at all, and JOINED/LEFT (`0x00`/`0x01`) are the member -/// lines rather than notices. +/// Each token names a `CHAT__NOTICE` string (`ChatFrame.lua:1416`, `:1424`); the tokenless bytes +/// are MODE_CHANGE (`0x0C`), which fires nothing, and the member lines JOINED and LEFT. #[test] fn every_notice_token_resolves_and_the_tokenless_bytes_stay_silent() { let _data = benilla_formats::wow_data_or_skip!(); @@ -1210,12 +1038,8 @@ fn every_notice_token_resolves_and_the_tokenless_bytes_stay_silent() { } } -/// The one notice whose two names are **not** in the order it prints them: -/// `CHAT_INVITE_NOTICE = "%2$s has invited you to join the channel '%1$s'."`, filled from the -/// reference's own fixed `(arg4, arg2)` list (l.1418). This is decision 2045's whole argument in -/// one assertion β€” hand-typing the English bakes in one locale's word order, and only a -/// positional fill off the shipped string can put the inviter first while the channel is -/// argument one. +/// `CHAT_INVITE_NOTICE` is `"%2$s has invited you to join the channel '%1$s'."`, filled from the +/// fixed `(arg4, arg2)` list (`ChatFrame.lua:1418`). #[test] fn the_invite_notice_reorders_its_two_names() { let _data = benilla_formats::wow_data_or_skip!(); @@ -1235,8 +1059,7 @@ fn the_invite_notice_reorders_its_two_names() { ); } -/// The `ALL` sweep list really is every variant. A new kind fails [`super::event::event_name`]'s -/// exhaustive match at compile time; this is what makes you add it to `ALL` as well. +/// A new kind breaks [`super::event::event_name`]'s exhaustive match; this makes it join `ALL`. #[test] fn every_kind_is_in_all() { let _data = benilla_formats::wow_data_or_skip!(); @@ -1248,8 +1071,6 @@ fn every_kind_is_in_all() { seen.sort_unstable(); seen.dedup(); assert_eq!(seen.len(), before, "a kind is listed twice in ALL"); - // 93 since 2077: `CHAT_MSG_FILTERED` (`0x5B`) is a real server line, not the never-wire value - // this tree had it filed as β€” the server's "your message was filtered" notice. assert_eq!(before, 93, "93 kinds β€” update this when the kind set grows"); } @@ -1268,12 +1089,9 @@ fn colors_match_the_shipped_table() { assert_eq!(default_color(K::BgSystemAlliance), [0, 174, 239]); } -// ── the submitted-line grammar (0288 P5): type switches + action commands ────────────────── +// ── the submitted-line grammar: type switches and action commands ────────────────────────── -/// The grammar fixture: a command table built from a *stub* of the reference's alias strings, in -/// the same `SLASH_` / `EMOTE_CMD` shape the shipped `GlobalStrings.lua` has. The -/// aliases here are fixture data for the ARGUMENT grammar; that the real ones all resolve is -/// [`real_alias_table_resolves_the_shipped_commands`]'s job, on the real files. +/// A command table from a stub of the shipped `SLASH_` and `EMOTE_CMD` strings. fn stub_table() -> super::commands::SlashCommands { const STRINGS: &[(&str, &str)] = &[ ("SLASH_JOIN1", "/join"), @@ -1294,7 +1112,7 @@ fn stub_table() -> super::commands::SlashCommands { ("SLASH_SCRIPT1", "/script"), // One emote index, in the two-table shape: the alias, and the token it resolves through. ("EMOTE1_CMD1", "/wave"), - ("EMOTE1_CMD2", "/hello"), // an alias that is NOT the token β€” the 0881 class of bug + ("EMOTE1_CMD2", "/hello"), // an alias that is not the token ("EMOTE1_TOKEN", "WAVE"), ]; super::commands::SlashCommands::build( @@ -1308,8 +1126,6 @@ fn stub_table() -> super::commands::SlashCommands { ) } -/// The Enter-path type switch (send path β€” no trailing-space requirement). - #[test] fn action_commands_parse() { let _data = benilla_formats::wow_data_or_skip!(); @@ -1334,18 +1150,14 @@ fn action_commands_parse() { name: "world".into() } ); - // `/afk` and `/dnd` run the reference's OWN `SlashCmdList` bodies rather than building a bare - // send (2088). They used to be a `ParsedChat::AfkDnd` that went straight to the wire β€” correct - // until the away law landed, and a second implementation the moment it did: no echo, no - // client-side default substitution, no mirror write. The argument still rides WHOLE, which is - // what this row has always pinned. + // `/afk` and `/dnd` run the stock `SlashCmdList` bodies, with the argument whole. assert_eq!( parse_line("/afk farming"), ParsedChat::Lua { body: "SlashCmdList[\"CHAT_AFK\"](\"farming\")".into() } ); - // Bare, because that is the toggle β€” and the empty string has to survive to the send. + // Bare is the toggle: the empty string survives to the call. assert_eq!( parse_line("/dnd"), ParsedChat::Lua { @@ -1363,11 +1175,10 @@ fn action_commands_parse() { ); assert_eq!(parse_line("/played"), ParsedChat::Played); assert_eq!(parse_line("/help"), ParsedChat::Help); - // /pvp takes no argument (decision 0646 Β§3): the binding has no state form, so a trailing - // word is ignored rather than read as a target. + // /pvp takes no argument; a trailing word is ignored. assert_eq!(parse_line("/pvp"), ParsedChat::Pvp); assert_eq!(parse_line("/pvp on"), ParsedChat::Pvp); - // /r rides its own arm (the reply state lives on ChatEditState). + // /r rides its own arm: the reply state lives on ChatEditState. assert_eq!( parse_line("/r hey"), ParsedChat::Reply { text: "hey".into() } @@ -1380,9 +1191,7 @@ fn emote_aliases_resolve_through_the_table() { let t = stub_table(); let parse_line = |line: &str| super::input::parse_line(&t, line); assert_eq!(parse_line("/wave"), ParsedChat::TextEmote(101)); - // The 0881 fix in one line: an alias that is NOT its token's `EmotesText` name resolves too. - // `/hello` (token WAVE) is the shape `/lol` (token LAUGH) has in the shipped table β€” before - // the table, matching on the DBC name alone left 61 such commands unresolvable. + // An alias that is not its token's `EmotesText` name resolves too, as `/lol` (LAUGH) does. assert_eq!(parse_line("/hello"), ParsedChat::TextEmote(101)); // An emote takes an argument (`DoEmote(token, msg)`): the command is the first word only. assert_eq!(parse_line("/wave Bob"), ParsedChat::TextEmote(101)); @@ -1405,14 +1214,14 @@ fn one_line_reference_bodies_run_in_the_vm() { let _data = benilla_formats::wow_data_or_skip!(); let t = stub_table(); let parse_line = |line: &str| super::input::parse_line(&t, line); - // `/trade` is the ref's `InitiateTrade("target")`, verbatim. + // `/trade` is the stock `InitiateTrade("target")`. assert_eq!( parse_line("/trade"), ParsedChat::Lua { body: "InitiateTrade(\"target\")".into() } ); - // `/script` runs the typed text AS the chunk (the ref's `RunScript(msg)`); bare is a no-op. + // `/script` runs the typed text as the chunk (stock `RunScript(msg)`); bare is a no-op. assert_eq!( parse_line("/script Print(\"hi\")"), ParsedChat::Lua { @@ -1422,11 +1231,7 @@ fn one_line_reference_bodies_run_in_the_vm() { assert_eq!(parse_line("/script"), ParsedChat::Unknown); } -/// `/castvis` is one of benilla's own instruments, so 1179 gates the whole dev alias table behind -/// `run_mode::dev_affordances()` β€” in a player build the alias is never claimed and the line falls -/// through to the reference's "unknown command". This test therefore asserts the grammar in a dev -/// build and the *absence* of the grammar in a player one, rather than assuming the configuration -/// it happens to run in. (It assumed, until 1180's `player-tests` gate ran it the other way.) +/// `/castvis` is a dev instrument: a player build never claims it (`run_mode::dev_affordances()`). #[test] fn castvis_parses_id_and_phase() { let _data = benilla_formats::wow_data_or_skip!(); @@ -1457,7 +1262,7 @@ fn castvis_parses_id_and_phase() { ground: false } ); - // `ground` is a GO too β€” the pure-dest shape, the only one that flies the location fallback. + // `ground` is a GO too, the pure-destination shape. assert_eq!( parse_line("/castvis 1543 GROUND"), ParsedChat::CastVis { @@ -1484,16 +1289,12 @@ fn unknown_slash_command_is_dropped_not_said_aloud() { let _data = benilla_formats::wow_data_or_skip!(); let t = stub_table(); let parse_line = |line: &str| super::input::parse_line(&t, line); - // The regression this grammar exists to fix: `/yell` used to literally SAY "/yell hello" β€” - // any unresolved slash-line must never fall through to plain chat. assert_eq!(parse_line("/dancemove"), ParsedChat::Unknown); assert_eq!(parse_line("/frobnicate"), ParsedChat::Unknown); } -/// The RUNTIME leg on the real data (the `every_mount_key_resolves…` pattern): build the table the -/// way boot does β€” the shipped `GlobalStrings.lua` and `ChatFrame.lua`'s token table executed into -/// a real VM, joined to the real `EmotesText.dbc` β€” and assert the commands 0881 was opened for. -/// Skips without client data. +/// The command table built as boot builds it: `GlobalStrings.lua` and `ChatFrame.lua`'s token +/// table run in a VM, joined to `EmotesText.dbc`. #[test] fn real_alias_table_resolves_the_shipped_commands() { let data = benilla_formats::wow_data_or_skip!(); @@ -1504,8 +1305,7 @@ fn real_alias_table_resolves_the_shipped_commands() { .read_file(&format!("Interface\\FrameXML\\{file}")) .expect("FrameXML file in the chain"); let src = String::from_utf8_lossy(&src).into_owned(); - // GlobalStrings runs whole (it is only assignments); ChatFrame contributes its token - // table alone, through the production filter. + // GlobalStrings runs whole; ChatFrame contributes its token table alone. let src = if file == "ChatFrame.lua" { src.lines() .map(str::trim) @@ -1525,14 +1325,12 @@ fn real_alias_table_resolves_the_shipped_commands() { ); let parse_line = |line: &str| super::input::parse_line(&table, line); - // The reported symptom: `/sit` resolves to the SIT text emote (EmotesText id 86), whose - // `Emotes.dbc` row (13, STATE_SIT) is the posture emote that sets stand state 1. + // `/sit` is EmotesText 86, whose `Emotes.dbc` row 13 (STATE_SIT) sets stand state 1. assert_eq!(parse_line("/sit"), ParsedChat::TextEmote(86)); assert_eq!( cat.text_emote(86).and_then(|e| cat.posture_state(e)), Some(1) ); - // Every posture command the shipped tables expose, with the state it sets. for (line, state) in [ ("/stand", 0), ("/sit", 1), @@ -1546,8 +1344,7 @@ fn real_alias_table_resolves_the_shipped_commands() { let posture = cat.text_emote(text_id).and_then(|e| cat.posture_state(e)); assert_eq!(posture, Some(state), "{line} sets stand state {state}"); } - // The dead-alias class 0881 found: an alias that differs from its token's DBC name. These all - // returned "Type '/help'…" before the table. + // Aliases that differ from their token's DBC name. for line in [ "/lol", "/hi", @@ -1567,15 +1364,11 @@ fn real_alias_table_resolves_the_shipped_commands() { "{line} resolves to an emote" ); } - // …and the three names the reference has NO command for, which the DBC-name match used to - // accept as emotes. + // DBC emote names the reference has no command for. for line in ["/joke", "/puzzle", "/attackmytarget"] { assert_eq!(parse_line(line), ParsedChat::Unknown, "{line}"); } - // `/follow` was the sharp one in that class: the DBC-name match fired a text emote where the - // real client's `SlashCmdList["FOLLOW"]` follows your target. 0881 made it honestly unknown; - // 0890 makes it the real command, over all three shipped aliases (SLASH_FOLLOW1-6 β†’ `/f`, - // `/follow`, `/fol`). + // `SlashCmdList["FOLLOW"]`, over the three distinct aliases of SLASH_FOLLOW1-6. for line in ["/follow", "/f", "/fol"] { assert_eq!( parse_line(line), @@ -1591,9 +1384,7 @@ fn real_alias_table_resolves_the_shipped_commands() { ); // A command whose handler benilla does not register answers like any unknown command. assert_eq!(parse_line("/ginvite"), ParsedChat::Unknown); - // The by-name selection pair (decision 0886) β€” every shipped alias, and the whole-argument - // grammar that makes a multi-word creature name ONE name. `/tar` and `/a` are the short forms - // the shipped strings carry (SLASH_TARGET2/4, SLASH_ASSIST1/3). + // /target and /assist take the whole argument as one name; `/tar` and `/a` are shipped aliases. assert_eq!( parse_line("/target Kobold Vermin"), ParsedChat::Target { @@ -1615,9 +1406,7 @@ fn real_alias_table_resolves_the_shipped_commands() { } ); assert_eq!(parse_line("/assist"), ParsedChat::Assist { name: None }); - // The macro family (decision 0983): `/cast` (with its `/spell` alias β€” SLASH_CAST1-4 spell two - // distinct strings across four slots) runs the ref's own one-line body `CastSpellByName(msg)`; - // `/macro`/`/m` open the window; `/macrohelp` prints the shipped five lines. + // `/cast` and `/spell` run the stock `CastSpellByName(msg)`; `/macro` and `/m` open the window. assert_eq!( parse_line("/cast Fireball(Rank 1)"), ParsedChat::Lua { @@ -1646,9 +1435,7 @@ fn real_alias_table_resolves_the_shipped_commands() { } assert_eq!(parse_line("/macrohelp"), ParsedChat::MacroHelp); assert_eq!(parse_line("/convertraid"), ParsedChat::ConvertRaid); - // `/console` from a line that skipped the stock edit box (a probe rig) forwards to the verb - // the stock handler calls, so a CVar write lands like a typed one (2008). The long-bracket - // quoting steps its level past anything the text could close. + // `/console` from a line that skipped the stock edit box forwards to the stock handler's verb. assert_eq!( parse_line("/console fpsJournal 1"), ParsedChat::Lua { @@ -1661,18 +1448,14 @@ fn real_alias_table_resolves_the_shipped_commands() { body: "ConsoleExec(\"reloadUI\")".into() } ); - // **The quoting is a SHORT string, because 1.12's lexer has no long-string levels** (2136). - // The old long-bracket form stepped its `=` level past whatever the payload could close, which - // is a construct the reference cannot compile at all β€” see `lua_quoted_string`. + // The quoting is a short string: 1.12's Lua lexer has no long-string levels. assert_eq!(super::input::lua_quoted_string("a]]b"), "\"a]]b\""); assert_eq!(super::input::lua_quoted_string("a]]b]=]c"), "\"a]]b]=]c\""); assert_eq!( super::input::lua_quoted_string("say \"hi\"\\n"), "\"say \\\"hi\\\"\\\\n\"" ); - // …and the generated literal ROUND-TRIPS through a real VM, which is the assertion that - // actually pins the grammar: the old form compiled here and would not have on the reference, - // so a string check alone could not have caught it. + // The literal round-trips through a real VM. { let vm = benilla_ui::script::UiScript::new().expect("VM"); for payload in [ @@ -1689,27 +1472,10 @@ fn real_alias_table_resolves_the_shipped_commands() { assert_eq!(got, payload, "and must carry the text unchanged"); } } - // The whole shipped surface, so a table that half-loaded fails loudly: **225 distinct emote - // commands** over the 169 `EmotesText` names (the strings repeat β€” `EMOTE87_CMD1` and `_CMD2` - // are both "/sit" β€” and EMOTE27 "UNUSED" has no row, so it contributes none), and **68 distinct - // aliases** across the 36 registered `SlashCmdList` indices (0886 added TARGET's `/target` - // `/tar` and ASSIST's `/assist` `/a` to 0881's 55; 0890 added FOLLOW's `/f` `/follow` `/fol`; - // 0983 added CAST's `/cast` `/spell`, MACRO's `/macro` `/m`, and MACROHELP's `/macrohelp`; - // 1291 added CONSOLE's `/console` β€” one distinct alias, SLASH_CONSOLE1 and 2 are both the - // same string). - // - // The third number is benilla's own player-facing additions: `/reload` (1291), `/errors` - // `/err` (1495, the script error log) and `/convertraid` (the raid conversion trigger the - // unbuilt RaidFrame tab would otherwise carry) β€” 4 aliases over 3 commands. Present in - // every build, deliberately counted apart from the shipped surface so the seam stays visible. - // The error log is player-facing on purpose and NOT an instrument: gating it on - // `dev_affordances()` would leave exactly the reporters who asked for it unable to type it. - // - // The fourth is the instrument **seam** (decision 1179): benilla's own instrument commands - // (`/castvis` `/chattest` `/partytest` `/shot` `/liquid` `/reaction` `/react` β€” 7 aliases over 6 - // commands) are registered only when `run_mode::dev_affordances()`, so a player build claims - // none of them and `/partytest` falls through to the reference's "unknown command". Asserted - // against the predicate rather than a literal, so the row states the rule in both builds. + // The shipped surface: 68 distinct aliases over 36 `SlashCmdList` indices and 225 emote + // commands over 169 `EmotesText` names (aliases repeat; EMOTE27 "UNUSED" has no row). Then + // benilla's own `/reload`, `/errors`, `/err` and `/convertraid`, which are not 1.12 commands, + // in every build, and 7 instrument aliases, in dev builds only. let instruments = if crate::run_mode::dev_affordances() { 7 } else { @@ -1722,14 +1488,8 @@ fn real_alias_table_resolves_the_shipped_commands() { ); } -/// **The `0x4000` "requires standing still" arm** (decision 1904) β€” the one gate arm that does not -/// suppress silently. It reports [`EmoteGate::Moving`] and the CALLER turns that into a red -/// `ERR_NOEMOTEWHILERUNNING`, but only while the caster is self-controlled. -/// -/// The mask is the reference's own `0x20ff` β€” [`move_flags::INTEGRATED`] β€” and what it leaves out -/// matters as much as what it holds: **SWIMMING is not in it**, so a swimmer standing still in the -/// water emotes fine. That negative is why this arm has nothing to do with the swim suppression -/// tested above. +/// The `0x4000` "stand still" flag reports [`EmoteGate::Moving`] on any bit of the reference's +/// `0x20ff` mask (`move_flags::INTEGRATED`), which holds no SWIMMING. #[test] fn the_standing_still_arm_reports_moving_and_ignores_swimming() { use super::input::EmoteGate; @@ -1737,9 +1497,8 @@ fn the_standing_still_arm_reports_moving_and_ignores_swimming() { const STILL: u32 = 0x4000; - // Not moving: it sends. assert_eq!(emote_send_eligible(STILL, 0, false, 0), EmoteGate::Send); - // Any INTEGRATED bit trips it β€” a direction, a keyboard turn, or a fall. + // Any INTEGRATED bit trips it: a direction, a turn or a fall. for f in [ move_flags::FORWARD, move_flags::BACKWARD, @@ -1753,7 +1512,7 @@ fn the_standing_still_arm_reports_moving_and_ignores_swimming() { "move flag {f:#x}" ); } - // SWIMMING is NOT in `0x20ff`: a still swimmer is still. + // SWIMMING is not in `0x20ff`: a still swimmer is still. assert_eq!( emote_send_eligible(STILL, 0, false, move_flags::SWIMMING), EmoteGate::Send, @@ -1769,18 +1528,15 @@ fn the_standing_still_arm_reports_moving_and_ignores_swimming() { emote_send_eligible(0, 0, false, move_flags::FORWARD), EmoteGate::Send ); - // An unconditionally-suppressed emote never reaches this arm β€” it stays SILENT, which is why - // only 20 of the 33 rows carrying `0x4000` can actually raise the line. + // An unconditionally suppressed emote (`0x0400`) never reaches this arm: it stays silent. assert_eq!( emote_send_eligible(0x4400, 0, false, move_flags::FORWARD), EmoteGate::Suppressed ); } -/// **`IsSelfControlled`'s polarity** (`0x5fa550`, decision 1904) β€” the half that decides whether -/// the moving refusal is heard. It is `true` for an ordinary player and `false` while confused, -/// fleeing or move-disabled, so the red line fires in the NORMAL case and a feared player emotes -/// away. STUNNED is deliberately absent from the mask. +/// `IsSelfControlled` (`0x5fa550`) is false only while confused, fleeing or move-disabled, the +/// `0xc00004` mask, which leaves STUNNED out. #[test] fn self_controlled_is_true_for_an_ordinary_player() { use crate::player::self_controlled; @@ -1796,8 +1552,7 @@ fn self_controlled_is_true_for_an_ordinary_player() { ); } -// ── The send-side posture-eligibility gate (`emote_send_eligible`) β€” the director-verified rows -// against `CheckEmoteEligible 0x47db40`, real `Emotes.dbc` `EmoteFlags` values. +// ── the send-side emote gate, `CheckEmoteEligible` (`0x47db40`), on real `EmoteFlags` values ── const BOW: u32 = 0x4801; const RUDE: u32 = 0x0001; const APPLAUD: u32 = 0x0000; @@ -1808,7 +1563,7 @@ const LAUGH: u32 = 0x0980; #[test] fn seated_stand_required_emotes_are_suppressed() { let _data = benilla_formats::wow_data_or_skip!(); - assert_eq!(emote_send_eligible(BOW, 1, false, 0), EmoteGate::Suppressed); // 0x4801 has 0x1 (requires STAND) + assert_eq!(emote_send_eligible(BOW, 1, false, 0), EmoteGate::Suppressed); // 0x1 needs STAND assert_eq!( emote_send_eligible(RUDE, 1, false, 0), EmoteGate::Suppressed @@ -1853,31 +1608,19 @@ fn unconditional_and_sleep_dead_rules() { emote_send_eligible(0x0400, 0, false, 0), EmoteGate::Suppressed ); // unconditional suppress - assert_eq!(emote_send_eligible(0, 3, false, 0), EmoteGate::Suppressed); // SLEEP without the allow bit - assert_eq!(emote_send_eligible(0, 7, false, 0), EmoteGate::Suppressed); // DEAD without the allow bit - assert_eq!(emote_send_eligible(0x0200, 3, false, 0), EmoteGate::Send); // "allowed while asleep/dead" + assert_eq!(emote_send_eligible(0, 3, false, 0), EmoteGate::Suppressed); // SLEEP, no allow bit + assert_eq!(emote_send_eligible(0, 7, false, 0), EmoteGate::Suppressed); // DEAD, no allow bit + assert_eq!(emote_send_eligible(0x0200, 3, false, 0), EmoteGate::Send); // 0x0200 allows it } -// ── The open-the-box law, shared by the ENTER key and an addon's ChatFrame_OpenChat ────────── +// ── received lines: the addon lane, the language header, the talk gesture ────────────────── -/// **The inbound addon split, and the direction a reimplementation gets backwards.** -/// -/// `CHAT_MSG_ADDON` (event 227) carries `(prefix, message, distribution, sender)`. The text divides -/// on its **FIRST** tab (`0x49a8d0`) β€” and with **no tab at all the whole text is the PREFIX** with -/// an empty message, not the reverse. That direction is the counter-intuitive one; this test is -/// where it is pinned. -/// -/// `distribution` is the remap at `0x49aff4`: only the four lanes have names, and anything else -/// reports `"UNKNOWN"` rather than being dropped β€” the reference hands the addon a string it can -/// branch on either way. +/// The text divides on its first tab (`0x49a8d0`), and with no tab it is all prefix; a lane +/// without a name reports `"UNKNOWN"` (`0x49aff4`). #[test] fn an_inbound_addon_line_splits_on_the_first_tab_only() { let _data = benilla_formats::wow_data_or_skip!(); - // **Imported, not hand-copied.** These read `0x03`/`0x04`/`0x18` for RAID/GUILD/BATTLEGROUND, - // which are all wrong β€” and because the test carried the SAME wrong bytes as the code under - // test, it agreed with the defect instead of catching it. A test that restates the value it is - // checking cannot fail on that value; taking it from the protocol crate is what makes it a - // check rather than an echo. + // The lane bytes come from the protocol crate, never copied by hand. use benilla_protocol::messages as m; let party = m::CHAT_TYPE_PARTY as u8; let raid = m::CHAT_TYPE_RAID as u8; @@ -1888,13 +1631,12 @@ fn an_inbound_addon_line_splits_on_the_first_tab_only() { let (PARTY, RAID, GUILD, BATTLEGROUND, SAY) = (party, raid, guild, battleground, say); let mut log = super::feed::ChatLog::default(); - // The ordinary shape. log.push_addon("oRA\tSYNC:1", PARTY, 7); - // A message that itself contains tabs: only the FIRST one divides. + // Tabs in the message: only the first divides. log.push_addon("CTRA\tA\tB\tC", RAID, 7); - // NO TAB β€” the whole text is the prefix, the message is empty. + // No tab: all prefix, an empty message. log.push_addon("BareTag", GUILD, 7); - // An empty message after a trailing tab is still an empty message, not a missing one. + // A trailing tab: an empty message. log.push_addon("Tag\t", BATTLEGROUND, 7); // A lane with no name still arrives, labelled. log.push_addon("X\ty", SAY, 7); @@ -1911,15 +1653,7 @@ fn an_inbound_addon_line_splits_on_the_first_tab_only() { ); } -/// **`CHAT_MSG_ADDON` reaches Lua with the reference's four arguments, in the reference's order.** -/// -/// The split test above covers the parse; this covers the FIRE, which is the half that can be -/// silently wrong β€” an addon reading `arg3` as the sender instead of the distribution gets a string -/// either way and misbehaves without erroring. -/// -/// The reference fires `SignalEvent2(227, "%s%s%s%s", prefix, message, distribution, sender)` -/// (`0x49a95f`); `BigWigs` self-delivers the identical order by hand. The handler below records all -/// four positionally, so a reordering fails on the values rather than on a count. +/// `CHAT_MSG_ADDON` carries prefix, message, distribution, sender, in that order (`0x49a95f`). #[test] fn the_addon_event_reaches_lua_with_four_arguments_in_order() { let _data = benilla_formats::wow_data_or_skip!(); @@ -1971,21 +1705,8 @@ fn the_addon_event_reaches_lua_with_four_arguments_in_order() { ); } -/// **The two halves of the addon lane, against each other.** -/// -/// Send (1235/1236) and receive (7bd5567f) landed in different sessions, and the agent that built -/// the send half flagged the gap honestly: they pass together but *"I have not independently -/// exercised the two together."* A two-account live loopback is still the only thing that proves -/// the round trip on the wire β€” this proves the halves agree with each OTHER, which is the part -/// that can drift without either side looking wrong on its own. -/// -/// The composition and the split are separate transcriptions of the same byte law (`0x49f9b3` -/// composes on a tab, `0x49a8d0` splits on the first one), written by different sessions from the -/// same note. If one had picked a different separator, or split last-tab instead of first, every -/// test on both sides would still pass. -/// -/// A message CONTAINING tabs is the case that discriminates: compose glues one tab, the split takes -/// only the first, so the payload must come back with its own tabs intact. +/// The send joins on a tab (`0x49f9b3`) and the receive splits on the first (`0x49a8d0`), so a +/// payload's own tabs come back intact. #[test] fn an_addon_message_survives_its_own_send_and_receive() { let _data = benilla_formats::wow_data_or_skip!(); @@ -1999,8 +1720,7 @@ fn an_addon_message_survives_its_own_send_and_receive() { let sent = &sends[0]; assert_eq!(sent.distribution.token(), "PARTY"); - // Now the wire turns around: the same text arrives as an ordinary PARTY line carrying - // LANG_ADDON, and the receive half parses it. + // The same text arrives as a PARTY line in LANG_ADDON. let mut log = super::feed::ChatLog::default(); log.push_addon(&sent.text, 0x01, 7); @@ -2015,17 +1735,8 @@ fn an_addon_message_survives_its_own_send_and_receive() { ); } -/// **The `[Language]` header keys off the frame's DEFAULT tongue, not off "Common"** (B262). -/// -/// `ChatFrame.lua`'s test is `strlen(arg3) > 0 and arg3 ~= "Universal" and arg3 ~= this.defaultLanguage`, -/// and `GetDefaultLanguage()` answers the **faction** language β€” Common for every Alliance race, -/// Orcish for every Horde one (`0x5ec890`, and benilla's own -/// `ChrRaces` field-8 join). The composer hardcoded `"Common"`, which is right for half the game -/// and exactly backwards for the other half: a Horde character saw `[Orcish]` on every ordinary -/// line of their own faction's chat, and no tag at all on the Common they cannot read. -/// -/// The condition is about the default language, never about whether the listener understands it β€” -/// so a fully-understood foreign line still carries its tag. +/// The header shows unless arg3 is empty, "Universal" or `this.defaultLanguage` +/// (`ChatFrame.lua:1442`), the faction's language (`GetDefaultLanguage`, `0x5ec890`). #[test] fn the_language_header_suppresses_only_the_frames_own_default_tongue() { let _data = benilla_formats::wow_data_or_skip!(); @@ -2048,8 +1759,7 @@ fn the_language_header_suppresses_only_the_frames_own_default_tongue() { "|Hplayer:Ann|h[Ann]|h says: hello" ); - // A Horde body (default Orcish): exactly the mirror. This is the assertion that fails against - // the hardcoded "Common". + // A Horde body (default Orcish): the mirror. assert_eq!( compose(&orcish, K::Say, "Orcish").unwrap(), "|Hplayer:Grom|h[Grom]|h says: lok'tar" @@ -2059,16 +1769,14 @@ fn the_language_header_suppresses_only_the_frames_own_default_tongue() { "|Hplayer:Ann|h[Ann]|h says: [Common] hello" ); - // Language 0 arrives as an empty arg3 and is never tagged, whatever the default is β€” which is - // also how a GM and the narration chat types come through, since all three force the field to 0. + // Language 0 arrives as an empty arg3 and is never tagged. let universal = ev(K::Say, "system", "Ann"); assert_eq!( compose(&universal, K::Say, "Orcish").unwrap(), "|Hplayer:Ann|h[Ann]|h says: system" ); - // A language the listener fully understands still carries its tag: the test is about the - // default tongue, not about comprehension. A dwarf reading Dwarvish sees the header. + // Comprehension does not matter: an understood foreign tongue is still tagged. let dwarvish = ChatEvent { language: "Dwarvish".into(), ..ev(K::Say, "here we go", "Bran") @@ -2079,19 +1787,8 @@ fn the_language_header_suppresses_only_the_frames_own_default_tongue() { ); } -/// **The talk/laugh gesture reads the PLAINTEXT, not the garbled line** β€” so it is -/// language-independent, and a Horde player yelling `lol` laughs for every observer, Alliance -/// included. -/// -/// This corrects an inference we had already wired: the consumer census of the display path -/// `0x49a870` found the chat line, the Lua `arg1` and the bubble all sharing the rewritten buffer, -/// and we concluded the gesture did too. It does not β€” the selector is not on that path at all. It -/// lives in the **parser** `0x49d560` at `0x49d820`-`0x49d8ae`, matching against `[ebp-0x10]`, which -/// is the very buffer `0x49dbc2` then hands to `0x49a870` as its `src`. The garbled buffer is a -/// local of a frame that does not exist yet, so the census could never have found this consumer. -/// -/// The two inputs are observably different, which is the whole point of the test: feed the garbled -/// text here and the laugh silently becomes a plain talk. +/// The gesture selector in the parser `0x49d560` (`0x49d820`-`0x49d8ae`) matches the plaintext that +/// `0x49dbc2` then hands to the display path `0x49a870`, so a laugh is language-independent. #[test] fn the_talk_gesture_reads_the_plaintext_not_the_garbled_line() { let _data = benilla_formats::wow_data_or_skip!(); @@ -2099,7 +1796,7 @@ fn the_talk_gesture_reads_the_plaintext_not_the_garbled_line() { use benilla_protocol::messages::CHAT_MSG_SAY; let Some(data) = benilla_formats::wow_data() else { - return; // no client data β€” the same skip every data-gated test here takes + return; // no client data }; let mut chain = benilla_formats::Chain::open(&data).expect("open patch chain"); let words = benilla_formats::load_language_words(&mut chain).expect("load word pools"); @@ -2121,17 +1818,10 @@ fn the_talk_gesture_reads_the_plaintext_not_the_garbled_line() { ); } -// ── B297: the combat log reaches addons ────────────────────────────────────────────────────── +// ── the combat log reaches addons ──────────────────────────────────────────────────────────── -/// **The bug, stated as a test.** B297 is "benilla emits no combat-log chat events at all", and the -/// consumer named in the report is Quiver's TranqAnnouncer, whose *only* detector is -/// `CHAT_MSG_SPELL_SELF_DAMAGE`. So the test is an addon registering exactly that event and -/// receiving exactly that sentence. -/// -/// `arg1` carrying the whole formatted line is the load-bearing half: every 1.12 damage meter and -/// announcer parses `arg1` with a Lua pattern built from its own copy of the GlobalStrings, so a -/// fire with an empty or differently-shaped arg1 would pass a "does it fire" check and still be -/// useless. This asserts the text. +/// Combat-log addons parse arg1 with patterns built from the GlobalStrings, so arg1 is the whole +/// sentence. #[test] fn an_addon_sees_the_combat_log_line_it_registers_for() { let _data = benilla_formats::wow_data_or_skip!(); @@ -2162,12 +1852,6 @@ fn an_addon_sees_the_combat_log_line_it_registers_for() { assert!(s.errors().is_empty(), "handler errors: {:?}", s.errors()); } -/// **Every combat-log kind reaches an addon that registers it**, not just the one B297 named. -/// -/// [`an_addon_sees_the_combat_log_line_it_registers_for`] pins the shape on `SPELL_SELF_DAMAGE`; -/// this sweeps the whole block, which is what makes 1703's eleven new types a *fact* rather than a -/// hope β€” a kind whose name is misspelled in `event_name`, or that the router drops, fires nothing -/// and would otherwise be found by a player's damage meter months later. #[test] fn every_combat_log_kind_reaches_an_addon() { let _data = benilla_formats::wow_data_or_skip!(); @@ -2209,18 +1893,14 @@ fn every_combat_log_kind_reaches_an_addon() { } } -/// The combat block is **verbatim**: the composer adds `arg1` and nothing else β€” no `[Name]` link, -/// no `` prefix, no language header. The reference says so by prefix -/// (`ChatFrame_OnEvent` l.1397/1399, two arms that only `AddMessage(arg1, …)`), and the failure -/// this guards against is the player/monster branch's decorations leaking onto a combat line. +/// The `COMBAT_` and `SPELL_` arms only `AddMessage(arg1)` (`ChatFrame.lua:1397`, `:1399`). #[test] fn a_combat_log_line_renders_verbatim() { let _data = benilla_formats::wow_data_or_skip!(); let default_language = String::from("Common"); for kind in K::ALL.iter().copied().filter(|k| k.is_combat_log()) { let mut e = ChatEvent::text_only(kind, "You hit Kobold Vermin for 5.".into()); - // Deliberately populated: a combat line never carries these, and if the composer ever fell - // through to the player branch it would splice them in. + // Populated so a fall-through to the player branch would show. e.sender = "Somebody".into(); e.flag = "AFK".into(); e.language = "Orcish".into(); @@ -2233,18 +1913,6 @@ fn a_combat_log_line_renders_verbatim() { } } -/// **Both dock tabs exist, and clicking one selects its window.** -/// -/// This is the test that was missing when 1571 shipped the combat log's chat lines: the lines -/// routed correctly into ChatFrame2 and no player could ever see them, because `ChatFrame2Tab` had -/// never been authored. Every piece of machinery around it *did* exist and was written for two -/// tabs β€” `BenillaFCF`'s fade/resize/flash loops all run `for i = 1, 2` β€” but each carries an -/// `if tab then` guard, so the absence was swallowed silently for as long as it existed. The -/// director found it by looking at the screen. -/// -/// So the assertion is deliberately about the tab BUTTON and the selection it drives, not about -/// routing (which `the_combat_log_lands_in_window_two_only` already covers and which was never -/// the broken half). #[test] fn both_dock_tabs_exist_and_select_their_window() { let _data = benilla_formats::wow_data_or_skip!(); @@ -2264,7 +1932,7 @@ fn both_dock_tabs_exist_and_select_their_window() { assert!(s.eval::("return ChatFrame1:IsShown()").unwrap()); assert!(!s.eval::("return ChatFrame2:IsShown()").unwrap()); - // Clicking the Combat Log tab swaps them β€” the path a player takes to read the combat log. + // Clicking the Combat Log tab swaps them. s.run("FCF_SelectDockFrame(ChatFrame2)").unwrap(); assert_eq!( s.eval::("return SELECTED_DOCK_FRAME:GetID()").unwrap(), @@ -2275,9 +1943,8 @@ fn both_dock_tabs_exist_and_select_their_window() { assert!(s.errors().is_empty(), "handler errors: {:?}", s.errors()); } -/// The two dock tabs are labelled from the install's own `GlobalStrings.lua` (`GENERAL`, -/// `COMBAT_LOG`) rather than from words quoted into our XML β€” the loader's ALL-CAPS key rule, and -/// the same shape the combat log's format strings use (1571). Skips without client data. +/// The stock tabs default to the `GENERAL` and `COMBAT_LOG` GlobalStrings +/// (`FloatingChatFrame.lua:684-686`). #[test] fn the_dock_tab_labels_come_from_the_install() { let data = benilla_formats::wow_data_or_skip!(); @@ -2293,16 +1960,6 @@ fn the_dock_tab_labels_come_from_the_install() { } } -/// **The Combat Log window has a real rect, and it is the dock's.** -/// -/// This is the test whose absence let 1575 ship a tab onto a window that rendered nothing: -/// `ChatFrame2` carries no `` and derives its whole rect from two anchors onto ChatFrame1, -/// and every check we had asked only whether lines *routed* into it. They did β€” 21 of them β€” into a -/// frame measuring 0Γ—0, so `GetNumMessages()` was 21 and the screen was empty. -/// -/// Asserting the rect (not the size attribute β€” `GetWidth` reports the explicit field, which is 0 -/// here by design and told us nothing) is what makes "the window exists" mean "the window has -/// pixels". #[test] fn the_combat_log_window_has_the_docks_rect() { let _data = benilla_formats::wow_data_or_skip!(); @@ -2323,12 +1980,8 @@ fn the_combat_log_window_has_the_docks_rect() { "ChatFrame2:{get}() must equal ChatFrame1's β€” a docked window shares the dock's rect" ); } - // **Three points, and which three is the assertion.** The XML authors two (TOPLEFT and - // BOTTOMRIGHT onto ChatFrame1) and `FCF_DockUpdate` then clears them and applies the - // reference's own three for a non-first dock member β€” TOPLEFT / BOTTOMLEFT / BOTTOMRIGHT, - // FloatingChatFrame.lua l.1059-1063. So `3` here means the dock seeding ran and this window is - // in `DOCKED_CHAT_FRAMES`; `2` would mean it never did and the rect is standing on the XML - // alone. Either way one alone cannot size the frame, which is what 1575 shipped. + // `FCF_DockUpdate` anchors a docked window by three points (`FloatingChatFrame.lua:1059-1063`), + // so 3 means the dock seeding ran. assert_eq!( s.eval::("return ChatFrame2:GetNumPoints()").unwrap(), 3, @@ -2354,21 +2007,13 @@ fn the_combat_log_window_has_the_docks_rect() { ); } -/// Both dock windows carry the SAME chrome. `BenillaFCF_Textures(id)` looks each piece up by -/// `ChatFrame`, which is the reference's own per-frame `CHAT_FRAME_TEXTURES` shape β€” -/// and ChatFrame2 had only a background, no borders. Selecting it therefore hid ChatFrame1's -/// chrome (its textures are ChatFrame1's children) and put nothing in its place: the background -/// and border vanished, which is what the director saw. -/// -/// The ring is the eight **resize grips** since the move/resize arc, so the same check now covers -/// both halves of each piece: the texture the fade paints and the button that grabs it. +/// The stock fade and tint walk `CHAT_FRAME_TEXTURES` by frame name (`FloatingChatFrame.lua:14`), +/// so each dock window carries all nine pieces. #[test] fn both_dock_windows_carry_the_same_chrome() { let _data = benilla_formats::wow_data_or_skip!(); let s = chat_vm(); - // The reference's own `CHAT_FRAME_TEXTURES`, which is also the list the file declares β€” read - // out of the VM rather than restated here, so a piece that leaves the list cannot leave this - // check with it. + // Read out of the VM, so the check follows the list. let suffixes: Vec = (1..=9) .map(|i| { s.eval::(&format!("return CHAT_FRAME_TEXTURES[{i}]")) @@ -2389,8 +2034,7 @@ fn both_dock_windows_carry_the_same_chrome() { ); } } - // The grips themselves, not only their art: each texture's owning Button has to exist too, or - // the ring is painted and nothing can grab it. + // Each texture's owning grip Button exists too. for grip in [ "TopLeft", "TopRight", @@ -2411,15 +2055,8 @@ fn both_dock_windows_carry_the_same_chrome() { } } -/// **A docked chat window survives the managed-position pass.** -/// -/// The pass owns a frame's whole seat β€” it `ClearAllPoints()` first, by design (decision 1499) β€” -/// and the reference's own table carries a `ChatFrame2` row. That row cost B297 a visible fix -/// while the pass was ours, and our answer then was to drop the row from our copy. The stock pass -/// is the one that runs now (1988), row and all, and it ends by calling `FCF_DockUpdate()` β€” -/// which re-anchors every docked window onto `DEFAULT_CHAT_FRAME` in the same breath. That is the -/// reference's answer to its own row, and this is the behavioural check that it holds: after the -/// pass, the docked window is still exactly on the dock. +/// The stock pass anchors its `ChatFrame2` row (`UIParent.lua:1582`), then `FCF_DockUpdate()` +/// re-anchors every docked window onto `DEFAULT_CHAT_FRAME`. #[test] fn a_docked_chat_window_survives_the_managed_position_pass() { let _data = benilla_formats::wow_data_or_skip!(); @@ -2454,22 +2091,14 @@ fn a_docked_chat_window_survives_the_managed_position_pass() { assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); } -/// **What does NOT gate a `/sit` underwater** β€” the negative that sends B155's refusal to the -/// stand-state setter instead of here (`player::state::stand_state_refused`). -/// -/// [`emote_send_eligible`] carries the client's own swim suppression (`EmoteFlags & 0x0080` at -/// `0x47db7d`), so it is the obvious suspect for "the reference won't let me `/sit` in water" β€” and -/// it is the wrong one. Read off the shipped `Emotes.dbc`: every posture emote (`/sit`, `/sleep`, -/// `/kneel`, `/stand`) carries `0x6202`, and `0x0080` is clear in it, so the emote layer passes them -/// straight through while swimming. This asserts that on the real data, because it is the fact that -/// decides *where* the fix belongs: if a later data read made these rows carry `0x0080`, the two -/// gates would double up and this test is what says so. Skips without client data. +/// The swim bit (`EmoteFlags & 0x0080`, `0x47db7d`) is clear on every posture emote, so the +/// refusal to sit in water is the stand-state setter's (`player::state::stand_state_refused`). #[test] fn the_posture_emotes_carry_no_swim_suppression_flag() { let data = benilla_formats::wow_data_or_skip!(); let mut chain = benilla_formats::open_chain(&data).expect("open chain"); let cat = benilla_formats::load_emote_sound_catalog(&mut chain).expect("emote catalog"); - // Every posture row in the shipped table, found by scanning rather than by hardcoded id. + // Every posture row, found by scanning. let posture: Vec<(u32, u32, u32)> = (0..600u32) .filter_map(|id| Some((id, cat.posture_state(id)?, cat.emote_flags(id)?))) .collect(); @@ -2486,16 +2115,12 @@ fn the_posture_emotes_carry_no_swim_suppression_flag() { 0, "posture emote {id} (state {state}) carries the swim-suppress bit: {flags:#x}" ); - // STATE_DEAD (7, emote 65, `0x6602`) is the one posture row the client kills outright β€” - // `0x0400`, unconditional suppress, in water or out. It is not reachable from the slash - // grammar and it is not what B155 is about; every posture a player can actually ask for - // passes the emote gate mid-swim, which is the point. + // STATE_DEAD (emote 65, `0x6602`) carries the unconditional suppress bit `0x0400`. if flags & 0x0400 != 0 { assert_eq!(state, 7, "only STATE_DEAD is unconditionally suppressed"); continue; } - // …so the emote gate lets it through mid-swim. (`stand_state` here is the performer's - // CURRENT state; a standing swimmer pressing `/sit` is the reported case.) + // A standing swimmer's posture emote passes the gate. assert_eq!( super::input::emote_send_eligible(flags, 0, true, 0), super::input::EmoteGate::Send, @@ -2504,16 +2129,7 @@ fn the_posture_emotes_carry_no_swim_suppression_flag() { } } -/// **The ding's gains reach `PLAYER_LEVEL_UP` matched BY LEVEL, and a miss is zeros, not absence.** -/// -/// The net layer has no `UiScript`, so `SMSG_LEVELUP_INFO`'s tuple is parked on `ChatLog` and the -/// feed that owns the level edge picks it up (decision 1884). Matching on the level is what keeps -/// a stale entry from attaching to a later ding β€” the trigger is a descriptor diff and the gains -/// are packet-borne, so the two are only coincidentally in step. -/// -/// The miss case is the load-bearing half: a GM demotion writes the descriptor with no packet, and -/// the event must still carry nine arguments. `ChatFrame.lua` guards every one with -/// `if ( argN > 0 )`, so a zero reads as "no line" while a nil raises. +/// `SMSG_LEVELUP_INFO`'s gains park on `ChatLog` until the level edge that matches them. #[test] fn level_up_gains_are_matched_by_level_and_a_miss_is_not_an_absence() { use benilla_protocol::messages::LevelUpInfo; @@ -2539,19 +2155,8 @@ fn level_up_gains_are_matched_by_level_and_a_miss_is_not_an_absence() { ); } -/// **The ding block is printed once, by the reference's own window.** -/// -/// `benilla.toc` sources `Interface\FrameXML\ChatFrame.xml` off the player's chain, and stock -/// `ChatFrame_OnEvent` composes the whole five-line level-up block itself from `PLAYER_LEVEL_UP` -/// (`ChatFrame.lua` l.1283-1324) β€” `LEVEL_UP`, the health/mana pair, `LEVEL_UP_CHAR_POINTS`, and a -/// `LEVEL_UP_STAT` per positive gain. benilla fires that event with the reference's nine arguments -/// (decision 1884), and for a while it *also* routed its own Rust copy of the same five lines, -/// under a comment saying they would stay "until that window migrates". The window migrated; the -/// copy did not go. Every ding printed twice, and nothing could see it: both halves were correct -/// on their own, and the composer's own tests only ever checked the text it produced. -/// -/// So this counts what lands in the real window. The event is the whole of the ding now β€” which -/// also means the count below is the reference's own composition, not ours. +/// Stock `ChatFrame_OnEvent` prints the whole level-up block from `PLAYER_LEVEL_UP` +/// (`ChatFrame.lua:1283-1323`); the app composes none of it. #[test] fn the_ding_block_is_printed_once() { let _data = benilla_formats::wow_data_or_skip!(); @@ -2565,7 +2170,7 @@ fn the_ding_block_is_printed_once() { powers: [15, 0, 0, 0, 0], stats: [1, 0, 0, 0, 0], }; - // The whole of what the packet's apply does now: park the gains, print nothing. + // The packet's apply parks the gains and prints nothing. log.push_level_up_gains(&info, 1); let before = lines_in_window(&s); assert_eq!( @@ -2574,9 +2179,7 @@ fn the_ding_block_is_printed_once() { "the app composes no ding line of its own" ); - // Tap the window's own `AddMessage` so the assertion can be about the BLOCK and not just its - // length β€” this is where the deleted Rust composer's test went. The subject moved to the - // reference's Lua; the knowledge did not. + // Tap the window's `AddMessage` to read the block itself. s.run( r#" DingLines = {} @@ -2602,9 +2205,7 @@ fn the_ding_block_is_printed_once() { "LEVEL_UP, the health/mana pair, CHAR_POINTS, and one STAT β€” once each" ); - // `ChatFrame.lua` l.1283-1324's exact order and forms, off the shipped GlobalStrings: the - // singular `LEVEL_UP_CHAR_POINTS` at one point (`GetText`'s plural pick), and one - // `LEVEL_UP_STAT` for the single positive gain, named through `SPELL_STAT0_NAME`. + // The singular `LEVEL_UP_CHAR_POINTS` (`GetText`'s plural pick) and one `LEVEL_UP_STAT`. let lines: Vec = (1..=4) .map(|i| s.eval::(&format!("return DingLines[{i}]")).unwrap()) .collect(); @@ -2619,11 +2220,8 @@ fn the_ding_block_is_printed_once() { ); } -/// **The free-professions line, same question as the ding.** Stock `ChatFrame_OnEvent` handles -/// `CHARACTER_POINTS_CHANGED` too (`ChatFrame.lua` l.1324-1334): on `arg2 > 0` it reads -/// `UnitCharacterPoints("player")` and prints `GetText("LEVEL_UP_SKILL_POINTS", nil, cp2)`. -/// `ui_talent` fires that event *and* composes the same line. This asks the window which of them -/// lands. +/// On `CHARACTER_POINTS_CHANGED` with `arg2 > 0`, stock `ChatFrame_OnEvent` prints +/// `LEVEL_UP_SKILL_POINTS` (`ChatFrame.lua:1324-1334`). #[test] fn the_free_professions_line_is_printed_once() { let _data = benilla_formats::wow_data_or_skip!(); @@ -2665,26 +2263,11 @@ fn the_free_professions_line_is_printed_once() { ); } -// ───────────── The chat cache restores INSIDE the login, not after it (decision 2119) ───────── +// ───────────── the chat cache restores inside the login ───────── -/// **The login order, asserted at the two places that broke.** -/// -/// `UPDATE_CHAT_WINDOWS` is the only thing that registers a chat frame for any `CHAT_MSG_*` -/// (`ChatFrame_OnEvent`'s arm calls `ChatFrame_RegisterForMessages(GetChatWindowMessages(id))`), -/// and the `UPDATE_CHAT_COLOR` burst mirrors `WHISPER` into `ChatTypeInfo["REPLY"]`, whose `.id` -/// is 0 β€” the same id every `AddMessage` with no explicit colour carries β€” so the burst repaints -/// them. Both events come from the chat-cache restore, so the restore has to be finished before -/// `PLAYER_LOGIN`: before it, an addon's `Print` gets repainted whisper-pink, and any chat routed -/// in that window lands on a frame registered for nothing and is dropped in silence (1784). -/// -/// Pre-2119 the restore was an `Update` system and this probe saw `windows = nil`, -/// `colors = nil`, `registered = ""` at `PLAYER_LOGIN`. -/// -/// The probe is planted as a real loose ADDON, the way `world_entry_tests` plants its own. It -/// cannot be a frame created on the boot VM beforehand: since 2226 the entry load BUILDS the VM it -/// runs on, so anything seated on the character screen's VM is gone before the first event fires. -/// An addon's file scope runs inside the load β€” after the XML, before `VARIABLES_LOADED` β€” which -/// is exactly the vantage point this probe wants, and the one the reference gives an addon too. +/// `UPDATE_CHAT_WINDOWS` registers the chat frames for `CHAT_MSG_*`, and the `UPDATE_CHAT_COLOR` +/// burst repaints id-0 lines through `ChatTypeInfo["REPLY"]`, so both come before `PLAYER_LOGIN`. +/// The probe is a loose addon, because the entry load builds the VM it runs on. #[test] fn the_chat_cache_restore_is_finished_before_player_login() { let _data = benilla_formats::wow_data_or_skip!(); @@ -2797,10 +2380,8 @@ fn the_chat_cache_restore_is_finished_before_player_login() { "ChatFrame1 must carry the SYSTEM message group at PLAYER_LOGIN, not {:?}", read("ChatOrderProbe.loginRegistered") ); - // The reference's own login order (decision 2125): addons and their `ADDON_LOADED` - // (`0x4900a3`), then `VARIABLES_LOADED` - // (`0x4900b2`), then the chat-cache reader's burst (`0x4900d6`), then `PLAYER_LOGIN` - // (`0x490959`). 2119 put the burst ahead of `VARIABLES_LOADED`, one step too early. + // The reference's login order: `ADDON_LOADED` (`0x4900a3`), `VARIABLES_LOADED` (`0x4900b2`), + // the chat-cache burst (`0x4900d6`), `PLAYER_LOGIN` (`0x490959`). assert_eq!( read("ChatOrderProbe.order"), "VARIABLES_LOADED UPDATE_CHAT_WINDOWS UPDATE_CHAT_COLOR PLAYER_LOGIN ", diff --git a/crates/benilla-app/src/ui_craft.rs b/crates/benilla-app/src/ui_craft.rs index 7a6f0ee4..a668746f 100644 --- a/crates/benilla-app/src/ui_craft.rs +++ b/crates/benilla-app/src/ui_craft.rs @@ -1,21 +1,7 @@ -//! The app-side **Craft window feed** (decision 0437 phase 3) β€” Enchanting's client-built book, -//! [`crate::ui_tradeskill`]'s CraftFrame twin. Same law: the effect-47 opener for skill line 333 -//! opens it client-side (no packet β€” routed here by `ui_tradeskill::open_trade_skill`); the -//! recipe list is the player's known attr-`0x20` spells joined to line 333; reagents/tools off -//! the parsed `Spell.dbc` columns; the description is the spell's own `$`-token text, resolved -//! through the tooltip arc's substitution engine. -//! -//! **The item pick** (the one thing the TradeSkill window doesn't have): an enchant is an -//! item-targeted cast (`SPELL_EFFECT_ENCHANT_ITEM`/`_TEMPORARY`, `Targets = 0x10`). Since -//! decision 0923 this window owns none of that machinery β€” `DoCraft` goes down the ONE cast -//! ladder like every other caster surface, and the resolver's item arm raises the ONE targeting -//! cursor, which the bag and paper-doll click seams complete ([`crate::spell::targeting`]). -//! The private `PendingItemCast` this file used to carry β€” a second targeting state with its own -//! arm, its own bag-click completion and its own cursor overlay, bypassing every ladder rung -//! including the reagent check an enchant most needs β€” is gone. -//! -//! **Named INTERIM:** no replace-enchant confirm popup yet (event 0x193 is pure client gating and -//! 5875 has no replace opcode β€” it needs the item's enchantment fields, a later slice). +//! The Craft window feed: the client-built book of a craft type (Enchanting, Beast Training), +//! opened by its opener spell with no packet. Reagents and tools come off `Spell.dbc`, and the +//! description's `$` tokens resolve as a tooltip's do. An enchant is an item-targeted cast +//! (`Targets = 0x10`) whose pick is the ordinary targeting cursor ([`crate::spell::targeting`]). use bevy::prelude::*; @@ -38,19 +24,10 @@ use crate::ui_spellbook::SkillLines; use crate::ui_tradeskill::SpellFocus; use crate::ui_unit::UnitFeed; -// NOTE on the admission filter below: Craft recipes (enchants, the rod crafts) do NOT carry -// `SPELL_ATTR_IS_TRADESKILL` β€” they carry **`castUI != 0`** instead (pinned on the live 5875 data: -// 7418/7421 castUI=3, attributes bare 0x10000; exactly the spellbook add-gate's third exclusion -// leg). INTERIM admission, unconfirmed in the binary: a known spell joins the craft list when its -// SLA line matches AND (`castUI != 0` OR the tradeskill bit) and it is not an opener. - -/// The open Craft window: the skill line whose recipes it lists (`None` = closed) and the **craft -/// type** that opened it. Routed here by the opener's `EffectMiscValue[0] != 0` -/// (`Spell_C::TryCast 0x6e4b60`); that same misc value *is* the craft type (1 Beast Training Β· -/// 3 Enchanting), which the client keeps at `ds:0xbdcfb8` and reads for both the window's admission -/// filter and its row comparator (decision 1124). Client-local state, no wire. Cleared by the Lua -/// close and by the session end ([`on_session_end`]) β€” a logout's fresh VM never runs the old one's -/// `OnHide`, so the close alone would carry the window into the next login. +/// The open Craft window, client-local. A nonzero opener `EffectMiscValue[0]` routes here +/// (`Spell_C::TryCast` `0x6e4b60`) and is the craft type (1 Beast Training, 3 Enchanting) the +/// client keeps at `0xbdcfb8` for admission and row order. The session end clears it too: a +/// logout's fresh VM never runs the old one's `OnHide`. #[derive(Resource, Default)] pub(crate) struct CraftOpen { pub(crate) line: Option, @@ -69,15 +46,13 @@ impl Plugin for UiCraftPlugin { } } -/// The Craft window dies with the session β€” a listener on the session end (a second handler on -/// the kind, after the bridge's own teardown). +/// The Craft window dies with the session. fn on_session_end(In(_): In, mut open: ResMut) { *open = CraftOpen::default(); } -/// The line's `(rank, max, bonus)` off the skill block β€” the Craft window bands difficulty on -/// the EFFECTIVE skill (rank + bonuses, `0x5ea520` in the Craft build `0x4f60c0`), unlike the -/// TradeSkill window's raw rank. +/// The line's `(rank, max, bonus)`: the Craft window bands difficulty on rank plus bonuses +/// (`0x5ea520` in the Craft build `0x4f60c0`), where the TradeSkill window uses the raw rank. fn skill_rank(store: &ObjectStore, skill_id: u32) -> (u32, u32, i32) { for i in 0..PLAYER_SKILL_SLOTS { if let Some(s) = store.0.player_skill(i) { @@ -93,37 +68,17 @@ fn skill_rank(store: &ObjectStore, skill_id: u32) -> (u32, u32, i32) { (0, 0, 0) } -/// **Law D** β€” the Craft window's row icon, transcribing `GetCraftIcon 0x4f7160` (decision 1107): -/// **always** this recipe's own `SpellIconID`, straight off `Spell.dbc`. -/// -/// The one-liner is the point, and it is not an oversight to be "improved". The Craft window and -/// the TradeSkill window are two translation units of the *same* node, and their icon laws are -/// exact opposites: `+0x19c` (`EffectItemType[0]`) is never read anywhere in `[0x4f7160, -/// 0x4f7204]` β€” no item lookup, no `"%s%s%s"` path composition, no async callback. So a rod-making -/// `CREATE_ITEM` recipe fronts the *spell's* icon here while the byte-identical recipe in the -/// TradeSkill window fronts the *rod's* ([`crate::ui_tradeskill`]'s Law C). Substituting the -/// product here β€” which this used to do β€” is wrong on this surface with no diff to catch it. +/// Law D, the row icon: always the recipe's own `SpellIconID`, never the product. `GetCraftIcon` +/// (`0x4f7160`-`0x4f7204`) never reads `EffectItemType[0]` (`+0x19c`), so a rod recipe fronts the +/// spell's icon here and the rod's in the TradeSkill window. fn craft_icon(d: &benilla_formats::SpellDisplay) -> Option { d.icon.clone() } -/// The Craft window's **tooltip law** β€” `SetCraftSpell 0x533e90`. Like the trainer's, the binding -/// is a selector into the two shared builders and emits no line of its own; unlike the trainer's, -/// it reads the **recipe's own** effect columns: -/// -/// ```text -/// for i in 0..3: -/// if Effect[i] == 36 LEARN_SPELL: return Spell(EffectTriggerSpell[i]) # NOT validated -/// if Effect[i] == 24 CREATE_ITEM: return Item(EffectItemType[i]) -/// return Spell(recipe spell id) # every enchant -/// ``` -/// -/// Three differences from [`crate::ui_trainer::service_tooltip`] that a shared implementation would -/// erase: this tests the recipe's own `Effect[i]` where the trainer tests the *taught* spell's -/// `Attributes & 0x20`; it never tests `57 LEARN_PET_SPELL` and so never sets `altCaster`; and it -/// hops `EffectTriggerSpell[i]` **without** checking that it resolves, where the trainer's scan -/// falls through to the next slot on an unresolvable trigger. Same shape, different law β€” the -/// icon side's lesson (decision 1107) applies to the content side too. +/// The tooltip (`SetCraftSpell` `0x533e90`), off the recipe's own effects: the first slot that +/// is `LEARN_SPELL` names its trigger spell, unchecked, or `CREATE_ITEM` its item; otherwise the +/// recipe itself, as for every enchant. Unlike the trainer's law it never tests +/// `LEARN_PET_SPELL` and never falls through on an unresolvable trigger. fn craft_tooltip(spell_id: u32, d: &benilla_formats::SpellDisplay) -> CraftTooltip { for i in 0..3 { if d.effects[i] == SPELL_EFFECT_LEARN_SPELL { @@ -136,7 +91,7 @@ fn craft_tooltip(spell_id: u32, d: &benilla_formats::SpellDisplay) -> CraftToolt CraftTooltip::Spell(spell_id) } -/// Build the craft snapshot β€” `None` when the window is closed or the catalogs haven't loaded. +/// Builds the craft snapshot, `None` while the window is closed or the catalogs are not loaded. fn feed_craft( script: Option>, open: Res, @@ -176,17 +131,9 @@ fn feed_craft( home_area: None, text: &text, }; - // The **admission law** β€” `0x5e9c20`, byte-verified (decision 1124): the player knows the - // spell, it is not hidden (`Attributes & 0x20`), and its `castUI` **equals this window's - // craft type**. It is not a skill-line join: the client walks its own per-type list at - // `CGPlayer_C + 0x1cd0 + 0x10*type` and never consults `SkillLineAbility` for membership. On - // the shipped `Spell.dbc` the two agree on every spell a player can know (castUI 1 β†’ 110 of - // the 111 line-261 rows, the exception being the opener 5149 itself, which castUI 0 excludes - // where the old `effects[0] != TRADE_SKILL` clause used to; castUI 3 β†’ every line-333 - // recipe), so this is a law correction, not a content change. What it also does is keep the - // `spellLevel = 0` **trainer-wrapper tier** of every pet ability (24519-24522 and friends, - // castUI 1 but never known) out of the list β€” the tier whose presence would flatten the - // sort's rank key even with the right comparator. + // Admission (`0x5e9c20`): known, not hidden (`Attributes & 0x20`), and `castUI` equal to + // the craft type. The client walks its per-type list (`CGPlayer_C + 0x1cd0 + 0x10*type`) + // and never consults `SkillLineAbility` for membership. let recipes: Vec = actions .spells .iter() @@ -202,9 +149,7 @@ fn feed_craft( let mut num_available = u32::MAX; for &(entry, need) in d.reagents.iter().filter(|&&(e, n)| e != 0 && n != 0) { let have = count_of(&store.0, &objects, entry, InventoryScope::CARRIED); - // A reagent is an ITEM row, so it terminates in the one genuinely shared chain - // (`0x5d88b0`): ItemTemplate β†’ ItemDisplayInfo β†’ icon. Unlike the *recipe* icon - // above, there is nothing per-binding about this one. + // A reagent's icon is the shared item chain (`0x5d88b0`) through its display. let (name, icon) = match items.template(entry, 0, &commands) { Some(t) => ( Some(t.name.clone()), @@ -224,10 +169,8 @@ fn feed_craft( if reagents.is_empty() { num_available = 0; } - // **Focus first, then the totems** β€” `0x4ff980`'s own push order, and - // `GetCraftSpellFocus 0x4f78b0` returns the very same pair list despite its name. - // The focus's flag is the literal `1.0` with no predicate: the reference never - // reddens it. See [`crate::ui_tradeskill`]'s twin, where the law is written out. + // Focus first, then the totems (`0x4ff980`), the list `GetCraftSpellFocus` + // (`0x4f78b0`) returns. The focus's flag is a literal `1.0`, never reddened. let mut tools = Vec::new(); if d.requires_spell_focus != 0 { if let Some(n) = focus @@ -267,18 +210,13 @@ fn feed_craft( reagents, tools, tooltip: craft_tooltip(s, d), - // The engine field's law names `+0x74` (real spellLevel, col 29); until the - // base_level rename this was silently fed col 28. Identical on every ranked - // trainer family observed, but the wiring now matches the recorded law. + // `spellLevel` at `+0x74` (column 29), not `baseLevel` (column 28). spell_level: d.spell_level, }) }) .collect(); - // No app-side sort: the ROW ORDER is the engine's, transcribing this craft type's own - // comparator (`benilla_ui::script::craft::recipe_order`, decision 1124) β€” the same seam the - // trainer tree and the TradeSkill list already sit behind. What used to be here was a - // `req_skill_value`-descending sort keyed on a DBC column the client never reads, over rows - // whose iteration order was a `HashSet`'s. + // No sort here: the engine orders the rows by the craft type's own comparator + // (`benilla_ui::script::craft::recipe_order`). Some(CraftState { name, rank, @@ -292,8 +230,7 @@ fn feed_craft( return; } script.set_craft(fresh.clone()); - // The reagent templates `GetCraftReagentItemLink` reads, pre-asked as the trade-skill feed - // pre-asks its own (1973). + // Pre-ask the reagent templates `GetCraftReagentItemLink` reads. if let Some(f) = &fresh { script.ask_item_templates( f.recipes @@ -316,16 +253,9 @@ fn feed_craft( *last = fresh; } -/// Drain the Lua intents: every `DoCraft` goes down the ONE cast ladder, and the resolver decides -/// what happens next β€” an enchant's `Targets = 0x10` word arms the targeting cursor's item half -/// (decision 0923; the bag / paper-doll click completes it, in `spell::targeting`), a rod -/// craft's zero word commits immediately. `CloseCraft` closes the window; a pick armed by it is -/// the one targeting word, cancelled the ordinary ways (ESC, right-click, a new cast). -/// -/// Before 0923 this drain owned a private `PendingItemCast` with its own arm, its own bag-click -/// completion and its own cursor overlay β€” a second targeting machine that skipped every rung the -/// ladder runs (reagents included, which is what an enchant most needs). It is gone: the window -/// is now just another caster surface. +/// Drains the Lua intents. `DoCraft` goes down the cast ladder: an enchant's `Targets = 0x10` arms +/// the cursor's item half, a rod craft commits at once. `CloseCraft` closes the window, sends no +/// packet and leaves an armed pick to the ordinary cancels (Escape, right-click, a new cast). fn drain_craft( script: Option>, mut open: ResMut, @@ -356,10 +286,6 @@ mod tests { use super::*; use benilla_formats::SpellDisplay; - /// Law D never looks at an item. The regression this pins is the *sibling* one: a rod-making - /// `CREATE_ITEM` recipe β€” the exact case our old code substituted the product for β€” still - /// fronts the spell's own icon here, because `EffectItemType[0]` is never read anywhere in - /// `GetCraftIcon`'s extent. Craft and TradeSkill disagree on purpose. #[test] fn law_d_is_always_the_spells_own_icon_even_for_a_rod_recipe() { let rod = SpellDisplay { @@ -372,8 +298,7 @@ mod tests { assert_eq!(craft_icon(&rod), Some("SPELL".into())); } - /// A recipe with no `SpellIconID` row resolved is nil β€” the binding's `lua_pushnil` return. - /// There is no item arm to fall through to. + /// The binding pushes nil; there is no item arm to fall through to. #[test] fn law_d_is_nil_when_the_spell_carries_no_icon() { let d = SpellDisplay { @@ -382,18 +307,16 @@ mod tests { }; assert_eq!(craft_icon(&d), None); } - /// Law D's content twin. Three arms, and the point of each is that it differs from the - /// TRAINER's law on the same shape: this reads the recipe's OWN effect columns. #[test] fn craft_tooltip_reads_the_recipes_own_effects() { - // An enchant β€” no matching slot β€” describes the recipe spell itself. + // An enchant has no matching slot: the recipe spell itself. let enchant = SpellDisplay { effects: [SPELL_EFFECT_ENCHANT_ITEM, 0, 0], ..Default::default() }; assert_eq!(craft_tooltip(7420, &enchant), CraftTooltip::Spell(7420)); - // A rod craft β€” CREATE_ITEM in slot 0 β€” describes the ITEM, off the SAME slot. + // A rod craft: the item of the same slot. let rod = SpellDisplay { effects: [SPELL_EFFECT_CREATE_ITEM, 0, 0], effect_item_type: [6218, 0, 0], @@ -401,18 +324,13 @@ mod tests { }; assert_eq!(craft_tooltip(7421, &rod), CraftTooltip::Item(6218)); - // A LEARN_SPELL slot hops β€” and, unlike the trainer's law, does NOT check that the trigger - // resolves. There is no catalog argument here at all, which is the difference made - // structural: a trigger id this client has never heard of is still what gets described. + // A `LEARN_SPELL` slot hops to its trigger without checking that it resolves. let mut teacher = SpellDisplay::default(); teacher.effects[1] = SPELL_EFFECT_LEARN_SPELL; teacher.effect_trigger_spell[1] = 999_999; assert_eq!(craft_tooltip(5149, &teacher), CraftTooltip::Spell(999_999)); } - /// **The Craft window dies with the session** β€” the TradeSkill book's twin: only `CloseCraft` - /// cleared it, and a logout's fresh VM never runs the old one's `OnHide`, so the next login - /// fired `CRAFT_SHOW` into the new character's UI. #[test] fn the_session_end_closes_the_craft_window() { let mut app = App::new(); diff --git a/crates/benilla-app/src/ui_dialog_verbs.rs b/crates/benilla-app/src/ui_dialog_verbs.rs index 4295e556..cebb87bb 100644 --- a/crates/benilla-app/src/ui_dialog_verbs.rs +++ b/crates/benilla-app/src/ui_dialog_verbs.rs @@ -1,43 +1,8 @@ -//! The dialog engine's verbs, app half (decision 1963): the feeds behind the stock -//! `StaticPopup.lua` dialogs benilla never raised and the drains behind their buttons, each to -//! the reference's bytes unless a line says INFERRED. -//! -//! * **Pet trainer** β€” `SMSG_PET_UNLEARN_CONFIRM {guid, cost}` latches both and owes -//! `CONFIRM_PET_UNLEARN(cost)`; `ConfirmPetUnlearn()` answers with `CMSG_PET_UNLEARN {guid}` unless -//! the cost outruns the purse (`ERR_NOT_ENOUGH_MONEY`, nothing sent). The talent-wipe twin -//! (`crate::ui_talent_wipe`), latch for latch, leash for leash: the trainer walking out of -//! `INTERACT_DISTANCE` closes the question, which is what `CheckPetUntrainerDist()` polls. -//! * **Instance boot** β€” every `SMSG_RAID_GROUP_ONLY {delayMs, reason}` fires an event: a positive -//! delay arms the deadline and `INSTANCE_BOOT_START`, zero clears it and `INSTANCE_BOOT_STOP`, -//! and only the zero leg names reason 1/2 on screen. `GetInstanceBootTimeRemaining()` reads -//! whole seconds off the deadline; nothing clears it but a zero packet. -//! * **Area spirit healer** β€” `SMSG_AREA_SPIRIT_HEALER_TIME {guid, ms}` arms the wave clock and -//! fires `AREA_SPIRIT_HEALER_IN_RANGE` when the guid is the cached healer's; Accept sends -//! `0x2E3` with that guid, Cancel is the cancel-aura of spell 2584 plus `_OUT_OF_RANGE`. **The -//! cache has two writers** (2291), which is what made Accept reachable at all: the reference's -//! per-frame proximity scan [`poll_area_spirit_healer`] (`0x4923b0` β€” ghost-gated, acquire at -//! 20 yd, retain to 22) and the SPIRITGUIDE click arm [`AreaSpiritHealer::click_guide`] -//! (`0x5df950`, whose deliberate cache-bust makes a second click re-ask). Until 2291 this said -//! "no writer yet", which was true when 1963 wrote it and false from the moment the scan landed. -//! * **Battlefield queue** β€” `SMSG_BATTLEFIELD_STATUS` fills one of three slots and fires -//! `UPDATE_BATTLEFIELD_STATUS`; `AcceptBattlefieldPort(index, accept)` sends the slot's map id -//! with the answer as one byte. -//! * **Meeting stone** (1974) β€” two globals: the queued area -//! (`[0xb72038]`) and the cached status text (`[0xb7203c]`). `SMSG 0x295 {areaId, status}` -//! latches the old area, stores the new one unconditionally, prints one of five chat lines by -//! the status byte (with `0x4ca230`'s two asymmetries: status 0 names the OLD area and is -//! silent when it has no row; status 1 is skipped entirely when the area did not change, names -//! the NEW one with an `UNKNOWN` fallback, and plays the `HARDCODED Meeting Stone Join` visual -//! on the player), then β€” on EVERY path, an out-of-range status included β€” rebuilds the text -//! (`MEETINGSTONE_TOOLTIP` over the area's name or `UNKNOWN`, into a 256-byte buffer) and fires -//! `MEETINGSTONE_CHANGED`. World enter resets the text to the bare `UNKNOWN` and sends the empty -//! `CMSG 0x296` once per world session; world leave drops the text to none. -//! `CancelMeetingStoneRequest()` sends `0x293` unless in a party led by someone else -//! (`ERR_MEETING_STONE_NOT_LEADER`). The four display-only replies (`0x297/0x298/0x299/0x2BB`) -//! are chat lines with no state; the status-1 arm also triggers the Meeting Stones tutorial -//! (`crate::tutorial`, 1976). **JOINING** is the click's own leg (decision 2283): type 23's -//! use slot is its own validator, not the shared `CMSG_GAMEOBJ_USE` sender β€” four client-side -//! refusals ([`meeting_stone_join_refusal`]), then `CMSG 0x292 {u64 goGuid}`. +//! The app half of the verbs behind the stock `StaticPopup.lua` dialogs: the packet feeds that +//! raise them and the drains behind their buttons, for the pet trainer's unlearn, the instance +//! boot countdown, the area spirit healer's wave, the battleground queue and the meeting stone. +//! A meeting stone's click runs the stone's own use slot, which refuses a join client-side or +//! sends `CMSG_MEETINGSTONE_JOIN`, never the shared `CMSG_GAMEOBJ_USE`. use std::time::Instant; @@ -91,14 +56,12 @@ impl NpcSession for PetUnlearnState { #[derive(Resource, Default)] pub(crate) struct InstanceBoot { deadline: Option, - /// Events owed, in arrival order (`INSTANCE_BOOT_START` / `_STOP`). events: Vec<&'static str>, - /// Error lines owed (`ERR_RAID_GROUP_ONLY` / `_FULL`), the zero-delay leg's. errors: Vec<&'static str>, } impl InstanceBoot { - /// `SMSG_RAID_GROUP_ONLY`: `delay > 0` arms, else clears β€” and the event fires either way. + /// `SMSG_RAID_GROUP_ONLY`: a positive delay arms, zero clears; an event fires either way. pub(crate) fn apply(&mut self, delay_ms: u32, reason: u32, now: Instant) { if delay_ms > 0 { self.deadline = Some(now + std::time::Duration::from_millis(u64::from(delay_ms))); @@ -114,8 +77,7 @@ impl InstanceBoot { } } - /// Whole seconds left, 0 when idle or past β€” the reference's unsigned divide of a clamped - /// millisecond remainder. + /// Whole seconds left, 0 when idle or past, as the reference divides a clamped ms remainder. pub(crate) fn secs(&self, now: Instant) -> u32 { self.deadline .map(|d| d.saturating_duration_since(now).as_secs()) @@ -123,55 +85,41 @@ impl InstanceBoot { } } -/// `SPIRITGUIDE` β€” `UNIT_NPC_FLAGS` bit 6, the flag the acquire callback `0x4924c0` tests at -/// `0x4924fc shr eax,0x6; test al,1`. +/// `UNIT_NPC_FLAGS` bit 6, `SPIRITGUIDE`, which the acquire callback `0x4924c0` tests (`0x4924fc`). const NPC_FLAG_SPIRITGUIDE: u32 = 1 << 6; -/// The area spirit healer's aura, `0xA18` = 2584 β€” the one spell `0x4921c0`'s cancel leg and -/// `CancelAreaSpiritHeal` both name, and the only spell id `0x6e7040` fires -/// `AREA_SPIRIT_HEALER_OUT_OF_RANGE` for (`0x6e70b6 cmp esi,0xa18`). +/// The area spirit healer's aura, cancelled by `0x4921c0` and `CancelAreaSpiritHeal`; `0x6e7040` +/// fires `AREA_SPIRIT_HEALER_OUT_OF_RANGE` for this spell id alone (`0x6e70b6`). pub(crate) const AREA_SPIRIT_HEALER_AURA: u32 = 2584; -/// The area spirit healer's **acquire** radius β€” the `.rdata` f32 `[0x8044d0] = 20.0`, compared -/// squared in the enumerate callback `0x4924c0`. +/// The acquire radius, `[0x8044d0]` = 20.0, compared squared in the enumerate callback `0x4924c0`. const SPIRIT_GUIDE_ACQUIRE_YD: f32 = 20.0; -/// The **retain** radius: the same f32 times `[0x804580] = 1.1`, i.e. 22.0 (`0x492406`). A healer -/// is adopted inside 20 yd and kept until 22 β€” the hysteresis is the reference's, and without it -/// a body standing on the boundary would send a query every frame it jittered across. +/// The retain radius, the acquire radius times `[0x804580]` = 1.1 (`0x492406`): a healer adopted +/// inside 20 yd is kept until 22. const SPIRIT_GUIDE_RETAIN_YD: f32 = SPIRIT_GUIDE_ACQUIRE_YD * 1.1; -/// What [`AreaSpiritHealer::set_healer`] owes the rest of the frame. -/// -/// The reference does both of these inside `0x4921c0` itself; here the resource is a plain data -/// type with no access to the VM or the socket, so it *reports* them and the systems pay them. +/// What [`AreaSpiritHealer::set_healer`] owes the frame; the reference does both in `0x4921c0`. #[derive(Default, PartialEq, Eq, Debug)] pub(crate) struct SetHealerOutcome { - /// A wave deadline was pending and the healer changed, so `0x4921fc mov ecx,0xa18; - /// call 0x6e7040` ran: `AREA_SPIRIT_HEALER_OUT_OF_RANGE` fires and `CMSG_CANCEL_AURA(2584)` - /// goes out. **This is how walking away from a graveyard closes the wave dialog** β€” no Lua - /// call is involved, which is why the event has no argument and no caller. + /// A wave was pending and the healer changed (`0x4921fc`): `AREA_SPIRIT_HEALER_OUT_OF_RANGE` + /// fires and `CMSG_CANCEL_AURA` goes out for 2584; this, not Lua, closes the dialog. pub(crate) cancel_aura: bool, - /// The newly adopted healer, if the change landed on a non-zero guid: `CMSG 0x2E2` with it. + /// A newly adopted non-zero healer: `CMSG_AREA_SPIRIT_HEALER_QUERY` with it. pub(crate) query: Option, } /// The current-area spirit healer (`[0xb4e330/334]`) and its wave clock (`[0xb4e338]`). -/// -/// **The writer is [`Self::set_healer`], and it is the reference's `0x4921c0` to the branch.** -/// 1963 shipped this resource with the note "no writer yet"; the reference has the whole acquire -/// trio: the setter, the poll `0x4923b0` with its radii, and the click arm `0x5df950`. Both roads -/// in are built here. #[derive(Resource, Default)] pub(crate) struct AreaSpiritHealer { - /// The cached healer (`[0xb4e330/334]`), written only by [`Self::set_healer`]. + /// Written only by [`Self::set_healer`], the reference's `0x4921c0`. healer: Option, deadline: Option, in_range: bool, } impl AreaSpiritHealer { - /// `SMSG_AREA_SPIRIT_HEALER_TIME`: for the cached healer with a positive time, arm the clock - /// (a zero-landing deadline reads as 1 ms in the reference) and owe `_IN_RANGE`. + /// `SMSG_AREA_SPIRIT_HEALER_TIME`: arms the clock only for the cached healer (`0x4922a0`) and + /// a positive time, and owes `AREA_SPIRIT_HEALER_IN_RANGE`. pub(crate) fn on_time(&mut self, healer: u64, ms: u32, now: Instant) { if self.healer == Some(healer) && ms > 0 { self.deadline = Some(now + std::time::Duration::from_millis(u64::from(ms))); @@ -179,23 +127,11 @@ impl AreaSpiritHealer { } } - /// **`0x4921c0` β€” "set current area spirit healer"**, decoded at the branch: - /// - /// ```text - /// if (cached == new) return; // 0x4921cf/0x4921dd β€” guards EVERYTHING below - /// cached = new; // 0x4921e5 / 0x4921f5 - /// if (deadline != 0) CancelAura(0xA18); // 0x4921fc β€” fires _OUT_OF_RANGE, sends 0x136 - /// deadline = 0; // 0x492211 - /// if (cached != 0) send CMSG 0x2E2; // 0x492217 / 0x492219 - /// ``` - /// - /// The early return at the top is the load-bearing part and the one a paraphrase loses: with - /// the guid unchanged this routine does **nothing at all** β€” no cancel, no deadline clear, no - /// packet. That is what lets the per-frame poll call it unconditionally (it clears by calling - /// `set_healer(None)` every frame it has no ghost) at zero cost. + /// `0x4921c0`: an unchanged guid returns before anything else (`0x4921cf`/`0x4921dd`), so the + /// poll calls it every frame; a change cancels a pending wave (`0x4921fc`), zeroes the + /// deadline (`0x492211`) and queries a non-zero healer (`0x492217`). pub(crate) fn set_healer(&mut self, new: Option) -> SetHealerOutcome { - // The reference keeps a 0:0 guid where we keep `None`; normalise so a zero guid arriving - // from the wire cannot masquerade as a real healer. + // The reference's zero guid is our `None`. let new = new.filter(|&g| g != 0); if self.healer == new { return SetHealerOutcome::default(); @@ -203,9 +139,7 @@ impl AreaSpiritHealer { self.healer = new; let cancel_aura = self.deadline.take().is_some(); if cancel_aura { - // The cancel closes the dialog, so the `_IN_RANGE` this frame would have owed is - // stale β€” the reference cannot have one pending here either (the event is fired from - // the handler, which runs the poll first). + // The cancel closes the dialog, so an `_IN_RANGE` still owed is stale. self.in_range = false; } SetHealerOutcome { @@ -214,11 +148,8 @@ impl AreaSpiritHealer { } } - /// The **spirit-guide click arm**'s two calls (`0x5df950`: `0x4921c0(0,0)` then - /// `0x4921c0(guid)`). The first is a deliberate cache-bust β€” its own early return suppresses - /// the send for a zero guid β€” so the second **always** transmits, even for the healer already - /// cached. Clicking the guide you are standing next to therefore re-asks for the clock, which - /// is exactly what a player does when the dialog has been dismissed. + /// The spirit-guide click (`0x5df950`): `0x4921c0(0)` then `0x4921c0(guid)`, a cache-bust that + /// makes the second call query even for the healer already cached. pub(crate) fn click_guide(&mut self, guid: u64) -> SetHealerOutcome { let bust = self.set_healer(None); let set = self.set_healer(Some(guid)); @@ -228,7 +159,7 @@ impl AreaSpiritHealer { } } - /// The cached healer β€” `AcceptAreaSpiritHeal`'s guid and the poll's retain subject. + /// The cached healer: `AcceptAreaSpiritHeal`'s guid and the poll's retain subject. pub(crate) fn healer(&self) -> Option { self.healer } @@ -240,37 +171,27 @@ impl AreaSpiritHealer { } } -/// The three battleground queue slots (`0xb6e9d0`, stride `0x20`), each with the moment its -/// status landed β€” the clock every stamp in the slot is relative to. Kept across an in-session -/// world enter (`0x4a9db0`); zeroed whole at the session end, as the reference's module init -/// zeroes it at every login (`net::on_session_end`). +/// The three battleground queue slots (`0xb6e9d0`, stride `0x20`), each stamped with the moment +/// its status landed. Kept across an in-session world enter (`0x4a9db0`), zeroed at session end. #[derive(Resource, Default)] pub(crate) struct BattlefieldQueue { slots: [Option<(BattlefieldStatus, Instant)>; 3], changed: bool, - /// The slot the player is IN (`[0x8457cc]`, the status-3 arm) and its map. + /// The slot the player is in (`[0x8457cc]`, set by status 3) and its map. active: Option<(usize, u32)>, - /// The instance's two clocks (`[0xb6ebbc]`/`[0xb6ebb8]`): the run-time stamp `now βˆ’ Ξ”β‚‚` and - /// the expiration `now + Δ₁`, set by a status-3 message and zeroed by ANY non-clearing message - /// of another status β€” whatever slot it is about (an anomaly of the handler `0x4aa850`, - /// reproduced: 1972 zeroed them only for the active slot; 1974 corrects it to the handler's - /// unconditional clear). + /// The instance's run-time stamp (`[0xb6ebbc]`) and expiration (`[0xb6ebb8]`): set by status + /// 3, zeroed by any other non-clearing status for any slot, as the handler `0x4aa850` does. run_started: Option, instance_expiration: Option, - /// The status-3 arm rebuilds the scoreboard and fires `UPDATE_BATTLEFIELD_SCORE` before - /// `UPDATE_BATTLEFIELD_STATUS` (`0x4aaa5a`, then `0x4aab05`) β€” the score feed reads this first. + /// Status 3 fires `UPDATE_BATTLEFIELD_SCORE` before `_STATUS` (`0x4aaa5a`, then `0x4aab05`). score_dirty: bool, - /// The handler's two tutorial arms (`0x2f` on queued, `0x30` on confirm; 1976), owed to the - /// tutorial system on the next feed. + /// Tutorials the handler triggers (`0x2f` on queued, `0x30` on confirm), owed to the next feed. tutorials: Vec, } impl BattlefieldQueue { - /// `SMSG_BATTLEFIELD_STATUS` (`0x4aa850`): an out-of-range slot abandons the message; a zero - /// map takes the clear arm (the slot emptied, the instance clocks zeroed only when this was the - /// active slot β€” and the active index itself left alone, as the handler leaves `[0x8457cc]`); - /// status 3 stamps the instance clocks and names the slot active; every other status zeroes - /// the instance clocks unconditionally and un-names the slot if it was the active one. + /// `SMSG_BATTLEFIELD_STATUS` (`0x4aa850`). A zero map clears the slot, and the instance clocks + /// only if it was active, but leaves the active index (`[0x8457cc]`) alone. pub(crate) fn apply(&mut self, status: BattlefieldStatus) { self.apply_at(status, Instant::now()); } @@ -317,14 +238,11 @@ impl BattlefieldQueue { self.changed = true; } - /// The three slots with the instant each status landed β€” the queue verbs' view builder - /// (`crate::ui_battlefield`) reduces their stamps against `now`. pub(crate) fn slots(&self) -> &[Option<(BattlefieldStatus, Instant)>; 3] { &self.slots } - /// `GetBattlefieldInstanceExpiration()`: `deadline βˆ’ now` in ms, 0 when unset or past - /// (`[0xb6ebb8]`, the `jns` guard). + /// `GetBattlefieldInstanceExpiration()`: ms left on `[0xb6ebb8]`, 0 when unset or past. pub(crate) fn instance_expiration_ms(&self, now: Instant) -> u32 { self.instance_expiration.map_or(0, |d| { d.saturating_duration_since(now) @@ -333,7 +251,7 @@ impl BattlefieldQueue { }) } - /// The map of the battleground the player is in β€” `LeaveBattlefield`'s payload; `None` = 0. + /// The map of the battleground the player is in, `LeaveBattlefield`'s payload (0 for `None`). pub(crate) fn active_map(&self) -> Option { self.active.map(|(_, map)| map) } @@ -347,7 +265,6 @@ impl BattlefieldQueue { }) } - /// The status-3 arm's scoreboard rebuild, once per arrival. pub(crate) fn take_score_dirty(&mut self) -> bool { std::mem::take(&mut self.score_dirty) } @@ -361,9 +278,8 @@ impl BattlefieldQueue { } } -/// The cached status text's three states (`[0xb7203c]`): none from process start and after world -/// leave; the bare localized `UNKNOWN` from world enter until the server's `0x295` lands; a -/// built line after that. The two localized halves are resolved against the VM at push time. +/// The status text (`[0xb7203c]`): none before world enter and after world leave, the bare +/// `UNKNOWN` until the server's `0x295` lands, then a built line; localized at push time. #[derive(Clone, Debug, PartialEq, Eq, Default)] enum StoneText { #[default] @@ -378,11 +294,11 @@ const STONE_TEXT_BYTES: usize = 255; /// The meeting-stone queue: the two globals and what the wire still owes the screen. #[derive(Resource, Default)] pub(crate) struct MeetingStone { - /// `[0xb72038]` β€” the queued area id, `0` = none. + /// `[0xb72038]`, the queued area id, 0 for none. pub(crate) area: u32, text: StoneText, - /// `0x295` arrivals since the last feed: `(the area BEFORE the store, status)`; the new area - /// is already in `area` (the handler stores it before it switches). + /// `0x295` arrivals as `(previous area, status)`; the handler stores the new area before it + /// switches, so that one is already in `area`. updates: Vec<(u32, u8)>, notices: Vec, /// `0x299` guids whose name has not resolved yet. @@ -392,8 +308,7 @@ pub(crate) struct MeetingStone { } impl MeetingStone { - /// `SMSG 0x295`: the area is stored unconditionally; the line, the rebuild and the event - /// follow on the next feed, with the VM. + /// `SMSG 0x295` (`0x4ca230`): the area is stored at once; the line and event wait for the feed. pub(crate) fn apply(&mut self, area: u32, status: u8) { let old = self.area; self.area = area; @@ -405,8 +320,7 @@ impl MeetingStone { self.notices.push(notice); } - /// The enter-world bring-up (`0x4c9f40`): the text becomes the bare `UNKNOWN`; the area is - /// untouched (the server's reply resets it). + /// The enter-world bring-up (`0x4c9f40`): the text becomes the bare `UNKNOWN`, the area stays. fn enter_world(&mut self) { self.text = StoneText::Unknown; self.dirty = true; @@ -419,8 +333,8 @@ impl MeetingStone { } } -/// A `GlobalStrings` value as the client's `GetText` reads it: the string, or `""` when the Lua -/// global is missing (`0x882748`, the shared empty-string constant β€” never NULL). +/// A `GlobalStrings` value as the client's `GetText` reads it: `""` when the global is missing +/// (`0x882748`, the shared empty string, never NULL). fn global_text(script: &UiScript, key: &str) -> String { script .lua() @@ -451,9 +365,8 @@ fn build_stone_text(script: &UiScript, areas: Option<&AreaTableRes>, area: u32) text } -/// The `0x295` handler's five-way table (`0x4ca3a4`), as the line it prints for -/// `(old, new, status)` β€” `None` where the reference prints nothing: status 0 with no row for the -/// OLD area, status 1 with an unchanged area, and any status past 4. +/// The `0x295` handler's five-way table (`0x4ca3a4`): status 0 names the old area and is silent +/// without its row, status 1 names the new one and is silent when unchanged, past 4 is silent. fn stone_line( script: &UiScript, areas: Option<&AreaTableRes>, @@ -482,8 +395,7 @@ fn stone_line( } } -/// The inputs the meeting-stone feed reads beside the VM and its own state β€” bundled because the -/// feed sits at Bevy's parameter ceiling otherwise. +/// The meeting-stone feed's other inputs, bundled to stay under Bevy's system-parameter limit. #[derive(bevy::ecs::system::SystemParam)] pub(crate) struct MeetingStoneInputs<'w, 's> { areas: Option>, @@ -513,8 +425,8 @@ fn feed_meeting_stone( let new = stone.area; lines.extend(stone_line(&script, areas, old, new, status)); if status == 1 && new != old { - // The status-1 arm's extra block: `Effect_C` kind `0xc` on the local player, then - // the Meeting Stones tutorial (`0x4ca363`, 1976). + // Status 1's extra block (`0x4ca363`): `Effect_C` kind `0xc` on the local player, + // then the Meeting Stones tutorial. if let (Some(visuals), Ok(entity)) = (inputs.visuals.as_deref(), inputs.self_q.single()) { if let Some(fx) = meeting_stone_join_fx(visuals, entity) { @@ -527,9 +439,8 @@ fn feed_meeting_stone( )); } } - // Every path β€” the silent legs and an out-of-range status included β€” rebuilds and fires. - // The two globals reach the VM BEFORE the event: the stock handler's first act is - // `IsInMeetingStoneQueue()`, which has to see the area this packet stored. + // Every status, silent or out of range, rebuilds and fires. The globals reach the VM + // first: the stock handler's first call is `IsInMeetingStoneQueue()` (`Minimap.xml:222`). let text = build_stone_text(&script, areas, new); stone.text = StoneText::Built(text.clone()); stone.dirty = false; @@ -594,31 +505,10 @@ fn feed_meeting_stone( } /// The enter-world bring-up's meeting-stone leg: the text reset, then the empty `CMSG 0x296`. -/// -/// **Once per UI LIFECYCLE, not once per world session**, and that distinction is the whole bug. -/// The reference's run-once byte `[0xb4b424]` is *cleared by the UI teardown* β€” `0x490bd0` calls -/// `0x490a80` at `0x490c20`, which zeroes it at `0x490a8d` β€” and `UI_Init` (`0x48fbf0`) then calls -/// `0x4908c0` at `0x490168` behind the same live-player gate, finds the byte clear, and re-runs -/// this entire bring-up: `0x490a14` β†’ `0x4c9f40` β†’ `0x4ca1c0` β†’ `PutUInt32(0x296)` + Send. So a -/// `ReloadUI()` **re-asks the server**, and that is what brings the icon back. -/// -/// Gated on `MessageReader`, this leg never ran for a `/reload`, which never -/// leaves the world (1291) and so produces no such message. Nothing re-armed -/// [`MeetingStone::dirty`] and nothing re-queried, so `IsInMeetingStoneQueue()` and -/// `GetMeetingStoneStatusText()` answered nil for the rest of the session β€” and stock -/// `Minimap.xml`'s `MiniMapMeetingStoneFrame` (built `hidden="true"`, shown only by -/// `MEETINGSTONE_CHANGED`, whose single firing site image-wide is the `0x295` handler at -/// `0x4ca38f`) stayed gone while the player was still queued. -/// -/// **A [`crate::ui_script::VmMemo`] claim IS the reference's gate.** A byte the UI teardown clears -/// is exactly "once per VM", and 1290's name for that is `claim` β€” so this reads as the same -/// question the binary asks, rather than as a workaround for the missing message. The one -/// round trip during which the icon is genuinely absent is faithful, not a defect: the reference -/// has the same gap, because only the server's reply fires the event. -/// -/// The queued area itself is untouched here, matching `[0xb72038]`, which is -/// referenced six times image-wide and by nothing in either reload closure β€” it survives, and -/// [`MeetingStone::enter_world`]'s `dirty` is what re-pushes it to the fresh VM. +/// Once per VM, a [`crate::ui_script::VmMemo`] claim: the UI teardown clears the run-once byte +/// `[0xb4b424]` (`0x490a8d`) and `UI_Init` (`0x48fbf0`) re-runs the bring-up, so a `/reload` +/// re-asks the server; until the reply fires `MEETINGSTONE_CHANGED` (`0x4ca38f`) the minimap icon +/// is hidden, as in the reference. The queued area survives the reload. fn meeting_stone_enter_world( script: Option>, mut stone: ResMut, @@ -635,73 +525,42 @@ fn meeting_stone_enter_world( let _ = commands.0.send(ClientCommand::MeetingStoneStatusQuery); } -/// A right-click on a `GAMEOBJECT_TYPE_MEETINGSTONE` (23) that got past the shared gates β€” the -/// GO click ladder's hand-off to this module (decision 2283). -/// -/// It travels as a message for the same reason the GameObject opener's cast does (2199): the -/// click system sits at Bevy's 16-`SystemParam` ceiling and cannot also hold the roster, the -/// template cache and the wire. The reference has no such split β€” `0x5f69d0` is one function β€” -/// so the *verdict* stays one function here too ([`meeting_stone_join_refusal`]); only the -/// plumbing is two systems. +/// A right-click on a `GAMEOBJECT_TYPE_MEETINGSTONE` (23) that passed the shared click gates. #[derive(Message, Clone, Copy, Debug, PartialEq, Eq)] pub(crate) struct MeetingStoneUse { pub(crate) go_guid: u64, } -/// What MEETINGSTONE(23)'s own use slot `0x5f69d0` does with one click β€” its three outcomes, as -/// the binary has them (decision 2283). +/// What the meeting stone's use slot `0x5f69d0` does with one click. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum StoneJoin { - /// `0x5f69f8` β€” no local player object: `false` with **no message and no packet**. Not a - /// refusal; the validator never starts. + /// No local player (`0x5f69f8`): no message and no packet. Silent, - /// One of the four refusals: `push ; call 0x496720` then `xor al,al; ret`. Every one is a - /// catalog **kind 2** row, so it paints the error frame (`UI_ERROR_MESSAGE`), never a chat - /// line β€” and none carries a sound (type tag `0x44`, cue `"NONE"`). + /// One of the four refusals (`0x496720`): an error-frame line with no sound, never chat. Refuse(&'static str), - /// The tail `0x5f6af6`: `CMSG 0x292 {u64 goGuid}`, and nothing else at all. + /// The tail `0x5f6af6`: `CMSG 0x292` with the stone's guid, and nothing else. Send, } -/// The four client-side refusals inside MEETINGSTONE(23)'s own use slot (`0x5f69d0`, whose tail -/// `0x5f6af6` is the sole caller of the `CMSG 0x292` builder `0x4c9ff0`). +/// The meeting stone's use slot `0x5f69d0`. With no local player it is silent (`0x5f69f8`); +/// otherwise four refusals in this order, the first two only in a group (`0x5f6a10`): /// -/// Two gates run before any of them. `0x5f69f8`: no local player β‡’ [`StoneJoin::Silent`]. -/// `0x5f6a10 je 0x5f6a65`: **not in a group β‡’ both group refusals are skipped**, and a solo player -/// drops straight to the level test. +/// 1. not the leader (`0x5f6a2f`, the full 64-bit guid against `[0xbc75f8]`); +/// 2. four other members (`0x5f6a4f`, `GetNumPartyMembers` at `0x4e86d0`); +/// 3. a level outside `data[0]..=data[1]`, unsigned (`0x5f6ab4`); +/// 4. in a raid (`0x5f6ad3`, the raid member count `[0xb713e0]`). /// -/// | # | at | refusal | predicate | key | -/// |---|---|---|---|---| -/// | 1 | `0x5f6a2f` | in a group we do not lead | the leader guid `[0xbc75f8]` against the active player's, full 64-bit | `ERR_MEETING_STONE_MUST_BE_LEADER` (`0x1b1`) | -/// | 2 | `0x5f6a4f` | the group is full | `0x4e86d0` is `GetNumPartyMembers` β€” the **other** members, 0..4 β€” and `cmp eax,4 / jb` refuses at β‰₯ 4 | `ERR_MEETING_STONE_GROUP_FULL` (`0x1ae`) | -/// | 3 | `0x5f6ab4` | the wrong level for this stone | `data[0] <= level <= data[1]`, **both bounds inclusive and unsigned**, over the player's own `UNIT_FIELD_LEVEL` | `ERR_MEETING_STONE_INVALID_LEVEL` (`0x1b0`) | -/// | 4 | `0x5f6ad3` | in a raid | `[0xb713e0] != 0` β€” the raid member **count** (`GetNumRaidMembers`) | `ERR_MEETING_STONE_NO_RAID_GROUP` (`0x1b2`) | -/// -/// **Three of the four are refused a second time by the server** (vmangos -/// `HandleMeetingStoneJoinOpcode` β†’ `MEETINGSTONE_FAIL_PARTYLEADER` / `_FULL_GROUP` / -/// `_RAID_GROUP`, which come back as `SMSG 0x2BB` and print the same three strings 1974 already -/// built). That copy is not redundant β€” it is what makes the refusal instant. **The level term is -/// the client's alone**: `HandleMeetingStoneJoinOpcode` reads `gInfo->meetingstone.areaID` and -/// nothing else off the template, so a client that skips it queues a level-1 character for a -/// sixty-level dungeon and the server agrees. -/// -/// **An unanswered template REFUSES, and that is the reference's own arithmetic rather than a -/// fail-closed choice of ours.** `0x5f8150` reads the cached template through `[GO+0x214]`, and an -/// uncached object returns **0 for both bounds** β€” so every level β‰₯ 1 falls outside `0..=0` and -/// takes the level refusal. The permissive "skip the term while the query is in flight" default -/// the highlight column takes is *wrong* here, and the first cut of this had it. The same -/// arithmetic makes a shipped `data[0] = data[1] = 0` refuse everyone; `0/60` is the open band. +/// vmangos repeats all but the level test (`LFGHandler.cpp:49-68`), which is the client's alone. +/// An uncached template reads 0/0 (`0x5f8150`), so it refuses every level. pub(crate) fn meeting_stone_join_refusal( group: Option<&GroupState>, self_guid: Option, level: Option, stone: Option, ) -> StoneJoin { - // `0x5f69f8` β€” the validator needs an active player before it asks anything. let (Some(self_guid), Some(level)) = (self_guid, level) else { return StoneJoin::Silent; }; - // `0x5f6a10` β€” no group at all skips past both group terms. let in_group = group.is_some_and(|g| g.in_group); if in_group { if group.map(|g| g.leader) != Some(self_guid) { @@ -711,7 +570,7 @@ pub(crate) fn meeting_stone_join_refusal( return StoneJoin::Refuse("ERR_MEETING_STONE_GROUP_FULL"); } } - // An uncached template reads `0/0` here, which refuses β€” see the doc above. + // An uncached template reads 0/0, which refuses. let (min_level, max_level) = stone.map_or((0, 0), |s| (s.min_level, s.max_level)); if level < min_level || level > max_level { return StoneJoin::Refuse("ERR_MEETING_STONE_INVALID_LEVEL"); @@ -722,21 +581,11 @@ pub(crate) fn meeting_stone_join_refusal( StoneJoin::Send } -/// How many **other** party members make the stone's `GROUP_FULL` refusal fire β€” a party it could -/// add nobody to. -/// -/// **Four, not five, and the difference is a real trap.** A vanilla party holds five *including* -/// the player, and vmangos refuses on exactly that: `Group::IsFull()` is -/// `m_memberSlots.size() >= MAX_GROUP_SIZE (5)`, counting the leader -/// (`Group/Group.h:49,232`) β†’ `MEETINGSTONE_FAIL_FULL_GROUP`. But `SMSG_GROUP_LIST` never lists -/// the recipient (0440), so [`GroupState::members`] is the other four and the comparison is -/// against **4** with no `+ 1`. Adding one β€” the first cut of this did β€” refuses a legal -/// four-person party the server would have queued, and the string says which reading is right: -/// `ERR_MEETING_STONE_GROUP_FULL` is *"You are already in a full group"*, and a group of four is -/// not full. +/// Four other members is a full party: [`GroupState::members`] omits the player, so this is +/// vmangos's five (`Group/Group.h:49,232`) and the client's `>= 4` (`0x5f6a4f`), with no `+ 1`. const MEETING_STONE_PARTY_CAP: usize = 4; -/// The join drain: MEETINGSTONE(23)'s use slot, with the click's guid. +/// The join drain: the meeting stone's use slot, run with the click's guid. fn drain_meeting_stone_joins( script: Option>, mut uses: MessageReader, @@ -756,9 +605,7 @@ fn drain_meeting_stone_joins( for &MeetingStoneUse { go_guid } in uses.read() { let stone = templates.get(go_guid).and_then(|t| t.meeting_stone); let verdict = meeting_stone_join_refusal(group.as_deref(), self_guid.0, level, stone); - // The interact chain's last link for this type, on the same `use` tag the click's own - // lines carry (2283): a stone that goes nowhere is one of four refusals, the no-player - // silence, or a send the server ignored β€” and one trace now says which. + // Traced on the click's own `use` tag: a refusal, the no-player silence, or the send. if benilla_assets::trace::enabled_for("use") { benilla_assets::trace::line( "use", @@ -792,20 +639,8 @@ fn meeting_stone_leave_world(mut stone: ResMut) { stone.leave_world(); } -/// Drop the cached spirit guide when the world does. -/// -/// **A resurrect wave is the most session-bound state this module holds**, and it was the one -/// resource here with no leave-world leg. A ghost who logs out at a Warsong Gulch graveyard with a -/// guide latched and a wave armed carried `healer`, `deadline` and `in_range` into the character -/// screen and into the *next* character's session β€” where, on the first frame the interface came -/// up, [`feed_dialog_verbs`] pushed the stale healer into the fresh VM and could fire -/// `AREA_SPIRIT_HEALER_IN_RANGE` at a living body standing in Stormwind, and the poll's ghost gate -/// then cleared it and sent a `CMSG_CANCEL_AURA(2584)` the reference never sends there. -/// -/// The reference does not need this leg because its equivalents are process-lifetime globals whose -/// poll keeps running over the character screen's frames; ours is a resource in a world that goes -/// away. Resetting at the seam is how every other per-session cache here behaves -/// (`crate::ui_aura::end_session_aura_state` is the established shape). +/// Drop the cached spirit guide when the world does, so no healer, wave or event reaches the +/// next session; the reference clears it only through `0x4921c0`, its world-leave path untraced. fn area_spirit_healer_leave_world(mut spirit: ResMut) { *spirit = AreaSpiritHealer::default(); } @@ -861,7 +696,7 @@ pub(crate) fn feed_dialog_verbs( } } -/// The pet trainer's confirm and the spirit healer's accept β€” the two drains over a latch. +/// The pet trainer's confirm and the spirit healer's accept, the two drains over a latch. fn drain_latch_verbs( script: Option>, pet: Res, @@ -874,8 +709,7 @@ fn drain_latch_verbs( return; }; - // ConfirmPetUnlearn: the latch, then the money gate β€” `cost > coinage` shows - // ERR_NOT_ENOUGH_MONEY and sends nothing; otherwise `0x2F0` with the latched guid. + // ConfirmPetUnlearn: `cost > coinage` shows ERR_NOT_ENOUGH_MONEY and sends nothing. let confirms = script.take_pet_unlearn_confirms(); if confirms > 0 { if let Some(npc) = pet.pending() { @@ -901,7 +735,7 @@ fn drain_latch_verbs( } } - // AcceptAreaSpiritHeal: the cached healer's guid (the binding was silent without one). + // AcceptAreaSpiritHeal: the cached healer's guid, nothing without one. let accepts = script.take_area_spirit_accepts(); if let Some(healer) = spirit.healer() { for _ in 0..accepts { @@ -912,7 +746,7 @@ fn drain_latch_verbs( } } -/// The battleground port and the meeting-stone leave β€” the two drains over a queue. +/// The battleground port and the meeting-stone leave, the two drains over a queue. fn drain_queue_verbs( script: Option>, queue: Res, @@ -925,7 +759,6 @@ fn drain_queue_verbs( return; }; - // AcceptBattlefieldPort: the slot's map id and the one-byte answer. for (index, accept) in script.take_battlefield_port_requests() { if let Some(map_id) = queue.map_id(index) { let _ = commands @@ -934,8 +767,8 @@ fn drain_queue_verbs( } } - // CancelMeetingStoneRequest: in a party and not its leader β†’ ERR_MEETING_STONE_NOT_LEADER; - // otherwise `0x293`, whatever is or is not queued. + // CancelMeetingStoneRequest: in a party we do not lead, ERR_MEETING_STONE_NOT_LEADER; + // otherwise `0x293`, queued or not. let cancels = script.take_meeting_stone_cancels(); if cancels > 0 { let not_leader = group @@ -955,30 +788,10 @@ fn drain_queue_verbs( } } -/// **The per-frame area-spirit-healer poll β€” `0x4923b0`**, which the reference runs from -/// `CGWorldFrame`'s own `OnUpdate` (`0x4818ca`) on **every frame**, mouse focus or not. -/// -/// ```text -/// player = the active player object; none -> set_healer(None); return -/// not a GHOST ([[player+0xe68]+8] bit 4) -> set_healer(None); return -/// if cached: -/// the cached guid no longer resolves to a unit -> set_healer(None) -/// else dΒ²(player, healer) > 22Β² -> set_healer(None) -/// if still cached: return // 0x492487 -/// enumerate every object: the first (last, really β€” // 0x492495 -/// the callback never stops the walk) unit that is -/// SPIRITGUIDE, CanAssist, and within 20 yd -> set_healer(it) -/// ``` -/// -/// **Ghost-gated at the top**, so a living player never holds a healer and never sends a query β€” -/// the graveyard's wave clock only exists for the dead. That gate is why this costs nothing in -/// ordinary play: one flag read per frame and out. -/// -/// The callback `0x4924c0` returns 1 unconditionally, so the enumeration is **not** stopped by a -/// match β€” the last qualifying unit in enumeration order is the one that sticks. `set_healer`'s -/// own early return makes that harmless for a stable set (only a genuine change sends), and -/// reproducing "last wins" rather than "nearest wins" matters at a graveyard with two guides in -/// range: the reference does not pick the closer one, and neither does this. +/// The area-spirit-healer poll `0x4923b0`, run every frame from `CGWorldFrame`'s `OnUpdate` +/// (`0x4818ca`): only a ghost (`[[player+0xe68]+8]` bit 4) holds a healer; a cached one is kept +/// within 22 yd, else the last unit in the walk that is a `SPIRITGUIDE`, assistable and within +/// 20 yd is adopted. Last, not nearest: the enumerate callback `0x4924c0` never stops the walk. fn poll_area_spirit_healer( mut spirit: ResMut, me: Query<(&ObjectStore, &Transform), With>, @@ -1000,8 +813,7 @@ fn poll_area_spirit_healer( ) { let mut apply = |outcome: SetHealerOutcome| { if outcome.cancel_aura { - // `0x6e7040(0xA18)` does both halves, and both are the reference's: the event with no - // argument, and the packet with no guid. + // `0x6e7040(0xA18)`: the event with no argument, the packet with no guid. if let Some(script) = script.as_deref_mut() { script.fire_event("AREA_SPIRIT_HEALER_OUT_OF_RANGE", vec![]); } @@ -1026,8 +838,7 @@ fn poll_area_spirit_healer( } let here = self_tf.translation; - // The retain leg. A cached guid that no longer streams to us is dropped exactly as one that - // walked out of range is β€” the reference's `ObjectPtr` miss falls into the same clear. + // Retain: a guid that no longer streams drops like one out of range (an `ObjectPtr` miss). if let Some(cached) = spirit.healer() { let still = units.iter().find(|(guid, ..)| guid.0 == cached); let keep = still.is_some_and(|(_, _, _, tf)| { @@ -1041,9 +852,7 @@ fn poll_area_spirit_healer( return; } - // The acquire walk. `can_assist` is `0x6066f0`, the same predicate the target scanner and the - // buff gate already run β€” its owner chase wants a store by guid, which is what the index is - // for. + // Acquire: `can_assist` is `0x6066f0`; its owner lookup wants a store by guid. let store_of = |guid: u64| -> Option { let entity = *index.as_ref()?.0.get(&guid)?; stores.get(entity).ok().cloned() @@ -1069,15 +878,14 @@ fn poll_area_spirit_healer( ) { continue; } - adopted = Some(guid.0); // last wins β€” the callback never stops the walk + adopted = Some(guid.0); // last wins: the callback never stops the walk } if let Some(guid) = adopted { apply(spirit.set_healer(Some(guid))); } } -/// The dialog verbs' packet handlers (in the net handler table since 2313) β€” each parks a -/// question or a countdown on its own store for the feed to turn into a StaticPopup. +/// The dialog verbs' packet handlers: each parks a question or a countdown for the feed. mod net { use benilla_protocol::{SessionEvent, SessionEventKind}; use bevy::prelude::*; @@ -1085,7 +893,6 @@ mod net { use super::{AreaSpiritHealer, BattlefieldQueue, InstanceBoot, MeetingStone, PetUnlearnState}; use crate::net::NetHandlerApp; - /// Register the handlers β€” called from [`super::UiDialogVerbsPlugin`]. pub(super) fn register(app: &mut App) { use SessionEventKind as K; app.net_handler(K::PetUnlearnConfirm, on_pet_unlearn_confirm) @@ -1097,19 +904,13 @@ mod net { .net_handler(K::Disconnected, on_session_end); } - /// The battleground queue is zeroed at every login (module init `0x4a9c40`, from - /// `InitializeGame` β€” the three slots, `[0x8457cc] = -1`, the - /// scalars), and vmangos never sends a clear for the queue of a player who logged out. A - /// listener on the session end (a second handler on the kind, after the bridge's own - /// teardown). The in-session world enter (`0x4a9db0`) is a different edge that KEEPS the slots, - /// and it does not come through here. + /// Zero the battleground queue at session end, as module init `0x4a9c40` does at every login + /// (`[0x8457cc]` to -1); vmangos sends no clear for a player who logged out. fn on_session_end(In(_): In, mut queue: ResMut) { *queue = BattlefieldQueue::default(); } - /// The pet trainer's question (decision 1963) β€” the talent wipe's twin - /// ([`crate::ui_talent_wipe`]); a zero guid is the reference's own `ERR_TALENT_WIPE_ERROR` - /// leg, carried over as observed. + /// The pet trainer's question; a zero guid shows `ERR_TALENT_WIPE_ERROR` instead. fn on_pet_unlearn_confirm( In(ev): In, mut unlearn: ResMut, @@ -1170,83 +971,34 @@ impl Plugin for UiDialogVerbsPlugin { Update, ( close_npc_session_out_of_range::.before(feed_dialog_verbs), - // Gated on the interface being up (decision 2279): `InstanceBoot::events` - // is server-driven β€” vmangos sends `SMSG_RAID_GROUP_ONLY` from - // `Player::UpdateHomebindTime` on the first map tick after a login inside a - // raid instance with no raid group, i.e. inside the login burst's own - // drain β€” and `INSTANCE_BOOT_START`, the event that raises the stock - // countdown popup, would be fired at the boot VM in 2214's one-frame window - // and lost. Gated, the queue waits; nothing here clears without a VM. + // Gated on the in-game interface: vmangos can send `SMSG_RAID_GROUP_ONLY` in + // the login burst (`Player.cpp:18686`), and an `INSTANCE_BOOT_START` fired at + // the boot VM would be lost; the queue waits instead. feed_dialog_verbs .in_set(UiFeed) .run_if(crate::ui_script::ingame_ui_up), - // **After the frame's pick, which is the reference's own order.** The poll - // is `0x4923b0`, called from `CGWorldFrame`'s OnUpdate at `0x4818ca` β€” and - // that same function ran the mouse pick eighty bytes earlier, at `0x48184a`. - // So a right-click that adopts a spirit guide is decided BEFORE the poll gets - // to look, not after; ordering it `.after(TargetUpdate)` (the set - // `act_on_right_click` chains inside) reproduces that, and settles the one - // real write-write pair this system has β€” both it and the click's - // `ServiceArms` hold `AreaSpiritHealer`. - // - // The cost is that an adopt reaches `feed_dialog_verbs` on the NEXT frame - // rather than this one, because `UiInput` sits before `WorldStage::Input` and - // `TargetUpdate` after it β€” the two orders cannot both hold. That is the - // right way round to lose: the event this system fires itself - // (`_OUT_OF_RANGE`) is immediate, and what lags is one frame of a clock the - // server ticks in seconds. - // - // **Gated on the interface being up** like its neighbour, and safely so: this - // poll is LEVEL-triggered, not edge-triggered. It re-derives the whole cache - // from the ghost flag, the streamed guides and the distances every frame, so a - // frame it does not run is a frame it simply has not got to yet β€” the very - // next one adopts and queries. That is what makes it exempt in substance from - // the one-shot loss 2214's window causes (2220/2232): there is no edge here to - // lose. The `_OUT_OF_RANGE` it can fire is the one thing that needs the VM, - // and it can only follow an adopt this same system made. + // After the mouse pick, the reference's order: `CGWorldFrame`'s OnUpdate picks + // at `0x48184a`, then polls at `0x4818ca`. Gating is safe: the poll is + // level-triggered and re-derives the cache every frame. poll_area_spirit_healer .after(crate::target::TargetUpdate) .run_if(crate::ui_script::ingame_ui_up) .in_set(crate::char_select::InWorldGated), - // In-world only: the claim is about the VM, but the query is a world - // packet, and the boot VM exists at the glue screen too. + // In world only: the query is a world packet, and the glue screen has a VM. meeting_stone_enter_world .in_set(crate::ui_script::UiFeed) .before(feed_meeting_stone) .in_set(crate::char_select::InWorldGated), feed_meeting_stone.in_set(UiFeed), - // **Before the target chain, not merely after the input pass** β€” and this one - // is a correctness order, not a tidiness one. - // - // `drain_latch_verbs` takes `AcceptAreaSpiritHeal`'s presses with - // `take_area_spirit_accepts()` **unconditionally**, and only then gates the - // send on `spirit.healer()`. Both systems that can *clear* that healer β€” - // `poll_area_spirit_healer` (which drops it past the 22 yd retain radius) and - // `act_on_right_click` (which re-points it at another guide) β€” live in or - // after `TargetUpdate`, and `.after(UiInput)` alone constrains this against - // neither: `UiInput` sits *before* `WorldStage::Input` and `TargetUpdate` - // after it. So with the order undeclared, a ghost who clicks **Accept** on the - // same frame they step out of range either sends - // `CMSG_AREA_SPIRIT_HEALER_QUEUE` or has the press silently eaten β€” decided by - // graph layout, with no message either way. - // - // `before` is the reference's own answer, not a coin toss: the dialog's Lua - // handler runs inside the UI dispatch, while `0x4923b0` (the poll) and - // `0x48184a` (the pick) both run later in the same frame off `CGWorldFrame`'s - // OnUpdate. The press is resolved against the healer the frame *began* with. - // Its neighbour `drain_meeting_stone_joins` below argued the same hazard and - // declared its way out of it; this one was missed. + // Before the target chain, where the poll and the click can change the + // healer: the reference resolves an Accept press in the UI dispatch, before + // its pick and poll run later in the frame. drain_latch_verbs .after(UiInput) .before(crate::target::TargetUpdate), drain_queue_verbs.after(UiInput), - // MEETINGSTONE(23)'s use slot (2283): the click resolved the object, this - // runs the validator and sends. Ordered after the **target chain**, not just - // after the input pass like its neighbours β€” `UiInput` sits *before* - // `WorldStage::Input` and `TargetUpdate` after it, so "after UiInput" alone - // says nothing about the writer and would let the join drift a frame. A - // message survives that (two-frame lifetime) and 16 ms would not be visible, - // but a click's own packet should not leave on an undefined frame. + // After the target chain, which writes `MeetingStoneUse`, so the join leaves + // on the click's own frame. drain_meeting_stone_joins .after(UiInput) .after(crate::target::TargetUpdate), @@ -1259,16 +1011,12 @@ impl Plugin for UiDialogVerbsPlugin { } } -/// The area spirit healer's aura as the test below names it β€” the same 2584 as -/// [`AREA_SPIRIT_HEALER_AURA`], kept under its own name because the test is about -/// `CancelAreaSpiritHeal`'s spell and the constant above is about `0x4921c0`'s. +/// `CancelAreaSpiritHeal`'s spell, the same 2584 as [`AREA_SPIRIT_HEALER_AURA`]. #[cfg(test)] const AREA_SPIRIT_HEALER_SPELL: u32 = AREA_SPIRIT_HEALER_AURA; -/// The generic cancel-aura routine's refusal (`0x6e7040`): it returns without sending when the -/// spell's `AttributesEx` has bit 13 set and bit 2 -/// clear **and** `0x5ee290(player)` holds. Whether the third leg ever matters for spell 2584 is -/// decided by the first two, read off the shipped Spell.dbc in [`tests::spell_2584_never_trips_the_cancel_gate`]. +/// The cancel-aura routine `0x6e7040` sends nothing when `AttributesEx` has bit 13 set, bit 2 +/// clear and `0x5ee290(player)` holds; these are the first two terms. #[cfg(test)] fn cancel_gate_could_apply(attributes_ex: u32) -> bool { attributes_ex & 0x2000 != 0 && attributes_ex & 0x4 == 0 @@ -1281,11 +1029,6 @@ mod tests { /// `UNIT_FIELD_LEVEL`, absolute field 34. const LEVEL_FIELD: u16 = 34; - /// **`0x4921c0`'s early return guards everything.** The first two compares - /// (`0x4921cf cmp ecx,eax` / `0x4921dd cmp edx,ecx`) jump straight to the epilogue at - /// `0x492282` when the guid is unchanged β€” so a repeat call sends no packet, cancels no aura - /// and does **not** clear the pending deadline. That last one is the part a paraphrase loses, - /// and it is what lets the per-frame poll call this routine unconditionally. #[test] fn setting_the_same_healer_is_a_complete_no_op() { let mut spirit = AreaSpiritHealer::default(); @@ -1313,10 +1056,6 @@ mod tests { ); } - /// The change legs, in the order the routine runs them: a pending deadline is cancelled - /// (`0x4921fc`, which is how walking out of range fires `_OUT_OF_RANGE` with no Lua call), - /// the deadline is zeroed unconditionally (`0x492211`), and only a **non-zero** new guid - /// sends (`0x492217`'s `je`). #[test] fn dropping_a_healer_cancels_the_wave_and_sends_nothing() { let mut spirit = AreaSpiritHealer::default(); @@ -1335,7 +1074,6 @@ mod tests { assert_eq!(spirit.secs(now), 0, "the clock is zeroed"); assert_eq!(spirit.healer(), None); - // And a drop with no clock armed cancels nothing. spirit.set_healer(Some(9)); assert_eq!( spirit.set_healer(None), @@ -1347,9 +1085,6 @@ mod tests { ); } - /// `SMSG_AREA_SPIRIT_HEALER_TIME` is addressed: a clock for a healer that is **not** the - /// cached one is dropped (`0x4922a0 cmp eax,[ebp+8]`), which is what keeps a stale reply from - /// a graveyard you already left out of the dialog. #[test] fn a_clock_for_another_healer_is_ignored() { let mut spirit = AreaSpiritHealer::default(); @@ -1363,10 +1098,6 @@ mod tests { assert_eq!(spirit.secs(now), 25); } - /// **The click's cache-bust.** `0x5df950` calls the setter twice β€” `(0,0)` then the guid β€” - /// precisely so the second call is never swallowed by the unchanged-guid early return. So - /// clicking the guide you are already standing next to DOES re-ask for the clock, which is - /// the behaviour a player relies on after dismissing the dialog. #[test] fn clicking_the_cached_guide_still_asks_again() { let mut spirit = AreaSpiritHealer::default(); @@ -1387,8 +1118,7 @@ mod tests { assert_eq!(spirit.healer(), Some(7)); } - /// `UNIT_FIELD_FLAGS`, absolute field 46 β€” `UNIT_FLAG_PVP` (`0x1000`) is what carries a - /// non-player-controlled unit through `can_assist`'s last gate. + /// `UNIT_FIELD_FLAGS`, absolute field 46; `UNIT_FLAG_PVP` (`0x1000`) passes `can_assist`. const UNIT_FLAGS_FIELD: u16 = 46; /// `UNIT_NPC_FLAGS`, absolute field 147. const NPC_FLAGS_FIELD: u16 = 147; @@ -1399,16 +1129,9 @@ mod tests { ObjectStore(benilla_protocol::ObjectFields::from_pairs(pairs)) } - /// A world holding exactly what [`poll_area_spirit_healer`] reads, with the body at the origin - /// and one candidate unit at `dist` yards along +X. - /// - /// `ghost` drives `PLAYER_FLAGS 0x10`; `guide` drives `UNIT_NPC_FLAGS` bit 6. The candidate - /// carries `UNIT_FLAG_PVP` and faction template **35** ("friendly to all"), against a body on - /// template **1** (PLAYER, Human) β€” a real friendly pair out of the shipped DBC, so - /// `can_assist` passes for the same reason it passes on the live server rather than by - /// accident. A test that wants the acquire walk to *run* must therefore hand over the real - /// catalog; `None` serves the cases that refuse **before** the walk (not a ghost, not a guide, - /// too far), which is why those need no client data and never skip. + /// What [`poll_area_spirit_healer`] reads: the body at the origin, one unit `dist` yards along + /// +X. Faction templates 35 (friendly to all) and 1 (Human) are a real friendly pair, so the + /// acquire walk needs the shipped catalog; `None` serves the cases refused before the walk. fn poll_world( ghost: bool, guide: bool, @@ -1452,8 +1175,6 @@ mod tests { /// `UNIT_FIELD_FACTIONTEMPLATE`, absolute field 35. const FACTION_TEMPLATE_FIELD: u16 = 35; - /// The shipped FactionTemplate.dbc, or an early return when this machine has no client data β€” - /// the same `wow_data_or_skip!` shape `target::ring`'s own reaction tests use. macro_rules! catalog_or_skip { () => {{ let data = benilla_formats::wow_data_or_skip!(); @@ -1478,10 +1199,6 @@ mod tests { .collect() } - /// **The ghost gate is the poll's first test** (`0x4923b0`, the `0x5df74a`-shaped read of - /// `[[player+0xe68]+8] bit 4`). A living body standing on top of a spirit guide adopts nothing - /// and sends nothing β€” which is what keeps the resurrect dialog off the screen of everyone who - /// is merely walking through a graveyard. #[test] fn a_living_body_never_adopts_a_spirit_guide() { let (mut world, rx) = poll_world(false, true, 1.0, None); @@ -1490,8 +1207,6 @@ mod tests { assert!(queries(&rx).is_empty(), "no clock is asked for"); } - /// The acquire leg: a ghost inside 20 yd adopts, and the adopt is what sends - /// `CMSG_AREA_SPIRIT_HEALER_QUERY` β€” the packet that was unreachable before 2291. #[test] fn a_ghost_adopts_a_guide_inside_the_acquire_radius_and_asks_for_the_clock() { let catalog = catalog_or_skip!(); @@ -1505,7 +1220,6 @@ mod tests { ); } - /// …and 20 yd is a real boundary, not decoration: one yard further and nothing is adopted. #[test] fn a_guide_past_the_acquire_radius_is_not_adopted() { let (mut world, rx) = poll_world(true, true, 21.0, None); @@ -1514,11 +1228,6 @@ mod tests { assert!(queries(&rx).is_empty()); } - /// **The hysteresis, which is the whole reason there are two radii.** A guide adopted at 19 yd - /// is RETAINED at 21 β€” past the acquire radius β€” and only dropped past 22 - /// (`20.0 Γ— 1.1`). Equal radii would re-query every frame a body jittered over the boundary. - /// The drop is not silent: it cancels the wave (`CMSG_CANCEL_AURA` on 2584), which on vmangos - /// takes the player out of the resurrect queue. #[test] fn an_adopted_guide_is_retained_past_the_acquire_radius_and_dropped_past_the_retain_one() { let catalog = catalog_or_skip!(); @@ -1527,7 +1236,6 @@ mod tests { assert_eq!(world.resource::().healer(), Some(GUIDE)); let _ = queries(&rx); - // 21 yd: outside acquire, inside retain β€” kept, and no second query. let guide = world .query_filtered::>() .iter(&world) @@ -1544,7 +1252,7 @@ mod tests { ); assert!(queries(&rx).is_empty(), "a retained guide is not re-asked"); - // 23 yd: outside both β€” dropped. + // 23 yd: outside both. world .entity_mut(guide) .insert(Transform::from_xyz(23.0, 0.0, 0.0)); @@ -1552,8 +1260,6 @@ mod tests { assert_eq!(world.resource::().healer(), None); } - /// The unit filter is the `SPIRITGUIDE` bit and nothing else. A unit standing in the same spot - /// without it β€” a battle master, a herald, another player's corpse-side NPC β€” is skipped. #[test] fn a_unit_without_the_spiritguide_flag_is_never_adopted() { let (mut world, rx) = poll_world(true, false, 1.0, None); @@ -1562,8 +1268,6 @@ mod tests { assert!(queries(&rx).is_empty()); } - /// Resurrecting drops the guide. The poll is **level**-triggered, so the release happens on the - /// first frame the ghost flag clears β€” with the guide still standing right there. #[test] fn losing_the_ghost_state_drops_the_guide() { let catalog = catalog_or_skip!(); @@ -1587,14 +1291,11 @@ mod tests { ); } - /// The four client-side refusals of MEETINGSTONE(23)'s use slot (`0x5f69d0`, decision 2283), - /// in the reference's own order β€” and the pass that reaches `CMSG 0x292`. #[test] fn the_meeting_stone_join_refuses_in_the_references_order() { use crate::go_templates::MeetingStoneTemplate; const ME: u64 = 0x5e1f; const MATE: u64 = 0xa11e; - // A stone anybody 15-60 may use β€” the shape most of 1.12's dungeon stones carry. let stone = Some(MeetingStoneTemplate { min_level: 15, max_level: 60, @@ -1615,30 +1316,27 @@ mod tests { ..GroupState::default() }; - // Solo, in range of the level band: nothing refuses. assert_eq!( meeting_stone_join_refusal(None, Some(ME), Some(40), stone), StoneJoin::Send ); - // Leading a party of FOUR (three others): still fine β€” a full party is five, and the - // stone's job is to find the fifth. This is the assertion the `+ 1` bug failed. + // Leading a party of four, three others: not full. assert_eq!( meeting_stone_join_refusal(Some(&party(ME, 3, 0)), Some(ME), Some(40), stone), StoneJoin::Send ); - // 1 β€” in a party someone else leads. + // 1: in a party someone else leads. assert_eq!( meeting_stone_join_refusal(Some(&party(MATE, 1, 0)), Some(ME), Some(40), stone), StoneJoin::Refuse("ERR_MEETING_STONE_MUST_BE_LEADER") ); - // 2 β€” leading a FULL party: four others, five including us, which is what - // `Group::IsFull()` refuses server-side too. + // 2: leading a full party, four others. assert_eq!( meeting_stone_join_refusal(Some(&party(ME, 4, 0)), Some(ME), Some(40), stone), StoneJoin::Refuse("ERR_MEETING_STONE_GROUP_FULL") ); - // 3 β€” the level band, both ends, inclusive. + // 3: the level band, both ends inclusive. assert_eq!( meeting_stone_join_refusal(None, Some(ME), Some(14), stone), StoneJoin::Refuse("ERR_MEETING_STONE_INVALID_LEVEL") @@ -1655,23 +1353,19 @@ mod tests { meeting_stone_join_refusal(None, Some(ME), Some(60), stone), StoneJoin::Send ); - // 4 β€” a raid. Its leader is refused too, which is what puts it BELOW the leader term. + // 4: a raid, its leader included. assert_eq!( meeting_stone_join_refusal(Some(&party(ME, 1, 1)), Some(ME), Some(40), stone), StoneJoin::Refuse("ERR_MEETING_STONE_NO_RAID_GROUP") ); - // The order is observable where two terms hold at once: a raid we do not lead answers - // MUST_BE_LEADER, not NO_RAID_GROUP. + // Where two terms hold, the leader term answers first. assert_eq!( meeting_stone_join_refusal(Some(&party(MATE, 4, 1)), Some(ME), Some(40), stone), StoneJoin::Refuse("ERR_MEETING_STONE_MUST_BE_LEADER") ); - // **An unanswered template refuses**, because `0x5f8150` reads `0/0` off an uncached - // object and every level >= 1 is outside `0..=0`. The permissive "skip the term while the - // query is in flight" default the highlight column takes is not this slot's β€” the first - // cut of this code had it. + // An uncached template reads 0/0 (`0x5f8150`) and refuses. assert_eq!( meeting_stone_join_refusal(None, Some(ME), Some(5), None), StoneJoin::Refuse("ERR_MEETING_STONE_INVALID_LEVEL") @@ -1696,8 +1390,7 @@ mod tests { StoneJoin::Send ); - // `0x5f69f8` β€” no active player: SILENT, and not one of the four refusals. Neither a - // missing guid nor a level the store has not streamed reaches a message or a packet. + // No active player, whether the guid or the level is missing: silent (`0x5f69f8`). assert_eq!( meeting_stone_join_refusal(None, Some(ME), None, stone), StoneJoin::Silent @@ -1708,14 +1401,11 @@ mod tests { ); } - /// The join drain end to end: a clicked stone the player may use puts `CMSG 0x292` on the - /// wire with that object's guid, and a refused one puts **nothing** there. #[test] fn the_join_drain_sends_the_clicked_stone_and_refuses_silently_on_the_wire() { use crate::go_templates::GameObjectTemplates; - // A real GameObject guid: `counter | (entry << 24) | (HIGH_GAMEOBJECT << 48)` β€” the - // template cache is keyed by the entry the guid carries, so a made-up number would - // silently miss and skip the level term. + // A real GameObject guid, `counter | entry << 24 | HIGH_GAMEOBJECT << 48`: the template + // cache is keyed by the entry it carries. const STONE_ENTRY: u32 = 0x5701; const STONE: u64 = 0xF110 << 48 | (STONE_ENTRY as u64) << 24 | 0x22; const ME: u64 = 0x5e1f; @@ -1760,7 +1450,7 @@ mod tests { app.update(); assert_eq!(joins(&rx), vec![STONE], "the clicked stone's own guid"); - // Now in a party led by someone else: the same click sends nothing at all. + // In a party led by someone else: the same click sends nothing. app.world_mut().resource_mut::().in_group = true; app.world_mut().resource_mut::().leader = MATE; app.world_mut() @@ -1772,18 +1462,8 @@ mod tests { ); } - /// **The `/reload` re-query** β€” the meeting-stone half of 1290's class, and the reference's - /// own behaviour rather than an invention of ours. - /// - /// The run-once byte `[0xb4b424]` is cleared by the UI teardown at `0x490a8d`, and - /// `UI_Init` re-runs the bring-up and re-sends `CMSG 0x296`. So the gate is once per **VM**, - /// not once per world session β€” and against the old `MessageReader` - /// shape this fails on the second VM, which is exactly the reported symptom: the queued - /// player's minimap icon never comes back after a `/reload`. - /// - /// **A registered schedule, not `run_system_once`**: the gate is a `Local>`, and - /// `run_system_once` builds a fresh system β€” and so a fresh `Local` β€” on every call, which - /// would make the claim look unclaimed every frame (the same trap `ui_loot`'s tests name). + /// A registered schedule: `run_system_once` would build a fresh `Local>` per call, + /// an unclaimed memo every frame. #[test] fn a_rebuilt_vm_re_asks_the_server_for_the_meeting_stone_queue() { let (tx, rx) = crossbeam_channel::unbounded(); @@ -1798,11 +1478,9 @@ mod tests { .count() }; - // No VM yet β€” the glue screen: nothing is asked. app.update(); assert_eq!(queries(&rx), 0, "no VM, no bring-up"); - // The first VM: the bring-up runs once, however many frames pass. app.insert_non_send_resource(UiScript::new().expect("VM")); app.update(); assert_eq!(queries(&rx), 1, "the first VM asks the server"); @@ -1810,11 +1488,11 @@ mod tests { app.update(); assert_eq!(queries(&rx), 0, "…and does not ask again on later frames"); - // The server answers: the player IS queued, and the host holds that. + // The server answers: the player is queued. app.world_mut().resource_mut::().area = 1519; app.world_mut().resource_mut::().dirty = false; - // `ReloadUI()`: a fresh VM, and nothing on the wire. + // `ReloadUI()`: a fresh VM, with no world leave. app.insert_non_send_resource(UiScript::new().expect("VM")); app.update(); assert_eq!( @@ -1835,7 +1513,6 @@ mod tests { ); } - /// Spell 2584's flags off the shipped Spell.dbc: the cancel-aura gate's first two legs. #[test] fn spell_2584_never_trips_the_cancel_gate() { let data = benilla_formats::wow_data_or_skip!(); @@ -1911,8 +1588,6 @@ mod tests { assert_eq!(q.map_id(2), None, "a zero map clears the slot"); } - /// The instance clocks (`0x4aa850`): stamped by status 3, zeroed by any other status of ANY - /// slot, and by a clear of the active slot only β€” which leaves the active index alone. #[test] fn the_instance_clocks_follow_the_status_handler() { let mut q = BattlefieldQueue::default(); @@ -1932,7 +1607,7 @@ mod tests { assert_eq!(q.instance_expiration_ms(now), 90_000); assert_eq!(q.run_time_ms(now), 30_000); assert!(q.take_score_dirty()); - // A QUEUED update for slot 2 zeroes both clocks β€” the handler's unconditional arm. + // A queued status for slot 2 zeroes both clocks too. let mut queued = active.clone(); queued.slot = 1; queued.map_id = 529; @@ -1948,7 +1623,7 @@ mod tests { assert_eq!(q.instance_expiration_ms(now), 0); assert_eq!(q.run_time_ms(now), 0); assert_eq!(q.map_id(2), Some(529)); - // Re-arm, then clear the ACTIVE slot: the clocks go, the index stays (`[0x8457cc]`). + // Re-arm, then clear the active slot: the clocks go, the index stays (`[0x8457cc]`). q.apply_at(active.clone(), now); active.map_id = 0; q.apply_at(active, now); @@ -1961,7 +1636,6 @@ mod tests { ); } - /// `0x295`'s store is unconditional and the old id is latched first; the feed reads both. #[test] fn the_stone_latches_the_old_area_before_storing_the_new() { let mut stone = MeetingStone::default(); @@ -1981,7 +1655,6 @@ mod tests { assert_eq!(stone.text, StoneText::None); } - /// The five-way table with its two asymmetries, and the rebuild's fallbacks and buffer. #[test] fn the_status_table_and_the_rebuild_follow_the_handler() { let data = benilla_formats::wow_data_or_skip!(); @@ -2000,7 +1673,6 @@ mod tests { .unwrap(); let a = Some(&areas); let text = |l: Option| l.map(|l| l.text().to_string()); - // Status 0 names the OLD area β€” and is silent when it has no row. assert_eq!( text(stone_line(&s, a, 1519, 0, 0)).as_deref(), Some("You are no longer queued for Stormwind City.") @@ -2011,7 +1683,6 @@ mod tests { "no row for area 0: silent" ); assert_eq!(text(stone_line(&s, a, 999_999, 0, 0)), None); - // Status 1 names the NEW area, falls back to UNKNOWN, and is skipped when unchanged. assert_eq!( text(stone_line(&s, a, 0, 1519, 1)).as_deref(), Some("You are now in the queue to join a party for Stormwind City.") @@ -2033,7 +1704,6 @@ mod tests { None, "past the table: nothing" ); - // The rebuild. assert_eq!( build_stone_text(&s, a, 1519), "Looking for more for Stormwind City" @@ -2065,12 +1735,6 @@ mod tests { assert_eq!(pet.cost, 0); } - /// **The queue is zeroed at every login, not kept across it**: module init `0x4a9c40`, run - /// from `InitializeGame`, zeroes the three slots, the active index (`[0x8457cc] = -1`) and the - /// scalars, and vmangos never sends a clear for a queue whose player logged out. So a slot, - /// the active map and the instance clocks from the last session must not reach the next β€” - /// only an in-session world enter (`0x4a9db0`) keeps the slots, and that is - /// `ui_battlefield`'s, untouched here. #[test] fn the_session_end_zeroes_the_battlefield_queue() { let mut app = App::new(); diff --git a/crates/benilla-app/src/ui_dressup.rs b/crates/benilla-app/src/ui_dressup.rs index c1787a71..9938f82e 100644 --- a/crates/benilla-app/src/ui_dressup.rs +++ b/crates/benilla-app/src/ui_dressup.rs @@ -1,33 +1,8 @@ -//! The app-side **dressing-room feed** (decisions 1060, 1969): the bridge between the stock -//! window's `DressUpModel` widget verbs ([`DressUpIntent`], queued engine-side) and the booth's -//! look ([`DressUpPreview`]). -//! -//! Three jobs, each frame, in the feed phase before the VM ticks ([`UiFeed`]): -//! -//! - **Apply the intents, in order.** `Dress` drops every substitution (the ref's -//! `SetUnit("player")` on open and `Dress()` on Reset); `Undress` strips every worn piece but -//! the hands; `TryOn(id)` records one; `Close` β€” derived here from the window going invisible, -//! the stock file having no hook of ours β€” empties the room. Order matters β€” `DressUpItem` -//! resets *then* tries on in the same breath. -//! - **Resolve each tried-on item to a display.** An item id is all a `|Hitem:` link carries, so the -//! display id / inventory type come from the ask-once template cache -//! ([`Items::template`] β€” `CMSG_ITEM_QUERY_SINGLE` on a miss, exactly as the reference's -//! ItemCache does). A substitution whose answer is still in flight stays **pending** and lands on -//! a later frame: that is the normal case for an item the player has never seen, e.g. one linked -//! in chat by someone else. -//! - **Compose the look.** The player's own body + appearance + their `PLAYER_VISIBLE_ITEM_*` -//! displays, with each resolved substitution written into the slot its `InventoryType` maps to -//! (the shared [`equip_slot`] table β€” the same one the glue/select preview dresses by, so a robe -//! lands on the chest here exactly as it does there). The **held** triple then goes through -//! [`held_lanes`], which is where the two previews stop agreeing: the character-select mannequin -//! drops the ranged slot and this window puts it in a hand (decision 1076). 1060's own record -//! claimed "a wand in the ranged hand here exactly as it does there" β€” that was wrong in the -//! second half, and the ranged slot was silently discarded until 1076. -//! -//! **What is NOT here:** any notion of *fit*. The reference's dressing room previews whatever it is -//! handed β€” a plate helm on a mage, a mail chest on a rogue β€” because it is a look, not an equip: -//! `DressUpItemLink` reaches `TryOn` with no class/level/proficiency check anywhere in the path -//! (`DressUpFrame.lua:2-16`). Ours does the same. +//! The dressing-room feed: applies the stock window's `DressUpModel` verbs ([`DressUpIntent`]) in +//! order, since `DressUpItem` can reset and try on in one call, and composes the booth's look +//! ([`DressUpPreview`]): the player's own gear with each substitution in its [`equip_slot`], the +//! held triple through [`held_lanes`]. A tried-on item waits on its template ([`Items::template`]). +//! There is no fit check: the reference previews whatever it is handed (`DressUpFrame.lua:2-16`). use benilla_protocol::CharEnumItem; use benilla_ui::script::{DressUpIntent, UiScript}; @@ -39,36 +14,23 @@ use crate::net::{NetCommands, NetEntity, ObjectStore, SelfPlayer}; use crate::portrait::{DressUpLook, DressUpPreview}; use crate::ui_script::UiFeed; -/// The equipment slots a dressing-room look reads off the player β€” every rendered slot -/// (`EQUIPMENT_SLOT_*`), which is exactly the set [`equip_slot`] can map an item into. +/// Every rendered equipment slot, the set [`equip_slot`] can map an item into. const LOOK_SLOTS: [u8; 14] = [0, 2, 3, 4, 5, 6, 7, 8, 9, 14, 15, 16, 17, 18]; -/// The three **held** equipment slots β€” main hand Β· off hand Β· ranged. The widget keeps only *two* -/// lanes for them, which is the whole reason [`held_lanes`] exists. +/// Main hand, off hand and ranged: three slots for the widget's two held lanes ([`held_lanes`]). const HELD_SLOTS: [usize; 3] = [15, 16, 17]; -/// **The dress-up widget has two held lanes, and a ranged weapon takes one of them** (decision -/// 1076). +/// The widget's two held lanes. `TryOn` (`0x504d90`) installs into lane `0x0f` (HandRight) or +/// `0x10` (HandLeft, or the Shield point): a bow (15) takes the off lane, a gun, crossbow, wand +/// (26) or thrown (25) the main one. An item the other lane cannot coexist with evicts it +/// (`0x504bc0`), and a ranged weapon coexists with nothing. /// -/// `DressUpModel::TryOn 0x504d90` installs into lane `0x0f` (β†’ HandRight) or lane `0x10` (β†’ -/// HandLeft, or the Shield point for a shield) β€” never a third. `INVTYPE_RANGED` (15, bows) lands -/// in the **off** lane; gun/crossbow/wand (26) and thrown (25) land in the **main** one, matching -/// the world's own drawn-ranged split exactly. -/// -/// Before installing, `0x504bc0(mainInvType, offInvType, dualWield)` asks whether the two lanes may -/// coexist; if not, the incoming item **evicts** the other. `0x0f` appears on neither side of that -/// test, so a ranged weapon coexists with nothing: a bow always clears the main hand and overwrites -/// the off one, a crossbow always clears the off hand and overwrites the main one. Never both. -/// -/// The base the try-ons land on is the player's **melee** pair only. `Dress()` / `SetUnit` do not -/// install gear slot-by-slot at all β€” `0x5059a0` duplicates the unit's live `CM2Model` and -/// deep-copies its attachment tree (`0x70ea00`) β€” so the base is literally what the player is -/// showing in the world, where a ranged weapon renders only while ranged-drawn and our booth is -/// frozen melee-drawn (0465 / 1060). A *worn* bow therefore does not appear until it is tried on, -/// which is also the behaviour that shipped, and is why the ranged slot of a dressing-room look is -/// only ever filled by a substitution. +/// Deviation: the base is the player's melee pair, held drawn, because the booth shares the +/// select screen's assembly; the reference clones the live model with its attachments, drawn or +/// sheathed (`0x5059a0`, `0x70ea00`). A worn ranged weapon, shown only while ranged-drawn, appears +/// once tried on. fn held_lanes(equipment: &mut [CharEnumItem; 19], room: &DressUpRoom) { - // The player's own melee pair β€” anything the room substituted is replayed below instead. + // The player's own melee pair; substitutions replay below. let base = |slot: usize| { (room.worn[slot].is_none() && equipment[slot].display_id != 0).then_some(equipment[slot]) }; @@ -78,7 +40,7 @@ fn held_lanes(equipment: &mut [CharEnumItem; 19], room: &DressUpRoom) { let Some(item) = room.worn[slot] else { continue; }; - // Which lane the item installs into. A bow is the one ranged type that goes LEFT. + // A bow is the one ranged type that takes the off lane. let to_off = slot == 16 || (slot == 17 && item.inventory_type == 15); let (mine, other) = if to_off { (&mut off, &mut main) @@ -98,16 +60,9 @@ fn held_lanes(equipment: &mut [CharEnumItem; 19], room: &DressUpRoom) { *mine = Some(item); } - // Back into the enum array's held triple β€” where the index is **which held law `held_wants` - // asks**, not "the slot this item's inventory type names": 15/16 are the melee-drawn main/off - // attach points, and 17 is the ranged-drawn arm, which carries the bow-left / gun-right split - // itself. So a lane writes back at its own lane, and a ranged item writes back at 17 whichever - // lane it won. - // - // Routing this through [`equip_slot`] instead is what collapsed a dual-wielded pair into one - // weapon (decision 1079): an off-hand one-hander is `INVTYPE_WEAPON` 13 just like a main-hand - // one, and that table β€” which answers "where is this item WORN", the right question everywhere - // else β€” maps 13 to the main hand, so the off hand overwrote the main. + // Write back by lane, never through `equip_slot`: the index picks the held law `held_wants` + // asks (15 and 16 melee-drawn, 17 ranged-drawn with its own bow-left split), and an off-hand + // one-hander is `INVTYPE_WEAPON` 13, which `equip_slot` maps to the main hand. for slot in HELD_SLOTS { equipment[slot] = CharEnumItem::default(); } @@ -118,49 +73,31 @@ fn held_lanes(equipment: &mut [CharEnumItem; 19], room: &DressUpRoom) { } } -/// `0x504bc0(mainInvType, offInvType, dualWield)` β€” may these two held lanes coexist? -/// -/// TRUE only for a one-hand main (`WEAPON` 13 / `WEAPONMAINHAND` 21) beside a `SHIELD` (14) or -/// `HOLDABLE` (23), plus the dual-wield arm for a `WEAPON` (13) / `WEAPONOFFHAND` (22) off-hand. -/// Everything else β€” a two-hander beside anything, and **any** ranged type on either side β€” -/// evicts. -/// -/// **The dual-wield arm is taken as permitted, and that is a stated assumption.** The binary gates -/// it on a global (`0xc4d770`) whose source is unidentified, and we have no dual-wield -/// capability streamed to read. Permitted is the conservative direction here: it evicts strictly -/// less than the alternative, so it can only ever leave the pre-1076 behaviour in place for a -/// melee pair β€” while the ranged evictions this record is actually about are unconditional in the -/// binary and unaffected by the global either way. +/// `0x504bc0(main, off, dualWield)`: a one-hand main (13, 21) coexists with a shield or holdable +/// (14, 23), and with a one-hand off (13, 22) under dual-wield; anything else evicts. Dual-wield is +/// taken as allowed here; the reference reads it from `[0xc4d770]`, whose source is untraced. fn lanes_coexist(main: u8, off: u8) -> bool { matches!(main, 13 | 21) && matches!(off, 13 | 14 | 22 | 23) } -/// What the room is currently showing: the substitutions applied on top of the player's own gear, -/// and the ones still waiting on a template answer. +/// The room's substitutions over the player's own gear, resolved and pending. #[derive(Resource, Default)] pub(crate) struct DressUpRoom { - /// Open? `false` = the window is closed and the booth stays empty (the `Close` intent). + /// False after `Close`: the booth is empty. open: bool, - /// `Undress()` has stripped the body: the player's own worn pieces are not part of the look - /// until the next `Dress`. The hands are untouched β€” the widget clears components bodyslots - /// `0..0xb` (`0x504490`) and no hand lane. + /// `Undress()` has taken off the player's own pieces until the next `Dress`. The hands keep + /// theirs here; the reference's `0x504490` clears bodyslots `0..0xb` and both hand lanes. bare: bool, - /// Resolved substitutions by equipment slot β€” `(display id, inventory type)`, the same pair the - /// enum-shaped array carries. + /// Resolved substitutions by equipment slot. worn: [Option; 19], - /// The **held** substitutions in the order they landed, most recent last (slots 15/16/17 only). - /// - /// The widget installs each try-on on top of the model it is already showing, and a held item - /// can *evict* the opposite lane, so which one arrived last decides what survives β€” a fact the - /// per-slot array above cannot express on its own. See [`held_lanes`] (decision 1076). + /// Held substitutions (slots 15-17) in landing order, latest last: a held try-on can evict the + /// other lane, so the order decides what survives. held_order: Vec, - /// Item ids whose template has not answered yet, oldest first. Retried every frame; a - /// substitution only becomes visible once its display id is known. + /// Item ids still waiting on their template, retried every frame. pending: Vec, } impl DressUpRoom { - /// Apply one intent (see [`DressUpIntent`]). fn apply(&mut self, intent: DressUpIntent) { match intent { DressUpIntent::Dress => { @@ -173,8 +110,7 @@ impl DressUpRoom { DressUpIntent::Undress => { self.open = true; self.bare = true; - // Every worn piece off β€” base (`bare`, read by `player_look`) and tried-on alike β€” - // while a held substitution, like a held base item, stays in its hand. + // Every worn piece off, base (`bare`) and tried-on alike; held items stay. for slot in 0..self.worn.len() { if !HELD_SLOTS.contains(&slot) { self.worn[slot] = None; @@ -195,14 +131,12 @@ impl DressUpRoom { } } - /// Resolve whatever is still waiting on a template answer. `Items::template` asks once and - /// answers on a later frame; an id the server never answers for simply stays pending, showing - /// the player's own gear in that slot rather than a hole. + /// An id the server never answers stays pending, and its slot shows the player's own gear. fn resolve_pending(&mut self, items: &Items, commands: &NetCommands) { let mut pending = std::mem::take(&mut self.pending); pending.retain(|item| { let Some(t) = items.template(*item, 0, commands) else { - return true; // still in flight β€” keep asking + return true; // in flight }; let (display_id, inventory_type) = (t.display_info_id, t.inventory_type as u8); if let Some(slot) = equip_slot(inventory_type) { @@ -210,14 +144,13 @@ impl DressUpRoom { display_id, inventory_type, }); - // Held slots also record *when* they landed β€” the eviction law reads the order. + // Held slots record their order, which eviction reads. if HELD_SLOTS.contains(&slot) { self.held_order.retain(|&s| s != slot); self.held_order.push(slot); } } - // A non-worn item (a potion, a bag) resolves to no slot and simply previews nothing β€” - // the reference's `TryOn` is equally happy to be handed one. + // A non-worn item maps to no slot and previews nothing. false }); self.pending = pending; @@ -240,8 +173,8 @@ fn feed_dressup( items: Res, commands: Res, self_q: Query<(&ObjectStore, &NetEntity), With>, - // The guild identity cache (1257) β€” `ResMut` because it is lazy: a miss is what sends the - // `CMSG_GUILD_QUERY` whose answer paints the tabard the room may be previewing (1704). + // `ResMut` because the guild cache is lazy: a miss sends the `CMSG_GUILD_QUERY` whose answer + // paints the tabard. mut guilds: ResMut, ) { let Some(mut script) = script else { @@ -250,29 +183,22 @@ fn feed_dressup( for intent in script.take_dressup_intents() { room.apply(intent); } - // The window went away: the room empties and the booth stops baking. Read off the frame - // itself β€” the file is the reference's and has no hook of ours in its OnHide (1969); the - // reference's widget keeps its state while hidden, but its next `DressUpItem` re-issues - // `SetUnit("player")` precisely because the frame was not visible, so nothing kept is ever - // observable. - // Two windows drive the one room since 1971: the dressing room and the auction house's - // embedded dress-up pane (`AuctionDressUpFrame`); the booth empties when neither is up. + // The room empties when neither the dressing room nor the auction house's + // `AuctionDressUpFrame` is shown, read off the frames (the stock files carry no hook of ours). + // The reference's widget keeps its state while hidden, but `DressUpItem` re-issues + // `SetUnit("player")` on a hidden frame, so the kept state is never seen. if room.open && !script.frame_visible("DressUpFrame") && !script.frame_visible("AuctionDressUpFrame") { room.apply(DressUpIntent::Close); } - // The pane's rotate buttons own the yaw through the stock `Model_RotateLeft/Right/OnUpdate`, - // which write `DressUpModel:SetRotation`; the booth mirrors the pane (the paper doll's own - // law, 0208 Β§5 / 1751). + // The stock rotate buttons set the yaw with `DressUpModel:SetRotation`; the booth mirrors it. preview.yaw = script.model_pane_facing("DressUpModel"); room.resolve_pending(&items, &commands); - // The guild join is the caller's, not [`player_look`]'s: that function resolves an *outfit* out - // of the descriptor and the room's substitutions, and the crest is neither β€” it comes off a - // separate lazy cache this system holds the handle to. + // The crest comes off the guild cache this system holds, not the outfit `player_look` builds. let look = match (room.open, self_q.single().ok()) { (true, Some((store, net))) => { player_look(store, net, &room, &items, &commands).map(|l| DressUpLook { @@ -287,9 +213,7 @@ fn feed_dressup( } } -/// The player's own dressed look with the room's substitutions written in β€” `None` while the -/// descriptor cannot answer race/sex, or before the body display is known (the frame or two right -/// after entering the world). +/// The player's own look with the room's substitutions written in. fn player_look( store: &ObjectStore, net: &NetEntity, @@ -301,34 +225,25 @@ fn player_look( let mut equipment = [CharEnumItem::default(); 19]; for slot in LOOK_SLOTS { let idx = slot as usize; - // A substitution wins over what the player is actually wearing β€” that IS the preview. + // A substitution wins over the player's own piece. if let Some(worn) = room.worn[idx] { equipment[idx] = worn; continue; } - // Undressed: the player's own worn pieces are off; the hands (attachments, not bodyslots) - // keep what they hold. + // Undressed: the player's own pieces are off, the held ones stay. if room.bare && !HELD_SLOTS.contains(&idx) { continue; } - // …and past that point we are dressing the player's OWN gear, which is where the two - // equipment-display preferences apply β€” and where the reference applies them too, by - // construction rather than by a test (decision 1472). `DressUpModel::SetUnit 0x476cb0` - // `rep movsd`s all twelve per-bodyslot `ItemDisplayInfo` pointers verbatim off the live - // player (head `+0x4a8`, cloak `+0x4d0`) and deep-copies its attach tree, so a piece the - // world already suppressed is simply not in what gets cloned: **hidden in the world β‡’ - // hidden in the dressing room.** A TRY-ON gates on nothing and previews the helm or cloak - // regardless β€” which is the branch above, and the reason this test sits *below* it β€” and - // `Dress()` (the Reset button) drops the substitutions and so re-clones back to hidden. - // (This replaced an INFERRED guess that the mannequin was unconditional.) + // Hidden in the world, hidden here: `SetUnit` (`0x505d70`) clones the live player's + // bodyslot display pointers verbatim (`0x476cb0`). A try-on, handled above, gates on + // nothing. if (idx == 0 && s.player_hides_helm()) || (idx == 14 && s.player_hides_cloak()) { continue; } let Some(entry) = s.player_visible_item_entry(slot).filter(|e| *e != 0) else { continue; }; - // Template-only ask, like the inspect feed's: the visible-item field carries an item - // ENTRY, and the display id lives on its template. + // The visible-item field carries an item entry; the display id is on its template. if let Some(t) = items.template(entry, 0, commands) { equipment[idx] = CharEnumItem { display_id: t.display_info_id, @@ -336,7 +251,6 @@ fn player_look( }; } } - // …and then the widget's own two-lane law over the held triple (decision 1076). held_lanes(&mut equipment, room); Some(DressUpLook { display_id: net.display_id?, @@ -348,7 +262,7 @@ fn player_look( hair_color: s.player_hair_color().unwrap_or(0), facial_hair: s.player_facial_hair().unwrap_or(0), equipment, - // The outfit's, not the crest's: [`feed_dressup`] stamps that in (see its note there). + // `feed_dressup` stamps the crest in. emblem: None, }) } @@ -362,11 +276,9 @@ mod tests { use crate::items::test_template; use crate::net::ClientCommand; - /// A self-player descriptor: a human male wearing `entries` (equipment slot β†’ item entry). - /// The raw field indices are the wire's own (`UNIT_FIELD_BYTES_0` 36, `PLAYER_BYTES` 193, - /// `PLAYER_VISIBLE_ITEM_1_CREATOR` 258 + 12 per slot, entry at +2) β€” the same literal-index - /// idiom the descriptor fixtures elsewhere use, since the constants are crate-private to - /// benilla-protocol. + /// A human male self-player wearing `entries` (slot, item entry). Field indices: + /// `UNIT_FIELD_BYTES_0` 36, `PLAYER_BYTES` 193, `PLAYER_VISIBLE_ITEM_1_CREATOR` 258 + 12 per + /// slot, entry at +2. fn player(entries: &[(u8, u32)]) -> ObjectStore { let mut pairs = vec![ (36u16, 1 | 1 << 8), // race 1 (human), class 1, gender 0 (male) @@ -391,7 +303,6 @@ mod tests { (NetCommands(tx), rx) } - /// An item template with a known display + inventory type. fn worn(name: &str, display_info_id: u32, inventory_type: u32) -> ItemInfo { ItemInfo { display_info_id, @@ -400,8 +311,6 @@ mod tests { } } - /// A try-on replaces exactly its own slot and leaves the rest of the player's gear standing β€” - /// the whole point of the room: you see YOUR character in the item, not a mannequin. #[test] fn a_try_on_substitutes_only_its_own_slot() { let (cmds, _rx) = commands(); @@ -433,8 +342,7 @@ mod tests { assert_eq!(look.display_id, 49, "the player's own body"); } - /// Reset (`DressUpModel:Dress()`) drops every substitution β€” the player's own gear comes back β€” - /// and closing the window empties the room entirely. + /// Reset is `DressUpModel:Dress()`. #[test] fn reset_drops_substitutions_and_close_empties_the_room() { let (cmds, _rx) = commands(); @@ -468,9 +376,6 @@ mod tests { assert!(!room.open, "closing empties the room (the booth goes dark)"); } - /// `DressUpModel:Undress()` strips every worn piece β€” the player's own and a tried-on one β€” - /// and no hand lane (bodyslots `0..0xb` only, `0x504490`); a later try-on lands on the bare - /// body, and `Dress()` puts the player's own gear back. #[test] fn undress_strips_the_body_but_not_the_hands_and_a_try_on_lands_on_the_bare_body() { let (cmds, _rx) = commands(); @@ -515,9 +420,7 @@ mod tests { assert_eq!(look.equipment[0].display_id, 8000); } - /// An item whose template has not answered yet stays PENDING rather than previewing nothing: - /// linked-in-chat items are the normal case here, and the first click on one always misses the - /// cache. It lands on the frame the answer does β€” and the ask goes out exactly once. + /// A chat-linked item usually misses the cache on the first click; the ask goes out once. #[test] fn an_unknown_item_waits_for_its_template_then_lands() { let (cmds, rx) = commands(); @@ -537,7 +440,6 @@ mod tests { 5000, "until it lands, the player's own gear is what shows" ); - // Exactly one query for the unknown entry (`Items` asks once). let asks = rx .try_iter() .filter(|c| matches!(c, ClientCommand::ItemQuery { entry: 2000, .. })) @@ -557,8 +459,6 @@ mod tests { ); } - /// A non-worn item (a potion) is handed to `TryOn` by any ctrl-click on one, and previews - /// nothing β€” it maps to no equipment slot. It must not stay pending forever either. #[test] fn a_non_worn_item_previews_nothing_and_does_not_linger() { let (cmds, _rx) = commands(); @@ -571,13 +471,7 @@ mod tests { assert!(room.worn.iter().all(Option::is_none)); } - /// The director's report (2026-08-06): *"when I ctrl click a bow or cross bow I still see only - /// the swords no bow"*. Decision 1076 β€” the widget installs a tried-on ranged weapon at a hand, - /// and it coexists with neither melee lane, so the sword and shield go. - /// - /// Both ranged directions in one test because they evict *opposite* lanes in the binary and a - /// single-sided implementation would still pass one half: a bow takes the off lane (clearing - /// the main), a crossbow takes the main (clearing the off). + /// A bow takes the off lane and clears the main; a crossbow takes the main and clears the off. #[test] fn a_tried_on_ranged_weapon_takes_a_hand_and_clears_the_melee_pair() { let (cmds, _rx) = commands(); @@ -607,10 +501,7 @@ mod tests { } } - /// The other half of the same law, and the reason the fix is not "always render slot 17": a - /// **worn** bow stays invisible. `Dress()`/`SetUnit` clone the live world model rather than - /// installing gear per slot, and there a ranged weapon shows only while ranged-drawn β€” our - /// booth is frozen melee-drawn. So the hunter standing in the room holds their sword. + /// The booth is melee-drawn ([`held_lanes`]); a ranged weapon shows only while ranged-drawn. #[test] fn a_worn_ranged_weapon_is_not_shown_until_it_is_tried_on() { let (cmds, _rx) = commands(); @@ -629,9 +520,7 @@ mod tests { assert_eq!(look.equipment[15].display_id, 5500, "the sword is in hand"); } - /// Eviction runs **both ways, in try-on order** β€” the half a "hide the melee pair whenever the - /// ranged slot is filled" shortcut would get wrong. Try on a bow, then a two-hander: the - /// two-hander takes the main lane and the bow, which can coexist with nothing, goes. + /// Eviction follows try-on order: a two-hander after a bow evicts the bow. #[test] fn a_later_melee_try_on_evicts_the_ranged_one() { let (cmds, _rx) = commands(); @@ -652,10 +541,6 @@ mod tests { assert_eq!(look.equipment[17].display_id, 0, "…and the bow is gone"); } - /// The lanes that DO coexist are left alone β€” a one-hander beside a shield is the ordinary case - /// and 1076 must not start evicting there. (A two-hander beside a shield is a different matter, - /// and the reference evicts that one; it is asserted in the same breath so the coexistence set - /// is pinned rather than merely "nothing changed".) #[test] fn a_shield_coexists_with_a_one_hander_but_not_with_a_two_hander() { let (cmds, _rx) = commands(); @@ -681,25 +566,14 @@ mod tests { assert_eq!(look.equipment[16].display_id, 5600); } - /// The director's report (2026-08-07): *"there is an issue now where the preview is only - /// showing 1 wep, while I was just previewing some boots"* β€” decision 1079, a regression from - /// 1076's write-back. - /// - /// A dual-wielded pair is two items of the **same** inventory type: an off-hand one-hander is - /// `INVTYPE_WEAPON` 13 exactly like the main-hand one, and [`equip_slot`] answers "where is - /// this WORN", which for 13 is the main hand. Both lanes therefore landed on slot 15 and the - /// off hand overwrote the main. Nothing about it needed a held try-on β€” the boots are in the - /// test because that is what the director was previewing, and the pair has to survive an - /// unrelated substitution untouched. - /// - /// Every off-lane inventory type is looped rather than only 13, so what is pinned is "a lane - /// keeps its own item" and not "13 got a special case". + /// An off-hand one-hander is `INVTYPE_WEAPON` 13, which [`equip_slot`] maps to the main hand; + /// each lane keeps its own item beside an unrelated try-on, for every off-lane type. #[test] fn a_dual_wielded_pair_keeps_both_hands() { let (cmds, _rx) = commands(); let mut items = Items::default(); items.insert_template(1500, Some(worn("Main Sword", 5500, 13))); // INVTYPE_WEAPON - items.insert_template(2000, Some(worn("Shiny Boots", 7000, 8))); // FEET β€” the try-on + items.insert_template(2000, Some(worn("Shiny Boots", 7000, 8))); // FEET, the try-on for (entry, display, inv, what) in [ (1600u32, 5600u32, 13u32, "a second one-hander"), @@ -725,11 +599,8 @@ mod tests { } } - /// **The dressing room inherits the equipment-display preferences, and a try-on overrides - /// them** (decision 1472; `DressUpModel::SetUnit 0x476cb0`'s verbatim clone of the live - /// player's display pointers). Hidden helm + hidden cloak, so the player's own two pieces are - /// absent from the look β€” and then a tried-on helm shows anyway, because previewing it is the - /// whole feature. + /// The room inherits hide-helm and hide-cloak through the clone (`SetUnit` `0x505d70`, copying + /// display pointers at `0x476cb0`); a try-on overrides them. #[test] fn a_hidden_helm_stays_hidden_on_the_mannequin_until_one_is_tried_on() { let (cmds, _rx) = commands(); @@ -757,8 +628,7 @@ mod tests { "everything else they are wearing is untouched" ); - // A try-on is not their own gear, so nothing suppresses it β€” this is the branch the - // reference reaches by never testing the flag on the TryOn path at all. + // The reference's `TryOn` path never tests the flag. room.apply(DressUpIntent::TryOn(2000)); room.resolve_pending(&items, &cmds); let look = player_look(&store, &net(), &room, &items, &cmds).expect("a look"); @@ -767,7 +637,7 @@ mod tests { "the tried-on helm previews regardless of the preference" ); - // …and Reset drops the substitution, so the mannequin goes back to bare-headed. + // Reset drops the substitution: bare-headed again. room.apply(DressUpIntent::Dress); let look = player_look(&store, &net(), &room, &items, &cmds).expect("a look"); assert_eq!( diff --git a/crates/benilla-app/src/ui_duel.rs b/crates/benilla-app/src/ui_duel.rs index 0f23e212..b21e22a3 100644 --- a/crates/benilla-app/src/ui_duel.rs +++ b/crates/benilla-app/src/ui_duel.rs @@ -1,46 +1,12 @@ -//! Duels β€” the challenge, the countdown, the bounds timer, and the outcome line (decision 0633). +//! Duels: the challenge, the countdown, the bounds and the outcome line, after the 1.12 client's +//! handlers (registered at `0x4d4710`). The challenge (`0x4d49d0`) reaches both players, and the +//! challenger's own copy accepts at once (`0x4d4830`). Bounds (`0x4d4aa0`, `0x4d4ac0`) only fire +//! events: the server enforces them (`Player::CheckDuelDistance`: 75 yd out, 70 yd back in, 10 s +//! to return). //! -//! The smallest complete multiplayer verb: no window of its own, two StaticPopups, four events, -//! four Era globals, and one descriptor-driven consequence β€” a duel opponent reads *hostile*, so -//! the whole targeting/attack stack lights up without a special case (see -//! [`crate::target::ring`]'s duel leg). -//! -//! **Both ends of every mechanism here are byte-pinned** β€” vmangos for what the server sends, -//! WoW.exe's own duel TU (`E:\build\buildWoW\WoW\Source\Ui\DuelInfo.cpp`, handlers registered at -//! `0x4d4710`) for what the client does with it. The laws this module implements, each with its -//! address: -//! -//! - **The challenge is symmetric** (`0x4d49d0`). `SMSG_DUEL_REQUESTED` goes to challenger and -//! challenged alike. The client stores the arbiter guid, then compares the challenger guid -//! against its own: **equal** β‡’ show `ERR_DUEL_REQUESTED` ("You have requested a duel.") and -//! **immediately send `CMSG_DUEL_ACCEPTED`** (`call 0x4d4830` β€” a no-op server-side, which -//! drops an accept from the initiator, but it is what goes on the wire); **different** β‡’ resolve -//! the challenger's player object and fire `DUEL_REQUESTED` with its name. -//! - **The countdown is client-driven** (`0x4d4ae0` β†’ `0x4d4930`, timer named `ProcessCountdown`). -//! The wire carries milliseconds; the client divides by 1000, prints -//! `format(DUEL_COUNTDOWN, n)` as a **CHAT_MSG_SYSTEM chat line** β€” not an on-screen banner β€” -//! then decrements and re-arms a 1000 ms timer while non-zero. vmangos's 3000 therefore reads -//! "Duel starting: 3 / 2 / 1" one second apart. -//! - **Completion** (`0x4d4b20`). Only acts if an arbiter is held. `started == 0` (the duel never -//! began) additionally shows `ERR_DUEL_CANCELLED` ("Duel cancelled."). Either way the arbiter is -//! cleared, `DUEL_FINISHED` fires, and any running countdown is cancelled. -//! - **The outcome line** (`0x4d4ba0`) is composed client-side from `SMSG_DUEL_WINNER`'s flag and -//! two names against `DUEL_WINNER_KNOCKOUT` / `DUEL_WINNER_RETREAT`, and printed as -//! CHAT_MSG_SYSTEM. The server broadcasts it to everyone nearby, so bystanders read it too. -//! - **Bounds** (`0x4d4aa0` / `0x4d4ac0`) are bare event fires; the 10 s forfeit clock and its -//! text belong to the `DUEL_OUTOFBOUNDS` popup, and the *enforcement* is entirely the server's -//! (`Player::CheckDuelDistance`: 75 yd out, 70 yd back in, 10 s to return). -//! -//! One deviation, stated rather than hidden (the ignore branch β€” `0x4d4a33`'s silent -//! `CMSG_DUEL_CANCELLED` for a challenge from an ignored player β€” is implemented as of decision -//! 0668, which supplied the ignore list it needs): -//! -//! 1. **The challenger's name is resolved asynchronously.** The reference reads it off the -//! challenger's `CGPlayer` and fires nothing at all if the object is missing (`0x4d4a72`). -//! benilla resolves through the [`NameCache`], which may need a `CMSG_NAME_QUERY` round trip β€” -//! so the popup can arrive a frame or two late instead of never. In practice the challenger is -//! always streamed (the duel spell's range is 10 yd), which is exactly the case where the -//! reference has the name for free. +//! Deviation: the challenger's name comes from the [`NameCache`], so a name still being queried +//! delays the popup rather than dropping it; the reference reads it off the streamed player and +//! fires nothing without one (`0x4d4a72`). use benilla_ui::script::{DuelRequest, ScriptValue, UiScript}; use bevy::prelude::*; @@ -51,33 +17,19 @@ use crate::target::Selection; use crate::ui_action::Spells; use crate::ui_script::{UiFeed, UiInput}; -/// `SPELL_EFFECT_DUEL` β€” the `Effect[0]` value that identifies the duel spell in the player's own -/// spellbook (7266 "Duel" on 1.12 data, granted to every race/class at creation). The reference -/// does exactly this lookup rather than hardcoding the id: its spell-learned walk stores the id -/// of any learned spell whose `SpellRec+0xf4` is `0x53` into the duel-spell global `[0xb71130]` -/// (`0x4b2605`), which `StartDuel`/`StartDuelUnit` then cast (`0x4d4810`). We mirror the -/// mechanism, so a data change moves with the data. +/// The `Effect[0]` that marks the duel spell (7266 on 1.12 data): the reference's spell-learned +/// walk stores any learned spell with `SpellRec+0xf4 == 0x53` in `[0xb71130]` (`0x4b2605`), which +/// `0x4d4810` casts. const SPELL_EFFECT_DUEL: u32 = 83; -/// A line this window owes but cannot yet write: the VM that holds the string table is -/// [`feed_duel`]'s, not the net drain's (decision 2045 β€” the text is the player's `GlobalStrings`, -/// so it is resolved where the VM is, and named by key everywhere else). -/// -/// **These three keys are not message-catalog rows** β€” `DUEL_COUNTDOWN` and the two -/// `DUEL_WINNER_*` appear nowhere in the reference's FrameXML either, which is what marks them -/// engine-composed. So they cannot ride `UiErrorKeys`: an unknown key takes `Shown::keyed`'s -/// fallback and turns the line red. They go out as `Shown::unkeyed` on the system chat channel -/// they have always used (2054's `/ginfo` shape). The two `ERR_DUEL_*` lines beside them ARE rows -/// and take the other road entirely. +/// A line named by its `GlobalStrings` key, resolved in [`feed_duel`] where the VM is. The three +/// keys are engine-composed, not message-catalog rows, so they go out as `Shown::unkeyed`: through +/// `Shown::keyed` an unknown key turns the line red. enum OwedLine { /// `DUEL_COUNTDOWN` ("Duel starting: %d") with the seconds left. Countdown(u32), - /// `DUEL_WINNER_RETREAT` when the loser fled, else `DUEL_WINNER_KNOCKOUT`. - /// - /// Both templates are **positional** β€” `"%1$s has defeated %2$s in a duel"` against - /// `"%2$s has fled from %1$s in a duel"` β€” so the same (winner, loser) pair fills them in - /// opposite orders. It is the canonical case for why an ordered fill is not enough and why a - /// re-typed English sentence cannot carry the rule at all. + /// `DUEL_WINNER_RETREAT` when the loser fled, else `DUEL_WINNER_KNOCKOUT`; both are positional + /// (`%1$s` winner, `%2$s` loser), and the retreat line names the loser first. Winner { fled: bool, winner: String, @@ -85,40 +37,29 @@ enum OwedLine { }, } -/// The duel session mirror. Written by the net drain's duel arms, read by [`feed_duel`] (which -/// fires the Era events on its edges) and [`tick_countdown`]. Cleared on disconnect beside the -/// other per-login resources. +/// The duel session: written by the net handlers, read by [`feed_duel`], which fires the events on +/// its edges, and by [`tick_countdown`]. #[derive(Resource, Default)] pub(crate) struct DuelState { - /// Lines composed here but resolvable only where the VM is β€” [`feed_duel`] drains them - /// (see [`OwedLine`]). The `ui_guild`/`ui_petition` shape (decisions 2054/2045). owed: Vec, - /// The duel-flag GameObject guid identifying the pending or running duel; `0` = none. This is - /// the client's `[0xb73240]`: set by the request, echoed on accept/cancel, cleared only by - /// completion. Its non-zeroβ†’zero edge is what fires `DUEL_FINISHED`. + /// The duel flag's guid, `[0xb73240]`: set by the request, echoed on accept and cancel, and + /// cleared only by completion, which fires `DUEL_FINISHED`. `0` is no duel. pub(crate) arbiter: u64, - /// The challenger's guid while a challenge popup is owed to the UI β€” `None` once fired (or - /// when we are ourselves the challenger, who never gets a popup). + /// Set while the challenge popup is owed; the challenger never gets one. challenger: Option, - /// We are outside the duel bubble; drives the `DUEL_OUTOFBOUNDS`/`DUEL_INBOUNDS` edge pair. out_of_bounds: bool, - /// The running "Duel starting: N" countdown: ticks left to print, and the 1 s re-arm. countdown: Option, } -/// The `ProcessCountdown` timer's state (`0x4d4930`): a remaining count and a one-second re-arm. +/// The `ProcessCountdown` timer (`0x4d4930`). struct Countdown { remaining: u32, tick: Timer, } impl DuelState { - /// `SMSG_DUEL_REQUESTED`. Returns the error line to display when the challenge is our own β€” - /// the reference's `DisplayError(ERR_DUEL_REQUESTED)` β€” and leaves [`Self::challenger`] set - /// for the feed to turn into the popup when it is someone else's. - /// - /// The caller sends the challenger's auto-accept: the reference's own request handler calls - /// `AcceptDuel` inline (`0x4d4a12`), so the accept is on the wire before any UI runs. + /// `SMSG_DUEL_REQUESTED`: true when the challenge is our own, and the caller then shows + /// `ERR_DUEL_REQUESTED` and accepts inline, as the reference's handler does (`0x4d4a12`). fn apply_requested(&mut self, arbiter: u64, challenger: u64, own: Option) -> bool { self.arbiter = arbiter; let ours = Some(challenger) == own; @@ -126,18 +67,14 @@ impl DuelState { ours } - /// Take the challenger owed a response, if any β€” the partner probe's accept hook - /// (`WOW_PROBE=partner`, decision 0637). Taking it discharges the popup - /// debt exactly as the feed's `DUEL_REQUESTED` edge does, so the probe never leaves a dialog - /// owed to a UI it isn't driving. + /// The partner probe's accept hook (`WOW_PROBE=partner`): taking the challenger discharges the + /// popup as the feed's `DUEL_REQUESTED` edge does. pub(crate) fn take_challenger(&mut self) -> Option { self.challenger.take() } - /// `SMSG_DUEL_COMPLETE`. Returns whether the "Duel cancelled." line is owed (the duel never - /// started). Gated on holding an arbiter, exactly like `0x4d4b20` β€” a stray completion with - /// no duel in flight is silent. Cancels any countdown unconditionally, which the reference - /// does *outside* that gate. + /// `SMSG_DUEL_COMPLETE`: true when "Duel cancelled." is owed. Gated on an arbiter, as + /// `0x4d4b20` is, except the countdown cancel, which the reference also runs outside the gate. fn apply_complete(&mut self, started: bool) -> bool { self.countdown = None; if self.arbiter == 0 { @@ -149,9 +86,8 @@ impl DuelState { !started } - /// `SMSG_DUEL_COUNTDOWN`. A zero count arms nothing: the reference would print "Duel starting: - /// 0" and then re-arm forever on the `dec`'s wraparound, an unreachable path on 1.12 servers - /// (vmangos always sends 3000) that is not worth reproducing. + /// `SMSG_DUEL_COUNTDOWN`. Deviation: zero arms nothing, where the reference prints 0 and + /// re-arms forever on the `dec`'s wraparound, a bug vmangos never reaches (it sends 3000). fn apply_countdown(&mut self, seconds: u32) { self.countdown = (seconds > 0).then(|| Countdown { remaining: seconds, @@ -160,8 +96,7 @@ impl DuelState { } } -/// Resolve one owed line against the VM's own string table. `None` = no string, no line β€” the -/// reference's data-suppression face. +/// Resolve an owed line against the VM's string table; a missing string is no line. fn owed_text(line: &OwedLine, get: &dyn Fn(&str) -> Option) -> Option { use benilla_ui::strings::{fill, Arg}; let text = match line { @@ -176,26 +111,20 @@ fn owed_text(line: &OwedLine, get: &dyn Fn(&str) -> Option) -> Option, mut duel: ResMut) { *duel = DuelState::default(); } - /// `SMSG_DUEL_REQUESTED` β€” store the arbiter, and either own the challenge (error line + - /// immediate accept) or hand the popup to the feed. - /// - /// `ignored` short-circuits both: a challenge from a player on the ignore list is answered - /// with `CMSG_DUEL_CANCELLED` and nothing is shown (`0x4d4a33`). This is the branch decision - /// 0633 recorded as absent for want of an ignore list; decision 0668 supplies one. + /// `SMSG_DUEL_REQUESTED`. A challenge from an ignored player is declined with + /// `CMSG_DUEL_CANCELLED` and shows nothing (`0x4d4a33`). pub(crate) fn requested( duel: &mut DuelState, errors: &mut crate::ui_action::UiErrorKeys, @@ -302,8 +225,6 @@ pub(crate) mod net { } } - /// `SMSG_DUEL_COMPLETE` β€” clear the session, cancel the countdown, and raise - /// `ERR_DUEL_CANCELLED` when the duel never started. pub(crate) fn complete( duel: &mut DuelState, errors: &mut crate::ui_action::UiErrorKeys, @@ -316,7 +237,8 @@ pub(crate) mod net { } } - /// `SMSG_DUEL_WINNER` β€” the outcome line, owed to the feed. + /// `SMSG_DUEL_WINNER`: the outcome line, composed client-side (`0x4d4ba0`); the server + /// broadcasts it, so bystanders see it too. pub(crate) fn winner(duel: &mut DuelState, fled: bool, winner: &str, loser: &str) { duel.owed.push(OwedLine::Winner { fled, @@ -325,19 +247,16 @@ pub(crate) mod net { }); } - /// `SMSG_DUEL_COUNTDOWN` β€” arm the client-side tick. pub(crate) fn countdown(duel: &mut DuelState, seconds: u32) { duel.apply_countdown(seconds); } - /// `SMSG_DUEL_OUTOFBOUNDS` / `SMSG_DUEL_INBOUNDS` β€” the feed turns the edge into its event. pub(crate) fn bounds(duel: &mut DuelState, outside: bool) { duel.out_of_bounds = outside; } } -/// Fire the four duel events on their state edges β€” the [`crate::ui_party::feed`] pattern: the -/// drain mutates [`DuelState`], the feed diffs it against what it last announced. +/// Fire the four duel events on [`DuelState`]'s edges. fn feed_duel( script: Option>, mut duel: ResMut, @@ -351,9 +270,7 @@ fn feed_duel( }; let fed = fed.get(&script); - // The owed countdown/outcome lines, resolved against the VM's own string table. They land - // ahead of the event edges below for the same reason the social feed's do: the line describes - // the transition the event announces. + // Lines before events: a line describes the transition its event announces. let owed = std::mem::take(&mut duel.owed); let lines: Vec = { let get = |key: &str| script.lua().globals().get::(key).ok(); @@ -367,7 +284,7 @@ fn feed_duel( }; crate::ui_action::show_messages(&mut script, &mut sink, "ui_duel", lines); - // DUEL_REQUESTED(challengerName) β€” held until the name resolves (module doc, deviation 2). + // `DUEL_REQUESTED(name)`, held until the name resolves (the module's deviation). if let Some(guid) = duel.challenger { if let Some(name) = names.resolve(guid, &commands).map(str::to_string) { script.fire_event("DUEL_REQUESTED", vec![ScriptValue::Str(name)]); @@ -375,14 +292,14 @@ fn feed_duel( } } - // DUEL_FINISHED on the arbiter's non-zeroβ†’zero edge (`0x4d4b51`). + // `DUEL_FINISHED` when the arbiter clears (`0x4d4b51`). let held = duel.arbiter != 0; if fed.held && !held { script.fire_event("DUEL_FINISHED", Vec::new()); } fed.held = held; - // The bounds pair (`0x4d4aa0` / `0x4d4ac0`). + // The bounds pair (`0x4d4aa0`, `0x4d4ac0`). if fed.out_of_bounds != duel.out_of_bounds { fed.out_of_bounds = duel.out_of_bounds; let event = if duel.out_of_bounds { @@ -401,9 +318,8 @@ struct FedDuel { out_of_bounds: bool, } -/// `ProcessCountdown` (`0x4d4930`): print "Duel starting: N" now, then once a second while the -/// count is non-zero. The first line lands the frame the packet arrives β€” the reference calls the -/// tick body directly before arming the timer β€” so the timer here starts already elapsed. +/// `ProcessCountdown` (`0x4d4930`, armed by `0x4d4ae0`): a `DUEL_COUNTDOWN` system chat line on +/// arrival, since the reference runs the tick body before arming its timer, then one a second. fn tick_countdown(time: Res