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