Comments in benilla-app, the UI windows: one line of fact each, no history, no record numbers (#284)
Part of #41: the second of five `benilla-app` pieces. This one is the UI windows: chat, the NPC windows (gossip, merchant, trainer, taxi, bank, mail, auction, quest giver), the unit, party, raid and aura feeds, the action bar, bags, tooltips, macros, the spellbook, the world map and the smaller panels. Their comments are rewritten to the rule in `docs/METHOD.md`, which is what the code does and the reference fact behind it, in a line. 26,648 comment lines become 9,221. Fixes #98. - **The mechanical strip.** The citations that were only a private record number go first, and each edit is shown to be a pure removal. - **Wrong facts corrected.** The rewrite corrects the comments that stated wrong facts. Among them: - Several client addresses carried a `0x400000` slip (`UPDATE_MACROS` is `0x852460`, not `0x452460`; the macro store's globals likewise). - `QUEST_WATCH_UPDATE` carries the quest-log row (`0x4df880` scans the log table at `0xbb71c0`), not a watch-list position. - The stock `ChatFrame_DisplayTimePlayed` now ships, so benilla's own `/played` lines duplicate it (#194); trainer refusals are logged, not shown on the error line; the player's cast result is `SMSG_CAST_RESULT`. - Stock FrameXML and `Blizzard_*` line citations are checked against the stock 1.12 files, and vmangos line citations against the vmangos revision nearest the one the local server runs. - Dozens more turned up on the way: docs sitting on the wrong item, removed functions, stale counts, broken intra-doc links. - One test comment in `ui_script/questlog_tests.rs`, which landed with #184, carried the same watch-index slip and gets the same fix. - **Deviation labels.** "Deviation:" now marks only a choice with a reason. An unbuilt behaviour, or one whose reference side is untraced, says so plainly. - **Verification.** Every file strips to identical code, and `cargo fmt --check` is clean. Six second readers compared every file old against new, restored the facts the rewrite had dropped, and checked each new citation against the code, the stock files, vmangos and the reference. Behaviour differences the rewrite surfaced are filed as their own issues (#188, #204, #222 and #259 among them). The comments here state what the code does. String literals are untouched; they are #92. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
a2629b623d
commit
2f323ff0b3
@@ -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_<name>")` — [`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<u32>,
|
||||
/// `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<u32, ShapeshiftForm>>,
|
||||
}
|
||||
|
||||
/// 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_<name>")` — 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<String>,
|
||||
) -> Option<CastFailLine> {
|
||||
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_<name>) 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()
|
||||
|
||||
@@ -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<crate::bindings::BindingsState>,
|
||||
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<crate::ui_action::GoOpenerCasts>,
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
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<UiErrorKeys>,
|
||||
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<PlayerActions>,
|
||||
targeting: cast_target::CastTargeting,
|
||||
mut acquire: MessageWriter<crate::target::AttackNearestRequest>,
|
||||
// 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<UiErrorKeys>,
|
||||
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<NonSendMut<UiScript>>,
|
||||
mut actions: ResMut<PlayerActions>,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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<NonSendMut<UiScript>>,
|
||||
self_q: Query<(&crate::net::Guid, &crate::net::ObjectStore), With<crate::net::SelfPlayer>>,
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<CastFail>);
|
||||
|
||||
/// 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<u32>,
|
||||
/// **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<u8>);
|
||||
|
||||
/// 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 <id>; 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<String>) -> 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<UiError>);
|
||||
|
||||
/// [`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<String>) -> Option<String> {
|
||||
// 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<benilla_ui::strings::Arg<'_>> = e
|
||||
.args
|
||||
.iter()
|
||||
@@ -335,13 +218,8 @@ pub(crate) fn ui_error_text(e: &UiError, get: &dyn Fn(&str) -> Option<String>) -
|
||||
(!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<Shown> {
|
||||
let text = script.lua().globals().get::<String>(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<Shown> {
|
||||
let template = script.lua().globals().get::<String>(key).ok()?;
|
||||
let args: Vec<benilla_ui::strings::Arg<'_>> = 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<u64>,
|
||||
@@ -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<u64>,
|
||||
) -> 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 <item>" / "Missing reagent: <item>") 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();
|
||||
|
||||
@@ -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<u32, ActionSlot>,
|
||||
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<Res<…>>` 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<Res<'w, crate::ui_tradeskill::SpellFocus>>,
|
||||
/// `AreaTable.dbc` — the map arc's, for `0x5d`.
|
||||
/// `AreaTable.dbc`, for `0x5d`.
|
||||
areas: Option<Res<'w, crate::area::AreaTableRes>>,
|
||||
/// `SpellMechanic.dbc` — for `0x8d`, the one arm whose word is produced locally.
|
||||
/// `SpellMechanic.dbc`, for `0x8d`.
|
||||
mechanics: Option<Res<'w, super::SpellMechanics>>,
|
||||
}
|
||||
|
||||
@@ -110,14 +71,10 @@ pub(super) fn feed_actions(
|
||||
mut cast_errors: ResMut<CastErrors>,
|
||||
mut mount_errors: ResMut<MountErrors>,
|
||||
mut pet_tame_failures: ResMut<PetTameFailures>,
|
||||
// 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<UiErrorKeys>, ResMut<UiErrorTexts>),
|
||||
spells: Option<Res<Spells>>,
|
||||
self_q: Query<&ObjectStore, With<SelfPlayer>>,
|
||||
// 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<Items>,
|
||||
icons: Option<Res<ItemDisplays>>,
|
||||
@@ -125,8 +82,6 @@ pub(super) fn feed_actions(
|
||||
name_tables: FailNameTables,
|
||||
commands: Res<NetCommands>,
|
||||
mut memory: Local<crate::ui_script::VmMemo<FeedMemory>>,
|
||||
// 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<crate::ui_action::CastFail> = 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<crate::ui_chat::combat::PendingCombat> = Vec::new();
|
||||
let fail_args = name_tables.args(spells.as_deref());
|
||||
let texts: Vec<cast_fail::CastFailLine> = 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::<String>(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<cast_fail::CastFailLine> {
|
||||
// 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_<name>` 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<Shown> = 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<Shown> = 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<Shown> = 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 <errorId>; 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<Shown> = 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<u32, ActionSlot> = 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<u32> = 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,
|
||||
|
||||
@@ -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<ClientCommand>) {
|
||||
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<ClientCommand
|
||||
kind: ACTION_KIND_ITEM,
|
||||
},
|
||||
);
|
||||
// SMSG_ACTION_BUTTONS' own arm sets this; the feed's first pass is what clears it.
|
||||
// As `SMSG_ACTION_BUTTONS` sets it; the feed's first pass clears it.
|
||||
actions.dirty = true;
|
||||
|
||||
let displays = HashMap::from([(
|
||||
@@ -64,7 +55,7 @@ fn app_with_food_on_the_bar() -> (App, crossbeam_channel::Receiver<ClientCommand
|
||||
.init_resource::<PetTameFailures>()
|
||||
.init_resource::<UiErrorKeys>()
|
||||
.init_resource::<UiErrorTexts>()
|
||||
// The cast-failure combat-log line (1703) rides the same drain.
|
||||
// The cast-failure combat-log line rides the same drain.
|
||||
.init_resource::<crate::ui_chat::ChatLog>()
|
||||
.init_resource::<crate::sound::MessageSounds>()
|
||||
.insert_resource(ItemDisplays::icons_for_tests(
|
||||
@@ -76,8 +67,8 @@ fn app_with_food_on_the_bar() -> (App, crossbeam_channel::Receiver<ClientCommand
|
||||
(app, rx)
|
||||
}
|
||||
|
||||
/// What the VM believes the food button's icon is — the exact read `ActionBar.xml` does before it
|
||||
/// falls back to `BENILLA_FALLBACK_ICON` (the question mark).
|
||||
/// The food button's `GetActionTexture`, as `ActionButton_Update` reads it
|
||||
/// (`ActionButton.lua:157`); a nil hides the icon.
|
||||
fn fed_texture(app: &mut App) -> Option<String> {
|
||||
app.world_mut()
|
||||
.non_send_resource::<UiScript>()
|
||||
@@ -85,7 +76,8 @@ fn fed_texture(app: &mut App) -> Option<String> {
|
||||
.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<i64> {
|
||||
app.world_mut()
|
||||
.non_send_resource::<UiScript>()
|
||||
@@ -93,10 +85,7 @@ fn fed_consumable(app: &mut App) -> Option<i64> {
|
||||
.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::<PetTameFailures>()
|
||||
.init_resource::<UiErrorKeys>()
|
||||
.init_resource::<UiErrorTexts>()
|
||||
// The cast-failure combat-log line (1703) rides the same drain.
|
||||
// The cast-failure combat-log line rides the same drain.
|
||||
.init_resource::<crate::ui_chat::ChatLog>()
|
||||
.init_resource::<crate::sound::MessageSounds>()
|
||||
.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::<UiScript>()
|
||||
.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::<UiScript>()
|
||||
@@ -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::<UiScript>();
|
||||
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::<UiErrorTexts>();
|
||||
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::<crate::sound::MessageSounds>()
|
||||
.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_<reason>` 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_<reason>` 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::<UiScript>();
|
||||
// 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::<PetTameFailures>().0.push(9);
|
||||
app.world_mut().resource_mut::<PetTameFailures>().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::<PetTameFailures>().0.push(1);
|
||||
app.update();
|
||||
assert_eq!(
|
||||
|
||||
@@ -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<u8, ActionButton>,
|
||||
/// 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<u32>,
|
||||
/// 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<GoOpener>);
|
||||
|
||||
/// 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<u32, benilla_formats::ShapeshiftForm>,
|
||||
/// `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<u32>,
|
||||
/// `[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<u32>,
|
||||
/// `[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<u32>,
|
||||
}
|
||||
|
||||
/// `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<PlayerActions>,
|
||||
spells: Option<Res<Spells>>,
|
||||
@@ -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<Res<benilla_asset
|
||||
debug!("ui_action: {} spell-mechanic name(s)", catalog.len());
|
||||
commands.insert_resource(SpellMechanics { catalog });
|
||||
}
|
||||
// Absent data is the strip fallback, not a failure: the refusal still appears, it just
|
||||
// shows its bare stem instead of naming what is holding you.
|
||||
Err(e) => 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),
|
||||
|
||||
@@ -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<SessionEvent>, mut errors: ResMut<MountErrors>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// `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 {
|
||||
|
||||
@@ -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<PlayerActions>,
|
||||
skill_lines: Option<Res<SkillLines>>,
|
||||
@@ -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<std::path::PathBuf> {
|
||||
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 };
|
||||
|
||||
@@ -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<u64>,
|
||||
engaged: bool,
|
||||
auto_repeat: Option<u32>,
|
||||
/// Last `benilla_assets::trace` "cd tick" stamp — the once-per-second gate (trace runs only).
|
||||
last_cd_trace: Option<Instant>,
|
||||
}
|
||||
|
||||
/// 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 <item>` 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<NonSendMut<UiScript>>,
|
||||
@@ -107,11 +76,7 @@ pub(super) fn feed_action_state(
|
||||
mut cooldowns: ResMut<Cooldowns>,
|
||||
clock: Res<crate::ui_script::UiClock>,
|
||||
auto_repeat: Res<AutoRepeatActive>,
|
||||
// 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<crate::spell::PendingCast>,
|
||||
Res<crate::spell::QueuedMeleeSpell>,
|
||||
@@ -122,9 +87,7 @@ pub(super) fn feed_action_state(
|
||||
),
|
||||
self_q: Query<(&ObjectStore, &Transform, Has<Engaged>, Option<&Casting>), With<SelfPlayer>>,
|
||||
selection: Res<Selection>,
|
||||
// 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<SelfPlayer>>,
|
||||
factions: Option<Res<crate::target::Factions>>,
|
||||
@@ -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<u32> = 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::<crate::net::Reputations>()
|
||||
.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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<u32, benilla_formats::ShapeshiftForm>,
|
||||
@@ -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<u32>, 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);
|
||||
}
|
||||
|
||||
@@ -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<String>,
|
||||
pub(crate) texture: Option<String>,
|
||||
pub(crate) quality: Option<u32>,
|
||||
/// 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<String>,
|
||||
pub(crate) link: Option<String>,
|
||||
}
|
||||
|
||||
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<AuctionListEntry>,
|
||||
/// `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<u64>,
|
||||
/// 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<f64>,
|
||||
/// 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<AuctionMessage>,
|
||||
/// 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<u32>,
|
||||
}
|
||||
|
||||
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<AuctionListEntry>, 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<u32> {
|
||||
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<u64> {
|
||||
self.auctioneer
|
||||
@@ -413,18 +269,12 @@ impl Plugin for UiAuctionPlugin {
|
||||
app.init_resource::<AuctionOpen>().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::<AuctionOpen>.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<u64>,
|
||||
@@ -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<u64>,
|
||||
@@ -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<Res<'w, crate::ui_items::ItemClasses>>,
|
||||
Option<Res<'w, crate::ui_items::ItemSubClasses>>,
|
||||
@@ -603,8 +438,7 @@ type AuctionCatalogs<'w> = (
|
||||
Option<Res<'w, crate::items::Enchants>>,
|
||||
);
|
||||
|
||||
/// 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<NonSendMut<UiScript>>,
|
||||
mut auction: ResMut<AuctionOpen>,
|
||||
@@ -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<Res<ItemDisplays>>,
|
||||
names: Res<NameCache>,
|
||||
self_q: Query<(&ObjectStore, &crate::net::Guid), With<SelfPlayer>>,
|
||||
// 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<Res<crate::items::RandomProperties>>,
|
||||
enchants: Option<Res<crate::items::Enchants>>,
|
||||
) {
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<SessionEvent>, mut auction: ResMut<Auction
|
||||
}
|
||||
}
|
||||
|
||||
/// An open auction house dies with the socket (decision 1511): every auction command
|
||||
/// re-validates the auctioneer server-side, so a session that survived a reconnect would be a
|
||||
/// window whose every button silently failed. A listener on the session end (a second handler
|
||||
/// on `Disconnected`, after the bridge's own teardown, `net::session::on_disconnected`).
|
||||
/// The window dies with the socket: the server re-validates the auctioneer on every command, so
|
||||
/// a window kept across a reconnect would fail silently.
|
||||
fn on_session_end(In(_): In<SessionEvent>, mut auction: ResMut<AuctionOpen>) {
|
||||
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<AuctionListEntry>,
|
||||
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<AuctionListEntry>,
|
||||
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();
|
||||
|
||||
@@ -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")];
|
||||
|
||||
+148
-487
File diff suppressed because it is too large
Load Diff
@@ -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<u64>,
|
||||
}
|
||||
|
||||
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<u64> {
|
||||
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<u32>);
|
||||
|
||||
@@ -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::<BankOpen>.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<NonSendMut<UiScript>>,
|
||||
open: Res<BankOpen>,
|
||||
@@ -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<NonSendMut<UiScript>>,
|
||||
mut open: ResMut<BankOpen>,
|
||||
@@ -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),
|
||||
|
||||
@@ -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<SessionEvent>, mut errors: ResMut<BankErrors>)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<SessionEvent>, mut bank: ResMut<BankOpen>) {
|
||||
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();
|
||||
|
||||
@@ -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<BattlefieldList>,
|
||||
/// `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<Vec3>,
|
||||
/// `SMSG_GROUP_JOINED_BATTLEGROUND` results awaiting their line.
|
||||
verdicts: Vec<u32>,
|
||||
/// 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<u64> {
|
||||
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<NonSendMut<UiScript>>,
|
||||
mut state: ResMut<Battlefield>,
|
||||
@@ -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::<Option<String>>(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<NonSendMut<UiScript>>,
|
||||
state: Res<Battlefield>,
|
||||
@@ -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<EnteredWorldMessage>,
|
||||
mut state: ResMut<Battlefield>,
|
||||
@@ -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<SessionEvent>, mut battlefield: ResMut<Battlefield>) {
|
||||
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!();
|
||||
|
||||
@@ -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<PositionsPacket>,
|
||||
/// When the last request went out — the throttle's stamp.
|
||||
last_request: Option<Instant>,
|
||||
/// 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<u64>,
|
||||
}
|
||||
|
||||
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<NetEntity>>,
|
||||
names: Res<NameCache>,
|
||||
commands: Res<NetCommands>,
|
||||
// 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<crate::ui_script::VmMemo<Option<(bool, u32)>>>,
|
||||
) {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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<PvpLogData>,
|
||||
@@ -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<BattlefieldStatColumn> {
|
||||
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<SessionEvent>, mut board: ResMut<BattlefieldScoreboard>) {
|
||||
*board = BattlefieldScoreboard::default();
|
||||
}
|
||||
@@ -210,8 +190,7 @@ impl Plugin for BattlefieldScorePlugin {
|
||||
app.init_resource::<BattlefieldScoreboard>().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<String> = 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();
|
||||
|
||||
@@ -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<Option<PendingEquip>>,
|
||||
}
|
||||
|
||||
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<PendingEquip> {
|
||||
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<PendingUse>);
|
||||
|
||||
/// 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<Items>` 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<Items>` 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<Res<'w, Enchants>>,
|
||||
}
|
||||
|
||||
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![]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<u64>,
|
||||
/// 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<u64> {
|
||||
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<NonSendMut<UiScript>>,
|
||||
mut binder: ResMut<BinderState>,
|
||||
@@ -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<u32>,
|
||||
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<NonSendMut<UiScript>>,
|
||||
binder: Res<BinderState>,
|
||||
@@ -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::<BinderState>().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::<BinderState>.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();
|
||||
|
||||
@@ -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<CastBarEdge>);
|
||||
|
||||
/// `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<NonSendMut<UiScript>>,
|
||||
mut feed: ResMut<CastBarFeed>,
|
||||
@@ -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::<CastBarFeed>().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!();
|
||||
|
||||
+161
-467
File diff suppressed because it is too large
Load Diff
@@ -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::<recruitment::GuildRecruitmentCascade>()
|
||||
.init_resource::<language::ChatLanguages>()
|
||||
.init_resource::<combat::CombatLogRanges>()
|
||||
// `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::<combat::LogPeriodicSpells>()
|
||||
// `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<Res<_>>` `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<NonSendMut<benilla_ui::script::UiScript>>,
|
||||
mut log: ResMut<ChatLog>,
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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<String>,
|
||||
/// The mirror's value afterwards. `None` = unchanged (every DND row, and `/afk M` while AFK).
|
||||
pub(crate) mirror: Option<u32>,
|
||||
/// 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 <msg>` 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 <msg>`, 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 <msg>` 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 <msg>` 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<u32>);
|
||||
|
||||
/// **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 `<AFK>` 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<crate::net::SelfPlayer>>,
|
||||
mut mirror: ResMut<AfkMirror>,
|
||||
mut memo: ResMut<AfkMirrorMemo>,
|
||||
) {
|
||||
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<crate::bindings::BindingsState>,
|
||||
cvars: Res<crate::cvars::Cvars>,
|
||||
@@ -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<String> {
|
||||
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;
|
||||
|
||||
@@ -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<Res<WorldAssets>>) {
|
||||
let Some(assets) = assets else { return };
|
||||
let loaded = {
|
||||
@@ -124,9 +52,7 @@ pub(super) fn load_server_messages(mut commands: Commands, assets: Option<Res<Wo
|
||||
benilla_formats::load_server_messages_catalog(&mut chain)
|
||||
};
|
||||
match loaded {
|
||||
// Logged like its two neighbours, and for their reason: a silently-empty load is the exact
|
||||
// failure `load_chat_channels`'s comment describes, and a count in the run log is the only
|
||||
// thing that distinguishes it from a healthy one.
|
||||
// The row count tells an empty load from a healthy one.
|
||||
Ok(cat) => {
|
||||
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<Res<Wo
|
||||
}
|
||||
}
|
||||
|
||||
/// The joined channels a defense broadcast about `subject_zone` reaches, in slot order.
|
||||
///
|
||||
/// The reference's per-entry test, transcribed (module doc): live + joined, a `ChatChannels.dbc`
|
||||
/// row carrying `DEFENSE`, and — only for a `ZONE_DEP` row — the zone match. `player_zone` is
|
||||
/// `None` before the world under the player has streamed, which fails the zone-dependent arm and
|
||||
/// leaves the global one (WorldDefense) intact: the same split the reference gets from a
|
||||
/// not-yet-written `[0xb6e5d0]`.
|
||||
///
|
||||
/// benilla's joined list holds names, not records, so `+0x9c`'s join state has no representative
|
||||
/// here — an entry in [`ChannelState::joined`] is one the server confirmed with YOU_JOINED, which
|
||||
/// is the state the reference's `== 0` test selects. The suspended state (`3`) is unmodeled on
|
||||
/// both sides of that list (see [`super::feed::deliver`]'s note).
|
||||
/// The joined channels a defense broadcast about `subject_zone` reaches, in slot order. With
|
||||
/// `player_zone` unknown only non-`ZONE_DEP` rows pass, as with the reference's unset
|
||||
/// `[0xb6e5d0]`. Every slot counts whatever its state; the reference takes only state 0
|
||||
/// (`[e+0x9c]`, `0x49dd5a`), skipping pending, renamed and suspended slots.
|
||||
pub(crate) fn defense_targets(
|
||||
channels: &ChannelState,
|
||||
subject_zone: u32,
|
||||
@@ -156,7 +74,7 @@ pub(crate) fn defense_targets(
|
||||
.iter_names()
|
||||
.filter(|name| {
|
||||
let Some(row) = channels.channels.row_for_name(name) else {
|
||||
return false; // a custom channel — no DBC row, no defense flag
|
||||
return false; // a custom channel: no DBC row, no defense flag
|
||||
};
|
||||
if row.flags & chan_flags::DEFENSE == 0 {
|
||||
return false;
|
||||
@@ -167,29 +85,20 @@ pub(crate) fn defense_targets(
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The `ZONE_UNDER_ATTACK` line — FrameXML's own template with the area's name in its `%s`.
|
||||
///
|
||||
/// `replacen(.., 1)` because the reference's `snprintf` at `0x49dd26` takes **one** vararg: a
|
||||
/// localization carrying a second `%s` would fill it with stack garbage there, and filling only the
|
||||
/// first is the closest honest thing (the [`ServerMessagesCatalog::compose`] argument exactly).
|
||||
///
|
||||
/// The template is `"|cffffff00%s is under attack!|r"` in the shipped enUS strings, but it is never
|
||||
/// quoted here: the engine reads it out of the VM's globals (`FrameScript_GetText`, `0x49dd14`),
|
||||
/// which is what keeps a localized install localized.
|
||||
/// The VM's `ZONE_UNDER_ATTACK` string (`0x49dd14`) with the area's name in its first `%s`: the
|
||||
/// reference's `snprintf` (`0x49dd26`) passes one argument.
|
||||
fn zone_under_attack_line(template: &str, area_name: &str) -> String {
|
||||
template.replacen("%s", area_name, 1)
|
||||
}
|
||||
|
||||
/// The parent zone a broadcast's area id resolves to — `[rec+0x8]` when nonzero, else the id
|
||||
/// itself (`0x49dd2b`/`0x49de80`). `None` when the table has no such row, which the two callers
|
||||
/// answer differently (module doc).
|
||||
/// The area's parent zone, or the area itself when it has none (`0x49dd2b`, `0x49de80`).
|
||||
fn parent_zone(areas: &AreaTableRes, id: u32) -> Option<u32> {
|
||||
let row = areas.0.get(id)?;
|
||||
Some(if row.zone_id == 0 { id } else { row.zone_id })
|
||||
}
|
||||
|
||||
/// Resolve every parked broadcast into chat events. Runs before [`super::feed::feed_chat`] so a
|
||||
/// broadcast lands the same frame it decodes, like every other chat source.
|
||||
/// 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<ChatLog>,
|
||||
script: Option<NonSendMut<benilla_ui::script::UiScript>>,
|
||||
@@ -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] : <text>` — 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!();
|
||||
|
||||
@@ -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<u32>,
|
||||
/// **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<u32>,
|
||||
/// **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<NonSendMut<benilla_ui::script::UiScript>>,
|
||||
channels: Res<ChannelState>,
|
||||
@@ -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<NonSendMut<benilla_ui::script::UiScript>>,
|
||||
mut channels: ResMut<ChannelState>,
|
||||
@@ -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<NonSendMut<benilla_ui::script::UiScript>>,
|
||||
mut channels: ResMut<ChannelState>,
|
||||
@@ -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<ChannelState>,
|
||||
assets: Option<Res<benilla_assets::WorldAssets>>,
|
||||
@@ -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<NetCommands>,
|
||||
mut channels: ResMut<ChannelState>,
|
||||
areas: Option<Res<AreaTableRes>>,
|
||||
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<Res<crate::player::Player>>,
|
||||
Option<Res<crate::cinematic::Cinematic>>,
|
||||
@@ -539,31 +379,19 @@ pub(super) fn auto_join_zone_channels(
|
||||
mut catalog_fed: Local<crate::ui_script::VmMemo<Option<(String, bool)>>>,
|
||||
) {
|
||||
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::<AreaTableRes>()
|
||||
@@ -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();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<char> {
|
||||
let mut out = Vec::new();
|
||||
let mut chars = template.chars().peekable();
|
||||
@@ -23,8 +21,8 @@ fn signature(template: &str) -> Vec<char> {
|
||||
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<char> {
|
||||
family
|
||||
.slots
|
||||
@@ -38,11 +36,8 @@ fn declared_signature(family: Family, variant: Variant) -> Vec<char> {
|
||||
.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<n>_NAME` (decision 2127). Lowercase is what MikScrollingBattleText's
|
||||
// school compare misses.
|
||||
// Capitalized: the reference takes this word from `Resistances.dbc`, not from
|
||||
// `SPELL_SCHOOL<n>_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);
|
||||
|
||||
@@ -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<Res<'w, Factions>>,
|
||||
pub reputations: Res<'w, Reputations>,
|
||||
pub spells: Option<Res<'w, crate::ui_action::Spells>>,
|
||||
/// 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<String> {
|
||||
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<ChatLog>,
|
||||
) {
|
||||
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<FieldChanged> = 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;
|
||||
|
||||
@@ -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_<ChatTypeInfo index><n>`,
|
||||
//! the actions walk `SLASH_<SlashCmdList index><n>`, and the emotes walk `EMOTE<i>_CMD<j>` →
|
||||
//! `EMOTE<i>_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_<INDEX><n>`** — 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<i>_CMD<j>` / `EMOTE<i>_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_<INDEX><n>` for the actions, `EMOTE<i>_CMD<j>` →
|
||||
//! `EMOTE<i>_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_<INDEX><n>` 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_<INDEX><n>` prefix.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum SlashIndex {
|
||||
Reply,
|
||||
@@ -74,42 +42,28 @@ pub(crate) enum SlashIndex {
|
||||
Target,
|
||||
Assist,
|
||||
Follow,
|
||||
/// `/cast <name>` — 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 <name>`: 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 <cmd>` — 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 <line>`: `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_<KEY><n>` prefix its aliases live under.
|
||||
/// The reference's index name, the `SLASH_<KEY><n>` 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<String, Command>,
|
||||
/// 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<String>,
|
||||
text_id: impl Fn(&str) -> Option<u32>,
|
||||
) -> Self {
|
||||
let mut by_alias: HashMap<String, Command> = HashMap::new();
|
||||
|
||||
// 1 · the action commands: SLASH_<INDEX><n>, walked from 1 until the first gap (the
|
||||
// reference's own `while cmdString` walk).
|
||||
// 1 · the actions: `SLASH_<INDEX><n>` 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<i>_CMD<j> → EMOTE<i>_TOKEN → EmotesText id. The outer walk stops at
|
||||
// the first index with no CMD1, exactly like `ChatEdit_ParseText`'s.
|
||||
// 2 · the emotes, `EMOTE<i>_CMD<j>` → `EMOTE<i>_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<String, Command>, 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<String, Command>, 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<NonSend<benilla_ui::script::UiScript>>,
|
||||
@@ -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"
|
||||
|
||||
@@ -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<SendType> {
|
||||
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<String>` 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<Option<ChannelSlot>>,
|
||||
/// `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<u32>,
|
||||
/// **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<String>,
|
||||
}
|
||||
|
||||
/// 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<String> {
|
||||
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<u32> {
|
||||
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<u32> {
|
||||
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<SlotState> {
|
||||
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<Option<String>> {
|
||||
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<u32> {
|
||||
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<u32> {
|
||||
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())
|
||||
|
||||
@@ -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_<token>_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_<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_<token>_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_<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<ChatEventKind>,
|
||||
@@ -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<super::edit::SlotState>,
|
||||
}
|
||||
|
||||
@@ -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<u8> {
|
||||
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<ScriptValue> {
|
||||
// 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 `ChatTypeInfo` key>`.
|
||||
///
|
||||
/// 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_<X>_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_<X>_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_<token>_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<super::edit::SlotState>,
|
||||
@@ -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<ChatEventKind> {
|
||||
use benilla_protocol::messages as m;
|
||||
use ChatEventKind as K;
|
||||
@@ -783,8 +652,8 @@ pub(crate) fn kind_of_wire(chat_type: u8) -> Option<ChatEventKind> {
|
||||
})
|
||||
}
|
||||
|
||||
/// The `<AFK>`/`<DND>`/`<GM>` flag token for a wire chat-tag byte (`Player::GetChatTag`) — the
|
||||
/// event's `flag` field (the ref's arg6), consumed as `CHAT_FLAG_<flag>`.
|
||||
/// The arg6 flag token for a wire chat-tag byte (vmangos `Player::GetChatTag`), read as
|
||||
/// `CHAT_FLAG_<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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Res<WorldAssets>>) {
|
||||
let Some(assets) = assets else { return };
|
||||
let loaded = {
|
||||
@@ -53,10 +45,9 @@ pub(super) fn load_emote_texts(mut commands: Commands, assets: Option<Res<WorldA
|
||||
|
||||
/// One queued item awaiting its turn through [`feed_chat`].
|
||||
enum Pending {
|
||||
/// A decoded `SMSG_MESSAGECHAT`: needs kind mapping + (player kinds) an ask-once name resolve.
|
||||
/// A decoded `SMSG_MESSAGECHAT`; the player types wait on the sender's name.
|
||||
Wire { msg: ChatMessage, tries: u16 },
|
||||
/// A channel notice whose tail carries guids to resolve (`a` = affected/actor, `b` = the
|
||||
/// second actor of kicked/banned/unbanned; `0` = absent).
|
||||
/// A channel notice with guids to name (`b_guid` is a kick's or ban's actor, 0 when absent).
|
||||
Notice {
|
||||
notice: u8,
|
||||
channel: String,
|
||||
@@ -64,10 +55,8 @@ enum Pending {
|
||||
b_guid: u64,
|
||||
tries: u16,
|
||||
},
|
||||
/// An inbound addon line awaiting its SENDER's name — `CHAT_MSG_ADDON` (event 227, fired at
|
||||
/// `0x49a95f`). Parked here rather than fired at decode because the reference fires it
|
||||
/// *downstream* of the name resolve, from the `CMSG_NAME_QUERY` callback (`0x49ccc0`), so
|
||||
/// `sender` is a NAME and never a guid.
|
||||
/// An inbound addon line waiting on its sender's name: the reference fires `CHAT_MSG_ADDON`
|
||||
/// (event 227, `0x49a95f`) from the name-query callback (`0x49ccc0`), so `sender` is a name.
|
||||
Addon {
|
||||
prefix: String,
|
||||
message: String,
|
||||
@@ -83,70 +72,41 @@ enum Pending {
|
||||
guid: u64,
|
||||
tries: u16,
|
||||
},
|
||||
/// A kill-XP award awaiting the victim's name ("%s dies, you gain %d experience." —
|
||||
/// COMBATLOG_XPGAIN_FIRSTPERSON; decision 0304). `bonus` = rested (total − base).
|
||||
/// An XP award waiting on the victim's name (0: unnamed); `bonus` is total minus base.
|
||||
XpGain {
|
||||
victim: u64,
|
||||
total: u32,
|
||||
bonus: u32,
|
||||
tries: u16,
|
||||
},
|
||||
/// An honor award awaiting the victim's name (`SMSG_PVP_CREDIT`; decision 1512) — the XP
|
||||
/// node's twin, parked for the identical reason: the sentence's first `%s` is a NAME and the
|
||||
/// packet carries a guid.
|
||||
///
|
||||
/// `rank` is the victim's **internal** rank as the packet sent it (0 = none), turned into a
|
||||
/// title by the drain — which is the right place for it, because the title's GlobalString key
|
||||
/// needs the victim's own **side and sex**, and those come out of the very `NameCache` record
|
||||
/// the name resolve below is already waiting on (`SMSG_NAME_QUERY_RESPONSE` carries
|
||||
/// race/class/gender beside the name). Resolving it at push time would have needed the
|
||||
/// victim's descriptor, which is gone the moment their body despawns.
|
||||
/// An honor award waiting on the victim's name; `rank` (0 none) becomes a title at the drain,
|
||||
/// from the victim's side in the same name-query record.
|
||||
HonorGain {
|
||||
victim: u64,
|
||||
honor: i32,
|
||||
rank: u8,
|
||||
tries: u16,
|
||||
},
|
||||
/// A text emote (`SMSG_TEXT_EMOTE`) awaiting the PERFORMER's name — decision 1274.
|
||||
///
|
||||
/// Parked here for the reference's own reason, not merely for convenience: `0x49dbe0` composes
|
||||
/// the sentence immediately when the `NameCache` already holds the performer, and otherwise
|
||||
/// queues a `PENDINGTEXTEMOTE` node (`0x49cc00`) that the name-query callback (`0x49d0d0`)
|
||||
/// drains into the same composer. This queue *is* that node, and the bounded `tries` is its
|
||||
/// give-up edge.
|
||||
///
|
||||
/// `target` is the raw wire name (empty = untargeted), never re-resolved — the reference
|
||||
/// passes the server's string straight into the format.
|
||||
/// A text emote waiting on its performer's name: the reference's `PENDINGTEXTEMOTE` node
|
||||
/// (`0x49cc00`), queued when `0x49dbe0` finds no cached name and drained by `0x49d0d0`.
|
||||
/// `target` is the wire's name, empty when untargeted, used as sent.
|
||||
TextEmote {
|
||||
performer: u64,
|
||||
text_id: u32,
|
||||
target: String,
|
||||
tries: u16,
|
||||
},
|
||||
/// An area discovery (`SMSG_EXPLORATION_EXPERIENCE`): the toast + conditional chat line pair
|
||||
/// (the drain fires them — the toast needs the script, which only [`feed_chat`] holds).
|
||||
/// An area discovery (`SMSG_EXPLORATION_EXPERIENCE`), fired at the drain, which holds the VM.
|
||||
Discovery { area: String, xp: u32 },
|
||||
/// One combat-log line, classified and family-picked at the packet, waiting only on the two
|
||||
/// endpoint names (B297). The reference's deferred-message queue `DAT_00c4e208`, whose records
|
||||
/// carry a type tag and two guids for exactly this reason and are replayed by the name-ready
|
||||
/// callback `0x6294b0`.
|
||||
/// One combat-log line, classified at the packet, waiting on its two endpoint names: the
|
||||
/// reference's deferred queue (`0xc4e208`), replayed by the name-ready callback `0x6294b0`.
|
||||
Combat(Box<super::combat::PendingCombat>),
|
||||
/// 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<Pending>,
|
||||
broadcasts: Vec<super::broadcast::Broadcast>,
|
||||
/// 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_<X>_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<String> {
|
||||
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<String> + '_ {
|
||||
|key: &str| script.lua().globals().get::<String>(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<String>,
|
||||
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<String>,
|
||||
@@ -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<String>,
|
||||
b: Option<String>,
|
||||
) -> Option<ChatEvent> {
|
||||
// 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<NonSendMut<benilla_ui::script::UiScript>>,
|
||||
mut log: ResMut<ChatLog>,
|
||||
@@ -775,24 +605,18 @@ pub(super) fn feed_chat(
|
||||
names: Res<NameCache>,
|
||||
mut speaker: SpeakerEffects,
|
||||
commands: Res<NetCommands>,
|
||||
// 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<Res<EmoteTexts>>,
|
||||
self_guid: Res<SelfGuid>,
|
||||
// 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<GuidIndex>,
|
||||
stores: Query<&ObjectStore>,
|
||||
states: Res<crate::world_state::WorldStates>,
|
||||
// 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<super::language::ChatLanguages>,
|
||||
// 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<crate::items::Items>,
|
||||
// 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<NonSendMut<benilla_ui::script::UiScript>>,
|
||||
commands: Res<NetCommands>,
|
||||
|
||||
@@ -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 `<AFK>/<DND>/<GM>` 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_<X>_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_<TYPE>_GET` and the notice
|
||||
//! token into `CHAT_<TOKEN>_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::<String>(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<String> {
|
||||
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_<TYPE>_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_<TYPE>_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>) -> String {
|
||||
let ty = event_name(kind)
|
||||
.strip_prefix("CHAT_MSG_")
|
||||
@@ -213,15 +145,9 @@ fn get_pattern(kind: ChatEventKind, get: &dyn Fn(&str) -> Option<String>) -> 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_<TOKEN>_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,
|
||||
|
||||
@@ -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<Time<Real>>,
|
||||
mut last: ResMut<LastInput>,
|
||||
@@ -137,12 +57,8 @@ pub(crate) fn stamp_input(
|
||||
mut wheel: MessageReader<MouseWheel>,
|
||||
motion: Res<AccumulatedMouseMotion>,
|
||||
) {
|
||||
// `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<Time<Real>>,
|
||||
last: Res<LastInput>,
|
||||
@@ -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::<ChatLog>().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::<AfkMirror>().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::<Vec<_>>();
|
||||
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.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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["<KEY>"](<arg>)` (1959).
|
||||
/// A social slash line as the reference runs it: `SlashCmdList["<KEY>"](<arg>)`.
|
||||
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 <name> [password]` (aliases /channel /chan — SLASH_JOIN).
|
||||
/// `/join <name> [password]`.
|
||||
Join { name: String, password: String },
|
||||
/// `/leave <name>` (aliases /chatleave /chatexit).
|
||||
/// `/leave <name>`.
|
||||
Leave { name: String },
|
||||
/// `/chatlist <name>` (aliases /chatwho /chatinfo) — the member roster ask.
|
||||
/// `/chatlist <name>`: 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<String> },
|
||||
/// `/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<String> },
|
||||
/// `/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<String> },
|
||||
/// `/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<String> },
|
||||
/// `/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<String> },
|
||||
/// `/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<String> },
|
||||
/// `/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<String> },
|
||||
/// `/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<String> },
|
||||
/// `/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<String> {
|
||||
(!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 <name>` = 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 <line>` — 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 <spell_id> [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 <spell_id> [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();
|
||||
|
||||
@@ -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<LanguageWords>,
|
||||
/// language id → this character's skill in it. Absent = 0 = never understood.
|
||||
skill: HashMap<u32, u32>,
|
||||
/// `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<ChatLanguages>,
|
||||
assets: Option<Res<benilla_assets::WorldAssets>>,
|
||||
@@ -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<ChatLanguages>,
|
||||
actions: Option<Res<PlayerActions>>,
|
||||
@@ -148,7 +102,7 @@ pub(super) fn feed_language_skills(
|
||||
self_q: Query<Ref<ObjectStore>, With<SelfPlayer>>,
|
||||
) {
|
||||
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<String> = 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<super::frames::ChatWindows>,
|
||||
langs: Option<Res<crate::ui_unit::DefaultLanguagesRes>>,
|
||||
@@ -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);
|
||||
|
||||
@@ -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<std::fs::File>,
|
||||
@@ -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),
|
||||
|
||||
@@ -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<SessionEvent>, mut errors: ResMut<UiErrorKeys>)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<SessionEvent>, mut chat_log: ResMut<ChatLog>) {
|
||||
let b = match ev {
|
||||
SessionEvent::ZoneUnderAttack { area_id } => Broadcast::ZoneUnderAttack { area_id },
|
||||
@@ -87,9 +81,8 @@ fn on_toast(In(ev): In<SessionEvent>, mut errors: ResMut<UiErrorTexts>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<SessionEvent>,
|
||||
mut answer: ResMut<PlayedTimeAnswer>,
|
||||
@@ -124,23 +117,13 @@ fn on_random_roll(In(ev): In<SessionEvent>, mut chat_log: ResMut<ChatLog>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<SessionEvent>, mut chat_log: ResMut<ChatLog>) {
|
||||
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<ServerSaidMessage>,
|
||||
) {
|
||||
// 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::<ChatLog>().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();
|
||||
|
||||
@@ -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<u32>,
|
||||
}
|
||||
|
||||
@@ -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<Cascade> {
|
||||
if !auto {
|
||||
return None;
|
||||
@@ -123,8 +81,7 @@ pub(crate) fn cascade(auto: bool, guild_id: u32, in_capital: bool) -> Option<Cas
|
||||
})
|
||||
}
|
||||
|
||||
/// The cascade as a system — chained **after** the walk, which is where the reference runs it
|
||||
/// (the tail of `ZoneChannelRefresh`) and where the zone it reads has just been resolved.
|
||||
/// The cascade, chained after the walk: the reference runs it at the walk's tail, on a fresh zone.
|
||||
pub(super) fn guild_recruitment_cascade(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
mut state: ResMut<GuildRecruitmentCascade>,
|
||||
@@ -135,14 +92,11 @@ pub(super) fn guild_recruitment_cascade(
|
||||
commands: Res<NetCommands>,
|
||||
) {
|
||||
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();
|
||||
|
||||
@@ -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/<ACC>/<REALM>/<CHAR>/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/<ACC>/<REALM>/<CHAR>/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/<realm>-<character>.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<PathBuf>,
|
||||
/// 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<Option<(String, String)>>,
|
||||
/// 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<bool>,
|
||||
last_change: Option<std::time::Instant>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
/// 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<u32>,
|
||||
/// `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::<u32>().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::<crate::char_select::Roster>()
|
||||
@@ -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::<super::edit::ChannelState>() 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::<NetCommands>().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::<ChatWindowFile>() 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::<super::edit::ChannelState>() {
|
||||
// `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<NonSendMut<UiScript>>, mut file: ResMut<ChatWindowFile>) {
|
||||
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<NonSendMut<UiScript>>, mut file: ResMut<ChatW
|
||||
}
|
||||
|
||||
fn write(script: &UiScript, channels: &super::edit::ChannelState, path: &std::path::Path) {
|
||||
// Never compose a file from an unseated mask: `ZONECHANNELS 0` in every block is exactly the
|
||||
// damage 2120 repaired, and a `None` here means the character's file was never read.
|
||||
// Never write from an unseated mask: `None` means the file was never read, and writing would
|
||||
// zero every block's `ZONECHANNELS`.
|
||||
let Some(zone_mask) = channels.zone_mask else {
|
||||
warn!(
|
||||
"chat cache: refusing to write {} — the zone mask has not been seated",
|
||||
@@ -692,22 +552,14 @@ fn write(script: &UiScript, channels: &super::edit::ChannelState, path: &std::pa
|
||||
}
|
||||
}
|
||||
|
||||
/// Is a write owed **now**? Two reasons, and they gate differently (decision 2144):
|
||||
/// Whether a write is owed now.
|
||||
///
|
||||
/// - **A flush** (`exiting`) — the session end or the window close — writes **unconditionally**,
|
||||
/// provided this VM restored the character's file (`restored`): the reference rewrites the
|
||||
/// whole file at chat teardown (`0x499a80` from `0x490c55`) with no dirty flag in the way
|
||||
/// (`[0xb6e5c4]` has three writers and no reader). The
|
||||
/// `ZONECHANNELS` word and the guild-recruitment latch are host state that no Lua write ever
|
||||
/// dirties — gating the flush on `dirty` is how a `/leave General` was persisted only when the
|
||||
/// player also happened to drag a window, and 2120's "durable state" was durable on paper.
|
||||
/// - **The debounce** writes only what Lua moved (`dirty`) once the quiet time has passed — our
|
||||
/// own improvement over the reference's teardown-only write, so a crash loses at most a second
|
||||
/// of drags; it never fires for host-only changes, which the flush carries.
|
||||
///
|
||||
/// `restored` is the guard that keeps a fresh VM — whose look table is back at the stock row —
|
||||
/// from composing the player's file out of nothing: it is exactly "the file was read into this
|
||||
/// VM", the `identity` memo.
|
||||
/// - A flush (`exiting`: session end or window close) writes whenever this VM restored the file
|
||||
/// (`restored`, never a fresh VM's stock table), as the reference rewrites it whole at chat
|
||||
/// teardown (`0x499a80` from `0x490c55`) with no dirty check (`[0xb6e5c4]` is never read); host
|
||||
/// state such as the zone mask never marks the file dirty.
|
||||
/// - Deviation: the debounce also writes Lua's changes (`dirty`) after the quiet time, where the
|
||||
/// reference writes only at teardown, so a crash loses at most a second of changes.
|
||||
fn owes_write(exiting: bool, restored: bool, dirty: bool, quiet: bool) -> 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::<ChatWindowFile>() {
|
||||
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<ChatWindowFile>| {
|
||||
let (Some(script), Some(channels)) = (
|
||||
world.get_non_send_resource::<UiScript>(),
|
||||
@@ -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::<ChatWindowFile>()
|
||||
// **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::<UiScript>()
|
||||
.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::<crate::ui_script::ReloadUiPending>().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::<UiScript>()
|
||||
.eval::<String>("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<super::super::edit::ChannelState>| {
|
||||
let mut script = world.non_send_resource_mut::<UiScript>();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<u32>,
|
||||
@@ -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<SessionEvent>, mut open: ResMut<CraftOpen>) {
|
||||
*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<String> {
|
||||
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<NonSendMut<UiScript>>,
|
||||
open: Res<CraftOpen>,
|
||||
@@ -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<CraftRecipe> = 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<NonSendMut<UiScript>>,
|
||||
mut open: ResMut<CraftOpen>,
|
||||
@@ -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();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<CharEnumItem>; 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<usize>,
|
||||
/// 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<u32>,
|
||||
}
|
||||
|
||||
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<Items>,
|
||||
commands: Res<NetCommands>,
|
||||
self_q: Query<(&ObjectStore, &NetEntity), With<SelfPlayer>>,
|
||||
// The guild identity cache (1257) — `ResMut` because it is lazy: a miss is what sends the
|
||||
// `CMSG_GUILD_QUERY` whose answer paints the tabard the room may be previewing (1704).
|
||||
// `ResMut` because the guild cache is lazy: a miss sends the `CMSG_GUILD_QUERY` whose answer
|
||||
// paints the tabard.
|
||||
mut guilds: ResMut<crate::ui_guild::GuildState>,
|
||||
) {
|
||||
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!(
|
||||
|
||||
@@ -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<OwedLine>,
|
||||
/// 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<u64>,
|
||||
/// 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<Countdown>,
|
||||
}
|
||||
|
||||
/// 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<u64>) -> 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<u64> {
|
||||
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<String>) -> Option<String> {
|
||||
use benilla_ui::strings::{fill, Arg};
|
||||
let text = match line {
|
||||
@@ -176,26 +111,20 @@ fn owed_text(line: &OwedLine, get: &dyn Fn(&str) -> Option<String>) -> Option<St
|
||||
} else {
|
||||
"DUEL_WINNER_KNOCKOUT"
|
||||
};
|
||||
// Winner first, loser second — always. Which one the sentence *names* first is the
|
||||
// template's business, through its `%1$s`/`%2$s`.
|
||||
// Always winner then loser; the template's `%1$s`/`%2$s` picks the order.
|
||||
fill(&get(key)?, &[Arg::S(winner), Arg::S(loser)])
|
||||
}
|
||||
};
|
||||
(!text.is_empty()).then_some(text)
|
||||
}
|
||||
|
||||
/// The duel's packet handlers (decision 0633; in the net handler table since 2312), beside the
|
||||
/// state they drive: the session mirror + the two `DisplayError` lines the handlers emit inline;
|
||||
/// the Era events fire off the mirror's edges in [`feed_duel`], and the countdown ticks in its own
|
||||
/// system. `own` is our own guid.
|
||||
/// The duel's packet handlers; the events fire off the state's edges in [`feed_duel`].
|
||||
pub(crate) mod net {
|
||||
use super::*;
|
||||
use benilla_protocol::{SessionEvent, SessionEventKind};
|
||||
|
||||
use crate::net::NetHandlerApp;
|
||||
|
||||
/// Register the duel's handlers — called from [`UiDuelPlugin`]. One per kind, plus the
|
||||
/// session-end listener.
|
||||
pub(super) fn register(app: &mut App) {
|
||||
use SessionEventKind as K;
|
||||
app.net_handler(K::DuelRequested, on_requested)
|
||||
@@ -267,20 +196,14 @@ pub(crate) mod net {
|
||||
}
|
||||
}
|
||||
|
||||
/// A pending challenge, a running duel, and its countdown all die with the socket (decision
|
||||
/// 0633) — the server drops the duel too (`Player::DuelComplete(DUEL_FLED)` on logout), and a
|
||||
/// stale arbiter guid would make the next AcceptDuel echo a dead object. A listener on the
|
||||
/// session end (a second handler on the kind, after the bridge's own teardown).
|
||||
/// The duel dies with the socket, as on the server (`Player::DuelComplete(DUEL_FLED)` on
|
||||
/// logout); a stale arbiter would make the next `AcceptDuel` echo a dead object.
|
||||
fn on_session_end(In(_): In<SessionEvent>, mut duel: ResMut<DuelState>) {
|
||||
*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<NonSendMut<UiScript>>,
|
||||
mut duel: ResMut<DuelState>,
|
||||
@@ -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<crate::ui_action::Shown> = {
|
||||
let get = |key: &str| script.lua().globals().get::<String>(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<Time>, mut duel: ResMut<DuelState>, mut started: Local<bool>) {
|
||||
let Some(countdown) = duel.countdown.as_mut() else {
|
||||
*started = false;
|
||||
@@ -428,7 +344,7 @@ fn tick_countdown(time: Res<Time>, mut duel: ResMut<DuelState>, mut started: Loc
|
||||
}
|
||||
}
|
||||
|
||||
/// Drain the Era API's duel intents into their sends.
|
||||
/// Drain the duel verbs' intents into their sends.
|
||||
fn drain_duel(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
duel: Res<DuelState>,
|
||||
@@ -465,8 +381,8 @@ fn drain_duel(
|
||||
}
|
||||
}
|
||||
DuelRequest::StartByUnit(token) => {
|
||||
// The reference resolves the token to a CGObject and requires typemask 0x10 —
|
||||
// a player (`0x4d4c66`). Ours are the tokens the unit popup actually passes.
|
||||
// `StartDuelUnit` (`0x4d4c90`) resolves the token through `0x515970`, with no
|
||||
// player gate; these are the tokens the unit popup passes.
|
||||
let guid = match token.as_str() {
|
||||
"target" => selection.guid,
|
||||
"player" => self_guid,
|
||||
@@ -475,10 +391,9 @@ fn drain_duel(
|
||||
challenge(guid, self_guid, &index, &spells, &actions, &commands);
|
||||
}
|
||||
DuelRequest::StartByName(name) => {
|
||||
// `/duel <name>`: the reference looks the name up in the **object manager**
|
||||
// (`0x515970`) — only a streamed player can be found this way, which is also the
|
||||
// spell's own requirement. Same population here: the streamed guid index, named
|
||||
// through the cache.
|
||||
// `/duel <name>`: `StartDuel` (`0x4d4c40`) matches the name through `0x493aa0`
|
||||
// with typemask `0x10` (`0x4d4c66`), players only; here, the streamed guid index
|
||||
// named through the cache.
|
||||
let guid = streamed_player_named(&name, &index, &names);
|
||||
challenge(guid, self_guid, &index, &spells, &actions, &commands);
|
||||
}
|
||||
@@ -486,8 +401,7 @@ fn drain_duel(
|
||||
}
|
||||
}
|
||||
|
||||
/// Find a streamed player by name — the reference's object-manager name lookup. Case-insensitive:
|
||||
/// the caller is a `/duel` argument the player typed.
|
||||
/// A streamed player by name, case-insensitive, for a typed `/duel` argument.
|
||||
pub(crate) fn streamed_player_named(
|
||||
name: &str,
|
||||
index: &GuidIndex,
|
||||
@@ -501,9 +415,9 @@ pub(crate) fn streamed_player_named(
|
||||
.find(|g| names.peek(*g).is_some_and(|n| n.eq_ignore_ascii_case(name)))
|
||||
}
|
||||
|
||||
/// Cast the duel spell at `guid` — the reference's `0x4d4810`, which is a plain spell cast at a
|
||||
/// unit target, not a duel opcode. Silently drops a missing/self/non-player/unstreamed target the
|
||||
/// way the reference's guid and typemask gates do.
|
||||
/// Cast the duel spell at `guid`, a plain spell cast as in the reference's `0x4d4810`, not a duel
|
||||
/// opcode. Drops a missing, self, non-player or unstreamed target; the reference's bindings drop
|
||||
/// only a zero guid, plus a non-player in `StartDuel`'s name match.
|
||||
fn challenge(
|
||||
guid: Option<u64>,
|
||||
own: Option<u64>,
|
||||
@@ -527,8 +441,7 @@ fn challenge(
|
||||
});
|
||||
}
|
||||
|
||||
/// The learned spell whose `Effect[0]` is [`SPELL_EFFECT_DUEL`] — the reference's `[0xb71130]`,
|
||||
/// filled by its spell-learned walk rather than hardcoded (see the constant's note).
|
||||
/// The learned spell whose `Effect[0]` is [`SPELL_EFFECT_DUEL`], the reference's `[0xb71130]`.
|
||||
fn duel_spell(
|
||||
spells: &Option<Res<Spells>>,
|
||||
actions: &crate::ui_action::PlayerActions,
|
||||
@@ -542,7 +455,7 @@ fn duel_spell(
|
||||
})
|
||||
}
|
||||
|
||||
/// Duels: the wire session, the countdown tick, the Era events, and the outbound intents.
|
||||
/// Duels: the wire session, the countdown tick, the events and the outbound intents.
|
||||
pub(crate) struct UiDuelPlugin;
|
||||
|
||||
impl Plugin for UiDuelPlugin {
|
||||
@@ -551,9 +464,8 @@ impl Plugin for UiDuelPlugin {
|
||||
app.init_resource::<DuelState>().add_systems(
|
||||
Update,
|
||||
(
|
||||
// `tick_countdown` only *names* its line now — `feed_duel` resolves it against
|
||||
// the VM — so the tick has to run first or every "Duel starting: N" lands a
|
||||
// frame late.
|
||||
// The tick names its line and `feed_duel` resolves it, so the tick runs first or
|
||||
// each "Duel starting: N" lands a frame late.
|
||||
tick_countdown.before(feed_duel),
|
||||
feed_duel.in_set(UiFeed),
|
||||
drain_duel.after(UiInput),
|
||||
@@ -566,9 +478,7 @@ impl Plugin for UiDuelPlugin {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The challenge is symmetric: our own request takes the error-line branch and leaves no
|
||||
/// popup owed; someone else's leaves the challenger for the feed. Either way the arbiter is
|
||||
/// stored — the challenger holds one too, which is why `DUEL_FINISHED` reaches both.
|
||||
/// Both sides store the arbiter, so `DUEL_FINISHED` reaches both.
|
||||
#[test]
|
||||
fn our_own_challenge_takes_the_error_branch() {
|
||||
let mut duel = DuelState::default();
|
||||
@@ -581,8 +491,7 @@ mod tests {
|
||||
assert_eq!(duel.challenger, Some(9));
|
||||
}
|
||||
|
||||
/// Completion is gated on holding an arbiter (`0x4d4b20`), and only an unstarted duel earns
|
||||
/// "Duel cancelled.".
|
||||
/// Only an unstarted duel earns "Duel cancelled." (`0x4d4b20`).
|
||||
#[test]
|
||||
fn completion_is_gated_on_holding_an_arbiter() {
|
||||
let mut duel = DuelState::default();
|
||||
@@ -601,7 +510,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A zero countdown arms nothing (the reference's unreachable wraparound path).
|
||||
#[test]
|
||||
fn zero_countdown_arms_nothing() {
|
||||
let mut duel = DuelState::default();
|
||||
@@ -611,14 +519,8 @@ mod tests {
|
||||
assert_eq!(duel.countdown.as_ref().map(|c| c.remaining), Some(3));
|
||||
}
|
||||
|
||||
/// The duel-spell lookup against the **real 5875 Spell.dbc** — the fact the whole challenge
|
||||
/// path rests on, and the one a unit test with a synthetic catalog could not catch. Asserts
|
||||
/// what the reference's own spell-learned walk asserts by construction: scanning a spellbook
|
||||
/// for `Effect[0] == SPELL_EFFECT_DUEL` finds **exactly one** spell, and it is 7266 "Duel"
|
||||
/// (the id vmangos grants every race/class in `playercreateinfo_spell` and re-grants at every
|
||||
/// login through `Player::LoadFromDB` → `LearnDefaultSpells`). A second match would mean the
|
||||
/// reference's single global `[0xb71130]` is ambiguous — worth knowing if it ever happens.
|
||||
/// Skips without client data.
|
||||
/// 7266 "Duel", which vmangos grants every race and class (`playercreateinfo_spell`), is the
|
||||
/// only `SPELL_EFFECT_DUEL` spell in the shipped `Spell.dbc`, so `[0xb71130]` is unambiguous.
|
||||
#[test]
|
||||
fn the_duel_spell_resolves_to_7266_on_real_data() {
|
||||
let data = benilla_formats::wow_data_or_skip!();
|
||||
@@ -634,14 +536,7 @@ mod tests {
|
||||
assert_eq!(catalog.get(7266).unwrap().name, "Duel");
|
||||
}
|
||||
|
||||
/// **The two outcome templates take (winner, loser) in that order, and one of them prints
|
||||
/// them back to front.** That is the whole reason they are positional, and the reason this is
|
||||
/// resolved from the player's own `GlobalStrings.lua` rather than typed here: the swap lives
|
||||
/// in the *string*, so a locale that orders the clause differently gets it right for free and
|
||||
/// a re-typed English sentence never could (decision 2045).
|
||||
///
|
||||
/// Resolved against the real shipped file, because a stub would be asserting our own idea of
|
||||
/// the templates back at us.
|
||||
/// Against the shipped `GlobalStrings.lua`: the retreat template names the loser first.
|
||||
#[test]
|
||||
fn the_outcome_line_places_both_names_positionally() {
|
||||
let data = benilla_formats::wow_data_or_skip!();
|
||||
@@ -677,15 +572,8 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// **The two `ERR_DUEL_*` lines are catalog rows, and the catalog disagrees with where this
|
||||
/// window used to put them.** `ERR_DUEL_CANCELLED` is `MsgKind::Info` — the yellow
|
||||
/// `UI_INFO_MESSAGE`, not a chat line — and `ERR_DUEL_REQUESTED` carries the `LEVELUP` cue,
|
||||
/// which a straight `chat_log.push_event` had no row in hand to play. Both were invisible
|
||||
/// while the sentences were composed here; both are read now.
|
||||
///
|
||||
/// The three engine-composed templates are asserted to be the opposite case — **not** rows —
|
||||
/// because that is what forbids them the `Shown::keyed` road, where an unknown key takes the
|
||||
/// red-line fallback.
|
||||
/// `ERR_DUEL_CANCELLED` is the yellow info line and `ERR_DUEL_REQUESTED` plays `LEVELUP`; the
|
||||
/// three templates are not rows, which `Shown::keyed` would turn red.
|
||||
#[test]
|
||||
fn the_error_pair_are_catalog_rows_and_the_templates_are_not() {
|
||||
use benilla_ui::messages::{by_key, MsgKind};
|
||||
|
||||
@@ -1,52 +1,10 @@
|
||||
//! The app-side **follow bridge** — the seam between the auto-follow movement mode
|
||||
//! ([`crate::player::follow`], decision 0890) and the UI that has to say so.
|
||||
//! The follow bridge: drains `FollowUnit`/`FollowByName` into [`crate::player::FollowRequest`]
|
||||
//! and fires `AUTOFOLLOW_BEGIN`/`AUTOFOLLOW_END` on [`crate::player::FollowState`]'s transitions,
|
||||
//! the reference's whole follow interface to the UI (there is no `IsFollowing()`).
|
||||
//!
|
||||
//! Two directions, one small module:
|
||||
//!
|
||||
//! - **In.** Drain the Era API's follow intents ([`benilla_ui::script::FollowRequest`], queued by
|
||||
//! `FollowUnit`/`FollowByName`) into the app's own [`crate::player::FollowRequest`] funnel, which
|
||||
//! [`crate::target::by_name`] resolves. The unit popup's **Follow** row is the shipped caller;
|
||||
//! the slash command reaches the same funnel directly, because benilla parses slash lines in Rust
|
||||
//! (decision 0881).
|
||||
//! - **Out.** Fire `AUTOFOLLOW_BEGIN` / `AUTOFOLLOW_END` on the follow state's transitions. That
|
||||
//! pair is the *entire* interface the reference gives the UI for follow — there is no
|
||||
//! `IsFollowing()` and no unit token for the followee, so a status display is only possible at
|
||||
//! all because the events carry the name.
|
||||
//!
|
||||
//! ## What the events drive
|
||||
//!
|
||||
//! `AutoFollowStatus` (ZoneText.xml) — a `setAllPoints` frame over `UIParent` carrying one
|
||||
//! centre-anchored 20px `GameFontNormal` string. On `AUTOFOLLOW_BEGIN` it latches `arg1`, sets alpha
|
||||
//! to 1 and shows `AUTOFOLLOWSTART` ("Following %s."); on `AUTOFOLLOW_END` it swaps the text to
|
||||
//! `AUTOFOLLOWSTOP` ("You stop following %s.") — reusing the *latched* name, which is why END needs
|
||||
//! no argument of its own — and starts a linear 4-second alpha fade to hidden. It is not the
|
||||
//! FadingFrame kit; `AutoFollowStatus_OnUpdate` rolls its own fade, with no fade-in and no hold.
|
||||
//!
|
||||
//! So the "You stop following X" line is the reason [`crate::player::FollowState`] latches the name
|
||||
//! at start rather than re-reading it: by the time the follow ends, the followee may well be gone.
|
||||
//!
|
||||
//! ## Transitions
|
||||
//!
|
||||
//! Start → `BEGIN`. Stop → `END`. A follow that *switches* subject fires **`END` then `BEGIN`**:
|
||||
//! the reference's arm routine calls the canceller first (`0x61113e`, inside `0x611130`), so
|
||||
//! re-issuing `/follow` while already following really does emit both, in that order. On screen the
|
||||
//! pair is indistinguishable from `BEGIN` alone — `AutoFollowStatus_OnEvent`'s BEGIN arm is a full
|
||||
//! reset, clearing `fadeTime` and restoring alpha — but an addon listening for `AUTOFOLLOW_END`
|
||||
//! would notice, so we emit what the reference emits.
|
||||
//!
|
||||
//! ## The three paths where the reference does NOT fire `END`
|
||||
//!
|
||||
//! Two writers set the follow mode cell `0xc4d888` back to `0xc` **directly** (`0x5fb646`,
|
||||
//! `0x60396f`), bypassing the canceller entirely: no event fires, and `0xc4d980` is left holding a
|
||||
//! stale guid. A third miss is the canceller's own early-out (`0x60fb70`), which skips the fire
|
||||
//! *and* the state clear. The two direct writers' triggers are INFERRED to be zoning / world-leave
|
||||
//! / logout.
|
||||
//!
|
||||
//! We do not reproduce this. It is a reference **bug** whose visible symptom is the status line
|
||||
//! surviving a zone change with nobody to hide it — and our shape cannot reproduce it anyway, since
|
||||
//! the line follows [`FollowState`] rather than a separately-cleared cell. Recorded because "the
|
||||
//! status text is stuck after a zone" is a real report someone may one day file *against the real
|
||||
//! client*, and the answer is "yes, it does that".
|
||||
//! Deviation: every stop fires `END`, because the reference's three silent stops are a bug: two
|
||||
//! writers reset the follow mode cell `0xc4d888` directly (`0x5fb646`, `0x60396f`), leaving a
|
||||
//! stale guid at `0xc4d980`, and the canceller's early-out (`0x60fb70`) fires nothing.
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
@@ -55,25 +13,18 @@ use benilla_ui::script::{FollowRequest as ScriptFollowRequest, ScriptValue, UiSc
|
||||
use crate::player::{FollowRequest, FollowState};
|
||||
use crate::ui_script::{UiFeed, UiInput};
|
||||
|
||||
/// What the VM has last been told, so the two events fire exactly on transitions and never once a
|
||||
/// frame. Mirrors [`FollowState::guid`]; `None` means the VM believes no follow is running.
|
||||
///
|
||||
/// "What THIS VM has been told" is exactly what a [`crate::ui_script::VmMemo`] keys (1290): a
|
||||
/// `/reload` (1291) replaces the VM mid-follow without touching [`FollowState`], and the fresh
|
||||
/// memo's `None` makes the next frame re-fire `AUTOFOLLOW_BEGIN` — the replacement VM never heard
|
||||
/// it, so its status line has to be told again.
|
||||
/// The followee guid each VM was last told of, so the events fire on transitions only; a VM
|
||||
/// replaced by `/reload` mid-follow starts at `None` and is told `BEGIN` again.
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct FollowFeed {
|
||||
vm: crate::ui_script::VmMemo<FollowFeedMemo>,
|
||||
}
|
||||
|
||||
/// The per-VM half of [`FollowFeed`] — the one transition base.
|
||||
#[derive(Default)]
|
||||
struct FollowFeedMemo {
|
||||
told: Option<u64>,
|
||||
}
|
||||
|
||||
/// Push the follow state's transitions into the VM as the reference's two events.
|
||||
fn feed_follow(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
follow: Res<FollowState>,
|
||||
@@ -82,11 +33,10 @@ fn feed_follow(
|
||||
let Some(mut script) = script else {
|
||||
return;
|
||||
};
|
||||
// Resolved against THIS VM (1290/1291): a replacement VM reads `told: None` and gets its BEGIN.
|
||||
let memo = feed.vm.get(&script);
|
||||
match (follow.guid, memo.told) {
|
||||
// Started, or switched subject. A switch emits END first, because the reference's arm
|
||||
// routine calls the canceller before arming (see the module header).
|
||||
// Started, or switched subject: a switch fires END first, as the reference's arm routine
|
||||
// (`0x611130`) calls the canceller (`0x61113e`) before arming.
|
||||
(Some(guid), told) if told != Some(guid) => {
|
||||
if told.is_some() {
|
||||
script.fire_event("AUTOFOLLOW_END", vec![]);
|
||||
@@ -97,8 +47,7 @@ fn feed_follow(
|
||||
);
|
||||
memo.told = Some(guid);
|
||||
}
|
||||
// Stopped — by arrival at an unresolvable followee, by the turn-away cancel, or by any of
|
||||
// the input cancels. END carries no argument: the frame reuses the name BEGIN latched.
|
||||
// Stopped. END carries no name: `AutoFollowStatus` (`ZoneText.lua`) reuses BEGIN's.
|
||||
(None, Some(_)) => {
|
||||
script.fire_event("AUTOFOLLOW_END", vec![]);
|
||||
memo.told = None;
|
||||
@@ -107,7 +56,6 @@ fn feed_follow(
|
||||
}
|
||||
}
|
||||
|
||||
/// Drain the Era API's follow intents into the app's own funnel.
|
||||
fn drain_follow(script: Option<NonSendMut<UiScript>>, mut out: MessageWriter<FollowRequest>) {
|
||||
let Some(mut script) = script else {
|
||||
return;
|
||||
|
||||
@@ -1,24 +1,13 @@
|
||||
//! The player-UI **gamma composite lane**'s single decode (decision 0254) — the UI-arc twin of
|
||||
//! [`benilla_world::ffx_glow`], which owns the world lane's one decode (0161).
|
||||
//! The player-UI gamma lane's one decode. [`ui_quad.wgsl`](shaders/ui_quad.wgsl) composites the
|
||||
//! UI in gamma bytes, as the reference's fixed-function device does into its 8-bit backbuffer:
|
||||
//! blends are gamma arithmetic clamped at each write, and `alphaMode="ADD"` is `dst + texel·α`
|
||||
//! (EGxBlend 3, `glBlendFunc(GL_SRC_ALPHA, GL_ONE)`; factor tables `0x85c1f8`/`0x85c224`). This
|
||||
//! node decodes the finished image to linear once, straight into the swapchain (output mode
|
||||
//! `Skip`, no blit), whose sRGB write re-encodes the client's byte. Without it the UI presents
|
||||
//! about 2.2 times too bright. [`UiGammaLane`] gates it off every other `Camera2d` on `Core2d`.
|
||||
//!
|
||||
//! [`ui_quad.wgsl`](shaders/ui_quad.wgsl) composites the UI in gamma bytes, the way the
|
||||
//! reference's fixed-function device composites into its 8-bit backbuffer: every tint is a gamma
|
||||
//! multiply, every blend is gamma arithmetic clamped at each write, and `alphaMode="ADD"` is the
|
||||
//! byte add `dst + texel·α` (EGxBlend 3 = `glBlendFunc(GL_SRC_ALPHA, GL_ONE)`;
|
||||
//! factor tables `0x85c1f8`/`0x85c224`). This node converts that finished gamma
|
||||
//! image to linear ONCE, rendering straight into the swapchain — the camera's output mode is
|
||||
//! `Skip`, so there is no output blit (decision 2206, [`benilla_world::final_pass`]) — whose sRGB
|
||||
//! write re-encodes it to the exact client byte.
|
||||
//!
|
||||
//! The node is **mandatory** on the player-UI camera: without it the whole UI presents ~2.2× bright
|
||||
//! (the same failure mode `$WOW_NO_FFX` produces for the world). It is gated on [`UiGammaLane`], so
|
||||
//! it runs on that camera and no other `Camera2d` sharing the `Core2d` graph (the egui dev overlay).
|
||||
//!
|
||||
//! The module also carries the lane's **Bevy-UI half** (decision 0541): the glue + loading screens
|
||||
//! are Bevy UI trees rather than quads, so they need the same gamma conversion in their own shaders
|
||||
//! before this decode is correct for them — see [`use_gamma_ui_shaders`]. That is why
|
||||
//! [`crate::ui_pass`] marks the player-UI camera `IsDefaultUiCamera`: Bevy UI has to land on the one
|
||||
//! camera this node runs on.
|
||||
//! Bevy UI (the glue and loading screens) converts in its own shaders ([`use_gamma_ui_shaders`])
|
||||
//! and lands on this camera, which [`crate::ui_pass`] makes `IsDefaultUiCamera`.
|
||||
|
||||
use bevy::core_pipeline::core_2d::graph::{Core2d, Node2d};
|
||||
use bevy::core_pipeline::FullscreenShader;
|
||||
@@ -43,11 +32,11 @@ use bevy::ui_render::{init_ui_pipeline, UiPipeline};
|
||||
|
||||
use benilla_world::final_pass::FinalPassTarget;
|
||||
|
||||
/// Marks the camera whose target holds a gamma-composited image awaiting this pass's one decode,
|
||||
/// and carries the [`DisplayGamma`] exponent that decode applies on the way through (2182).
|
||||
/// Marks the camera whose target holds the gamma-composited UI, with the [`DisplayGamma`]
|
||||
/// exponent the decode applies.
|
||||
#[derive(Component, Clone, Copy, ExtractComponent)]
|
||||
pub(crate) struct UiGammaLane {
|
||||
/// The `gamma` CVar's value, ready for the shader. `1.0` is the identity ramp.
|
||||
/// The clamped `gamma` CVar; `1.0` is the identity ramp.
|
||||
pub(crate) gamma: f32,
|
||||
}
|
||||
|
||||
@@ -59,38 +48,25 @@ impl Default for UiGammaLane {
|
||||
}
|
||||
}
|
||||
|
||||
/// **The display-brightness correction** (decision 2182) — the `gamma` CVar, as the reference
|
||||
/// registers it (`0x402d70`, name `0x82e924`, default string `0x82e92c` = `"1.0"`, flags 0).
|
||||
/// The `gamma` CVar (registered at `0x402d70`, name `0x82e924`, default `"1.0"` at `0x82e92c`,
|
||||
/// flags 0). The reference's callback `0x4034d0` hands `SetDeviceGammaRamp` the ramp
|
||||
/// `__ftol(pow(i / 255, gamma) · 65535)` (`0x591680`); there is no other whole-frame grade and
|
||||
/// no `Brightness` or `Contrast` CVar.
|
||||
///
|
||||
/// The reference applies it as an OS **hardware gamma ramp**: its change callback `0x4034d0`
|
||||
/// builds `ramp[i] = __ftol(pow(i · 1/255, gamma) · 65535)` at `0x591680` and hands the 3×256 words
|
||||
/// to `GDI32!SetDeviceGammaRamp` (there is no other whole-frame grade in the client, and
|
||||
/// `Brightness`/`Contrast` CVars do not exist in the binary at all). That upload is **skipped
|
||||
/// windowed** — `byte[dev+0x20b]` is `CGxFormat +0x07`, which is `gxWindow` — and windowed is every
|
||||
/// mode benilla has (1627), so copying the mechanism byte for byte would ship a slider that never
|
||||
/// moves a pixel.
|
||||
///
|
||||
/// So the ramp goes where a ramp goes when you own the compositor: **the same curve, applied to the
|
||||
/// same values, one stage later.** `i/255` is the framebuffer byte the RAMDAC would have read;
|
||||
/// [`UiGammaNode`] samples exactly that value (the UI lane composites in gamma bytes — the module
|
||||
/// doc above) and raising it to `gamma` before the one decode is the continuous form of the
|
||||
/// reference's 256-entry LUT.
|
||||
/// Deviation: the reference skips that upload when windowed (`byte[dev+0x20b]` is `gxWindow`), and
|
||||
/// every benilla mode is windowed, so the same curve applies in the compositor, where the slider
|
||||
/// can move pixels: [`UiGammaNode`] raises the gamma byte the RAMDAC would have read to `gamma`
|
||||
/// before its decode, the continuous form of the 256-entry ramp.
|
||||
#[derive(Resource, Debug, Clone, Copy, PartialEq)]
|
||||
pub(crate) struct DisplayGamma(pub(crate) f32);
|
||||
|
||||
/// The reference's registered `"1.0"` — the identity ramp, and the value the pass is a **no-op**
|
||||
/// at, byte for byte (see [`UiGammaNode`]'s uniform branch).
|
||||
/// The registered `"1.0"`: the identity ramp, at which the pass changes no byte.
|
||||
pub(crate) const DEFAULT_GAMMA: f32 = 1.0;
|
||||
|
||||
/// **The clamp is ours, and the reference has none** — `SetGamma(5)` writes `gamma = "-4.000000"`
|
||||
/// there with no arm anywhere to catch it (`SetGamma 0x4891f0`, with
|
||||
/// `baseMip`'s validating callback `0x689090` as the positive control). The reference can afford
|
||||
/// that because its ramp is a fullscreen-only OS call a player can escape by alt-tabbing; ours is
|
||||
/// the image itself, and `pow(x, 12)` is a black screen with the panel that undoes it somewhere
|
||||
/// inside it. This range is the widest that keeps the client legible enough to reach that panel —
|
||||
/// it spans the stock slider's own `[0.5, 1.5]` four times over, so nothing a player can do from
|
||||
/// the UI ever meets it, and the CVar still keeps whatever truth was written to it (0959's posture:
|
||||
/// the consumer clamps at its own edge, the store does not lie).
|
||||
/// Deviation: the reference never clamps (`SetGamma` `0x4891f0` writes `-4.000000` for 5), but its
|
||||
/// ramp is a fullscreen OS call and ours is the image, where an extreme exponent blacks out the
|
||||
/// panel that would undo it. The range spans the stock slider's `[0.5, 1.5]` four times over; the
|
||||
/// CVar keeps the value written and only its consumers clamp.
|
||||
pub(crate) const GAMMA_RANGE: std::ops::RangeInclusive<f32> = 0.25..=4.0;
|
||||
|
||||
impl Default for DisplayGamma {
|
||||
@@ -99,8 +75,7 @@ impl Default for DisplayGamma {
|
||||
}
|
||||
}
|
||||
|
||||
/// Carry the knob onto the camera the pass runs on. Change-detected on the resource, so a settled
|
||||
/// session writes nothing.
|
||||
/// Copies the clamped gamma onto the lane camera when it changes.
|
||||
fn stamp_lane_gamma(gamma: Res<DisplayGamma>, mut lanes: Query<&mut UiGammaLane>) {
|
||||
if !gamma.is_changed() {
|
||||
return;
|
||||
@@ -113,17 +88,15 @@ fn stamp_lane_gamma(gamma: Res<DisplayGamma>, mut lanes: Query<&mut UiGammaLane>
|
||||
#[derive(Debug, Hash, PartialEq, Eq, Clone, RenderLabel)]
|
||||
struct UiGammaLabel;
|
||||
|
||||
/// The decode's shared halves. The pipeline itself is specialised per view on the format it
|
||||
/// renders in ([`ViewUiGammaPipeline`]).
|
||||
/// The decode's shared parts; the pipeline is specialised per view ([`ViewUiGammaPipeline`]).
|
||||
#[derive(Resource)]
|
||||
struct UiGammaPipeline {
|
||||
layout: BindGroupLayoutDescriptor,
|
||||
sampler: Sampler,
|
||||
shader: Handle<Shader>,
|
||||
fullscreen: FullscreenShader,
|
||||
/// The ramp exponent's 16-byte uniform, rewritten each frame with a queue write (which lands
|
||||
/// before the graph's submit executes) — the same shape `ffx_glow`'s combine uses. One buffer,
|
||||
/// not one per view: [`UiGammaLane`] is on exactly one camera.
|
||||
/// The exponent's 16-byte uniform, queue-written each frame, which lands before the graph's
|
||||
/// submit. One buffer, not one per view: [`UiGammaLane`] is on exactly one camera.
|
||||
ramp: Buffer,
|
||||
}
|
||||
|
||||
@@ -139,10 +112,8 @@ impl SpecializedRenderPipeline for UiGammaPipeline {
|
||||
shader: self.shader.clone(),
|
||||
shader_defs: vec![],
|
||||
entry_point: Some("fs_decode".into()),
|
||||
// An 8-bit sRGB target either way: the swapchain's view for the player-UI camera
|
||||
// (`Skip`, 2206), or — for a `Write` camera — its own main texture, which is not
|
||||
// `Hdr` and so carries Bevy's default 8-bit sRGB format, the one that makes the
|
||||
// lane clamp at every blend like the reference.
|
||||
// An 8-bit sRGB target either way (the swapchain, or a `Write` camera's non-`Hdr`
|
||||
// main texture), so the lane clamps at every blend like the reference.
|
||||
targets: vec![Some(ColorTargetState {
|
||||
format,
|
||||
blend: None,
|
||||
@@ -154,9 +125,8 @@ impl SpecializedRenderPipeline for UiGammaPipeline {
|
||||
}
|
||||
}
|
||||
|
||||
/// The decode pipeline for one view — specialised on where the decode lands (2206,
|
||||
/// [`benilla_world::final_pass`]): the swapchain, for the player-UI camera, whose output mode is
|
||||
/// `Skip`. Stamped every frame, the way bevy stamps its own `ViewUpscalingPipeline`.
|
||||
/// The decode pipeline for one view, specialised on where the decode lands
|
||||
/// ([`benilla_world::final_pass`]); stamped every frame, as Bevy stamps `ViewUpscalingPipeline`.
|
||||
#[derive(Component)]
|
||||
struct ViewUiGammaPipeline(CachedRenderPipelineId);
|
||||
|
||||
@@ -187,8 +157,7 @@ fn init_pipeline(
|
||||
(
|
||||
texture_2d(TextureSampleType::Float { filterable: true }),
|
||||
sampler(SamplerBindingType::Filtering),
|
||||
// The ramp exponent, as a `vec4<f32>` — one scalar, but a uniform struct is
|
||||
// 16-byte aligned and a vec4 says so without a padding field to keep in step.
|
||||
// The exponent as a `vec4<f32>`: a uniform is 16-byte aligned, so no padding field.
|
||||
uniform_buffer_sized(false, Some(std::num::NonZero::new(16).unwrap())),
|
||||
),
|
||||
),
|
||||
@@ -237,18 +206,16 @@ impl ViewNode for UiGammaNode {
|
||||
let pipelines = world.resource::<UiGammaPipeline>();
|
||||
let pipeline_cache = world.resource::<PipelineCache>();
|
||||
let Some(decode) = pipeline_cache.get_render_pipeline(pipeline.0) else {
|
||||
// Still compiling. Skipping leaves the UI undecoded (over-bright) for a frame or two,
|
||||
// which beats dropping the UI entirely.
|
||||
// Still compiling: the UI stays undecoded, over-bright, for a frame or two.
|
||||
return Ok(());
|
||||
};
|
||||
// The ramp exponent for this frame (2182). `[g, 0, 0, 0]` — the shader reads `.x`.
|
||||
// This frame's exponent as `[g, 0, 0, 0]`; the shader reads `.x`.
|
||||
world.resource::<RenderQueue>().write_buffer(
|
||||
&pipelines.ramp,
|
||||
0,
|
||||
bytemuck::cast_slice(&[lane.gamma, 0.0, 0.0, 0.0]),
|
||||
);
|
||||
// Where the decode lands (2206, `final_pass`): the swapchain itself for the player-UI
|
||||
// camera (`Skip`), the ping-pong for a `Write` camera.
|
||||
// The swapchain for the `Skip` player-UI camera, the ping-pong target for a `Write` one.
|
||||
let out = FinalPassTarget::resolve(camera, view_target);
|
||||
let layout = pipeline_cache.get_bind_group_layout(&pipelines.layout);
|
||||
let bind = render_context.render_device().create_bind_group(
|
||||
@@ -260,7 +227,7 @@ impl ViewNode for UiGammaNode {
|
||||
pipelines.ramp.as_entire_binding(),
|
||||
)),
|
||||
);
|
||||
// Its diagnostic span: the journal's `gpu_ui` column counts this decode (2008).
|
||||
// The perf journal buckets this span's name into `gpu_ui`.
|
||||
let diagnostics = render_context.diagnostic_recorder();
|
||||
let scissor = out.scissor_rect();
|
||||
let mut pass = render_context
|
||||
@@ -284,25 +251,10 @@ impl ViewNode for UiGammaNode {
|
||||
}
|
||||
}
|
||||
|
||||
/// Put **Bevy UI** on the gamma lane too (decision 0541): swap the two stock pipelines' shaders for
|
||||
/// our gamma-emitting copies.
|
||||
///
|
||||
/// The glue screens (login / character select / character create) and the loading screen are Bevy UI
|
||||
/// trees, not [`crate::ui_pass`] quads, so they never went through `ui_quad.wgsl`'s conversion — they
|
||||
/// composited in LINEAR while the rest of the client composited in the reference's gamma bytes. Over
|
||||
/// the login scene's bright sky that washed the edit-box fills from byte ~77 to ~138 (measured).
|
||||
///
|
||||
/// Bevy UI exposes no colour-space hook, so the conversion goes where `ui_quad.wgsl` puts it: the
|
||||
/// fragment shader. [`UiPipeline`] and [`UiTextureSlicePipeline`] each hold a public `shader` handle
|
||||
/// they clone into every specialisation, so replacing it here — once, at `RenderStartup`, after the
|
||||
/// upstream init systems have built the resources — redirects every UI draw with no other change:
|
||||
/// art keeps loading as sRGB, colours keep being authored as `Color::srgb`, and the booth's linear
|
||||
/// scene image is converted by the same `linear_to_srgb` as everything else.
|
||||
///
|
||||
/// The shaders are vendored copies (see their headers) — the maintenance cost of Bevy not exposing
|
||||
/// the seam. The long-term exit is decision 0068's engine: the reference's glue screens are
|
||||
/// themselves FrameXML, so when they migrate onto our own quad pass this whole module's Bevy-UI half
|
||||
/// is deleted rather than maintained.
|
||||
/// Puts Bevy UI on the gamma lane: the glue and loading screens are Bevy UI trees, not quads, and
|
||||
/// would otherwise composite in linear. Bevy UI has no colour-space hook, so this swaps the
|
||||
/// `shader` handle [`UiPipeline`] and [`UiTextureSlicePipeline`] clone into every specialisation
|
||||
/// for vendored gamma-emitting copies, once, after their init systems.
|
||||
fn use_gamma_ui_shaders(
|
||||
asset_server: Res<AssetServer>,
|
||||
mut node: ResMut<UiPipeline>,
|
||||
@@ -314,9 +266,7 @@ fn use_gamma_ui_shaders(
|
||||
|
||||
pub(crate) struct UiGammaPlugin;
|
||||
|
||||
/// Brightness's change callback (2182, 2303). The clamp is OURS and the reference has none —
|
||||
/// the reason it costs one is on [`GAMMA_RANGE`], and nothing a player can reach from the panel
|
||||
/// meets it.
|
||||
/// The `gamma` CVar's change callback, clamped to [`GAMMA_RANGE`].
|
||||
pub(crate) fn on_cvar(ev: On<crate::cvars::CvarChanged>, mut gamma: ResMut<DisplayGamma>) {
|
||||
if ev.is(benilla_ui::script::CVAR_GAMMA) {
|
||||
gamma.0 = ev.num().clamp(*GAMMA_RANGE.start(), *GAMMA_RANGE.end());
|
||||
@@ -346,16 +296,14 @@ impl Plugin for UiGammaPlugin {
|
||||
.after(init_ui_texture_slice_pipeline),
|
||||
)
|
||||
.add_render_graph_node::<ViewNodeRunner<UiGammaNode>>(Core2d, UiGammaLabel)
|
||||
// After the quads are composited, in the slot before the output blit's — which a
|
||||
// `Skip` camera leaves empty: the decode IS the output write.
|
||||
// After the quads, before the output blit, which a `Skip` camera skips: the decode is
|
||||
// the output write.
|
||||
.add_render_graph_edges(
|
||||
Core2d,
|
||||
(Node2d::EndMainPass, UiGammaLabel, Node2d::Upscaling),
|
||||
)
|
||||
// ...and after the Bevy-UI subgraph, whose nodes now write gamma into the same target.
|
||||
// Upstream orders `UiPass` only against `EndMainPass`/`Upscaling`, which leaves it and
|
||||
// the decode mutually unordered — the glue screens would decode or not depending on how
|
||||
// the graph happened to sort. This edge pins it.
|
||||
// And after Bevy UI, which writes gamma into the same target: upstream orders `UiPass`
|
||||
// only against `EndMainPass` and `Upscaling`, leaving it unordered against the decode.
|
||||
.add_render_graph_edge(Core2d, NodeUi::UiPass, UiGammaLabel);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,66 +1,15 @@
|
||||
//! The GM trouble-ticket flow — the Help window's wire, its events, and its answers
|
||||
//! (decision 1673).
|
||||
//! The GM ticket flow behind the stock Help window: the events it hears and the packets it sends.
|
||||
//! The client holds no ticket: `SMSG_GMTICKET_GETTICKET` is the only truth, and every answer fires
|
||||
//! `UPDATE_TICKET`, an unchanged one too, as the window re-polls `GetGMTicket()` every 10 minutes.
|
||||
//! 1.12 has no response channel, so vmangos appends a GM's reply to the ticket text
|
||||
//! (`GMTicketMgr.cpp:124-136`).
|
||||
//!
|
||||
//! **The law worth stating first: the client holds no ticket state.** There is no local "I have a
|
||||
//! ticket" bit that survives anything; every question is a round trip, and `SMSG_GMTICKET_GETTICKET`
|
||||
//! is the only truth about whether a ticket exists, what it says, and how long the wait is. That is
|
||||
//! why this module is almost entirely feed-and-drain with a single counter of substance, and why
|
||||
//! the Help window re-polls rather than caching.
|
||||
//! Every send is fire-and-forget and never retried: vmangos answers some refusals with silence
|
||||
//! (`GMTicketHandler.cpp:91,106-113`), and a third `CMSG_GMTICKET_UPDATETEXT` in one world tick
|
||||
//! trips its anti-flood kick (`WorldSession.cpp:1316-1342`).
|
||||
//!
|
||||
//! ## Three events, and why each fires when it does
|
||||
//!
|
||||
//! - **`UPDATE_TICKET`** — fired for **every** `SMSG_GMTICKET_GETTICKET`, including the "you have
|
||||
//! no ticket" answer, and including the second identical answer in a row. It is an **answer
|
||||
//! ticket**, not a state diff ([`GmTicketState::answers`]): the shipped window polls
|
||||
//! `GetGMTicket()` every `GMTICKET_CHECK_INTERVAL` (600 s) from `TicketStatus_OnUpdate`, and a
|
||||
//! feed that suppressed an unchanged answer would make the poll look like a hang. The idiom is
|
||||
//! [`crate::ui_party`]'s saved-instance ticket, for exactly the same reason.
|
||||
//! - **`UPDATE_GM_STATUS`** — fired for every `SMSG_GMTICKETSYSTEMSTATUS`. Sets the window's
|
||||
//! `PETITION_QUEUE_ACTIVE`, which gates the whole "page a GM" path.
|
||||
//! - **`GMSURVEY_DISPLAY`** — **not fired, deliberately.** See "The survey" below.
|
||||
//!
|
||||
//! ## The argument order is NOT the wire order
|
||||
//!
|
||||
//! `HelpFrameOpenTicket_OnEvent` reads `UPDATE_TICKET` as `arg1` = category, `arg2` = description,
|
||||
//! `arg3..5` = the three day-valued floats, `arg6` = assignedToGM, `arg7` = openedByGM. The packet
|
||||
//! puts the **text first and the category second** (both emulators agree byte-for-byte; see
|
||||
//! [`benilla_protocol::messages::gm_ticket`]). So the reorder happens here, in [`feed_gm_ticket`],
|
||||
//! which is where the reference client's own handler does it. Do not "fix" the decoder to match
|
||||
//! the event: that would desynchronise every field after the string.
|
||||
//!
|
||||
//! **The no-ticket answer is a single `arg1 = 0`**, because the window's test is
|
||||
//! `if ( arg1 and arg1 ~= 0 )`. That branch is what resets Submit/Cancel back to SUBMIT/CANCEL and
|
||||
//! empties the edit box, so it is load-bearing rather than a null case.
|
||||
//!
|
||||
//! ## What vmangos actually does, and what that costs us
|
||||
//!
|
||||
//! Verified against the vmangos source (`Handlers/GMTicketHandler.cpp`, `GMTicketMgr.cpp`,
|
||||
//! `Commands/TicketCommands.cpp`) — four behaviours a client author has to design around:
|
||||
//!
|
||||
//! 1. **Silence is a legal answer.** Create returns with no packet at all when the queue is off,
|
||||
//! when the player is under `GMTickets.MinLevel`, or when the category is ≥ 11
|
||||
//! (`GMTicketHandler.cpp:91,106-113`); delete with no ticket likewise (`:73-86`). Nothing here
|
||||
//! may block on or retry an answer — and nothing does: every send is fire-and-forget, and the
|
||||
//! window's own re-poll is what eventually reconciles.
|
||||
//! 2. **`SMSG_GMTICKET_GETTICKET` and `SMSG_GMTICKET_DELETETICKET` arrive UNSOLICITED** when a GM
|
||||
//! runs `.ticket viewid`/`escalate`/`complete`/`delete` (`GMTicketMgr.cpp:153-159`,
|
||||
//! `TicketCommands.cpp:100-103,265,443-452`). They are handled identically to a solicited one,
|
||||
//! which is the whole benefit of not correlating answers to asks.
|
||||
//! 3. **The GM's reply arrives inside the ticket text.** 1.12 has no response channel, so vmangos
|
||||
//! appends `"…GM answer: <response>"` to the description itself (`GMTicketMgr.cpp:124-136`).
|
||||
//! The window renders it verbatim, which is correct — there is nowhere else for it to go.
|
||||
//! 4. **Editing is rate-limited to 2 per world tick, and the sanction is a KICK**
|
||||
//! (`WorldSession.cpp:1316-1342`). One send per click, never a retry.
|
||||
//!
|
||||
//! ## The survey
|
||||
//!
|
||||
//! `Blizzard_GMSurveyUI` and the `GMSURVEY_DISPLAY` event are **deferred, not forgotten**. The
|
||||
//! trigger does not exist on this server: `SMSG_GM_TICKET_STATUS_UPDATE` — the packet cmangos uses
|
||||
//! to raise a survey — is registered in vmangos's opcode table but **never constructed anywhere in
|
||||
//! its source**, so a vmangos client can never be asked to fill one in. Building the survey window
|
||||
//! now would be shipping a panel nothing can open. The DBCs it would read are already on disk and
|
||||
//! already understood (`GMSurveyCurrentSurvey` → `GMSurveySurveys` → `GMSurveyQuestions`, five
|
||||
//! questions, ids 28-32), so the work is scoped whenever a server grows the trigger.
|
||||
//! `GMSURVEY_DISPLAY` is not fired: vmangos never sends its trigger, `SMSG_GM_TICKET_STATUS_UPDATE`
|
||||
//! status 3, and the survey window (`Blizzard_GMSurveyUI`) is not built.
|
||||
|
||||
use benilla_ui::script::{GmTicketIntent, GmTicketWrite, ScriptValue, UiScript};
|
||||
use bevy::prelude::*;
|
||||
@@ -71,100 +20,63 @@ use crate::net::{ClientCommand, NetCommands};
|
||||
use crate::player::Player;
|
||||
use crate::ui_script::{UiFeed, UiInput, VmMemo};
|
||||
|
||||
/// Spell 7355 "Stuck" — what `Stuck()` casts, and the Help window's "Auto-Unstuck".
|
||||
///
|
||||
/// Pinned from **both** sides rather than assumed: `Spell.dbc` row 7355 is named "Stuck" in the
|
||||
/// shipped 5875 client, and it is the only spell in the server's whole `spell_template` table
|
||||
/// carrying `SPELL_EFFECT_STUCK` (84), whose handler teleports the player to their last safe
|
||||
/// position (`vmangos SpellEffects.cpp:4697`, gated by `CastUnstuck` which defaults on). So the
|
||||
/// id is not a guess in either direction.
|
||||
/// Spell 7355 "Stuck", what `Stuck()` casts: the only spell with `SPELL_EFFECT_STUCK` (84), which
|
||||
/// moves the player to their last safe position (vmangos `SpellEffects.cpp:4697`).
|
||||
const SPELL_STUCK: u32 = 7355;
|
||||
|
||||
/// What the last `SMSG_GMTICKET_GETTICKET` said, and **how many** have arrived.
|
||||
///
|
||||
/// The count is the load-bearing half. A `bool`/value diff cannot express "the server answered
|
||||
/// again with the same thing", and that is precisely the Help window's 10-minute poll — so the
|
||||
/// feed fires on the counter moving, never on the value changing.
|
||||
/// What the last `SMSG_GMTICKET_GETTICKET` said, and how many have arrived: the feed fires on the
|
||||
/// count, so the window's poll is answered even when nothing changed.
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct GmTicketState {
|
||||
/// The open ticket, or `None` for "you have no ticket" — the ordinary answer.
|
||||
/// The open ticket; `None` is the ordinary "no ticket" answer.
|
||||
ticket: Option<Box<benilla_protocol::messages::GmTicket>>,
|
||||
/// One per `SMSG_GMTICKET_GETTICKET`, wrapping. The answer ticket.
|
||||
/// One per `SMSG_GMTICKET_GETTICKET`, wrapping.
|
||||
answers: u32,
|
||||
/// Re-asks the ENGINE owes the server — see [`Self::note_write_landed`]. Drained into
|
||||
/// `CMSG_GMTICKET_GETTICKET` sends by [`drain_gm_ticket`], beside the ones Lua asked for.
|
||||
/// Re-asks the engine owes ([`Self::note_write_landed`]), sent by [`drain_gm_ticket`].
|
||||
engine_reasks: u32,
|
||||
/// The last queue status the server reported, and its own answer counter — same idiom, because
|
||||
/// `GetGMStatus()` is asked on every window open and the answer is usually unchanged.
|
||||
///
|
||||
/// **`i32`, not `u32`, and that is byte-exact rather than a preference**: the reference
|
||||
/// copies the field off the wire verbatim at `0x418e95` with no
|
||||
/// extension, pushes it unmodified at `0x5e467b`, and hands it to Lua through
|
||||
/// `0x704fa6 fild dword` — a **signed** load, with no `cmp`, `test`, clamp or mapping anywhere
|
||||
/// between the wire and the event. So `HelpFrame`'s `arg1 == -1` arm is reachable exactly when
|
||||
/// a server sends `0xFFFFFFFF`, and reading the field unsigned would turn that into
|
||||
/// 4294967295, silently costing the "the queue is down" dialog.
|
||||
/// The last queue status, with its own answer count. Signed as in the reference, which copies
|
||||
/// the wire dword verbatim (`0x418e95`), pushes it unmodified (`0x5e467b`) and loads it signed
|
||||
/// for Lua (`0x704fa6`), so `HelpFrame`'s `arg1 == -1` arm fires on `0xFFFFFFFF`.
|
||||
queue_status: i32,
|
||||
queue_answers: u32,
|
||||
}
|
||||
|
||||
impl GmTicketState {
|
||||
/// `SMSG_GMTICKET_GETTICKET` — replace the ticket wholesale and count the answer. **An empty
|
||||
/// answer still counts**: it is what "you abandoned it" and "you never had one" both look like,
|
||||
/// and the window has to hear it.
|
||||
/// `SMSG_GMTICKET_GETTICKET`: replace the ticket and count the answer, an empty one too.
|
||||
pub(crate) fn answer(&mut self, ticket: Option<Box<benilla_protocol::messages::GmTicket>>) {
|
||||
self.ticket = ticket;
|
||||
self.answers = self.answers.wrapping_add(1);
|
||||
}
|
||||
|
||||
/// **The client re-asks for its own ticket whenever a write lands** — the mechanism that makes
|
||||
/// the shipped UI's total silence on the three response opcodes correct rather than a gap.
|
||||
///
|
||||
/// `CMSG_GMTICKET_GETTICKET` (`0x211`) has three callers, and two of them are *engine* legs
|
||||
/// reacting to a server push — the `0x206`/`0x208` arm on response codes 2 (create-ok) and 4
|
||||
/// (update-ok) at `0x5e4479`, and the `0x328` handler on body value 1 at `0x5e7932`. **Neither
|
||||
/// carries an idempotence guard**, so it is one resend per qualifying push, which is what this
|
||||
/// counter models.
|
||||
///
|
||||
/// Without it the window would not learn its own ticket exists until the 10-minute poll came
|
||||
/// round: file a ticket, and the toast stays dark and the form stays a form for up to ten
|
||||
/// minutes. That is exactly the "goes stale" failure, and no FrameXML handler can fix it —
|
||||
/// there is none to fix.
|
||||
/// A write landed: re-ask for the ticket, as the reference engine does, once per push and
|
||||
/// unguarded, on create-ok (2) and update-ok (4) at `0x5e4479` and on status update 1 at
|
||||
/// `0x5e7932`. Without it a filed ticket stays unseen until the 10-minute poll.
|
||||
pub(crate) fn note_write_landed(&mut self) {
|
||||
self.engine_reasks = self.engine_reasks.saturating_add(1);
|
||||
}
|
||||
|
||||
/// Drain the engine's owed re-asks.
|
||||
fn take_reasks(&mut self) -> u32 {
|
||||
std::mem::take(&mut self.engine_reasks)
|
||||
}
|
||||
|
||||
/// `SMSG_GMTICKETSYSTEMSTATUS` — the petition queue's state, counted the same way.
|
||||
/// `SMSG_GMTICKETSYSTEMSTATUS`: the queue's state, counted the same way.
|
||||
pub(crate) fn answer_queue(&mut self, status: i32) {
|
||||
self.queue_status = status;
|
||||
self.queue_answers = self.queue_answers.wrapping_add(1);
|
||||
}
|
||||
|
||||
/// The socket died: a fresh session has not been answered, so both tickets go back to zero
|
||||
/// along with the state they described. Without this a reconnect would show the previous
|
||||
/// character's ticket until the first poll landed.
|
||||
/// Session end: a reconnect must not show the previous character's ticket.
|
||||
pub(crate) fn clear_session(&mut self) {
|
||||
*self = Self::default();
|
||||
}
|
||||
}
|
||||
|
||||
/// What the VM has already been told, per VM.
|
||||
///
|
||||
/// Behind a [`VmMemo`] (1290/1291) for the reason every feed's is: a `/reload` replaces the VM
|
||||
/// without despawning the world, and a memory of what the *old* VM heard would leave the new one
|
||||
/// with no categories, no ticket and no event ever coming.
|
||||
/// Per VM ([`VmMemo`]): a `/reload`'s new VM is fed the categories, ticket and status afresh.
|
||||
#[derive(Resource, Default)]
|
||||
struct GmTicketFeedState {
|
||||
vm: VmMemo<FedTicket>,
|
||||
}
|
||||
|
||||
/// The per-VM change bases: the two answer counters already fired, and whether the static category
|
||||
/// catalog has been handed over.
|
||||
#[derive(Default)]
|
||||
struct FedTicket {
|
||||
answers: u32,
|
||||
@@ -172,8 +84,7 @@ struct FedTicket {
|
||||
categories_pushed: bool,
|
||||
}
|
||||
|
||||
/// Fire `UPDATE_TICKET`/`UPDATE_GM_STATUS` for answers the UI has not heard yet, and push the
|
||||
/// category catalog once.
|
||||
/// Fire `UPDATE_TICKET` and `UPDATE_GM_STATUS` for unheard answers; push the categories once.
|
||||
fn feed_gm_ticket(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
state: Res<GmTicketState>,
|
||||
@@ -185,7 +96,6 @@ fn feed_gm_ticket(
|
||||
};
|
||||
let fed = feed.vm.get(&script);
|
||||
|
||||
// The catalog is static client data — pushed once per VM, before any window can ask for it.
|
||||
if !fed.categories_pushed {
|
||||
if let Some(categories) = categories.as_deref() {
|
||||
script.set_gm_ticket_categories(categories.0.clone());
|
||||
@@ -195,11 +105,8 @@ fn feed_gm_ticket(
|
||||
|
||||
if fed.queue_answers != state.queue_answers {
|
||||
fed.queue_answers = state.queue_answers;
|
||||
// arg1 is the queue status as a NUMBER: the window tests `arg1 == 1` for "up" and
|
||||
// `arg1 == -1` for "down, and say so". vmangos only ever sends 0 or 1, so the -1 arm — the
|
||||
// one that pops HELP_TICKET_QUEUE_DISABLED unprompted — is unreachable against this
|
||||
// server. Passed through unchanged rather than remapped: inventing a -1 would put a dialog
|
||||
// on screen that the server never asked for.
|
||||
// `arg1` is the status as a number: `HelpFrame` tests `== 1` for up and `== -1` to show
|
||||
// `HELP_TICKET_QUEUE_DISABLED`; vmangos sends only 0 or 1, passed through unmapped.
|
||||
script.fire_event(
|
||||
"UPDATE_GM_STATUS",
|
||||
vec![ScriptValue::Int(i64::from(state.queue_status))],
|
||||
@@ -212,11 +119,10 @@ fn feed_gm_ticket(
|
||||
}
|
||||
}
|
||||
|
||||
/// `UPDATE_TICKET`'s argument list — **the reorder** the module doc explains.
|
||||
///
|
||||
/// With a ticket: category, text, then the three day-floats, then the two status bytes. Without
|
||||
/// one: a single `0`, because `HelpFrameOpenTicket_OnEvent` gates its whole "you have a ticket"
|
||||
/// branch on `arg1 and arg1 ~= 0` and `TicketStatusFrame_OnEvent` on `arg1 ~= 0`.
|
||||
/// `UPDATE_TICKET`'s arguments: category, text, the three day figures, assigned, opened. The wire
|
||||
/// has the text first and the reference's handler reorders as it fires, so the decoder keeps wire
|
||||
/// order. No ticket is a single `0`: `HelpFrameOpenTicket_OnEvent` tests `arg1 and arg1 ~= 0`
|
||||
/// (`HelpFrame.lua:403`), and its else branch resets the form (`:468-475`).
|
||||
fn update_ticket_args(ticket: Option<&benilla_protocol::messages::GmTicket>) -> Vec<ScriptValue> {
|
||||
let Some(t) = ticket else {
|
||||
return vec![ScriptValue::Int(0)];
|
||||
@@ -224,9 +130,8 @@ fn update_ticket_args(ticket: Option<&benilla_protocol::messages::GmTicket>) ->
|
||||
vec![
|
||||
ScriptValue::Int(i64::from(t.category)),
|
||||
ScriptValue::Str(t.text.clone()),
|
||||
// Days, as f64 — and deliberately unclamped: a negative arg4/arg5 is the server saying
|
||||
// "I don't know", which the window renders as GM_TICKET_UNAVAILABLE. Clamping to 0 here
|
||||
// would turn "unavailable" into a confident wait estimate of zero.
|
||||
// Days, unclamped: a negative figure is the server's "unknown", which the window shows as
|
||||
// `GM_TICKET_UNAVAILABLE` (`HelpFrame.lua:442`).
|
||||
ScriptValue::Number(f64::from(t.ticket_age)),
|
||||
ScriptValue::Number(f64::from(t.oldest_ticket_age)),
|
||||
ScriptValue::Number(f64::from(t.update_time)),
|
||||
@@ -247,8 +152,7 @@ fn drain_gm_ticket(
|
||||
return;
|
||||
};
|
||||
|
||||
// The engine's own re-asks first: they answer a server push that arrived before this frame's
|
||||
// input ran, so they precede anything the player typed into it.
|
||||
// The engine's re-asks first: they answer a push that arrived before this frame's input.
|
||||
for _ in 0..reask.take_reasks() {
|
||||
let _ = commands.0.send(ClientCommand::GmTicketGet);
|
||||
}
|
||||
@@ -264,14 +168,10 @@ fn drain_gm_ticket(
|
||||
if intents.is_empty() {
|
||||
return;
|
||||
}
|
||||
// The position is stamped at SEND time, not at window-open time: a ticket says where you are
|
||||
// when you file it, which is what makes `.ticket go` land on the problem. Raw WoW coords —
|
||||
// the wire never sees Bevy's basis. Resolved once for the whole batch: every intent in it was
|
||||
// raised in the same frame, so they cannot honestly disagree about where the player stands.
|
||||
// Where the player stands at send time, in WoW coordinates: where `.ticket go` takes the GM.
|
||||
let pos = player.map(|p| bevy_to_wow(p.pos)).unwrap_or([0.0; 3]);
|
||||
let map = map.map(|m| m.0).unwrap_or(0);
|
||||
// **In call order.** A per-verb drain would reorder `DeleteGMTicket(); GetGMTicket()` into
|
||||
// get-then-delete, and the get would answer with the pre-delete state.
|
||||
// In call order: `DeleteGMTicket(); GetGMTicket()` must not become get-then-delete.
|
||||
for intent in intents {
|
||||
let _ = commands.0.send(match intent {
|
||||
GmTicketIntent::Ask => ClientCommand::GmTicketGet,
|
||||
@@ -294,31 +194,15 @@ fn drain_gm_ticket(
|
||||
}
|
||||
}
|
||||
|
||||
/// The category a `NewGMTicket`/`UpdateGMTicket` argument may put on the wire, or `None`.
|
||||
///
|
||||
/// **0 is legal and means "uncategorised"** (decision 1687). Our Help window has no category picker
|
||||
/// — one click goes from Home to the text box — so it sends 0 deliberately. vmangos accepts it: its
|
||||
/// whole validation is `if (packet.ticketType >= GMTICKET_MAX) return;` with `GMTICKET_MAX == 11`
|
||||
/// (`GMTicketHandler.cpp:112`; the enum starts at `GMTICKET_STUCK = 1`, `SharedDefines.h:1776`),
|
||||
/// and `GmTicket::GetTicketCategoryName` falls through its 1..10 switch to return **"Unknown"**
|
||||
/// (`GMTicketMgr.cpp:205-232`). So the GM's notification and queue read "Unknown" — the honest
|
||||
/// label, and far better than picking one of the ten real headings and mislabelling every ticket
|
||||
/// as an Item or a Stuck.
|
||||
///
|
||||
/// **Above 10 is still refused**, and that is not tidiness: the server drops those *silently*, with
|
||||
/// no response packet at all, so a bad argument would be indistinguishable from a filed ticket. The
|
||||
/// range test is deliberately on the **`u32`, before the narrowing** — a category of 256 truncated
|
||||
/// into a `u8` lands on 0 and would sail through as "uncategorised" when the caller meant something
|
||||
/// else entirely.
|
||||
/// The category a `NewGMTicket`/`UpdateGMTicket` call may put on the wire, 0 to 10. vmangos files
|
||||
/// 0 as "Unknown" (`GMTicketMgr.cpp:205-232`) and drops 11 and up with no answer at all
|
||||
/// (`GMTicketHandler.cpp:112`), so the test runs on the `u32`, where 256 cannot wrap to 0.
|
||||
fn category_for_wire(category: u32) -> Option<u8> {
|
||||
(category <= u32::from(GM_TICKET_CATEGORY_MAX)).then_some(category as u8)
|
||||
}
|
||||
|
||||
/// One drained write → its packet, or `None` when [`category_for_wire`] refuses the category.
|
||||
///
|
||||
/// Split out so the verb choice is testable without a world: **the window's own `hasTicket` flag
|
||||
/// picks the opcode**, and we must not re-derive it from whether we believe a ticket exists — our
|
||||
/// belief can be stale by up to the 10-minute poll.
|
||||
/// One drained write to its packet, or `None` for a refused category. The window's own
|
||||
/// `hasTicket` picks create or update, never our belief, which can be 10 minutes stale.
|
||||
fn client_command_for(write: GmTicketWrite, map: u32, pos: [f32; 3]) -> Option<ClientCommand> {
|
||||
let category = category_for_wire(write.category)?;
|
||||
Some(if write.is_new {
|
||||
@@ -336,21 +220,15 @@ fn client_command_for(write: GmTicketWrite, map: u32, pos: [f32; 3]) -> Option<C
|
||||
})
|
||||
}
|
||||
|
||||
/// The highest category the server will file — `GMTICKET_MAX - 1`, and also the last row of
|
||||
/// `GMTicketCategory.dbc`. The two agree, which is why one constant serves both.
|
||||
/// The highest category vmangos files (`GMTICKET_MAX - 1`), also `GMTicketCategory.dbc`'s last row.
|
||||
const GM_TICKET_CATEGORY_MAX: u8 = 10;
|
||||
|
||||
/// `GMTicketCategory.dbc`, loaded once — the ten rows `GetGMTicketCategories()` returns, already
|
||||
/// flattened to the `(id, name)` pairs the binding hands Lua.
|
||||
/// `GMTicketCategory.dbc` as the `(id, name)` pairs `GetGMTicketCategories()` returns.
|
||||
#[derive(Resource)]
|
||||
pub(crate) struct GmTicketCategories(pub(crate) Vec<(u32, String)>);
|
||||
|
||||
/// Startup (after the MPQ chain opens): the ticket-category catalog.
|
||||
///
|
||||
/// Ordered `.after(AssetSet::Open)` for the reason `ui_items`' loader records — a bare `Startup`
|
||||
/// slot races the chain open and, when it wins, silently leaves the resource absent for the whole
|
||||
/// session. Absent here means the "page a GM" list paints no rows, which is a quiet and confusing
|
||||
/// failure, so the ordering matters more than the warn line does.
|
||||
/// Startup, after the MPQ chain opens: a bare `Startup` races the open and can leave the category
|
||||
/// list empty for the whole session.
|
||||
fn load_gm_ticket_dbc(
|
||||
mut commands: Commands,
|
||||
world_assets: Option<Res<benilla_assets::WorldAssets>>,
|
||||
@@ -377,8 +255,7 @@ fn load_gm_ticket_dbc(
|
||||
}
|
||||
}
|
||||
|
||||
/// The GM ticket's packet handlers (decision 1673; in the net handler table since 2312), beside
|
||||
/// the state they drive.
|
||||
/// The GM ticket's packet handlers.
|
||||
pub(crate) mod net {
|
||||
use benilla_protocol::{SessionEvent, SessionEventKind};
|
||||
use bevy::prelude::*;
|
||||
@@ -387,8 +264,7 @@ pub(crate) mod net {
|
||||
|
||||
use super::GmTicketState;
|
||||
|
||||
/// Register the ticket's handlers — called from [`super::UiGmTicketPlugin`]. One per kind,
|
||||
/// plus the session-end listener.
|
||||
/// Register the ticket's handlers, called from [`super::UiGmTicketPlugin`].
|
||||
pub(super) fn register(app: &mut App) {
|
||||
use SessionEventKind as K;
|
||||
app.net_handler(K::GmTicket, on_ticket)
|
||||
@@ -400,9 +276,8 @@ pub(crate) mod net {
|
||||
.net_handler(K::Disconnected, on_session_end);
|
||||
}
|
||||
|
||||
/// The GETTICKET answer — EVERY one, including `None` ("you have no ticket") and including
|
||||
/// an unsolicited one pushed by a GM's `.ticket view`/`escalate`/`complete`: they are
|
||||
/// indistinguishable on the wire and want identical handling.
|
||||
/// Every GETTICKET answer, asked for or pushed after a GM's `.ticket` command
|
||||
/// (`GMTicketMgr.cpp:153-159`): the wire does not tell them apart.
|
||||
fn on_ticket(In(ev): In<SessionEvent>, mut ticket: ResMut<GmTicketState>) {
|
||||
if let SessionEvent::GmTicket { ticket: answer } = ev {
|
||||
ticket.answer(answer);
|
||||
@@ -415,23 +290,14 @@ pub(crate) mod net {
|
||||
}
|
||||
}
|
||||
|
||||
/// A GM touched the ticket. Value 1 makes the reference re-ask (`0x5e7932`), the same leg
|
||||
/// the create/update success codes take; 2 (closed) and 3 (survey offered) are recorded and
|
||||
/// not acted on — 3 is the survey trigger and that window is deferred. vmangos never sends
|
||||
/// this packet at all, so on our server the handler is dead; cmangos makes it the whole
|
||||
/// notification model, which is why it is parsed rather than dropped.
|
||||
/// `SMSG_GM_TICKET_STATUS_UPDATE`, which vmangos never sends; cmangos does, so it is parsed.
|
||||
fn on_status_update(In(ev): In<SessionEvent>, mut ticket: ResMut<GmTicketState>) {
|
||||
if let SessionEvent::GmTicketStatusUpdate { status } = ev {
|
||||
status_update(status, &mut ticket);
|
||||
}
|
||||
}
|
||||
|
||||
/// The three response codes have no consumer in the shipped 1.12 UI — no event, no handler.
|
||||
/// Logged so a refusal is visible in a session log rather than silent; the `ERR_TICKET_*`
|
||||
/// display path is still unpinned. Create-ok (2) and update-ok (4) make the ENGINE re-ask
|
||||
/// for the ticket — the reference's own `0x5e4479` arm, and the reason the shipped UI needs
|
||||
/// no handler for either opcode. Without it a filed ticket goes unseen until the 10-minute
|
||||
/// poll.
|
||||
/// The create and update responses, which no 1.12 FrameXML handler reads.
|
||||
fn on_written(In(ev): In<SessionEvent>, mut ticket: ResMut<GmTicketState>) {
|
||||
match ev {
|
||||
SessionEvent::GmTicketCreated { response } => {
|
||||
@@ -450,26 +316,15 @@ pub(crate) mod net {
|
||||
}
|
||||
}
|
||||
|
||||
/// The GM ticket is login-scoped (decision 1673), and for a sharper reason than most: the
|
||||
/// ticket belongs to the CHARACTER, and the next login may be a different one. Its answer
|
||||
/// counters go with it, so the first `SMSG_GMTICKET_GETTICKET` of the new session re-fires
|
||||
/// `UPDATE_TICKET` rather than being diffed away against the old character's answer count.
|
||||
/// A listener on the session end (a second handler on the kind, after the bridge's own teardown).
|
||||
/// The ticket is the character's and the next login may be another, so the answer counts
|
||||
/// reset too and the new session's first answer fires.
|
||||
fn on_session_end(In(_): In<SessionEvent>, mut ticket: ResMut<GmTicketState>) {
|
||||
ticket.clear_session();
|
||||
}
|
||||
|
||||
/// `SMSG_GMTICKET_CREATE` (2 = created) and `SMSG_GMTICKET_UPDATETEXT` (4 = saved) — the two
|
||||
/// codes the reference engine answers by **re-asking for the ticket**, at `0x5e4479`.
|
||||
///
|
||||
/// The shipped FrameXML registers no handler for either opcode, and that is not a gap: the
|
||||
/// engine turns the success code straight into a `CMSG_GMTICKET_GETTICKET`, whose answer fires
|
||||
/// the `UPDATE_TICKET` the window *does* listen for. So this arm is the whole reason filing a
|
||||
/// ticket updates the screen at all.
|
||||
///
|
||||
/// Every other code is logged and nothing else. The reference surfaces the `ERR_TICKET_*`
|
||||
/// GlobalStrings on the failures; which string rides which code, and through which display
|
||||
/// path, is not yet pinned, and a guess here would put wrong words on screen.
|
||||
/// `SMSG_GMTICKET_CREATE` 2 (created) and `SMSG_GMTICKET_UPDATETEXT` 4 (saved) re-ask for the
|
||||
/// ticket, as the reference engine does at `0x5e4479`. Other codes are logged only: which
|
||||
/// `ERR_TICKET_*` string the reference shows for each is untraced.
|
||||
pub(crate) fn write_response(
|
||||
what: &str,
|
||||
response: u32,
|
||||
@@ -484,13 +339,8 @@ pub(crate) mod net {
|
||||
}
|
||||
}
|
||||
|
||||
/// `SMSG_GM_TICKET_STATUS_UPDATE` — a GM touched the ticket.
|
||||
///
|
||||
/// **1 (updated) re-asks**, which is the reference's `0x5e7932` leg and the third of the three
|
||||
/// `CMSG_GMTICKET_GETTICKET` callers. 2 (closed) and 3 (a survey is offered) are logged: the
|
||||
/// close will arrive as an ordinary `UPDATE_TICKET` on the next poll either way, and the survey
|
||||
/// window is deferred (module doc), so acting on 3 would mean promising a panel that does not
|
||||
/// exist.
|
||||
/// `SMSG_GM_TICKET_STATUS_UPDATE`: 1 (updated) re-asks, the reference's `0x5e7932`; 2 (closed)
|
||||
/// shows on the next poll; 3 (survey offered) is logged, as the survey window is not built.
|
||||
pub(crate) fn status_update(status: u32, ticket: &mut super::GmTicketState) {
|
||||
match status {
|
||||
1 => {
|
||||
@@ -505,9 +355,8 @@ pub(crate) mod net {
|
||||
}
|
||||
}
|
||||
|
||||
/// `SMSG_GMTICKET_DELETETICKET` — logged only. The reference has no re-ask leg here (its
|
||||
/// `0x5e4479` arm covers create and update alone), and the abandon path's own `GetGMTicket()`
|
||||
/// is the Lua dialog's, not the engine's.
|
||||
/// `SMSG_GMTICKET_DELETETICKET`: logged only, as the reference's `0x5e4479` re-asks on create
|
||||
/// and update alone.
|
||||
pub(crate) fn response(what: &str, response: u32) {
|
||||
debug!("gm ticket: {what} answered {response}");
|
||||
}
|
||||
@@ -552,8 +401,7 @@ mod tests {
|
||||
})
|
||||
}
|
||||
|
||||
/// The reorder, pinned: the event leads with the CATEGORY, the packet leads with the TEXT.
|
||||
/// This test is the guard on someone "simplifying" the two into agreement.
|
||||
/// The event leads with the category, the packet with the text.
|
||||
#[test]
|
||||
fn the_event_args_lead_with_the_category_not_the_text() {
|
||||
let t = ticket();
|
||||
@@ -569,16 +417,13 @@ mod tests {
|
||||
assert_eq!(args[6], ScriptValue::Int(1), "arg7 openedByGM");
|
||||
}
|
||||
|
||||
/// "No ticket" is ONE argument and it is zero — the window's entire else-branch hangs on
|
||||
/// `arg1 and arg1 ~= 0`, so an empty arg list or a nil here would leave the Submit button
|
||||
/// reading "Save Changes" forever.
|
||||
/// The window's form reset hangs on `arg1 and arg1 ~= 0`.
|
||||
#[test]
|
||||
fn the_no_ticket_answer_is_a_single_zero() {
|
||||
assert_eq!(update_ticket_args(None), vec![ScriptValue::Int(0)]);
|
||||
}
|
||||
|
||||
/// A negative wait figure survives to Lua unclamped: it is the server saying "unavailable",
|
||||
/// and the window has a branch for exactly that (`arg4 < 0 or arg5 < 0`).
|
||||
/// The window's `arg4 < 0 or arg5 < 0` test shows "unavailable".
|
||||
#[test]
|
||||
fn an_unknown_wait_time_reaches_lua_as_a_negative_not_a_zero() {
|
||||
let mut t = ticket();
|
||||
@@ -589,9 +434,7 @@ mod tests {
|
||||
assert_eq!(args[4], ScriptValue::Number(-1.0));
|
||||
}
|
||||
|
||||
/// An unchanged answer is still an answer. This is the whole reason the state carries a
|
||||
/// counter: the window polls every 10 minutes and must be re-fed each time, so a second
|
||||
/// identical `SMSG_GMTICKET_GETTICKET` has to move the ticket that the feed diffs on.
|
||||
/// The window's 10-minute poll must be re-fed each time.
|
||||
#[test]
|
||||
fn a_repeated_identical_answer_still_counts_as_an_answer() {
|
||||
let mut state = GmTicketState::default();
|
||||
@@ -600,17 +443,14 @@ mod tests {
|
||||
state.answer(Some(ticket()));
|
||||
assert_ne!(state.answers, first, "the poll must re-fire UPDATE_TICKET");
|
||||
|
||||
// And so does a repeated *empty* one — "still no ticket" is the common case.
|
||||
// A repeated empty answer counts too: "still no ticket" is the common case.
|
||||
state.answer(None);
|
||||
let empty = state.answers;
|
||||
state.answer(None);
|
||||
assert_ne!(state.answers, empty);
|
||||
}
|
||||
|
||||
/// **The engine re-asks after a write lands** — the reference's `0x5e4479` arm, and the reason
|
||||
/// the shipped UI needs no handler for `SMSG_GMTICKET_CREATE`/`UPDATETEXT` at all. Without this
|
||||
/// a filed ticket is invisible until the 10-minute poll: the toast stays dark and the form
|
||||
/// stays a form. The failure codes must NOT re-ask — there is nothing new to fetch.
|
||||
/// The reference's `0x5e4479` re-ask; a refusal has nothing new to fetch.
|
||||
#[test]
|
||||
fn a_landed_write_makes_the_engine_reask_and_a_refused_one_does_not() {
|
||||
let mut state = GmTicketState::default();
|
||||
@@ -624,9 +464,7 @@ mod tests {
|
||||
assert_eq!(state.take_reasks(), 0, "a refusal has nothing to fetch");
|
||||
}
|
||||
|
||||
/// The queue status reaches Lua **signed**. `-1` is the value the window branches on to raise
|
||||
/// "GM Help Tickets are currently unavailable"; read unsigned it would arrive as 4294967295 and
|
||||
/// that arm could never fire. vmangos only sends 0 or 1, so no live run would catch this.
|
||||
/// vmangos sends only 0 or 1, so no live run reaches the window's -1 arm.
|
||||
#[test]
|
||||
fn a_minus_one_queue_status_survives_to_lua_as_minus_one() {
|
||||
let mut state = GmTicketState::default();
|
||||
@@ -635,8 +473,6 @@ mod tests {
|
||||
assert_eq!(i64::from(state.queue_status), -1_i64);
|
||||
}
|
||||
|
||||
/// The socket dying resets both counters and the ticket, so a reconnect cannot show the
|
||||
/// previous session's ticket while the first poll is in flight.
|
||||
#[test]
|
||||
fn a_dead_session_forgets_the_ticket_and_both_answer_counts() {
|
||||
let mut state = GmTicketState::default();
|
||||
@@ -648,8 +484,6 @@ mod tests {
|
||||
assert_eq!(state.queue_answers, 0);
|
||||
}
|
||||
|
||||
/// The window's own verb choice picks the opcode — never our belief about whether a ticket
|
||||
/// exists, which can be up to ten minutes stale.
|
||||
#[test]
|
||||
fn the_windows_verb_picks_the_opcode() {
|
||||
let write = |category, is_new| GmTicketWrite {
|
||||
@@ -671,14 +505,8 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
/// **0 rides the wire; anything above 10 does not** (decision 1687).
|
||||
///
|
||||
/// 0 is what our own window sends, because it has no category picker — vmangos renders it
|
||||
/// "Unknown" rather than refusing it. Above 10 the server drops the packet *silently*, so
|
||||
/// letting one through would be indistinguishable from a filed ticket.
|
||||
///
|
||||
/// The `256` case is the one worth having: it truncates to 0 in a `u8`, so a check written
|
||||
/// after the narrowing would accept it as "uncategorised" when the caller meant category 256.
|
||||
/// vmangos files 0 as "Unknown" and drops 11 and up unanswered; 256 wraps to 0 in a `u8`, so
|
||||
/// the check must run before the narrowing.
|
||||
#[test]
|
||||
fn zero_is_uncategorised_and_anything_above_ten_is_refused() {
|
||||
assert_eq!(category_for_wire(0), Some(0), "the uncategorised ticket");
|
||||
@@ -693,7 +521,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// And the whole path, not just the helper: a refused category produces no packet.
|
||||
// Through the whole path: a refused category makes no packet.
|
||||
let write = |category| GmTicketWrite {
|
||||
category,
|
||||
text: "x".into(),
|
||||
|
||||
@@ -1,27 +1,6 @@
|
||||
//! The app-side **gossip feed** (decision 0081 phase 3) — the inward half of the gossip seam around
|
||||
//! [`benilla_ui::script`]'s `gossip` module, the exact twin of [`crate::ui_items`]'s container seam.
|
||||
//!
|
||||
//! The net bridge fills [`GossipState`] from the wire (`SMSG_GOSSIP_MESSAGE` → options + an ask-once
|
||||
//! `CMSG_NPC_TEXT_QUERY` for the greeting; `SMSG_NPC_TEXT_UPDATE` → the greeting; `SMSG_GOSSIP_COMPLETE`
|
||||
//! → cleared). Each frame [`feed_gossip`] turns that state into a [`GossipMenu`] snapshot, pushes it
|
||||
//! into the VM ([`benilla_ui::script::UiScript::set_gossip`]), and fires `GOSSIP_SHOW` on open (or a
|
||||
//! content change while open — the NPC's name resolving) / `GOSSIP_CLOSED` on clear.
|
||||
//! [`drain_gossip`] pulls the Lua intents back out: `SelectGossipOption` →
|
||||
//! [`ClientCommand::GossipSelectOption`] (mapped through the wire option `index`; coded options are
|
||||
//! guarded, never sent — decision 0081), and `CloseGossip` → a local clear (vanilla's client-side
|
||||
//! close sends no packet; verified against the 1.12 opcode set — there is no `CMSG_GOSSIP_CLOSE`).
|
||||
//!
|
||||
//! **The menu opens only with its greeting resolved, and the hold fires nothing** — a first visit
|
||||
//! to a text id keeps the frame exactly as it was for the query round trip: hidden if it was
|
||||
//! hidden (never options over an empty page — B292, decision 1508), and **still painted with the
|
||||
//! previous menu if it was open** (a sub-menu's first visit repaints in place when its text lands,
|
||||
//! never hides and re-shows the window — decision 1994). Both are the reference's own law: on a
|
||||
//! cache miss `0x4e2010` sets its select latch and returns — no greeting write, no event — and its
|
||||
//! greeting write and `GOSSIP_SHOW` are adjacent and unconditional on the one success path, so
|
||||
//! "gossip frame open with a blank greeting" is not a reachable state, and neither is "gossip frame
|
||||
//! hidden by a pending reply". [`GossipState::text_pending`] is the latch; [`feed_gossip`] fires
|
||||
//! nothing while it holds; [`GossipState::open_menu`]/[`GossipState::text_arrived`] are the two
|
||||
//! wire edges that set and resolve it.
|
||||
//! The gossip window's app side: [`GossipState`] holds the menu and the NPC-text records,
|
||||
//! [`feed_gossip`] pushes the menu and fires its events, and [`drain_gossip`] sends the selects.
|
||||
//! As in the reference's `0x4e2010`, a menu opens only once its greeting is drawn.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
@@ -36,76 +15,45 @@ use crate::ui_quest::{row_is_active, row_is_one_click};
|
||||
use crate::ui_script::{UiFeed, UiInput};
|
||||
use crate::ui_session::{close_npc_session_out_of_range, npc_switched, NpcSession};
|
||||
|
||||
/// The open gossip menu, filled by the net bridge ([`crate::net`]) and read by [`feed_gossip`]. The
|
||||
/// ask-once `records` cache keyed by `text_id` (mirroring [`crate::items::Items`]'s template cache)
|
||||
/// means a revisit to the same NPC never re-queries the text. Cleared on `SMSG_GOSSIP_COMPLETE`,
|
||||
/// a client-side close, and disconnect; the record cache survives (NPC text is static per id).
|
||||
///
|
||||
/// The cache holds the **record**, not a greeting. Which of its 8 blocks greets you is drawn per
|
||||
/// menu-open from the NPC's gender and a die roll (`benilla_protocol::messages::select_greeting`),
|
||||
/// exactly as the reference re-draws it in its gossip handler — so the same `text_id` can greet you
|
||||
/// differently on the next visit, and two NPCs sharing a `text_id` can differ by gender. Caching a
|
||||
/// resolved string, as this did, froze the first draw for the whole session.
|
||||
/// The open gossip menu, plus the NPC-text records by id, which outlive it. The cache holds whole
|
||||
/// records: the reference draws the greeting afresh at each open, from the NPC's gender and a roll.
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct GossipState {
|
||||
/// The NPC whose menu is open; `None` = no menu open.
|
||||
pub(crate) npc: Option<u64>,
|
||||
/// The open menu's `NpcText` id (drives the text query / cache).
|
||||
pub(crate) text_id: u32,
|
||||
/// The greeting drawn for THIS menu-open, or `None` while its query is in flight — the state
|
||||
/// [`GossipState::text_pending`] reads, on which the feed fires nothing and the drains refuse
|
||||
/// selects (B292; module doc). A record that names no line ("Missing gossip text!") never
|
||||
/// parks here: [`GossipState::resolve_greeting`] ends the interaction instead, as the
|
||||
/// reference does.
|
||||
/// This open's drawn greeting; `None` while its text query is out.
|
||||
pub(crate) greeting: Option<String>,
|
||||
/// The selectable option rows (wire `GossipOption`: `index` echoed on select, `icon`, `coded`,
|
||||
/// `message`).
|
||||
pub(crate) options: Vec<GossipOption>,
|
||||
/// The quest rows riding the same packet — `(quest_id, dialog-status icon, level, title)`.
|
||||
/// The level joined the tuple with 1751 window 21: the reference's own
|
||||
/// `GetGossipAvailableQuests`/`GetGossipActiveQuests` return `(title, level)` PAIRS and stock
|
||||
/// `GossipFrame.lua` strides its walk by 2 over them, so a dropped level is a broken stride
|
||||
/// rather than a missing number. It was on the wire all along. The gossip
|
||||
/// window lists them above the options; a click sends `CMSG_QUESTGIVER_QUERY_QUEST` /
|
||||
/// `_COMPLETE_QUEST` (decision 0088).
|
||||
/// The packet's quest rows, `(quest_id, dialog-status icon, level, title)`. The level is kept
|
||||
/// because `GetGossipAvailableQuests` and `GetGossipActiveQuests` return `(title, level)`
|
||||
/// pairs, walked in twos by stock `GossipFrame.lua` (lines 66 and 90).
|
||||
pub(crate) quests: Vec<(u32, u32, u32, String)>,
|
||||
/// Ask-once NPC-text record cache keyed by `text_id` — the 8 undrawn blocks.
|
||||
records: HashMap<u32, Vec<NpcTextBlock>>,
|
||||
}
|
||||
|
||||
impl GossipState {
|
||||
/// The cached record for `text_id`, if `CMSG_NPC_TEXT_QUERY` was already answered for it.
|
||||
pub(crate) fn cached_record(&self, text_id: u32) -> Option<&[NpcTextBlock]> {
|
||||
self.records.get(&text_id).map(Vec::as_slice)
|
||||
}
|
||||
|
||||
/// Record an NPC-text answer (`SMSG_NPC_TEXT_UPDATE`) into the ask-once cache.
|
||||
pub(crate) fn remember_record(&mut self, text_id: u32, blocks: Vec<NpcTextBlock>) {
|
||||
self.records.insert(text_id, blocks);
|
||||
}
|
||||
|
||||
/// Draw this menu-open's greeting out of `text_id`'s cached record for an NPC of `npc_gender`
|
||||
/// — `None` if the record hasn't arrived yet, or names no line for that gender.
|
||||
/// `None` when the record has not arrived or has no line in the NPC's gender column.
|
||||
pub(crate) fn draw_greeting(&self, text_id: u32, npc_gender: u8) -> Option<String> {
|
||||
let blocks = self.cached_record(text_id)?;
|
||||
select_greeting(blocks, npc_gender, greeting_roll()).map(str::to_string)
|
||||
}
|
||||
|
||||
/// The reference's text-pending latch (`[0xbbb670]`): a
|
||||
/// session is latched but its greeting has not resolved — its `CMSG_NPC_TEXT_QUERY` is in
|
||||
/// flight, or (`text_id == 0`) was never sent. While it holds, [`feed_gossip`] fires nothing
|
||||
/// and both drains refuse every select (decisions 1508, 1994).
|
||||
/// The reference's text-pending latch (`[0xbbb670]`): a menu whose text query is out, or was
|
||||
/// never sent (`text_id` 0). While it holds, nothing fires and every select is refused.
|
||||
pub(crate) fn text_pending(&self) -> bool {
|
||||
self.npc.is_some() && self.greeting.is_none()
|
||||
}
|
||||
|
||||
/// A gossip menu arrived (`SMSG_GOSSIP_MESSAGE`): latch the session. Returns `true` when the
|
||||
/// greeting still needs its `CMSG_NPC_TEXT_QUERY` (first visit to this text id) — the caller
|
||||
/// sends it, and the frame keeps whatever it shows until [`Self::text_arrived`] resolves it
|
||||
/// (the hold, module doc). A cached record resolves the menu right here; a `text_id` of 0
|
||||
/// never queries and never resolves — the reference's `DBCache::Get` refuses id 0 before
|
||||
/// sending anything, so its frame stays as it was with option selects latched off, which is
|
||||
/// what our pending state is.
|
||||
/// Latch a `SMSG_GOSSIP_MESSAGE` menu; `true` when the caller owes the `CMSG_NPC_TEXT_QUERY`.
|
||||
/// A `text_id` of 0 stays pending: the reference's `DBCache::Get` never queries id 0.
|
||||
pub(crate) fn open_menu(
|
||||
&mut self,
|
||||
npc: u64,
|
||||
@@ -131,11 +79,9 @@ impl GossipState {
|
||||
}
|
||||
}
|
||||
|
||||
/// The NPC-text answer (`SMSG_NPC_TEXT_UPDATE`): seed the ask-once cache, and if it answers
|
||||
/// the menu still waiting on it, open that menu now. A late answer for a menu we already
|
||||
/// closed or switched away from just seeds the cache; an answer for a menu already open does
|
||||
/// not re-roll its greeting (the reference's cache callback only re-enters for the pending
|
||||
/// query it was registered by).
|
||||
/// Cache a `SMSG_NPC_TEXT_UPDATE` record and resolve the menu waiting on it, if any. An open
|
||||
/// menu's greeting is not re-drawn: the reference's cache callback re-enters only for the
|
||||
/// query it was registered for.
|
||||
pub(crate) fn text_arrived(&mut self, text_id: u32, blocks: Vec<NpcTextBlock>, npc_gender: u8) {
|
||||
self.remember_record(text_id, blocks);
|
||||
if self.npc.is_some() && self.text_id == text_id && self.greeting.is_none() {
|
||||
@@ -143,11 +89,9 @@ impl GossipState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw the open menu's greeting from its (present) record — or, when the record names no
|
||||
/// line for this NPC's gender column, end the interaction: the reference's `missing` path
|
||||
/// logs "Missing gossip text!" and fires `GOSSIP_CLOSED` instead of ever opening the frame
|
||||
/// (`0x4e216e`). Ours never opened either, so the clear is the whole
|
||||
/// close.
|
||||
/// Draw the open menu's greeting, or end the interaction when the record has no line for the
|
||||
/// NPC's gender: the reference's missing-text path (`0x4e216e`) logs "Missing gossip text!"
|
||||
/// and fires `GOSSIP_CLOSED`. Here the clear makes the feed fire it if a menu was showing.
|
||||
fn resolve_greeting(&mut self, npc_gender: u8) {
|
||||
match self.draw_greeting(self.text_id, npc_gender) {
|
||||
Some(line) => self.greeting = Some(line),
|
||||
@@ -162,17 +106,14 @@ impl GossipState {
|
||||
}
|
||||
}
|
||||
|
||||
/// The reference's uniform float in `[1.0, 2.0)` for the greeting draw: it stuffs PRNG bits straight
|
||||
/// into a mantissa (`(rand & 0x7fffff) | 0x3f800000`), which is what makes the range `[1, 2)` rather
|
||||
/// than the `[0, 1)` you would expect. We build the same shape from our own source — the *stream*
|
||||
/// can't match (it is the client's own PRNG, seeded per run) but the distribution is what the law
|
||||
/// needs.
|
||||
/// The greeting draw's roll in `[1.0, 2.0)`, built as the reference builds it: random bits in the
|
||||
/// mantissa of 1.0 (`(rand & 0x7fffff) | 0x3f800000`), from our own random source.
|
||||
fn greeting_roll() -> f32 {
|
||||
f32::from_bits((rand::random::<u32>() & 0x7f_ffff) | 0x3f80_0000)
|
||||
}
|
||||
|
||||
impl GossipState {
|
||||
/// Close the open menu (`SMSG_GOSSIP_COMPLETE` / a client-side close). Keeps the greeting cache.
|
||||
/// Close the menu; the records stay.
|
||||
pub(crate) fn clear(&mut self) {
|
||||
self.npc = None;
|
||||
self.text_id = 0;
|
||||
@@ -181,8 +122,7 @@ impl GossipState {
|
||||
self.quests.clear();
|
||||
}
|
||||
|
||||
/// Disconnect: drop the open menu (the greeting cache is static, like item templates — kept),
|
||||
/// mirroring [`crate::items::Items::clear_session`].
|
||||
/// The disconnect clear; NPC text is static per id, so the records stay.
|
||||
pub(crate) fn clear_session(&mut self) {
|
||||
self.clear();
|
||||
}
|
||||
@@ -191,7 +131,7 @@ impl GossipState {
|
||||
mod net;
|
||||
pub(crate) use net::gossip_complete as end_interaction;
|
||||
|
||||
/// The gossip window's feed + drain (decision 0081), cloned from [`crate::ui_items::UiItemsPlugin`].
|
||||
/// The gossip window's handlers, feed and drain.
|
||||
pub(crate) struct UiGossipPlugin;
|
||||
|
||||
impl Plugin for UiGossipPlugin {
|
||||
@@ -200,9 +140,7 @@ impl Plugin for UiGossipPlugin {
|
||||
app.init_resource::<GossipState>().add_systems(
|
||||
Update,
|
||||
(
|
||||
// Range-close before the feed so the clear turns into GOSSIP_CLOSED the same frame;
|
||||
// push 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 (mirrors ui_items).
|
||||
// Range-close first so the clear fires `GOSSIP_CLOSED` the same frame.
|
||||
close_npc_session_out_of_range::<GossipState>.before(feed_gossip),
|
||||
feed_gossip.in_set(UiFeed),
|
||||
drain_gossip.after(UiInput),
|
||||
@@ -211,9 +149,7 @@ impl Plugin for UiGossipPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
/// The gossip menu is an NPC session: the standardized range guard ([`crate::ui_session`])
|
||||
/// client-side-closes it — the exact `CloseGossip` clear, no packet — when the player walks out of
|
||||
/// the NPC's service range or the NPC despawns.
|
||||
/// The range guard closes the menu, as `CloseGossip` does, when the NPC is out of range or gone.
|
||||
impl NpcSession for GossipState {
|
||||
fn npc(&self) -> Option<u64> {
|
||||
self.npc
|
||||
@@ -224,24 +160,11 @@ impl NpcSession for GossipState {
|
||||
}
|
||||
}
|
||||
|
||||
/// The lowercase Era `GetGossipOptions()` icon *type* for a wire `GOSSIP_ICON` byte — the value
|
||||
/// the XML resolves to a `Interface\GossipFrame\<Type>GossipIcon` texture.
|
||||
/// The `GetGossipOptions()` type string for a wire `GOSSIP_ICON` byte, which the reference's
|
||||
/// `0x4e28d0` uses as a bare index into the 14-entry table at `0x84b7ac`.
|
||||
///
|
||||
/// **The byte is a bare index into [`GOSSIP_ICON_TYPES`], with no bounds check anywhere on the
|
||||
/// client's path** (decision 1335): `GetGossipOptions 0x4e28d0` reads the stored byte and does
|
||||
/// `mov edx,[eax*4 + 0x84b7ac]` straight into a 14-entry pointer table, then `lua_pushstring`s it.
|
||||
///
|
||||
/// The old map read the byte through **vmangos's `GossipDef.h` enum *names*** (`INTERACT_1`,
|
||||
/// `MONEY_BAG`, `TALK`, …), which name a **later** client's icon art. It was wrong for six of the
|
||||
/// eleven values; 5 — the icon every `GOSSIP_OPTION_INNKEEPER` row in the world DB sends — had no
|
||||
/// entry at all and fell through to the chat bubble, which is the icon half of B249 (decision 1331).
|
||||
///
|
||||
/// Two of the reference's out-of-range behaviours we deliberately do **not** reproduce, because
|
||||
/// both are its missing guard rather than its design:
|
||||
/// index **14** is a NULL in the table, so the reference pushes `nil` as the option's type and the
|
||||
/// row draws whatever the XML's own fallback gives it — the chat bubble, which is what we return
|
||||
/// directly. Index **≥15** walks off the end into the gossip Lua *binding* table, pushing API-name
|
||||
/// literals and `strlen`'d `.text` addresses as type strings, and eventually faults. We clamp.
|
||||
/// Deviation: 14 (a NULL, pushed as `nil`) and 15 up (a read past the table's end) give
|
||||
/// `"gossip"`, because the reference's behaviour there is a missing bounds check.
|
||||
fn gossip_icon_type(icon: u8) -> &'static str {
|
||||
GOSSIP_ICON_TYPES
|
||||
.get(icon as usize)
|
||||
@@ -249,15 +172,8 @@ fn gossip_icon_type(icon: u8) -> &'static str {
|
||||
.unwrap_or("gossip")
|
||||
}
|
||||
|
||||
/// The client's icon-name table verbatim — `0x84b7ac`, 14 entries, indexed by the wire
|
||||
/// `GOSSIP_ICON` byte (see [`gossip_icon_type`]). Indices 11-13 are genuine `gossip` aliases in the
|
||||
/// binary, not padding.
|
||||
///
|
||||
/// Ten of the eleven distinct names have a `Interface\GossipFrame\<Type>GossipIcon.blp` behind
|
||||
/// them on the 5875 chain; **`auctioneer` does not** — the table names art the client never shipped
|
||||
/// (auction houses came with 1.9, the icon later). [`gossip_icon_types_name_the_shipped_art`] pins
|
||||
/// both halves of that, and the XML deliberately has no `auctioneer` key: a missing texture draws
|
||||
/// nothing, our fallback draws the bubble, and the bubble is the better wrong answer.
|
||||
/// The reference's icon-type table at `0x84b7ac`, verbatim; 11-13 are real `gossip` aliases.
|
||||
/// `auctioneer` names art 1.12 never shipped, so its row has no icon, in the reference too.
|
||||
const GOSSIP_ICON_TYPES: [&str; 14] = [
|
||||
"gossip",
|
||||
"vendor",
|
||||
@@ -275,18 +191,14 @@ const GOSSIP_ICON_TYPES: [&str; 14] = [
|
||||
"gossip",
|
||||
];
|
||||
|
||||
/// Build the Lua-facing snapshot from [`GossipState`] — `None` when no menu is open. The second
|
||||
/// `?` is the hold's type-level edge (a `GossipMenu` always has its greeting — B292, module doc);
|
||||
/// [`feed_gossip`] gates on [`GossipState::text_pending`] before ever calling this, so a `None`
|
||||
/// it sees means no session, never "pending" — a pending frame keeps its last snapshot.
|
||||
/// The Lua-facing menu, `None` with no menu open. [`feed_gossip`] never calls it while the text
|
||||
/// is pending, so its `None` never means pending.
|
||||
fn snapshot(state: &GossipState) -> Option<GossipMenu> {
|
||||
state.npc?;
|
||||
Some(GossipMenu {
|
||||
greeting: state.greeting.clone()?,
|
||||
// Quest rows riding the gossip packet (decision 0088): split active-vs-available by the same
|
||||
// predicate the quest window's greeting panel uses — the WIRE ICON. The reference runs the
|
||||
// identical `{3,4}` test here, just lazily (`0x4e2430`/`0x4e2580`, behind
|
||||
// `GetGossipAvailableQuests`/`GetGossipActiveQuests`) rather than at parse time. Decision 0758.
|
||||
// Active or available by the wire icon: the reference's `{3,4}` test in `0x4e2430` and
|
||||
// `0x4e2580`, behind `GetGossipAvailableQuests` and `GetGossipActiveQuests`.
|
||||
quests: state
|
||||
.quests
|
||||
.iter()
|
||||
@@ -308,9 +220,7 @@ fn snapshot(state: &GossipState) -> Option<GossipMenu> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Push the current menu into the VM and fire the open/close events on a transition (or a content
|
||||
/// change — the greeting arriving a frame after the menu). Diffed against a `Local` memory, exactly
|
||||
/// like the container feed's per-bag diff.
|
||||
/// Push the menu into the VM and fire `GOSSIP_SHOW` or `GOSSIP_CLOSED` on a change.
|
||||
fn feed_gossip(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
state: Res<GossipState>,
|
||||
@@ -328,29 +238,20 @@ fn feed_gossip(
|
||||
let last = last.get(&script);
|
||||
let last_name = last_name.get(&script);
|
||||
let last_npc = last_npc.get(&script);
|
||||
// The gossip NPC's name resolves through the NameCache (ask-once — the merchant feed's pattern),
|
||||
// delivered as arg1 of GOSSIP_SHOW; `None`/empty while the query is in flight, and the diff below
|
||||
// tracks it so a name-only landing still re-fires GOSSIP_SHOW to repaint the title. It rides an
|
||||
// event arg rather than a `GossipMenu` field so no benilla-ui engine change is needed for the title.
|
||||
// Asked ahead of the hold, so a first visit's name query goes out beside its text query rather
|
||||
// than a round trip after it.
|
||||
// The NPC's name rides `GOSSIP_SHOW` as arg1, and its landing alone re-fires the event; the
|
||||
// reference fires `GOSSIP_SHOW` bare (`0x4e22b0`), and stock `GossipFrame.lua:33` titles with
|
||||
// `UnitName("npc")`. Asked before the hold, so the name query goes out beside the text query.
|
||||
let npc_name = state
|
||||
.npc
|
||||
.and_then(|g| names.resolve(g, &commands).map(str::to_string));
|
||||
// **The hold fires nothing** (decision 1994). While the greeting query is in flight the
|
||||
// reference's handler has set its select latch and RETURNED — no greeting write, no event
|
||||
// (`0x4e2010`, exit `0x4e2068`) — so its frame keeps whatever it last painted:
|
||||
// hidden if it was hidden, the previous menu if it was open, clicks refused (`drain_gossip`).
|
||||
// A pending menu is therefore NOT a closed one: the VM keeps its last snapshot, `last` keeps
|
||||
// its memory, and the text landing is a plain in-place `GOSSIP_SHOW` below. Reading the hold
|
||||
// as a close (the snapshot going `Some → None`) hid the open window for the round trip and
|
||||
// re-showed it when the text answered — the flash on a sub-menu's first visit, first visit
|
||||
// only because the record cache serves every later one without a hold.
|
||||
// While the text query is out, fire nothing and keep the last snapshot: the reference returns
|
||||
// at `0x4e2068` with no write and no event, so its frame keeps what it last showed. Reading
|
||||
// the hold as a close would flash an open window.
|
||||
if state.text_pending() {
|
||||
return;
|
||||
}
|
||||
let mut fresh = snapshot(&state);
|
||||
// Expand the greeting's chat-text macros ($N/$B/$G/$<n>w) client-side, as the real client does.
|
||||
// Expand the greeting's `$N`/`$B`/`$G`/`$<n>w` macros client-side, as the reference does.
|
||||
if let Some(greeting) = fresh.as_mut().map(|m| &mut m.greeting) {
|
||||
let player = crate::npc_text::player_identity(&self_q, &names, &commands);
|
||||
*greeting = crate::npc_text::substitute(
|
||||
@@ -362,12 +263,8 @@ fn feed_gossip(
|
||||
);
|
||||
}
|
||||
let name_changed = *last_name != npc_name;
|
||||
// A different NPC while the menu is already open is a real close+open (decision 0096 /
|
||||
// [`crate::ui_session::npc_switched`]); a cross-window switch is handled by OnHide → CloseX on
|
||||
// panel displacement (decision 0095). Past the hold, `fresh` is `Some` exactly when a session
|
||||
// is latched, so the shown menu's NPC is the session's — and a switch INTO a first-visit hold
|
||||
// (open A → pending B) is judged here when B's text answers, as one close+open pair; nothing
|
||||
// fires at the pending edge.
|
||||
// A different NPC while open is a close then an open; a switch into a first-visit hold is
|
||||
// judged when its text answers, as one pair.
|
||||
let switched = npc_switched(*last_npc, state.npc);
|
||||
if fresh == *last && !name_changed && !switched {
|
||||
return;
|
||||
@@ -375,18 +272,16 @@ fn feed_gossip(
|
||||
script.set_gossip(fresh.clone());
|
||||
let name_arg = || vec![ScriptValue::Str(npc_name.clone().unwrap_or_default())];
|
||||
if switched {
|
||||
// Close the old NPC's menu, open the new: the frame hides then shows, playing both kits.
|
||||
// GOSSIP_CLOSED routes through OnHide → CloseGossip (decision 0095), which queues a close
|
||||
// intent — consume it so the drain does not clear the menu we just re-opened.
|
||||
// `GOSSIP_CLOSED` queues a close intent through OnHide; consume it so the drain keeps
|
||||
// the new menu.
|
||||
script.fire_event("GOSSIP_CLOSED", vec![]);
|
||||
script.fire_event("GOSSIP_SHOW", name_arg());
|
||||
let _ = script.take_gossip_close();
|
||||
} else {
|
||||
match (&*last, &fresh) {
|
||||
// Opened, or the greeting/options/name changed while open → (re)paint via GOSSIP_SHOW.
|
||||
// Opened, or changed while open.
|
||||
(_, Some(_)) => script.fire_event("GOSSIP_SHOW", name_arg()),
|
||||
// Closed — the session is over (`clear`). OnHide → CloseGossip queues a close intent
|
||||
// for a session already gone; the drain's clear is a no-op, so nothing to consume.
|
||||
// The close intent OnHide queues finds no session, so there is nothing to consume.
|
||||
(Some(_), None) => script.fire_event("GOSSIP_CLOSED", vec![]),
|
||||
(None, None) => {}
|
||||
}
|
||||
@@ -396,9 +291,10 @@ fn feed_gossip(
|
||||
*last_npc = state.npc;
|
||||
}
|
||||
|
||||
/// Drain the Lua intents: a selected option → `CMSG_GOSSIP_SELECT_OPTION` (mapped to the wire option
|
||||
/// `index`; a coded option is guarded and never sent — decision 0081); a close → a local clear (no
|
||||
/// packet, vanilla).
|
||||
/// Drain the Lua intents: an option sends `CMSG_GOSSIP_SELECT_OPTION` with its wire `index`, a
|
||||
/// quest row its questgiver query, and `CloseGossip` clears locally, as there is no close packet.
|
||||
/// A coded option is not sent: the reference's code prompt (`GOSSIP_ENTER_CODE`, `0x4e237e`) is
|
||||
/// not built.
|
||||
fn drain_gossip(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
mut state: ResMut<GossipState>,
|
||||
@@ -409,9 +305,8 @@ fn drain_gossip(
|
||||
};
|
||||
for pos in script.take_gossip_selects() {
|
||||
let Some(npc) = state.npc else { continue };
|
||||
// While the greeting query is in flight — the frame may still show the previous menu
|
||||
// (1994) — refuse the select, as the reference does: its `SelectGossipOption` (`0x4e2320`)
|
||||
// is silently refused while the text-pending latch `0xbbb670` is set.
|
||||
// Refused while the text query is out, as the reference's `SelectGossipOption`
|
||||
// (`0x4e2320`) refuses under the latch `0xbbb670`.
|
||||
if state.text_pending() {
|
||||
debug!(
|
||||
"ui_gossip: SelectGossipOption({pos}) while the text query is in flight — refused"
|
||||
@@ -434,12 +329,10 @@ fn drain_gossip(
|
||||
None => debug!("ui_gossip: SelectGossipOption({pos}) out of range — ignored"),
|
||||
}
|
||||
}
|
||||
// Quest-row clicks (decision 0088): map the 1-based row to its quest id and send
|
||||
// QUERY_QUEST (available → look at/accept) or COMPLETE_QUEST (active → turn-in progress).
|
||||
// Quest-row clicks: the 1-based row picks the quest.
|
||||
for pos in script.take_gossip_quest_selects() {
|
||||
let Some(npc) = state.npc else { continue };
|
||||
// Same session-pending rule as the option selects above (the latch there is the verified
|
||||
// one; these rows belong to the same not-yet-resolved menu).
|
||||
// Refused while pending too; the reference's latch is traced for option selects only.
|
||||
if state.text_pending() {
|
||||
debug!(
|
||||
"ui_gossip: SelectGossipQuest({pos}) while the text query is in flight — refused"
|
||||
@@ -453,8 +346,8 @@ fn drain_gossip(
|
||||
Some((quest_id, icon, _level, _title)) => {
|
||||
let (quest, icon) = (*quest_id, *icon);
|
||||
let active = row_is_active(icon);
|
||||
// Same opcode law as the greeting panel: an active row is always a turn-in; an
|
||||
// available row turns into one only when its one-click flag is set.
|
||||
// As on the quest greeting panel: an active row is a turn-in, and so is an
|
||||
// available one with its one-click flag.
|
||||
let cmd = if active || row_is_one_click(icon) {
|
||||
ClientCommand::QuestgiverComplete { npc, quest }
|
||||
} else {
|
||||
@@ -476,8 +369,7 @@ fn drain_gossip(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The one type string [`GOSSIP_ICON_TYPES`] names that the 5875 install ships no art for.
|
||||
/// Test-only: nothing in the client needs to know, because the XML simply has no key for it.
|
||||
/// The one type in [`GOSSIP_ICON_TYPES`] with no art in the 1.12 install.
|
||||
const GOSSIP_ICON_TYPE_UNSHIPPED: &str = "auctioneer";
|
||||
|
||||
#[test]
|
||||
@@ -498,11 +390,9 @@ mod tests {
|
||||
assert_eq!(menu.options[0].icon_type, "vendor");
|
||||
}
|
||||
|
||||
/// B292's hold: a first visit (record not cached) keeps the menu CLOSED for the query round
|
||||
/// trip — options never render over an empty page — and the text arriving opens it complete.
|
||||
/// The reference's law (`0x4e229a`/`0x4e22b0`): the greeting write
|
||||
/// and `GOSSIP_SHOW` are adjacent and unconditional on one success path; every other exit
|
||||
/// fires no event.
|
||||
/// A first visit keeps the menu closed until its text answers, then opens it complete: the
|
||||
/// reference's greeting write and `GOSSIP_SHOW` sit together on its one success path
|
||||
/// (`0x4e229a`, `0x4e22b0`), and its cache-miss exit fires nothing.
|
||||
#[test]
|
||||
fn first_visit_holds_the_menu_until_the_text_answers() {
|
||||
let mut state = GossipState::default();
|
||||
@@ -533,9 +423,9 @@ mod tests {
|
||||
assert_eq!(menu.options.len(), 1, "options open WITH the greeting");
|
||||
}
|
||||
|
||||
// ── The hold fires nothing (decision 1994): the real feed, driven ─────────────────────────
|
||||
// ── The hold, through the real feed ─────────────────────────────────────────
|
||||
|
||||
/// A Lua event recorder for the two gossip events — what the VM saw, in order.
|
||||
/// Records the two gossip events the VM sees, in order.
|
||||
const RECORDER: &str = r#"
|
||||
SEEN = {}
|
||||
local f = CreateFrame("Frame")
|
||||
@@ -544,10 +434,8 @@ mod tests {
|
||||
f:SetScript("OnEvent", function() table.insert(SEEN, event) end)
|
||||
"#;
|
||||
|
||||
/// The real [`feed_gossip`] + [`drain_gossip`] over a VM — the [`crate::ui_quest`] harness
|
||||
/// shape: a minimal `App` with the feed's resources, the script as the non-send resource,
|
||||
/// the two systems in their plugin order. `prepare` seeds the VM (the recorder, or the stock
|
||||
/// window); the receiver is what the drain sent.
|
||||
/// The real [`feed_gossip`] and [`drain_gossip`] over a VM, in plugin order; `prepare` seeds
|
||||
/// the VM, and the receiver gets what the drain sent.
|
||||
fn feed_app(
|
||||
prepare: impl FnOnce(&mut UiScript),
|
||||
) -> (App, crossbeam_channel::Receiver<ClientCommand>) {
|
||||
@@ -568,8 +456,7 @@ mod tests {
|
||||
app.world_mut().resource_mut::<GossipState>()
|
||||
}
|
||||
|
||||
/// One frame: run the feed, then what the VM saw — the recorder's `SEEN` (drained) and
|
||||
/// `GetGossipText()` (`""` for nil).
|
||||
/// One frame, then the events the VM saw and `GetGossipText()` (`""` for nil).
|
||||
fn tick(app: &mut App) -> (Vec<String>, String) {
|
||||
app.update();
|
||||
let s = app.world_mut().non_send_resource_mut::<UiScript>();
|
||||
@@ -597,8 +484,7 @@ mod tests {
|
||||
}]
|
||||
}
|
||||
|
||||
/// The selects the drain actually sent, out of everything on the channel (the feed's own
|
||||
/// ask-once name query rides it too).
|
||||
/// The selects sent; the channel also carries the feed's name query.
|
||||
fn selects_sent(rx: &crossbeam_channel::Receiver<ClientCommand>) -> usize {
|
||||
rx.try_iter()
|
||||
.filter(|c| matches!(c, ClientCommand::GossipSelectOption { .. }))
|
||||
@@ -607,19 +493,9 @@ mod tests {
|
||||
|
||||
const GUARD: u64 = 0xF130_0000_0000_0007; // HIGHGUID_UNIT
|
||||
|
||||
/// **The hold fires nothing** (decision 1994) — the director's *"the whole window flashes
|
||||
/// before it shows the content"* on a Stormwind guard's *"Where is …"* option, first click
|
||||
/// only. The real feed is driven through the sequence the wire produces: the greeting menu
|
||||
/// open from the cache, then the select's answer — a NEW `SMSG_GOSSIP_MESSAGE` on the SAME NPC
|
||||
/// whose text id is a first visit, so the `CMSG_NPC_TEXT_QUERY` round trip — then its
|
||||
/// `SMSG_NPC_TEXT_UPDATE`.
|
||||
///
|
||||
/// Before 1994 the pending step fired `GOSSIP_CLOSED` (the snapshot went `Some → None` and
|
||||
/// the feed read that as a close) and the landing fired `GOSSIP_SHOW`: `HideUIPanel` then
|
||||
/// `ShowUIPanel` across a server round trip — the flash. First click only, because the record
|
||||
/// cache serves the second visit with no hold. The reference fires nothing at the pending
|
||||
/// step (`0x4e2010`, exit `0x4e2068`): its frame keeps the previous menu painted,
|
||||
/// selects refused, until the text answers — then one in-place repaint.
|
||||
/// A guard's sub-menu on a first-visit text: nothing fires while the query is out, the old
|
||||
/// menu stays with its selects refused, and the text repaints in place, as the reference's
|
||||
/// exit at `0x4e2068` leaves it. A close fired at the pending step would flash the window.
|
||||
#[test]
|
||||
fn the_hold_fires_nothing_a_pending_submenu_keeps_the_frame_painted() {
|
||||
let (mut app, rx) = feed_app(|s| s.run(RECORDER).unwrap());
|
||||
@@ -638,9 +514,8 @@ mod tests {
|
||||
)
|
||||
);
|
||||
|
||||
// "The bank" is clicked; the answer is a new menu on the same NPC with a text id never
|
||||
// seen — the query is owed, and for the whole round trip NOTHING fires: the first menu
|
||||
// stays in the VM, and a click on one of its (stale) rows is refused, not sent.
|
||||
// "The bank" is clicked: a new menu on the same NPC with an unseen text id. Nothing fires
|
||||
// for the round trip, and a click on the old menu's row is refused.
|
||||
assert!(
|
||||
state(&mut app).open_menu(GUARD, 51, option("Goodbye"), Vec::new(), 0),
|
||||
"first visit to the sub-menu's text: the query is owed"
|
||||
@@ -663,7 +538,7 @@ mod tests {
|
||||
"a select during the hold is refused (the latch)"
|
||||
);
|
||||
|
||||
// The text lands: one in-place repaint, never a close — and selects work again.
|
||||
// The text lands: one in-place repaint, and selects work again.
|
||||
state(&mut app).text_arrived(51, record("The bank is north of here."), 0);
|
||||
assert_eq!(
|
||||
tick(&mut app),
|
||||
@@ -679,14 +554,13 @@ mod tests {
|
||||
let _ = tick(&mut app);
|
||||
assert_eq!(selects_sent(&rx), 1);
|
||||
|
||||
// Controls. A real end of the session still closes …
|
||||
// Controls: a real end still closes, and a first visit from a closed frame stays shut
|
||||
// until its text answers.
|
||||
state(&mut app).clear();
|
||||
assert_eq!(
|
||||
tick(&mut app),
|
||||
(vec!["GOSSIP_CLOSED".to_string()], String::new())
|
||||
);
|
||||
// … and a first-ever visit from a closed frame still holds shut (B292) and opens once,
|
||||
// complete, when its text answers.
|
||||
assert!(state(&mut app).open_menu(GUARD, 52, option("Goodbye"), Vec::new(), 0));
|
||||
assert_eq!(
|
||||
tick(&mut app),
|
||||
@@ -700,11 +574,9 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The same sequence through the stock `GossipFrame.xml` (the reference's own file, 1751) —
|
||||
/// the symptom at the window: across the round trip it stays **visible**, plays no close kit,
|
||||
/// and still lists the first menu's row; the text landing repaints in place, and since
|
||||
/// `ShowUIPanel` early-returns on a visible frame (0096's own mechanism) that plays no open
|
||||
/// kit either. Needs client data.
|
||||
/// The same sequence through stock `GossipFrame.xml`: the window stays up, silent, on the
|
||||
/// first menu, and the repaint plays no open kit, as `ShowUIPanel` returns early on a visible
|
||||
/// frame.
|
||||
#[test]
|
||||
fn stock_gossip_frame_stays_visible_through_a_first_visit_submenu() {
|
||||
use benilla_ui::script::SoundRequest;
|
||||
@@ -748,7 +620,7 @@ mod tests {
|
||||
"the greeting menu opens with its kit"
|
||||
);
|
||||
|
||||
// The click's answer holds on its text: the window must not so much as blink.
|
||||
// The click's answer holds on its text: the window must not blink.
|
||||
assert!(state(&mut app).open_menu(GUARD, 51, option("Goodbye"), Vec::new(), 0));
|
||||
for _ in 0..3 {
|
||||
let (seen, _) = tick(&mut app);
|
||||
@@ -765,7 +637,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// The text lands: repainted in place — new greeting, new row, no kit.
|
||||
// The text lands: repainted in place, with no kit.
|
||||
state(&mut app).text_arrived(51, record("The bank is north of here."), 0);
|
||||
let (seen, _) = tick(&mut app);
|
||||
assert_eq!(seen, vec!["GOSSIP_SHOW".to_string()]);
|
||||
@@ -781,8 +653,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A revisit (record cached) opens immediately with no query: the empty frame is a
|
||||
/// first-visit-only state.
|
||||
#[test]
|
||||
fn revisit_opens_immediately_from_the_cache() {
|
||||
let mut state = GossipState::default();
|
||||
@@ -802,13 +672,11 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The reference's `missing` path (`0x4e216e`): a record that names no line for this NPC's
|
||||
/// gender column ends the interaction — the frame never opens with a blank page, and never
|
||||
/// parks half-open either. And `text_id == 0` never queries at all (`DBCache::Get` refuses
|
||||
/// id 0): the menu stays latched closed.
|
||||
/// The reference's missing-text path (`0x4e216e`) ends the interaction, and its
|
||||
/// `DBCache::Get` never queries id 0.
|
||||
#[test]
|
||||
fn missing_text_ends_the_interaction_and_id_zero_never_queries() {
|
||||
// Missing: the record arrives but its male column is empty → no line → session over.
|
||||
// The record's male column is empty, so a male NPC has no line.
|
||||
let mut state = GossipState::default();
|
||||
assert!(state.open_menu(0x42, 60, Vec::new(), Vec::new(), 0));
|
||||
state.text_arrived(
|
||||
@@ -829,8 +697,6 @@ mod tests {
|
||||
assert!(snapshot(&state).is_none());
|
||||
}
|
||||
|
||||
/// A late answer for a menu we closed or switched away from only seeds the cache; an answer
|
||||
/// for a menu already open does not re-roll its greeting.
|
||||
#[test]
|
||||
fn late_or_duplicate_answers_only_seed_the_cache() {
|
||||
let blocks = || {
|
||||
@@ -874,8 +740,7 @@ mod tests {
|
||||
}],
|
||||
);
|
||||
state.clear();
|
||||
// The open menu is gone but the cached RECORD is still servable (a revisit won't re-query
|
||||
// — it re-draws from these blocks).
|
||||
// The record outlives the menu; a revisit re-draws from it.
|
||||
assert!(state.cached_record(50).is_some());
|
||||
assert_eq!(
|
||||
state.draw_greeting(50, 0).as_deref(),
|
||||
@@ -885,9 +750,7 @@ mod tests {
|
||||
assert!(state.npc.is_none());
|
||||
}
|
||||
|
||||
/// The record is cached, the GREETING is not: a female NPC and a male NPC sharing one `text_id`
|
||||
/// must read their own columns. Caching a resolved string (as this did) froze whichever one
|
||||
/// asked first for the rest of the session.
|
||||
/// The record is cached, not the greeting, so NPCs sharing a `text_id` read their own columns.
|
||||
#[test]
|
||||
fn one_cached_record_greets_each_gender_from_its_own_column() {
|
||||
let mut state = GossipState::default();
|
||||
@@ -910,43 +773,30 @@ mod tests {
|
||||
assert_eq!(state.draw_greeting(78, 0), None, "record not yet arrived");
|
||||
}
|
||||
|
||||
/// The icon byte is an INDEX into the client's own table (`0x84b7ac`, VERIFIED — decision
|
||||
/// 1335), and these are the seats that were wrong before 1331. Each is pinned to the
|
||||
/// `option_id` the world DB pairs it with, because that pairing is what makes the table
|
||||
/// legible: spirit healers send 4, innkeepers 5, bankers 6, petitioners 7, tabard designers 8,
|
||||
/// battlemasters 9.
|
||||
/// The reference's table (`0x84b7ac`), each index beside the `GOSSIP_OPTION_*` the world DB
|
||||
/// sends it with.
|
||||
#[test]
|
||||
fn the_icon_byte_indexes_the_clients_own_table() {
|
||||
assert_eq!(gossip_icon_type(0), "gossip");
|
||||
assert_eq!(gossip_icon_type(1), "vendor"); // GOSSIP_OPTION_VENDOR / _ARMORER
|
||||
assert_eq!(gossip_icon_type(2), "taxi"); // _TAXIVENDOR
|
||||
assert_eq!(gossip_icon_type(3), "trainer"); // _TRAINER
|
||||
assert_eq!(gossip_icon_type(4), "healer"); // _SPIRITHEALER / _SPIRITGUIDE — was "binder"
|
||||
assert_eq!(gossip_icon_type(5), "binder"); // _INNKEEPER — B249: was the chat bubble
|
||||
assert_eq!(gossip_icon_type(6), "banker"); // _BANKER — was "tabard"
|
||||
assert_eq!(gossip_icon_type(7), "petition"); // _PETITIONER — was the chat bubble
|
||||
assert_eq!(gossip_icon_type(8), "tabard"); // _TABARDDESIGNER — was the chat bubble
|
||||
assert_eq!(gossip_icon_type(4), "healer"); // _SPIRITHEALER / _SPIRITGUIDE
|
||||
assert_eq!(gossip_icon_type(5), "binder"); // _INNKEEPER
|
||||
assert_eq!(gossip_icon_type(6), "banker"); // _BANKER
|
||||
assert_eq!(gossip_icon_type(7), "petition"); // _PETITIONER
|
||||
assert_eq!(gossip_icon_type(8), "tabard"); // _TABARDDESIGNER
|
||||
assert_eq!(gossip_icon_type(9), "battlemaster"); // _BATTLEFIELD
|
||||
assert_eq!(gossip_icon_type(10), "auctioneer"); // _AUCTIONEER — was the chat bubble
|
||||
// 11-13 are genuine `gossip` aliases in the binary, not padding.
|
||||
assert_eq!(gossip_icon_type(10), "auctioneer"); // _AUCTIONEER
|
||||
assert_eq!(gossip_icon_type(11), "gossip");
|
||||
assert_eq!(gossip_icon_type(13), "gossip");
|
||||
// 14 is a NULL the reference pushes as `nil`, and 15+ walks off its table into the Lua
|
||||
// binding array. Both are its missing guard, not its design — we clamp (see the fn's doc).
|
||||
// The deviation past the table: see `gossip_icon_type`.
|
||||
assert_eq!(gossip_icon_type(14), "gossip");
|
||||
assert_eq!(gossip_icon_type(255), "gossip");
|
||||
}
|
||||
|
||||
/// The table must name the art the install actually ships — the check that could not have
|
||||
/// passed while the mapping was wrong-by-naming, because the reference builds the path by
|
||||
/// concatenation (`GossipFrame.lua:123`:
|
||||
/// `SetTexture("Interface\\GossipFrame\\" .. arg[i+1] .. "GossipIcon")`), so a type with no BLP
|
||||
/// behind it is a blank row.
|
||||
///
|
||||
/// **`auctioneer` is the one exception, and it is the client's, not ours**: index 10 names a
|
||||
/// texture 5875 never shipped, so the reference itself draws nothing for it. Asserting the
|
||||
/// absence pins that as a fact rather than leaving it as a hole someone later "fixes" by
|
||||
/// inventing a path. Skips without client data.
|
||||
/// Stock `GossipFrame.lua:123` builds the icon path from the type, so every type has its BLP
|
||||
/// but `auctioneer`, which 1.12 never shipped.
|
||||
#[test]
|
||||
fn gossip_icon_types_name_the_shipped_art() {
|
||||
let data = benilla_formats::wow_data_or_skip!();
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
//! The gossip window's packet handlers (decision 0081 phase 3; in the net handler table since
|
||||
//! 2318, moved out of the drain's npc arm file) — each fills the [`GossipState`] the gossip feed
|
||||
//! ([`super`]) reads; nothing here touches the VM.
|
||||
//! The gossip window's packet handlers: they fill [`GossipState`] and never touch the VM.
|
||||
|
||||
use benilla_protocol::messages::{GossipOption, NpcTextBlock};
|
||||
use benilla_protocol::{SessionEvent, SessionEventKind};
|
||||
@@ -10,8 +8,7 @@ use super::GossipState;
|
||||
use crate::net::{ClientCommand, GuidIndex, NetCommands, NetHandlerApp, ObjectStore};
|
||||
use crate::ui_quest::QuestGiver;
|
||||
|
||||
/// Register the gossip handlers — called from [`super::UiGossipPlugin`]. One per kind, plus the
|
||||
/// session-end listener.
|
||||
/// Register the gossip handlers and the session-end listener.
|
||||
pub(super) fn register(app: &mut App) {
|
||||
use SessionEventKind as K;
|
||||
app.net_handler(K::GossipMenu, on_gossip_menu)
|
||||
@@ -68,16 +65,15 @@ fn on_gossip_complete(
|
||||
}
|
||||
}
|
||||
|
||||
/// An open gossip menu dies with the socket. A listener on the session end
|
||||
/// (a second handler on the kind, after the bridge's own teardown).
|
||||
/// The menu closes with the connection.
|
||||
fn on_session_end(In(_): In<SessionEvent>, mut gossip: ResMut<GossipState>) {
|
||||
gossip.clear_session();
|
||||
}
|
||||
|
||||
/// A streamed unit's gender (`UNIT_FIELD_BYTES_0` byte 2) by guid — the gossip greeting's column
|
||||
/// selector (`0x4e20c1`: tested `== 1` for female, so genderless `2` reads as
|
||||
/// male). `0` when the guid isn't streamed in or carries no descriptor yet, which is the same
|
||||
/// column the reference takes for a gossip target that isn't a unit at all.
|
||||
/// A streamed unit's gender (`UNIT_FIELD_BYTES_0` byte 2), which picks the greeting's column: the
|
||||
/// reference tests `== 1` for female (`0x4e20c1`), so genderless 2 reads male. `0` when the guid
|
||||
/// is not a streamed unit; the reference's non-unit arm takes the female column only when block
|
||||
/// 0 has no male text.
|
||||
fn npc_gender(guid: u64, index: &GuidIndex, stores: &Query<&ObjectStore>) -> u8 {
|
||||
index
|
||||
.0
|
||||
@@ -87,16 +83,8 @@ fn npc_gender(guid: u64, index: &GuidIndex, stores: &Query<&ObjectStore>) -> u8
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// A gossip menu opened (`SMSG_GOSSIP_MESSAGE`): fill the [`GossipState`] the gossip feed
|
||||
/// ([`super`]) reads. A first visit to the text id sends the ask-once
|
||||
/// `CMSG_NPC_TEXT_QUERY` and **the feed fires nothing until [`npc_greeting`] answers it** — a
|
||||
/// hidden frame stays hidden, an open one keeps its previous menu painted (B292's hold and
|
||||
/// 1994's no-event edge; the mechanics and the reference law live on
|
||||
/// [`GossipState::open_menu`]); a revisit serves from the cache and repaints right away.
|
||||
/// [`gossip_complete`] closes it.
|
||||
///
|
||||
/// The greeting is **drawn here**, not at the packet — this is the reference's own moment for it
|
||||
/// (`0x4e2010`), and the draw needs both this NPC's gender ([`npc_gender`]) and a fresh roll.
|
||||
/// `SMSG_GOSSIP_MESSAGE`: latch the menu, and on a first visit to its text send the query. The
|
||||
/// greeting is drawn at menu open, as the reference's `0x4e2010` draws it.
|
||||
fn gossip_menu(
|
||||
npc: u64,
|
||||
text_id: u32,
|
||||
@@ -120,12 +108,8 @@ fn gossip_menu(
|
||||
}
|
||||
}
|
||||
|
||||
/// The NPC-text answer (`SMSG_NPC_TEXT_UPDATE`) — seed the cache with the whole record, and open
|
||||
/// the menu still waiting on it (a late answer for a menu we already closed or switched just
|
||||
/// seeds the cache; the next open draws its own line).
|
||||
///
|
||||
/// The record answers a query we sent for the waiting menu, so its NPC is the one whose gender
|
||||
/// picks the column (decision 0081's ask-once flow).
|
||||
/// `SMSG_NPC_TEXT_UPDATE`: cache the record and open the menu waiting on it, whose NPC's gender
|
||||
/// picks the column.
|
||||
fn npc_greeting(
|
||||
text_id: u32,
|
||||
blocks: Vec<NpcTextBlock>,
|
||||
@@ -137,8 +121,7 @@ fn npc_greeting(
|
||||
gossip.text_arrived(text_id, blocks, npc_gender);
|
||||
}
|
||||
|
||||
/// `SMSG_GOSSIP_COMPLETE` ends the whole interaction (e.g. right after a quest accept), so the
|
||||
/// quest window closes with the gossip menu (decision 0088).
|
||||
/// `SMSG_GOSSIP_COMPLETE` ends the whole interaction, so the quest window closes with the menu.
|
||||
pub(crate) fn gossip_complete(gossip: &mut GossipState, quest: &mut QuestGiver) {
|
||||
debug!("net: gossip complete — closing the menu");
|
||||
gossip.clear();
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
//! The guild VM feed/drain — the systems half of [`super`]: resolve the wire's ids into the
|
||||
//! display-ready snapshot the guild windows read, fire the events on their edges, and turn the
|
||||
//! Lua-side [`GuildRequest`] intents into their sends.
|
||||
//!
|
||||
//! Everything resolved here is resolved *engine-side in the reference too*:
|
||||
//! `GetGuildRosterInfo 0x4d1200` hands Lua the localized `ChrClasses` and `AreaTable` names, not
|
||||
//! the ids (`0x4d1355`, `0x4d1391`), and the rank name out of the cached guild record — the house
|
||||
//! law from `script/social.rs:8-13`. Unlike the friend list and `/who`, the guild roster carries
|
||||
//! level, class and zone for **offline** members as well, so those columns stay populated and
|
||||
//! merely grey out.
|
||||
//! The guild VM feed and drain: resolve the roster into the snapshot the guild windows read, fire
|
||||
//! the guild events on their edges, and send the Lua [`GuildRequest`] intents. Names resolve
|
||||
//! engine-side as in the reference: `GetGuildRosterInfo 0x4d1200` returns the localized class and
|
||||
//! zone names, not ids (`0x4d1355`, `0x4d1391`), and the rank name from the cached guild record.
|
||||
|
||||
use benilla_formats::AreaTableCatalog;
|
||||
use benilla_protocol::messages::{
|
||||
@@ -25,17 +19,16 @@ use crate::ui_unit::class_names;
|
||||
|
||||
use super::{last_online, GuildState, Identity, RosterRow, ROSTER_REQUEST_THROTTLE_SECS};
|
||||
|
||||
/// The `<AFK>`/`<DND>` tags, `CHAT_FLAG_AFK`/`CHAT_FLAG_DND` (`GlobalStrings.lua:766-767`) — the
|
||||
/// same pair the friend list and the chat frame use, which is what the reference loads at
|
||||
/// `0x4d1404`/`0x4d13ed`.
|
||||
/// `CHAT_FLAG_AFK` and `CHAT_FLAG_DND` (`GlobalStrings.lua:534-535`), which the reference loads at
|
||||
/// `0x4d1404` and `0x4d13ed`.
|
||||
const CHAT_FLAG_AFK: &str = "<AFK>";
|
||||
const CHAT_FLAG_DND: &str = "<DND>";
|
||||
|
||||
/// What the feed last announced, so the events fire on edges rather than every frame.
|
||||
#[derive(Default)]
|
||||
pub(super) struct FedGuild {
|
||||
/// Has the VM been given a snapshot at all yet? The first push fires the roster update, so a
|
||||
/// frame loaded after the roster arrived still populates.
|
||||
/// Whether this VM has had a snapshot; the first push fires the roster update, so a frame
|
||||
/// loaded after the roster still populates.
|
||||
seeded: bool,
|
||||
motd: String,
|
||||
invite: Option<(String, String)>,
|
||||
@@ -56,9 +49,8 @@ pub(super) fn feed_guild(
|
||||
};
|
||||
let fed = fed.get(&script);
|
||||
|
||||
// Our own guild id and rank live in our own descriptor, not in any guild packet, so they are
|
||||
// mirrored per frame from it. Only when the avatar is actually streamed: a frame between the
|
||||
// logout despawn and the disconnect teardown must not read as "left the guild".
|
||||
// Our guild id and rank come from our own descriptor, mirrored only while the avatar is
|
||||
// streamed: the frames between logout despawn and teardown must not read as leaving the guild.
|
||||
let mut id_changed = false;
|
||||
let mut rank_changed = false;
|
||||
if let Some(store) = self_q.iter().next() {
|
||||
@@ -68,16 +60,14 @@ pub(super) fn feed_guild(
|
||||
guild.mirror_self(id, rank);
|
||||
}
|
||||
|
||||
// Rebuilding a large roster costs a string clone per column per member, so it happens on a
|
||||
// change rather than per frame — every apply and every local intent sets the flag.
|
||||
// Rebuilt only on a change: every apply and every local intent sets the flag.
|
||||
if !guild.dirty && fed.seeded {
|
||||
return;
|
||||
}
|
||||
guild.dirty = false;
|
||||
|
||||
// The lazy identity fill (`super`'s module doc): asking here is what the reference's
|
||||
// `GetGuildRosterInfo` does inline on a cache miss, and the answer arrives as its own
|
||||
// no-arg GUILD_ROSTER_UPDATE.
|
||||
// The lazy identity fill: the reference's `GetGuildRosterInfo` asks inline on a miss, and the
|
||||
// answer fires a bare `GUILD_ROSTER_UPDATE`.
|
||||
let guild_id = guild.guild_id;
|
||||
guild.request_identity(guild_id, &commands);
|
||||
let identity = guild.identity(guild_id).cloned();
|
||||
@@ -95,8 +85,7 @@ pub(super) fn feed_guild(
|
||||
.map(|i| i.rank_name(guild.rank_index).to_string())
|
||||
.unwrap_or_default(),
|
||||
rank_index: guild.rank_index,
|
||||
// `IsGuildLeader 0x516e40` — rank 0 IS the guild master, and being guildless is not being
|
||||
// one, however the rank field happens to read.
|
||||
// `IsGuildLeader 0x516e40`: rank 0 in a guild; a guildless rank 0 is not a leader.
|
||||
is_leader: guild.in_guild() && guild.rank_index == 0,
|
||||
rights: guild.own_rights(),
|
||||
motd: guild.motd.clone(),
|
||||
@@ -118,12 +107,11 @@ pub(super) fn feed_guild(
|
||||
show_offline: guild.show_offline,
|
||||
});
|
||||
|
||||
// GUILD_ROSTER_UPDATE, and whether it carries arg1 — the whole of that decision is
|
||||
// `super::RosterUpdate`, including why the number 1 rather than a boolean.
|
||||
// `GUILD_ROSTER_UPDATE`; `super::RosterUpdate` decides whether it carries arg1.
|
||||
let first = !fed.seeded;
|
||||
fed.seeded = true;
|
||||
if let Some(kind) = guild.roster_event.take().or(first.then_some(
|
||||
// A frame that loads after the roster did still needs one push to draw from.
|
||||
// A frame loaded after the roster still needs one push to draw from.
|
||||
super::RosterUpdate::Applied,
|
||||
)) {
|
||||
let args = if kind.arg1() {
|
||||
@@ -134,21 +122,16 @@ pub(super) fn feed_guild(
|
||||
script.fire_event("GUILD_ROSTER_UPDATE", args);
|
||||
}
|
||||
|
||||
// GUILD_MOTD carries the text itself; the chat line it prints is the FrameXML's
|
||||
// (`ChatFrame.lua:1335-1340`), which is why nothing composes one here.
|
||||
// `GUILD_MOTD` carries the text; its chat line is the FrameXML's (`ChatFrame.lua:1335-1340`).
|
||||
if fed.motd != guild.motd {
|
||||
fed.motd = guild.motd.clone();
|
||||
script.fire_event("GUILD_MOTD", vec![ScriptValue::Str(guild.motd.clone())]);
|
||||
}
|
||||
|
||||
// PLAYER_GUILD_UPDATE. Two of the reference's three paths are descriptor field callbacks:
|
||||
// PLAYER_GUILDRANK's fires with **no arguments** for any player object, and PLAYER_GUILDID's
|
||||
// fires **once per unit token** naming that player, with the token as arg1
|
||||
// (`0x5e27b0` / `0x5e2770` → `0x515e50`). Ours covers our own avatar, which is every consumer
|
||||
// 1.12's FrameXML has — `InGuildCheck` and the paper doll both read `GetGuildInfo("player")`
|
||||
// and ignore the argument. The per-token broadcast for OTHER units is deliberately not built:
|
||||
// it has no consumer here, and the broadcaster's per-token walk is the one part of that path
|
||||
// that is inferred, unconfirmed in the binary, so it is not something to build on yet.
|
||||
// `PLAYER_GUILD_UPDATE`, as the reference's descriptor callbacks fire it (`0x5e27b0`,
|
||||
// `0x5e2770`, `0x515e50`): bare on a `PLAYER_GUILDRANK` change, and once per unit token naming
|
||||
// the player, the token as arg1, on a `PLAYER_GUILDID` change. Only our own avatar's edges
|
||||
// fire here; other players' are not built, and the reference's per-token walk is unconfirmed.
|
||||
if id_changed {
|
||||
script.fire_event(
|
||||
"PLAYER_GUILD_UPDATE",
|
||||
@@ -159,9 +142,8 @@ pub(super) fn feed_guild(
|
||||
script.fire_event("PLAYER_GUILD_UPDATE", Vec::new());
|
||||
}
|
||||
|
||||
// GUILD_INVITE_REQUEST arms the popup. There is **no** hide edge: `GUILD_INVITE_CANCEL`'s
|
||||
// wrapper `0x48f470` has zero callers image-wide, so nothing in 1.12 raises it and neither do
|
||||
// we — the FrameXML registers for it and simply never hears it.
|
||||
// `GUILD_INVITE_REQUEST` shows the popup. There is no hide edge: nothing in the 1.12 client
|
||||
// raises `GUILD_INVITE_CANCEL` (its wrapper `0x48f470` has no callers).
|
||||
if fed.invite != guild.pending_invite {
|
||||
fed.invite = guild.pending_invite.clone();
|
||||
if let Some((inviter, name)) = &guild.pending_invite {
|
||||
@@ -185,19 +167,10 @@ pub(super) fn feed_guild(
|
||||
}
|
||||
}
|
||||
|
||||
/// `SMSG_GUILD_INFO`'s two chat lines — `GUILD_NAME_TEMPLATE` then `GUILD_INFO_TEMPLATE`, in the
|
||||
/// order `0x5e6fb0` emits them (`0x5e700f`, then `0x5e706b`).
|
||||
///
|
||||
/// **Neither is a message record**, so neither can ride the by-key queue: the handler resolves each
|
||||
/// token through the script VM (`0x703bf0`) and formats it into a chat line itself, with no `kind`
|
||||
/// and no sound to read. `MsgKind::Chat` here is therefore the handler's own surface rather than a
|
||||
/// catalog lookup, and the absent key is silent — the same GlobalStrings data-suppression the keyed
|
||||
/// route wears (decision 2054).
|
||||
///
|
||||
/// **The date is month-day-year and the wire is day-month-year.** The handler's cdecl pushes
|
||||
/// (`0x5e704d`–`0x5e7061`) place the arguments **wire#2, wire#1, wire#3, wire#4, wire#5** — the
|
||||
/// first two swapped — which is exactly what an enUS `m-d-y` template does to vmangos's
|
||||
/// `createdDay, createdMonth, createdYear` order.
|
||||
/// `SMSG_GUILD_INFO`'s chat lines, `GUILD_NAME_TEMPLATE` then `GUILD_INFO_TEMPLATE` (`0x5e6fb0`,
|
||||
/// at `0x5e700f` and `0x5e706b`). Not message records: the handler reads each template from the
|
||||
/// script VM (`0x703bf0`) and prints chat itself, so a missing template prints nothing. It passes
|
||||
/// wire fields 2, 1, 3, 4, 5 (`0x5e704d`-`0x5e7061`): month first, where the wire has day first.
|
||||
fn ginfo_lines(script: &UiScript, info: &GuildInfo) -> Vec<crate::ui_action::Shown> {
|
||||
use benilla_ui::strings::Arg;
|
||||
let get = |key: &str| script.lua().globals().get::<String>(key).ok();
|
||||
@@ -225,10 +198,8 @@ fn ginfo_lines(script: &UiScript, info: &GuildInfo) -> Vec<crate::ui_action::Sho
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The roster in display order — **every** member, sorted, never filtered (see [`super::sort`]).
|
||||
///
|
||||
/// The rank name comes from the guild's identity, which is why an unanswered query leaves that one
|
||||
/// column empty rather than the whole row absent.
|
||||
/// The roster in display order: every member, sorted, never filtered. Until the guild query
|
||||
/// answers, only the rank-name column is empty.
|
||||
pub(super) fn display_rows(
|
||||
guild: &GuildState,
|
||||
areas: Option<&AreaTableCatalog>,
|
||||
@@ -243,10 +214,8 @@ pub(super) fn display_rows(
|
||||
rows
|
||||
}
|
||||
|
||||
/// One roster row, ids resolved. Level, class and zone are filled for **offline** members too:
|
||||
/// the server keeps them in the guild table and puts them on the wire, so the pane greys the row
|
||||
/// rather than blanking its columns — the opposite of the friend list, whose wire carries nothing
|
||||
/// for an offline friend.
|
||||
/// One roster row, ids resolved. An offline member keeps level, class and zone, which the wire
|
||||
/// carries for every member, so the pane greys the row rather than blanking it.
|
||||
fn row(
|
||||
m: &GuildRosterMember,
|
||||
areas: Option<&AreaTableCatalog>,
|
||||
@@ -274,8 +243,7 @@ fn row(
|
||||
officer_note: m.officer_note.clone(),
|
||||
online,
|
||||
status: status_tag(m.presence).to_string(),
|
||||
// The wire sends no float at all for an online member, and the formatter reads
|
||||
// all-zeroes exactly as it reads nil.
|
||||
// No float on the wire for an online member; the formatter reads all zeroes as nil.
|
||||
last_online: if online {
|
||||
Default::default()
|
||||
} else {
|
||||
@@ -285,13 +253,8 @@ fn row(
|
||||
}
|
||||
}
|
||||
|
||||
/// The away tag a member's presence byte carries — `GetGuildRosterInfo`'s tenth return, and a
|
||||
/// **string on every path**, `""` and never nil.
|
||||
///
|
||||
/// **DND is tested before AFK** (`0x4d13e4 test al,0x4` before `0x4d13fb test al,0x2`), so a
|
||||
/// member flagged both displays as DND. The `online` flag beside it is the whole presence byte
|
||||
/// against zero (`0x4d0c12 test dl,dl`), never a mask against the ONLINE bit — see
|
||||
/// [`GuildRosterMember::is_online`].
|
||||
/// The away tag, `GetGuildRosterInfo`'s tenth return: always a string, `""` rather than nil. DND
|
||||
/// is tested before AFK (`0x4d13e4`, `0x4d13fb`), so a member flagged both shows as DND.
|
||||
pub(super) fn status_tag(presence: u8) -> &'static str {
|
||||
if presence & guild_presence::DND != 0 {
|
||||
CHAT_FLAG_DND
|
||||
@@ -302,9 +265,8 @@ pub(super) fn status_tag(presence: u8) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
/// The 1-based row a guid occupies in the shown order, `0` when it is not shown — the reference's
|
||||
/// own guid→index conversion (`GetGuildRosterSelection 0x4d1890` → `0x4d1030`, a linear search
|
||||
/// returning `index + 1`, so a member who left reads as "nothing selected").
|
||||
/// The 1-based row of a guid in the shown order, `0` when absent, as `GetGuildRosterSelection
|
||||
/// 0x4d1890` searches it (`0x4d1030`): a member who left reads as nothing selected.
|
||||
pub(super) fn index_of(order: &[u64], guid: u64) -> u32 {
|
||||
if guid == 0 {
|
||||
return 0;
|
||||
@@ -315,11 +277,8 @@ pub(super) fn index_of(order: &[u64], guid: u64) -> u32 {
|
||||
.map_or(0, |i| i as u32 + 1)
|
||||
}
|
||||
|
||||
/// Turn the Era API's guild intents into their sends.
|
||||
///
|
||||
/// Two of them need the app because only the app knows the display order: the note verbs carry a
|
||||
/// 1-based **row** and the wire wants the member's **name**. Three are local-only — select, sort
|
||||
/// and show-offline change what the next push says and send nothing at all.
|
||||
/// Turn the Lua guild intents into their sends. The note verbs carry a 1-based display row where
|
||||
/// the wire wants a name; select, sort and show-offline are local and send nothing.
|
||||
pub(super) fn drain_guild(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
mut guild: ResMut<GuildState>,
|
||||
@@ -336,10 +295,8 @@ pub(super) fn drain_guild(
|
||||
for request in requests {
|
||||
match request {
|
||||
GuildRequest::Roster => {
|
||||
// The reference throttles this binding itself, to one request per 10 s against
|
||||
// `0xb73130` (`0x4d10d0`), and gives no signal when it swallows one. Real time,
|
||||
// not virtual: this is a span measured against the server, and the virtual clock's
|
||||
// 250 ms `max_delta` falls behind under a hitch (decision 0846).
|
||||
// One request per 10 s, the rest swallowed silently (`0x4d10d0`, against
|
||||
// `0xb73130`). Real time: the virtual clock's 250 ms `max_delta` lags in a hitch.
|
||||
let now = time.elapsed_secs_f64();
|
||||
if now >= guild.roster_allowed_at {
|
||||
guild.roster_allowed_at = now + ROSTER_REQUEST_THROTTLE_SECS;
|
||||
@@ -382,9 +339,8 @@ pub(super) fn drain_guild(
|
||||
let _ = commands.0.send(ClientCommand::GuildMotd { motd });
|
||||
}
|
||||
GuildRequest::SetInfoText(text) => {
|
||||
// Deliberately no local write: the reference's `SetGuildInfoText 0x4d2380` sends
|
||||
// and does NOT touch `0xb72720`, so the visible text only changes when the next
|
||||
// roster brings it back.
|
||||
// No local write: `SetGuildInfoText 0x4d2380` only sends, leaving `0xb72720` to
|
||||
// change with the next roster.
|
||||
let _ = commands.0.send(ClientCommand::GuildInfoText { text });
|
||||
}
|
||||
GuildRequest::SetPublicNote { index, note } => {
|
||||
@@ -407,10 +363,9 @@ pub(super) fn drain_guild(
|
||||
}
|
||||
}
|
||||
GuildRequest::AddRank(name) => {
|
||||
// The client enforces the ladder's bounds itself, and silently: the reference's
|
||||
// Add button disables at ten ranks and its Remove button only appears above five
|
||||
// (`FriendsFrame.lua:887`, `:908`). Enforcing it here as well is what keeps a
|
||||
// macro or an addon from sending what the server would only ignore.
|
||||
// Ten ranks at most, and more than five to remove one, as the reference's
|
||||
// `GuildControlAddRank` (`0x4d2271`) and `GuildControlDelRank` (`0x4d22e6`) check
|
||||
// silently; the stock buttons follow them (`FriendsFrame.lua:887`, `:908`).
|
||||
if guild.rank_rights.len() < GUILD_RANKS_MAX_COUNT {
|
||||
if let Some(name) = capped_rank_name(name) {
|
||||
let _ = commands.0.send(ClientCommand::GuildAddRank { name });
|
||||
@@ -429,10 +384,8 @@ pub(super) fn drain_guild(
|
||||
}
|
||||
}
|
||||
|
||||
/// A note verb: resolve the display row to the member's name, write the note locally, and send
|
||||
/// **only if the text actually changed** — the reference writes the record first and gates the
|
||||
/// packet on that write having changed anything (`0x4d15e0`, whose `0x64a480` result is the
|
||||
/// condition on the send).
|
||||
/// A note verb: resolve the row to the member, write the note locally, and send only if it
|
||||
/// changed, as the reference gates the send on its own write (`0x4d15e0`, `0x64a480`).
|
||||
fn set_note(
|
||||
guild: &mut GuildState,
|
||||
commands: &NetCommands,
|
||||
@@ -462,13 +415,10 @@ fn set_note(
|
||||
let _ = commands.0.send(command);
|
||||
}
|
||||
|
||||
/// A rank name the wire will accept, or `None`.
|
||||
///
|
||||
/// Over-long is not a polite refusal on this packet: `HandleGuildRankOpcode` /
|
||||
/// `HandleGuildAddRankOpcode` run `ProcessAnticheatAction(… CHEAT_ACTION_KICK)`
|
||||
/// (`Handlers/GuildHandler.cpp:580-584`, `:600-604`). The reference caps at the edit box's
|
||||
/// `maxLetters`; we cap at the send too, and **drop rather than truncate** — a silently shortened
|
||||
/// rank name is the wrong name, which is its own bug.
|
||||
/// The rank name, or `None` past vmangos's cap of 15, where it kicks the session
|
||||
/// (`Handlers/GuildHandler.cpp:580-584`, `:600-604`); the stock edit box stops at 15. Deviation: a
|
||||
/// script's 16-letter name, which the reference's bindings pass (1 to 16 units, `0x4d20d0`,
|
||||
/// `0x4d2210`), is dropped rather than sent or truncated, because vmangos would kick the session.
|
||||
fn capped_rank_name(name: String) -> Option<String> {
|
||||
if name.chars().count() > GUILD_RANK_MAX_LENGTH {
|
||||
warn!(
|
||||
@@ -484,8 +434,6 @@ fn capped_rank_name(name: String) -> Option<String> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// DND is tested **before** AFK, so a member flagged both shows as DND — and the tag is a
|
||||
/// string on every path, including an offline member's.
|
||||
#[test]
|
||||
fn dnd_wins_over_afk_and_the_tag_is_always_a_string() {
|
||||
use guild_presence::{AFK, DND, OFFLINE, ONLINE};
|
||||
@@ -496,9 +444,6 @@ mod tests {
|
||||
assert_eq!(status_tag(OFFLINE), "", "never nil, even offline");
|
||||
}
|
||||
|
||||
/// `online` is the whole presence byte against zero, not a mask against the ONLINE bit — so an
|
||||
/// unknown future bit reads as online, which is the safe direction (it is also what decides
|
||||
/// whether the wire carries the last-online float at all).
|
||||
#[test]
|
||||
fn online_is_the_whole_byte() {
|
||||
let member = |presence| GuildRosterMember {
|
||||
@@ -511,8 +456,6 @@ mod tests {
|
||||
assert!(!member(guild_presence::OFFLINE).is_online());
|
||||
}
|
||||
|
||||
/// An offline member keeps their level, class and zone — the guild table has them and the
|
||||
/// wire sends them, which is exactly what the friend list cannot do.
|
||||
#[test]
|
||||
fn an_offline_row_keeps_its_level_class_and_zone() {
|
||||
let member = GuildRosterMember {
|
||||
@@ -536,14 +479,11 @@ mod tests {
|
||||
assert_eq!(row.guid, 5);
|
||||
}
|
||||
|
||||
/// An online member's last-online reads as all zeroes — the wire carries no float for them,
|
||||
/// and the formatter reads that exactly as it reads nil.
|
||||
#[test]
|
||||
fn an_online_row_reports_no_last_online() {
|
||||
let member = GuildRosterMember {
|
||||
presence: guild_presence::ONLINE,
|
||||
// Even if a value were somehow present, an online member never reaches the
|
||||
// arithmetic in the reference either (`0x4d14fd`).
|
||||
// Ignored for an online member, as in the reference (`0x4d14fd`).
|
||||
last_online_days: 9.0,
|
||||
..Default::default()
|
||||
};
|
||||
@@ -553,7 +493,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A row index maps through the *shown* order, and 0/past-the-end map to nothing.
|
||||
#[test]
|
||||
fn the_shown_order_maps_both_ways() {
|
||||
assert_eq!(index_of(&[11, 22, 33], 22), 2);
|
||||
@@ -562,7 +501,6 @@ mod tests {
|
||||
assert_eq!(index_of(&[11, 22], 0), 0, "nothing selected");
|
||||
}
|
||||
|
||||
/// An over-long rank name is dropped, not truncated and not sent — the server kicks over it.
|
||||
#[test]
|
||||
fn an_over_long_rank_name_is_refused() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -1,34 +1,8 @@
|
||||
//! The guild session's **system lines** — the `ERR_GUILD_*` messages the engine composes.
|
||||
//!
|
||||
//! Every guild event and every command verdict prints one line, and the *engine* composes it, not
|
||||
//! the FrameXML: `SMSG_GUILD_EVENT`'s handler `0x5e7180` routes its arms into
|
||||
//! `CGGameUI::DisplayError 0x496720`, and the invite / decline / command-result handlers do the
|
||||
//! same (`0x5e6f65`, `0x5e6f9a`, `0x5e7520`). Not one of the `ERR_GUILD_*` keys those resolve to
|
||||
//! appears anywhere in the reference FrameXML, which is what identifies them as engine-composed —
|
||||
//! [`crate::ui_social`]'s own test, applied again.
|
||||
//!
|
||||
//! **These are message ids, not sentences** (decision 2054). Each arm names a catalog key and the
|
||||
//! arguments the reference pushes beside it; the drain resolves the key against the player's own
|
||||
//! `GlobalStrings.lua` and the catalog row decides the surface and the sound. That is not a
|
||||
//! restatement of what was here before: this module used to carry 34 re-typed English constants and
|
||||
//! its own two `fill` helpers, which threw away three things the catalog knows —
|
||||
//! `ERR_GUILD_NAME_INVALID` and `ERR_GUILD_NAME_EXISTS_S` are `kind 2`, the **red** error line and
|
||||
//! not chat at all; `ERR_GUILD_CREATE_S` and `ERR_INVITED_TO_GUILD_SS` carry the `LEVELUP` cue; and
|
||||
//! `ERR_GUILD_PERMISSIONS` has `type_tag 0x3e`, under `VOCAL_UI_LINES`, so the reference *speaks* it
|
||||
//! (decision 1815).
|
||||
//!
|
||||
//! **The `GUILD_MOTD` line is deliberately not here.** The reference's `ChatFrame_OnEvent` composes
|
||||
//! `GUILD_MOTD_TEMPLATE` itself off the `GUILD_MOTD` event (`ChatFrame.lua:1335-1340`), so firing
|
||||
//! the event is the whole job and composing a line beside it would double it.
|
||||
//!
|
||||
//! **`/ginfo` is not here either, and that is faithful.** Its two lines are `GUILD_NAME_TEMPLATE`
|
||||
//! and `GUILD_INFO_TEMPLATE` — *not* catalog rows: `0x5e6fb0` resolves each token through the
|
||||
//! script VM (`0x703bf0` at `0x5e700f`/`0x5e706b`), formats it, and emits chat directly, never
|
||||
//! passing a message record. A route with no record has no `kind` and no sound to read, so it is
|
||||
//! resolved where the VM is — [`super::feed`] — rather than queued as a message.
|
||||
//!
|
||||
//! The id → key mapping is **the handler's own case arms** (`0x5e720a`→0x59 … `0x5e745a`→0x69),
|
||||
//! and the keys are matched against benilla's generated catalog rather than by name.
|
||||
//! The guild system lines: `ERR_GUILD_*` message ids the engine composes, not the FrameXML. The
|
||||
//! event, invite, decline and command-result handlers (`0x5e7180`, `0x5e6f65`, `0x5e6f9a`,
|
||||
//! `0x5e7520`) pass them to `CGGameUI::DisplayError 0x496720`, and the catalog row picks the
|
||||
//! surface and the sound. The `GUILD_MOTD` line is the FrameXML's (`ChatFrame.lua:1335-1340`) and
|
||||
//! `/ginfo`'s are not catalog rows (built in [`super::feed`]), so neither is here.
|
||||
|
||||
use benilla_protocol::messages::{
|
||||
guild_command, guild_command_error, guild_event, GuildCommandResult, GuildEventNotice,
|
||||
@@ -36,18 +10,9 @@ use benilla_protocol::messages::{
|
||||
|
||||
use crate::ui_action::UiError;
|
||||
|
||||
/// The strings `SMSG_GUILD_EVENT`'s shared emitter tail passes to `0x496720`.
|
||||
///
|
||||
/// **`strCount == 0` and `strCount >= 4` both pass ZERO strings** (`0x5e745f`) — the tail has
|
||||
/// three arms for 1, 2 and 3 and no other. That is not the
|
||||
/// same as padding the missing slots with empties, which is what this module did before: a template
|
||||
/// whose specifier runs out of arguments has the specifier **copied through verbatim**
|
||||
/// (`SStrPrintf`, and [`benilla_ui::strings::fill`] implements it), so a two-string promotion reads
|
||||
/// "Tigole has promoted Furor to %s." and not "…to .".
|
||||
///
|
||||
/// (The reference's read loop is *not* capped at three — a `strCount >= 4` writes past its three
|
||||
/// `0x100` stack buffers into the live locals. benilla's parser reads into a `Vec`, so the overflow
|
||||
/// has nothing to reproduce; the display rule is what carries over.)
|
||||
/// The strings the event handler's shared tail passes to `0x496720`: one to three as they arrived,
|
||||
/// none for a count of 0 or 4 and up (`0x5e745f`). Never padded: `SStrPrintf` copies a starved
|
||||
/// `%s` through as written, so a two-string promotion ends "to %s.".
|
||||
fn emitted_params(notice: &GuildEventNotice) -> Vec<&str> {
|
||||
match notice.params.len() {
|
||||
1..=3 => notice.params.iter().map(String::as_str).collect(),
|
||||
@@ -55,21 +20,9 @@ fn emitted_params(notice: &GuildEventNotice) -> Vec<&str> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The message one `SMSG_GUILD_EVENT` prints, if any.
|
||||
///
|
||||
/// `announce_signon` is the sign-on/sign-off pair's **whole display condition**, resolved by the
|
||||
/// caller because three of its four conjuncts need state this function does not see. See
|
||||
/// [`super::apply::event`] for the conjuncts and their byte addresses; what matters here is that
|
||||
/// the pair is the only place in this table with a condition at all, and that the guid those two
|
||||
/// arms carry exists to answer it.
|
||||
///
|
||||
/// **This argument used to be `ignored`, and that was a mislabel** (corrected 1589):
|
||||
/// `0x5ae810` is `FriendList::FindFriendSlot`, a **friends-list**
|
||||
/// membership test — base `this+8`, stride `0x20`, bound `0x32` — not the ignore-list check at
|
||||
/// `this+0x650`. Reading it as "ignore" got the behaviour backwards on both sides: an *ignored*
|
||||
/// guildmate was silenced where the reference announces them, and a guildmate who is also a
|
||||
/// *friend* was announced twice, because `SMSG_FRIEND_STATUS` says the same thing with no gate of
|
||||
/// its own. `0x5ae810` has exactly two callers image-wide, these two arms.
|
||||
/// The line one `SMSG_GUILD_EVENT` prints, if any; ids map to keys as the handler's case arms do
|
||||
/// (`0x5e720a` to `0x5e745a`). `announce_signon` is the sign-on/sign-off pair's whole display
|
||||
/// condition, decided in `super::net::event`; no other arm has one.
|
||||
pub(super) fn event_line(notice: &GuildEventNotice, announce_signon: bool) -> Option<UiError> {
|
||||
let args = emitted_params(notice);
|
||||
let shared = |key: &'static str| Some(UiError::strings(key, &args));
|
||||
@@ -82,9 +35,8 @@ pub(super) fn event_line(notice: &GuildEventNotice, announce_signon: bool) -> Op
|
||||
guild_event::LEADER_IS => shared("ERR_GUILD_LEADER_IS_S"),
|
||||
guild_event::LEADER_CHANGED => shared("ERR_GUILD_LEADER_CHANGED_SS"),
|
||||
guild_event::DISBANDED => shared("ERR_GUILD_DISBANDED"),
|
||||
// The sign-on/sign-off pair leaves the shared tail: each has its own emit with its own
|
||||
// argument shape, and `0x106` takes the ONE name TWICE — once for the `|Hplayer:%s|h`
|
||||
// link, once for the bracketed display name.
|
||||
// Outside the shared tail: `0x106` takes the one name twice, for the `|Hplayer:%s|h` link
|
||||
// and the bracketed display name.
|
||||
guild_event::SIGNED_ON if announce_signon => {
|
||||
let name = notice
|
||||
.params
|
||||
@@ -101,29 +53,19 @@ pub(super) fn event_line(notice: &GuildEventNotice, announce_signon: bool) -> Op
|
||||
.unwrap_or_default();
|
||||
Some(UiError::strings("ERR_FRIEND_OFFLINE_S", &[name]))
|
||||
}
|
||||
// MOTD's line is the FrameXML's (module doc); `UPDATE_RANK_NAME` warms a rank name and
|
||||
// `UPDATE_ROSTER` only re-requests, and neither prints; a sign-on the condition refuses
|
||||
// shows nothing.
|
||||
// Silent: the MOTD line is the FrameXML's, a rank rename and a roster update print
|
||||
// nothing, and neither does a refused sign-on.
|
||||
guild_event::MOTD | guild_event::UPDATE_RANK_NAME | guild_event::UPDATE_ROSTER => None,
|
||||
guild_event::SIGNED_ON | guild_event::SIGNED_OFF => None,
|
||||
// **`0x09` and everything past `0x0d` fall to the SAME arm**, and it is not silence:
|
||||
// `0x5e745a` pushes `0x69` = `ERR_GUILD_INTERNAL` and jumps into the shared tail like the
|
||||
// rest. This module used to return `None` here under a comment
|
||||
// saying the key "is not settled" — it is settled, in the reference and in benilla's own
|
||||
// generated catalog, and `TABARD_CHANGE` was silent for the same reason.
|
||||
// `0x09` and every id past `0x0d` share the default arm, which prints `0x69`,
|
||||
// `ERR_GUILD_INTERNAL`, through the shared tail (`0x5e745a`).
|
||||
_ => shared("ERR_GUILD_INTERNAL"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The message one `SMSG_GUILD_COMMAND_RESULT` prints, if any.
|
||||
///
|
||||
/// `result == 0` is the success side — the reference's `0x5e7550 test eax,eax` selects on exactly
|
||||
/// that — and then the [`guild_command`] tag chooses the line. Otherwise the result code does,
|
||||
/// except for `0x08`, whose two meanings are told apart by that same tag.
|
||||
///
|
||||
/// **Every `_S` key here is called with the name and every other with nothing** — the reference's
|
||||
/// `add esp,8` / `add esp,4` split, 22 of 22 (`0x5e7520`). That
|
||||
/// is a property a test can check against the key names, and one does.
|
||||
/// The line one `SMSG_GUILD_COMMAND_RESULT` prints, if any: on result 0 the [`guild_command`]
|
||||
/// tag picks it (`0x5e7550`), otherwise the result code does. Every `_S` key takes the name and
|
||||
/// every other key nothing (`0x5e7520`).
|
||||
pub(super) fn command_line(result: &GuildCommandResult) -> Option<UiError> {
|
||||
let name = result.name.as_str();
|
||||
let named = |key: &'static str| Some(UiError::s(key, name));
|
||||
@@ -133,8 +75,8 @@ pub(super) fn command_line(result: &GuildCommandResult) -> Option<UiError> {
|
||||
guild_command::INVITE => named("ERR_GUILD_INVITE_S"),
|
||||
guild_command::QUIT => named("ERR_GUILD_QUIT_S"),
|
||||
guild_command::FOUNDER => named("ERR_GUILD_FOUNDER_S"),
|
||||
// 19 and 20 re-request the roster and print nothing; vmangos's own note on the rest of
|
||||
// `Typecommand` is that they "have no effect" here.
|
||||
// 19 and 20 only re-request the roster; the rest have no effect
|
||||
// (vmangos `Guild/Guild.h:84`).
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
@@ -146,10 +88,7 @@ pub(super) fn command_line(result: &GuildCommandResult) -> Option<UiError> {
|
||||
guild_command_error::ALREADY_INVITED_TO_GUILD_S => named("ERR_ALREADY_INVITED_TO_GUILD_S"),
|
||||
guild_command_error::NAME_INVALID => Some(UiError::key("ERR_GUILD_NAME_INVALID")),
|
||||
guild_command_error::NAME_EXISTS_S => named("ERR_GUILD_NAME_EXISTS_S"),
|
||||
// The one collision: `0x08` is ERR_GUILD_LEADER_LEAVE under a QUIT and
|
||||
// ERR_GUILD_PERMISSIONS under anything else (benilla-protocol's own note on
|
||||
// `guild_command_error::LEADER_LEAVE`), which is why the command tag has to survive the
|
||||
// trip from the wire to here.
|
||||
// `0x08` is `ERR_GUILD_LEADER_LEAVE` under `QUIT` and `ERR_GUILD_PERMISSIONS` otherwise.
|
||||
guild_command_error::PERMISSIONS if result.command == guild_command::QUIT => {
|
||||
Some(UiError::key("ERR_GUILD_LEADER_LEAVE"))
|
||||
}
|
||||
@@ -165,20 +104,18 @@ pub(super) fn command_line(result: &GuildCommandResult) -> Option<UiError> {
|
||||
guild_command_error::RANKS_LOCKED => Some(UiError::key("ERR_GUILD_RANKS_LOCKED")),
|
||||
guild_command_error::RANK_IN_USE => Some(UiError::key("ERR_GUILD_RANK_IN_USE")),
|
||||
guild_command_error::IGNORING_YOU_S => named("ERR_IGNORING_YOU_S"),
|
||||
// 15 and 16 are silent; `UNK20` re-requests the roster for command 5 and prints nothing;
|
||||
// anything unknown says nothing.
|
||||
// Silent: 15, 16, `UNK20` (a roster re-request for command 5) and anything unknown.
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `SMSG_GUILD_INVITE`'s notice line — the one the reference prints *beside* the popup
|
||||
/// (`0x5e6f65 DisplayError(0x4f, inviter, guild)`; the popup is the separate
|
||||
/// `GUILD_INVITE_REQUEST` fire at `0x5e6f53`).
|
||||
/// `SMSG_GUILD_INVITE`'s notice line, printed beside the popup (`0x5e6f65`, message `0x4f`); the
|
||||
/// popup is the separate `GUILD_INVITE_REQUEST` at `0x5e6f53`.
|
||||
pub(super) fn invite_line(inviter: &str, guild: &str) -> UiError {
|
||||
UiError::strings("ERR_INVITED_TO_GUILD_SS", &[inviter, guild])
|
||||
}
|
||||
|
||||
/// `SMSG_GUILD_DECLINE` — our invitee said no (delivered to the inviter only).
|
||||
/// `SMSG_GUILD_DECLINE`: our invitee said no (sent to the inviter only).
|
||||
pub(super) fn decline_line(name: &str) -> UiError {
|
||||
UiError::s("ERR_GUILD_DECLINE_S", name)
|
||||
}
|
||||
@@ -206,9 +143,6 @@ mod tests {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A promotion names the catalog key and hands it the packet's three strings in order — the
|
||||
/// thing a re-typed sentence could not carry, since `%s` slots that take *different* values
|
||||
/// are what a replace-all substitution gets wrong ("Tigole has promoted Tigole to Tigole.").
|
||||
#[test]
|
||||
fn the_multi_slot_lines_name_the_key_and_pass_their_strings_in_order() {
|
||||
let e = event_line(
|
||||
@@ -220,10 +154,6 @@ mod tests {
|
||||
assert_eq!(strings(&e), ["Tigole", "Furor", "Officer"]);
|
||||
}
|
||||
|
||||
/// **A short `strCount` is passed SHORT, never padded** (`0x5e745f`): 0 and >= 4 pass nothing
|
||||
/// at all, and 1–3 pass exactly what arrived. The visible consequence is the starved
|
||||
/// specifier, which `SStrPrintf` copies through — the reason this is a behaviour and not an
|
||||
/// implementation detail.
|
||||
#[test]
|
||||
fn the_emitter_tail_passes_one_two_or_three_strings_and_otherwise_none() {
|
||||
for (params, want) in [
|
||||
@@ -238,13 +168,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The sign-on/sign-off lines are the friend-list ones, they take the name TWICE and ONCE, and
|
||||
/// they are the only two arms in the table with a display condition — the whole reason those
|
||||
/// two carry a guid.
|
||||
///
|
||||
/// **The polarity flipped in 1589** and this test says so on purpose: the flag used to mean
|
||||
/// "ignored" (suppress) and now means "announce", because `0x5ae810` turned out to be a
|
||||
/// friends-list test, not the ignore check the transcription named.
|
||||
#[test]
|
||||
fn sign_on_lines_are_the_friend_lines_and_obey_their_condition() {
|
||||
let on = event_line(¬ice(guild_event::SIGNED_ON, &["Tigole"]), true).expect("prints");
|
||||
@@ -266,7 +189,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The three genuinely silent events — and, beside them, the two that were silent by MISTAKE.
|
||||
#[test]
|
||||
fn only_the_three_roster_events_are_silent() {
|
||||
for event in [
|
||||
@@ -280,15 +202,13 @@ mod tests {
|
||||
"{event:#04x}"
|
||||
);
|
||||
}
|
||||
// `0x09` and every id past `0x0d` share the default arm, which pushes `0x69`. Both used to
|
||||
// return `None`; a message id the reference pushes is not a message we may drop.
|
||||
// `0x09` and every id past `0x0d` share the default arm, which pushes `0x69`.
|
||||
for event in [guild_event::TABARD_CHANGE, 0x0e, 0x77, 0xff] {
|
||||
let e = event_line(¬ice(event, &["x"]), false).expect("the default arm prints");
|
||||
assert_eq!(e.key, "ERR_GUILD_INTERNAL", "{event:#04x}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Result `0x08` means two different things, and only the command tag beside it says which.
|
||||
#[test]
|
||||
fn the_two_meanings_of_result_eight_are_told_apart_by_the_command() {
|
||||
let quit = command_line(&GuildCommandResult {
|
||||
@@ -307,7 +227,6 @@ mod tests {
|
||||
assert_eq!(other.key, "ERR_GUILD_PERMISSIONS");
|
||||
}
|
||||
|
||||
/// Result `0` is the success side, and there the command tag picks the line.
|
||||
#[test]
|
||||
fn result_zero_is_the_success_side() {
|
||||
let invited = command_line(&GuildCommandResult {
|
||||
@@ -329,10 +248,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// **The `_S` arity control, 22 of 22** — the reference's `add esp,8` at every `_S` key and
|
||||
/// `add esp,4` at every other (`0x5e7520`). It is checkable here because the key
|
||||
/// name carries the arity, and it is the one thing about this table that a wrong key cannot
|
||||
/// pass silently.
|
||||
#[test]
|
||||
fn every_underscore_s_key_takes_the_name_and_every_other_takes_nothing() {
|
||||
for result in 0u32..=0x15 {
|
||||
@@ -354,10 +269,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// **Every key this module names is a catalog row.** A key that is not one resolves to nothing
|
||||
/// or, worse, takes `Shown::keyed`'s fallback and turns a chat line red — which is exactly the
|
||||
/// class decision 2033 found sitting unnoticed for months. The surfaces are asserted with it,
|
||||
/// because two of these are NOT chat and the old text path sent all of them there.
|
||||
#[test]
|
||||
fn every_key_is_a_catalog_row_and_two_of_them_are_the_red_line() {
|
||||
let mut seen = Vec::new();
|
||||
@@ -382,19 +293,17 @@ mod tests {
|
||||
assert!(by_key(e.key).is_some(), "{} is not a catalog row", e.key);
|
||||
}
|
||||
|
||||
// The two name refusals are `kind 2` — the red `UI_ERROR_MESSAGE`, not chat. Every text
|
||||
// this module used to compose went to chat, so these two were on the wrong surface.
|
||||
// The two name refusals are `kind 2`, the red `UI_ERROR_MESSAGE`, not chat.
|
||||
for key in ["ERR_GUILD_NAME_INVALID", "ERR_GUILD_NAME_EXISTS_S"] {
|
||||
assert_eq!(by_key(key).map(|r| r.kind), Some(MsgKind::Error), "{key}");
|
||||
}
|
||||
// …and the rest of the guild family is chat, which is where they already were.
|
||||
// The rest of the guild family is chat.
|
||||
assert_eq!(
|
||||
by_key("ERR_GUILD_PROMOTE_SSS").map(|r| r.kind),
|
||||
Some(MsgKind::Chat)
|
||||
);
|
||||
}
|
||||
|
||||
/// The invite notice names the inviter first and the guild second.
|
||||
#[test]
|
||||
fn the_invite_notice_names_both() {
|
||||
let e = invite_line("Tigole", "Legacy of Steel");
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,23 +1,14 @@
|
||||
//! The guild roster's **view** — sorted, never filtered (the comparator `0x4d0d50`).
|
||||
//!
|
||||
//! Two things about it are the opposite of the obvious design, and both are verified at the bytes:
|
||||
//!
|
||||
//! - **The sort is multi-level.** The reference keeps an eight-slot `{key, direction}` chain
|
||||
//! (`0xb72680`), most-recently-chosen first, and its comparator walks the whole chain — so every
|
||||
//! column ever clicked survives as a tie-break behind the current one ([`SortStack`]).
|
||||
//! - **Show-offline is a sort input, not a filter.** When it is off, the comparator runs a pre-gate
|
||||
//! that sinks offline members below online ones (`0x4d0d60`–`0x4d0d8a`); the array still holds
|
||||
//! every member. That pre-gate is exactly what makes `GetNumGuildMembers`' smaller count address
|
||||
//! the online prefix (see [`super::GuildState::num_members`]).
|
||||
//! The guild roster's display order: sorted, never filtered (the reference comparator
|
||||
//! `0x4d0d50`). With show-offline off, offline members sink below online ones
|
||||
//! (`0x4d0d60`-`0x4d0d8a`), so `GetNumGuildMembers`' online count addresses the leading rows.
|
||||
|
||||
use std::cmp::Ordering;
|
||||
|
||||
use benilla_ui::script::GuildMemberInfo;
|
||||
|
||||
/// A column the guild roster can be ordered by — the eight `SortGuildRoster` accepts, in the
|
||||
/// reference's own key numbering (`0x4d1cb0`'s string chain: `rank` 0, `level` 1, `name` 2,
|
||||
/// `zone` 3, `class` 4, `group` 5, `online` 6, `note` 7). The numbering matters because the chain
|
||||
/// is initialized to exactly `key[i] = i` (`0x4d0a50`).
|
||||
/// A `SortGuildRoster` column, in the reference's key order (`0x4d1cb0`): `rank` 0, `level` 1,
|
||||
/// `name` 2, `zone` 3, `class` 4, `group` 5, `online` 6, `note` 7. The chain starts as
|
||||
/// `key[i] = i` (`0x4d0a50`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(super) enum SortField {
|
||||
Rank,
|
||||
@@ -25,16 +16,14 @@ pub(super) enum SortField {
|
||||
Name,
|
||||
Zone,
|
||||
Class,
|
||||
/// **A dead key.** Its jump-table entry is the comparator loop's own continue label
|
||||
/// (`0x4d0f44`'s slot 5 → `0x4d0efd`): it reads no field and orders nothing. Kept rather than
|
||||
/// dropped because dropping it would silently re-map an addon's `"group"` onto `name`.
|
||||
/// A dead key: its jump-table slot (`0x4d0f44`, slot 5) is the loop's continue label
|
||||
/// `0x4d0efd`, so it orders nothing. It stays so `"group"` does not parse as `name`.
|
||||
Group,
|
||||
Online,
|
||||
Note,
|
||||
}
|
||||
|
||||
/// The eight fields in key order — [`SortStack`]'s initial state, and the reference's own
|
||||
/// `for (i = 0; i < 8; i++) { key[i] = i; dir[i] = 0; }` (`0x4d0a50`–`0x4d0a62`).
|
||||
/// [`SortStack`]'s initial chain: the eight keys in order, all ascending (`0x4d0a50`-`0x4d0a62`).
|
||||
const SORT_FIELDS: [SortField; 8] = [
|
||||
SortField::Rank,
|
||||
SortField::Level,
|
||||
@@ -47,10 +36,8 @@ const SORT_FIELDS: [SortField; 8] = [
|
||||
];
|
||||
|
||||
impl SortField {
|
||||
/// The field a `SortGuildRoster(field)` string names. **Case-insensitive** (the reference's
|
||||
/// own collation `0x64a4c0` → `0x414310` folds `A-Z` with `+0x20` before comparing), and
|
||||
/// **anything unrecognised sorts by `name`** — the string chain leaves `edi` at its preloaded
|
||||
/// `mov edi,2` and calls with it anyway (`0x4d1cdb`, `0x4d1df4`). It neither raises nor no-ops.
|
||||
/// The column a `SortGuildRoster` string names, case-insensitively (`0x64a4c0`, `0x414310`);
|
||||
/// anything else sorts by `name`, the preloaded key (`0x4d1cdb`, `0x4d1df4`).
|
||||
pub(super) fn parse(field: &str) -> SortField {
|
||||
match field {
|
||||
f if f.eq_ignore_ascii_case("rank") => SortField::Rank,
|
||||
@@ -64,21 +51,16 @@ impl SortField {
|
||||
}
|
||||
}
|
||||
|
||||
/// Order two rows by this column alone. `Equal` means "this column does not decide it", which
|
||||
/// is what the reference's arms mean when they jump to the loop tail.
|
||||
/// Order two rows by this column alone; `Equal` is the reference arm's jump to the loop tail.
|
||||
fn compare(self, a: &RosterRow, b: &RosterRow) -> Ordering {
|
||||
match self {
|
||||
// Rank is the one column ordered by the OPPOSITE of its number: `0x4d0e54 cmp ecx,eax`
|
||||
// puts b against a and the `sbb/and/inc` idiom answers −1 when `b.rank < a.rank`, so
|
||||
// the HIGHER rank id — the lowest authority — comes first and the guild master sorts
|
||||
// last. Every other numeric column is the plain a-vs-b order (`0x4d0dd3`'s `setge`).
|
||||
// Reversed: the higher rank id sorts first, so the guild master sorts last
|
||||
// (`0x4d0e54`); every other numeric column is plain a-vs-b (`0x4d0dd3`).
|
||||
SortField::Rank => b.info.rank_index.cmp(&a.info.rank_index),
|
||||
SortField::Level => a.info.level.cmp(&b.info.level),
|
||||
SortField::Name => icmp(&a.info.name, &b.info.name),
|
||||
// Zone and class order by the resolved DBC *name*, not by the id (`0x4d0e67` and
|
||||
// `0x4d0ded` walk the id to a row and compare the localized string at
|
||||
// `+0x2c + locale*4` / `+0x14 + locale*4`) — and a row the DBC does not resolve makes
|
||||
// the column abstain rather than sort first (`0x4d0ea1`/`0x4d0e27 je` → the loop tail).
|
||||
// By the localized DBC name, not the id (`0x4d0e67`, `0x4d0ded`); an id the DBC does
|
||||
// not resolve abstains rather than sorting first (`0x4d0ea1`, `0x4d0e27`).
|
||||
SortField::Zone => abstaining(&a.info.zone, &b.info.zone),
|
||||
SortField::Class => abstaining(&a.info.class, &b.info.class),
|
||||
SortField::Group => Ordering::Equal,
|
||||
@@ -86,10 +68,9 @@ impl SortField {
|
||||
(true, true) => Ordering::Equal,
|
||||
(true, false) => Ordering::Less,
|
||||
(false, true) => Ordering::Greater,
|
||||
// Both offline: the more recently seen first — the reference's `fld`/`fcomp` on
|
||||
// the raw days-since-logout float (`0x4d0f19`). It answers ±1 rather than 0 for an
|
||||
// exact tie, which we deliberately do not copy: Rust's sort demands a total order,
|
||||
// and a non-antisymmetric comparator is a panic here and a coin flip there.
|
||||
// Both offline: the more recently seen first, on the raw days float (`0x4d0f19`).
|
||||
// Deviation: an exact tie is `Equal` where the reference answers ±1, because
|
||||
// Rust's sort requires a total order.
|
||||
(false, false) => a
|
||||
.last_online_days
|
||||
.partial_cmp(&b.last_online_days)
|
||||
@@ -100,16 +81,14 @@ impl SortField {
|
||||
}
|
||||
}
|
||||
|
||||
/// Case-insensitive ASCII order, allocation-free — the reference's `0x414310` (fold `A-Z` by
|
||||
/// `+0x20`, then compare bytes).
|
||||
/// Case-insensitive ASCII order, as the reference's `0x414310` folds `A-Z` and compares bytes.
|
||||
fn icmp(a: &str, b: &str) -> Ordering {
|
||||
a.bytes()
|
||||
.map(|c| c.to_ascii_lowercase())
|
||||
.cmp(b.bytes().map(|c| c.to_ascii_lowercase()))
|
||||
}
|
||||
|
||||
/// [`icmp`], except that an **empty** value on either side abstains instead of sorting first — the
|
||||
/// DBC-miss leg of the zone and class arms.
|
||||
/// [`icmp`], but an empty value on either side abstains: the zone and class arms' DBC miss.
|
||||
fn abstaining(a: &str, b: &str) -> Ordering {
|
||||
if a.is_empty() || b.is_empty() {
|
||||
return Ordering::Equal;
|
||||
@@ -117,18 +96,9 @@ fn abstaining(a: &str, b: &str) -> Ordering {
|
||||
icmp(a, b)
|
||||
}
|
||||
|
||||
/// The eight-slot `{key, direction}` chain the reference keeps (`0x4d0fb0` maintains it,
|
||||
/// `0x4d0d50` walks it), most-recently-chosen first.
|
||||
///
|
||||
/// `SortGuildRoster(field)` does one of two things:
|
||||
/// - the field is already at slot 0 → **flip its direction** (`0x4d0fcf`'s `sete`);
|
||||
/// - otherwise → **move it to slot 0**, shifting the rest down, keeping the direction it was last
|
||||
/// left at (`0x4d0fdc`–`0x4d0ff3`).
|
||||
///
|
||||
/// The chain starts as the eight columns in key order, all ascending, so a column's *first* click
|
||||
/// is ascending and its second reverses — but a column returned to after being reversed comes back
|
||||
/// **reversed**, and the column clicked before it is still the tie-break. A one-column-plus-
|
||||
/// direction model gets both of those wrong.
|
||||
/// The reference's eight-slot `{key, direction}` chain (`0xb72680`), most recent first: `0x4d0fb0`
|
||||
/// maintains it and `0x4d0d50` walks it, so every column clicked before stays a tie-break, and one
|
||||
/// returned to keeps the direction it was left at.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(super) struct SortStack([(SortField, bool); 8]);
|
||||
|
||||
@@ -139,7 +109,8 @@ impl Default for SortStack {
|
||||
}
|
||||
|
||||
impl SortStack {
|
||||
/// Apply a `SortGuildRoster(field)` — see the type doc for the two cases.
|
||||
/// `SortGuildRoster(field)`: a field already first flips its direction (`0x4d0fcf`); any other
|
||||
/// moves to the front with the direction it was left at (`0x4d0fdc`-`0x4d0ff3`).
|
||||
pub(super) fn select(&mut self, field: SortField) {
|
||||
let Some(at) = self.0.iter().position(|(f, _)| *f == field) else {
|
||||
return; // unreachable: the chain holds all eight, always
|
||||
@@ -153,17 +124,15 @@ impl SortStack {
|
||||
self.0[0] = moved;
|
||||
}
|
||||
|
||||
/// The current primary column and its direction — what the display order is *by*.
|
||||
/// The first column on the chain and whether it is descending.
|
||||
#[cfg(test)]
|
||||
fn primary(&self) -> (SortField, bool) {
|
||||
self.0[0]
|
||||
}
|
||||
|
||||
/// Order two rows. `show_offline` is a real input, not a filter: while it is **off**, an
|
||||
/// offline member sinks below every online one before any key is consulted (the pre-gate at
|
||||
/// `0x4d0d60`–`0x4d0d8a`, skipped entirely when it is on). Otherwise the first key on the
|
||||
/// chain that decides wins, with only *that* key's direction applied (`0x4d0f36`'s `neg eax`
|
||||
/// reads the deciding slot's flag, not slot 0's).
|
||||
/// Order two rows. With `show_offline` off, offline sinks below online before any key
|
||||
/// (`0x4d0d60`-`0x4d0d8a`); then the first deciding key wins, in its own direction, not slot
|
||||
/// 0's (`0x4d0f36`).
|
||||
fn compare(&self, a: &RosterRow, b: &RosterRow, show_offline: bool) -> Ordering {
|
||||
if !show_offline {
|
||||
match (a.info.online, b.info.online) {
|
||||
@@ -181,19 +150,14 @@ impl SortStack {
|
||||
Ordering::Equal
|
||||
}
|
||||
|
||||
/// Put `rows` in display order.
|
||||
///
|
||||
/// `sort_unstable_by`, deliberately: the reference is the MSVC CRT `qsort` (`0x73f727`,
|
||||
/// median-of-3 with an insertion-sort cutoff), which is **not** stable. Adding a stability we
|
||||
/// do not have would diverge on ties rather than converge.
|
||||
/// Put `rows` in display order; unstable, like the reference's MSVC CRT `qsort` (`0x73f727`).
|
||||
pub(super) fn order(&self, rows: &mut [RosterRow], show_offline: bool) {
|
||||
rows.sort_unstable_by(|a, b| self.compare(a, b, show_offline));
|
||||
}
|
||||
}
|
||||
|
||||
/// One roster row on its way to the VM: the display-resolved [`GuildMemberInfo`] the snapshot
|
||||
/// carries, plus the two things the *app* still needs — the member's guid (the selection is a
|
||||
/// guid, see [`super`]) and the raw days-since-logout the online column's tie-break compares.
|
||||
/// One roster row: the resolved [`GuildMemberInfo`] the VM gets, plus the guid the selection keys
|
||||
/// on and the raw days-since-logout the online column's tie-break compares.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct RosterRow {
|
||||
pub(crate) guid: u64,
|
||||
@@ -219,9 +183,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A repeat click reverses the column and a fresh column starts ascending — plus the half a
|
||||
/// `(field, direction)` pair cannot hold: a column returned to comes back **reversed**,
|
||||
/// because the reference remembers all eight directions.
|
||||
#[test]
|
||||
fn a_repeat_column_reverses_and_a_returned_one_keeps_its_direction() {
|
||||
let mut sort = SortStack::default();
|
||||
@@ -245,13 +206,11 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The chain is the tie-break: the column chosen *before* the current one still decides rows
|
||||
/// the current one ties on.
|
||||
#[test]
|
||||
fn the_previous_column_breaks_the_new_ones_ties() {
|
||||
let mut sort = SortStack::default();
|
||||
sort.select(SortField::Name); // then...
|
||||
sort.select(SortField::Level); // ...level on top, name behind it
|
||||
sort.select(SortField::Name);
|
||||
sort.select(SortField::Level); // level first, name behind it
|
||||
let a = row("Alice", true, 10, 0);
|
||||
let b = row("Bob", true, 10, 0);
|
||||
assert_eq!(
|
||||
@@ -266,8 +225,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Every one of the reference's eight strings maps to its own column, matching is
|
||||
/// case-insensitive, and anything else sorts by name rather than raising or no-opping.
|
||||
#[test]
|
||||
fn the_sort_field_names_are_the_references_eight() {
|
||||
for (text, field) in [
|
||||
@@ -287,7 +244,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// `group` is accepted and orders nothing — the key under it decides.
|
||||
#[test]
|
||||
fn the_group_key_is_dead() {
|
||||
let mut sort = SortStack::default();
|
||||
@@ -301,21 +257,16 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Rank sorts the guild master LAST at direction 0 — the one column whose byte order is the
|
||||
/// reverse of its number.
|
||||
#[test]
|
||||
fn rank_sorts_the_guild_master_last() {
|
||||
let mut sort = SortStack::default();
|
||||
sort.select(SortField::Rank);
|
||||
sort.select(SortField::Rank); // back to ascending (the default already had it at slot 0)
|
||||
sort.select(SortField::Rank); // back to ascending: rank starts at slot 0
|
||||
let master = row("Tigole", true, 60, 0);
|
||||
let initiate = row("Kaplan", true, 60, 4);
|
||||
assert_eq!(sort.compare(&initiate, &master, true), Ordering::Less);
|
||||
}
|
||||
|
||||
/// While show-offline is OFF the offline members sink to the bottom of the SAME array — which
|
||||
/// is what makes the smaller `GetNumGuildMembers` count address exactly the online prefix.
|
||||
/// While it is ON they interleave under the field keys.
|
||||
#[test]
|
||||
fn show_offline_sinks_rather_than_filters() {
|
||||
let mut sort = SortStack::default();
|
||||
@@ -337,8 +288,6 @@ mod tests {
|
||||
assert_eq!(names, ["Alice", "Bob", "Carol", "Dave"], "interleaved");
|
||||
}
|
||||
|
||||
/// Between two offline members the online column breaks the tie by who was seen most
|
||||
/// recently, and a zone or class the DBC could not name abstains instead of sorting first.
|
||||
#[test]
|
||||
fn the_online_tiebreak_and_the_dbc_miss_abstention() {
|
||||
let mut sort = SortStack::default();
|
||||
|
||||
@@ -1,33 +1,17 @@
|
||||
//! **TOGGLEUI** — the hide-the-interface binding (`ALT-Z`).
|
||||
//!
|
||||
//! The state ([`UiHidden`]) and the binding that flips it; the two things that *obey* it are the
|
||||
//! quad pass's mesh rebuild (`ui_pass::rebuild_ui_mesh` draws nothing) and the UI's pointer feed
|
||||
//! (`ui_script::input::feed_ui_input` stops hit-testing). Kept out of `ui_pass` deliberately: this
|
||||
//! is a *game binding*, not part of the render substrate.
|
||||
//! TOGGLEUI, the hide-the-interface binding (`ALT-Z`), and its state: while it is set the quad
|
||||
//! pass draws nothing and the UI's pointer feed stops hit-testing.
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::char_select::{ClientState, InWorldGated};
|
||||
use crate::ui_script::UiInput;
|
||||
|
||||
/// Is the player UI hidden right now? (`ALT-Z` — [`toggle_ui_hidden`].)
|
||||
///
|
||||
/// Hidden means *"draw nothing"*, not *"stop producing"*: the widget arena keeps ticking and both
|
||||
/// quad lanes keep filling, so open panels, tooltip state, cooldown sweeps and chat scrollback are
|
||||
/// exactly where they were the moment the UI comes back — only the pass's mesh batches go away.
|
||||
/// **Everything that lands in `ui_pass::UiQuads` goes dark together**: the FrameXML layer, the
|
||||
/// minimap, the V-plates, chat bubbles and floating combat text — the world and nothing else,
|
||||
/// which is the point of the binding. What stays up: the dev overlays (their own camera and their
|
||||
/// own dev chords — an instrument, not the player's UI), the glue/loading screens (Bevy UI
|
||||
/// nodes, not quads), and the cursor.
|
||||
///
|
||||
/// The UI also stops taking the **mouse** while hidden: an invisible action bar must not eat a
|
||||
/// click or arm a tooltip. The keyboard feed stays live — a hidden UI is still the client you're
|
||||
/// playing, and `ENTER`/`ESCAPE`/the bar keys keep working exactly as the reference's do.
|
||||
/// Whether the player UI is hidden: nothing in `ui_pass::UiQuads` is drawn and the UI takes no
|
||||
/// mouse, while widgets keep ticking. The keyboard stays live, as in the reference. Open windows
|
||||
/// stay open, where the stock binding runs `CloseAllWindows()` first (`Bindings.xml:655-661`).
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct UiHidden(pub bool);
|
||||
|
||||
/// The TOGGLEUI binding + its state.
|
||||
pub(crate) struct UiHidePlugin;
|
||||
|
||||
impl Plugin for UiHidePlugin {
|
||||
@@ -35,20 +19,14 @@ impl Plugin for UiHidePlugin {
|
||||
app.init_resource::<UiHidden>()
|
||||
.add_systems(
|
||||
Update,
|
||||
// After `UiInput`, like every other gameplay key reader: an EditBox that consumed
|
||||
// this frame's keys has already published its capture flag by then.
|
||||
// After `UiInput`: an EditBox that took this frame's keys has published its
|
||||
// capture flag by then.
|
||||
toggle_ui_hidden.after(UiInput).in_set(InWorldGated),
|
||||
)
|
||||
.add_systems(OnExit(ClientState::InWorld), show_ui);
|
||||
}
|
||||
}
|
||||
|
||||
/// TOGGLEUI through the binding table (0997; default `ALT-Z` — 0870's finding stands: the
|
||||
/// install's `bindings-cache.wtf` says `CTRL-Z` on all three accounts, but they descend from one
|
||||
/// rebound profile, and a saved-state file is evidence about a *player*, never about the client).
|
||||
/// The exact-modifier law (0585) and the typing gate this site used to enforce by hand — the
|
||||
/// `toggle_chord` alt-and-nothing-else check, the AppKit `keyUp` repeat hazard — now live once,
|
||||
/// in the dispatch.
|
||||
fn toggle_ui_hidden(binds: Res<crate::bindings::BindingsState>, mut hidden: ResMut<UiHidden>) {
|
||||
if !binds.fired(crate::bindings::cmd::TOGGLE_UI) {
|
||||
return;
|
||||
@@ -60,8 +38,8 @@ fn toggle_ui_hidden(binds: Res<crate::bindings::BindingsState>, mut hidden: ResM
|
||||
);
|
||||
}
|
||||
|
||||
/// Leaving the world clears the hide — safety, not fidelity: the binding is `InWorld`-only, so a UI
|
||||
/// left hidden at logout would come back invisible with no on-screen affordance to explain it.
|
||||
/// Leaving the world clears the hide: the binding is in-world only, so a UI hidden at logout would
|
||||
/// come back invisible with no way to see why.
|
||||
fn show_ui(mut hidden: ResMut<UiHidden>) {
|
||||
hidden.0 = false;
|
||||
}
|
||||
@@ -70,10 +48,6 @@ fn show_ui(mut hidden: ResMut<UiHidden>) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The consumer half only: TOGGLEUI fired → the flag flips, both ways. The chord/typing laws
|
||||
/// this file used to test by hand (ALT-and-nothing-else, the capture gate, bare Z belonging
|
||||
/// to the sheath toggle) are the DISPATCH's now, tested table-wide in
|
||||
/// `crate::bindings::tests` against real key events.
|
||||
#[test]
|
||||
fn a_fired_toggleui_flips_the_flag_both_ways() {
|
||||
let mut app = App::new();
|
||||
|
||||
@@ -1,55 +1,17 @@
|
||||
//! The app-side **honor feed** (decision 1512) — the local player's PRIVATE honor descriptor
|
||||
//! fields turned into the snapshot both Honor tabs read, the inspect-honor request/reply round
|
||||
//! trip, and the `SMSG_PVP_CREDIT` award turned into its chat line and floating number.
|
||||
//! The honor feed: our own honor descriptor fields become the snapshot the Honor tabs read, and
|
||||
//! the inspect-honor reply is matched to the inspected player. The honor block streams only to
|
||||
//! its owner, so another player's numbers come only from `MSG_INSPECT_HONOR_STATS`, which vmangos
|
||||
//! refuses by not answering (`MiscHandler.cpp:962-972`); the stock pane re-asks from its `OnShow`
|
||||
//! while it holds nothing, the only retry.
|
||||
//!
|
||||
//! The display law lives in `benilla-ui`'s `script::pvp`; this is the **data** law it consumes,
|
||||
//! and there are three pieces of it.
|
||||
//! The reference fires the pane's events from three field watches (`0x467e70`, callback
|
||||
//! `0x5de4b0`): `PLAYER_FIELD_SESSION_KILLS` fires `PLAYER_PVP_KILLS_CHANGED`, and
|
||||
//! `PLAYER_BYTES_3` and `PLAYER_FIELD_BYTES2` byte 0 fire `PLAYER_PVP_RANK_CHANGED`. Nothing
|
||||
//! watches the weekly, lifetime or highest-rank fields, which `HonorFrame.lua:10-11` repaints only
|
||||
//! on `PLAYER_ENTERING_WORLD`.
|
||||
//!
|
||||
//! ## 1 · The self snapshot is one descriptor read, and every field of it is PRIVATE
|
||||
//!
|
||||
//! The whole honor block (`PLAYER_FIELD_SESSION_KILLS` … `PLAYER_FIELD_BYTES2`) streams for
|
||||
//! nobody but the local player, so there is no per-unit form of this feed and there cannot be one.
|
||||
//! What a *foreign* player exposes is exactly two things — the PUBLIC current-rank byte, which
|
||||
//! rides [`crate::ui_unit`]'s snapshot, and whatever `MSG_INSPECT_HONOR_STATS` chooses to answer.
|
||||
//! That asymmetry is the shape of the entire arc.
|
||||
//!
|
||||
//! ## 2 · The two events are a field diff, and the reference watches **exactly three fields**
|
||||
//!
|
||||
//! The pane repaints on `PLAYER_PVP_KILLS_CHANGED` and `PLAYER_PVP_RANK_CHANGED`, which the real
|
||||
//! engine fires from field watches. In the watch table (`0x467e70`, callback
|
||||
//! `0x5de4b0` carrying the event id) there are **three registrations and no more**:
|
||||
//!
|
||||
//! | watched | fires |
|
||||
//! |---|---|
|
||||
//! | `PLAYER_FIELD_SESSION_KILLS` | `PLAYER_PVP_KILLS_CHANGED` (523) |
|
||||
//! | `PLAYER_BYTES_3` | `PLAYER_PVP_RANK_CHANGED` (524) |
|
||||
//! | `PLAYER_FIELD_BYTES2` byte 0 | `PLAYER_PVP_RANK_CHANGED` (524) |
|
||||
//!
|
||||
//! **Nothing watches the yesterday / this-week / last-week / lifetime / contribution fields, and
|
||||
//! nothing watches `+0x102b` (the highest-lifetime rank).** That is not an omission in the client;
|
||||
//! it is *why* `HonorFrame.lua` refreshes those rows on `PLAYER_ENTERING_WORLD` alone. The two
|
||||
//! facts are one fact, and a feed that fired `KILLS_CHANGED` on a lifetime total — which this one
|
||||
//! once did — would be firing an event the real client has no source for.
|
||||
//!
|
||||
//! One divergence, stated: the reference watches the **whole `PLAYER_BYTES_3` dword**, so a
|
||||
//! drunkenness change fires `PLAYER_PVP_RANK_CHANGED` there too. We watch byte 3 alone. The
|
||||
//! repaint that spurious fire produces is byte-identical to the one before it, so the difference
|
||||
//! is unobservable; carrying the other three bytes in an honor snapshot to reproduce it would cost
|
||||
//! a field nothing reads.
|
||||
//!
|
||||
//! ## 3 · The inspect round trip is a real one, unlike `CMSG_INSPECT`
|
||||
//!
|
||||
//! 0631's inspect request is fire-and-forget: the gear was already streamed and `SMSG_INSPECT`
|
||||
//! echoes the guid. This one is the opposite — `MSG_INSPECT_HONOR_STATS` is the *only* source of
|
||||
//! another player's honor numbers, the reply rides the **same opcode** as the request, and the
|
||||
//! reference's pane gates on `HasInspectHonorData()` precisely because the data may not be here
|
||||
//! yet. So: the pane asks, we resolve the inspect target's guid and send, the reply lands in
|
||||
//! [`InspectHonor`], and `INSPECT_HONOR_UPDATE` tells the pane to repaint.
|
||||
//!
|
||||
//! **A refusal is silent by construction.** The server answers nothing when the target is gone,
|
||||
//! out of the 10-yard `INSPECT_DISTANCE`, or attackable (`MiscHandler.cpp:962-977`) — there is no
|
||||
//! error shape on this opcode. The pane's own `OnShow` re-asks whenever it holds nothing, which is
|
||||
//! the reference's retry and the only one there is.
|
||||
//! Deviation: the reference watches the whole `PLAYER_BYTES_3` dword, so a drunkenness change also
|
||||
//! fires `PLAYER_PVP_RANK_CHANGED`; we watch byte 3 alone, because that repaint is identical.
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
@@ -58,35 +20,24 @@ use benilla_ui::script::{HonorState, InspectHonorData, ScriptValue, UiScript};
|
||||
use crate::net::{ClientCommand, NetCommands, ObjectStore, SelfPlayer};
|
||||
use crate::ui_script::{UiFeed, VmMemo};
|
||||
|
||||
/// The inspect-honor reply we currently hold, or `None` before one lands.
|
||||
///
|
||||
/// Keyed by nothing but its own `player_guid`: the reply carries whose it is, and the pane is
|
||||
/// keyed by a unit token, so the app is where the two are matched. One slot rather than a map —
|
||||
/// the reference's `HasInspectHonorData` is a single latch, and a window that can only inspect one
|
||||
/// player at a time needs no more.
|
||||
/// The inspect-honor reply held: one slot, like the reference's `HasInspectHonorData` latch.
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct InspectHonor(pub(crate) Option<benilla_protocol::messages::InspectHonorStats>);
|
||||
|
||||
/// What the last push told this VM, so the feed pushes and fires only on real change.
|
||||
///
|
||||
/// Behind a [`VmMemo`] (1290/1291) for the reason every other feed's is: a `/reload` replaces the
|
||||
/// VM without despawning the world, and a memory of what the *old* VM was told would leave the new
|
||||
/// one with an empty pane and no event ever coming.
|
||||
/// What the last push told this VM, keyed on the VM so a `/reload`'s fresh VM is told again.
|
||||
#[derive(Resource, Default)]
|
||||
struct HonorFeedState {
|
||||
vm: VmMemo<HonorFeedMemo>,
|
||||
}
|
||||
|
||||
/// The per-VM change bases — the self snapshot and the inspect reply's guid.
|
||||
/// The per-VM change bases: the last snapshot and the held reply's guid.
|
||||
#[derive(Default)]
|
||||
struct HonorFeedMemo {
|
||||
last: Option<HonorState>,
|
||||
last_inspect: Option<u64>,
|
||||
}
|
||||
|
||||
/// The honor pane's packet handler (decision 1512; in the net handler table since 2313). The
|
||||
/// honor arc's other inbound message, the award (`PvpCredit`), is a chat line and a floating
|
||||
/// number and stays with the chat family.
|
||||
/// The inspect-honor reply's handler; the honor award, `SMSG_PVP_CREDIT`, is the chat family's.
|
||||
mod net {
|
||||
use benilla_protocol::{SessionEvent, SessionEventKind};
|
||||
use bevy::prelude::*;
|
||||
@@ -94,14 +45,11 @@ mod net {
|
||||
use super::InspectHonor;
|
||||
use crate::net::NetHandlerApp;
|
||||
|
||||
/// Register the handler — called from [`super::UiHonorPlugin`].
|
||||
pub(super) fn register(app: &mut App) {
|
||||
app.net_handler(SessionEventKind::InspectHonorStats, on_inspect_stats);
|
||||
}
|
||||
|
||||
/// The inspect reply REPLACES whatever is held, including for a different player: the
|
||||
/// reference's latch is a single slot, and a pane still showing the last target's kills is
|
||||
/// the failure keeping the old one produces.
|
||||
/// A reply replaces whatever is held, whoever it is for: the reference's latch is one slot.
|
||||
fn on_inspect_stats(In(ev): In<SessionEvent>, mut inspect: ResMut<InspectHonor>) {
|
||||
if let SessionEvent::InspectHonorStats(stats) = ev {
|
||||
inspect.0 = Some(stats);
|
||||
@@ -120,29 +68,16 @@ impl Plugin for UiHonorPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the honor block off our own descriptor, or `None` while none of it has streamed.
|
||||
///
|
||||
/// **`None` and all-zeroes are the same thing on screen, and that is the point of the gate, not a
|
||||
/// flaw in it.** This doc used to say the reference "paints a player whose fields have not arrived
|
||||
/// as blank, not as zero". It does not: `0x51a4b0`–`0x51a7c0`'s "absent → 0.0" tails are about the
|
||||
/// absent player OBJECT, and once the object exists every one of those bindings reads a
|
||||
/// descriptor array that is allocated and zeroed, so a field the server never sent reads `0`.
|
||||
/// Our bindings answer the same zeros — `honor(lua).unwrap_or_default()` — so what the gate
|
||||
/// actually decides is only *when the first snapshot is pushed*, and with it when
|
||||
/// `PLAYER_PVP_KILLS_CHANGED`/`PLAYER_PVP_RANK_CHANGED` first fire. It cannot make a row differ
|
||||
/// from the reference.
|
||||
///
|
||||
/// That is worth stating because this gate was the other candidate cause of report B378, and it
|
||||
/// is ruled out by exactly this: a missing snapshot and a zeroed one paint the same pane. The
|
||||
/// cause was the rank title's team digit (decision 2227).
|
||||
/// Read the honor block off our own descriptor, `None` until it streams. That decides only when
|
||||
/// the first push and its events happen: the reference reads a field it was never sent as 0
|
||||
/// (`0x51a4b0`-`0x51a7c0`), and so do our bindings.
|
||||
fn honor_snapshot(store: &ObjectStore) -> Option<HonorState> {
|
||||
let f = &store.0;
|
||||
let session = f.player_session_kills();
|
||||
let yesterday = f.player_yesterday_kills();
|
||||
let last_week = f.player_last_week_kills();
|
||||
let this_week = f.player_this_week_kills();
|
||||
// The rank byte is PUBLIC and arrives with the unit block, so it alone is a poor presence
|
||||
// test; the honor block proper is what we wait for.
|
||||
// Presence is tested on a private field: the public rank byte streams with the unit block.
|
||||
session?;
|
||||
Some(HonorState {
|
||||
session_hk: session.map_or(0, |(hk, _)| hk),
|
||||
@@ -158,33 +93,24 @@ fn honor_snapshot(store: &ObjectStore) -> Option<HonorState> {
|
||||
last_week_standing: f.player_last_week_rank().unwrap_or(0),
|
||||
lifetime_hk: f.player_lifetime_honorable_kills().unwrap_or(0),
|
||||
lifetime_dk: f.player_lifetime_dishonorable_kills().unwrap_or(0),
|
||||
// The HIGHEST lifetime rank (PRIVATE, `PLAYER_FIELD_BYTES` byte 3) …
|
||||
// The highest lifetime rank, private `PLAYER_FIELD_BYTES` byte 3.
|
||||
highest_rank: f.player_honor_rank().unwrap_or(0),
|
||||
// … and the CURRENT one (PUBLIC, `PLAYER_BYTES_3` byte 3). Two bytes, two fields, and
|
||||
// they are equal for anyone who has never ranked down — which is exactly why reading one
|
||||
// for the other would have shipped green (decision 1512).
|
||||
// The current rank, public `PLAYER_BYTES_3` byte 3: another field, equal to the highest
|
||||
// until the player ranks down, so a swap would pass unnoticed.
|
||||
rank: f.player_pvp_rank().unwrap_or(0),
|
||||
rank_bar: f.player_honor_rank_bar().unwrap_or(0),
|
||||
})
|
||||
}
|
||||
|
||||
/// Which of the two reference events a change between two snapshots deserves — the module doc's
|
||||
/// three-row watch table, written out. A first push is both: the pane has never painted, and
|
||||
/// either event repaints it.
|
||||
///
|
||||
/// **Most of `HonorState` fires nothing at all.** The weekly figures, the two lifetime totals and
|
||||
/// the highest-lifetime rank are unwatched in the real client, so they ride the pane's world-entry
|
||||
/// repaint and move here in silence. Listing the watched fields positively — rather than
|
||||
/// "everything that is not a rank byte", which is what this did before — is what
|
||||
/// keeps a field added later from inventing an event for itself.
|
||||
/// `(kills changed, rank changed)` between two snapshots, from the three watched fields only; a
|
||||
/// first push fires both. The watched fields are listed so that a new field fires nothing.
|
||||
fn events_for(before: Option<&HonorState>, after: &HonorState) -> (bool, bool) {
|
||||
let Some(b) = before else {
|
||||
return (true, true);
|
||||
};
|
||||
// `PLAYER_FIELD_SESSION_KILLS`, both halves — the one field behind `PLAYER_PVP_KILLS_CHANGED`.
|
||||
// `PLAYER_FIELD_SESSION_KILLS`, both halves.
|
||||
let kills = |h: &HonorState| (h.session_hk, h.session_dk);
|
||||
// `PLAYER_BYTES_3` byte 3 and `PLAYER_FIELD_BYTES2` byte 0 — the two behind
|
||||
// `PLAYER_PVP_RANK_CHANGED`. NOT `highest_rank`: that is `+0x102b`, and nothing watches it.
|
||||
// `PLAYER_BYTES_3` byte 3 and `PLAYER_FIELD_BYTES2` byte 0; not `highest_rank`, unwatched.
|
||||
let ranks = |h: &HonorState| (h.rank, h.rank_bar);
|
||||
(kills(b) != kills(after), ranks(b) != ranks(after))
|
||||
}
|
||||
@@ -223,28 +149,11 @@ fn feed_honor(
|
||||
}
|
||||
|
||||
// --- the inspect reply -------------------------------------------------------------------
|
||||
//
|
||||
// **A reply is only valid while it is about the player currently being inspected.** The
|
||||
// reference's `HasInspectHonorData()` is the latch its pane's `OnShow` gates on: hold stale
|
||||
// data and inspecting a SECOND player repaints the FIRST one's kills, with no request ever
|
||||
// sent and nothing on screen to say so.
|
||||
//
|
||||
// **The real client's latch is invalidated by exactly one thing, and it is not this one**:
|
||||
// the slot is a single un-keyed store, and `0x4c6f70` — reached
|
||||
// from `NotifyInspect` — is its only GUID writer *and* its only invalidator. A `NotifyInspect`
|
||||
// naming a different player zeroes both flags; the same player is a no-op; there is no timeout;
|
||||
// and `ClearInspectPlayer` (the stock `InspectFrame_OnHide`) clears it outright.
|
||||
//
|
||||
// **We invalidate on the inspected TOKEN's guid moving instead, deliberately.** Our inspect
|
||||
// window re-resolves its token every frame so the paper doll follows a re-target (0631), and
|
||||
// the honor page reads that same token rather than the reference's hardcoded `"target"` — so
|
||||
// matching the reference's latch exactly would let one window show two different players'
|
||||
// data at once, on its two tabs. The reference cannot notice because its two pages disagree
|
||||
// about whose player they show in the first place. Ours agree, and this comparison is what
|
||||
// keeps them agreeing.
|
||||
//
|
||||
// (`ClearInspectPlayer` is covered too, one level up: it drops the token, `inspected` reads
|
||||
// `None`, and the mismatch below clears the slot.)
|
||||
// The reference's latch clears only at `0x4c6f70`, from a `NotifyInspect` naming another
|
||||
// player, and on `ClearInspectPlayer`. Deviation: we clear it whenever the inspected token's
|
||||
// guid moves, because our window re-resolves its token each frame and both tabs read it, so
|
||||
// the reference's rule would let the two tabs show different players. `ClearInspectPlayer`
|
||||
// drops the token, which clears the slot here too.
|
||||
let inspected = inspect_target
|
||||
.token
|
||||
.as_deref()
|
||||
@@ -254,9 +163,7 @@ fn feed_honor(
|
||||
.as_ref()
|
||||
.is_some_and(|reply| Some(reply.player_guid) != inspected)
|
||||
{
|
||||
// Dropped from the RESOURCE, not merely filtered on the way out: a reply nobody may read
|
||||
// is not data we are keeping, and leaving it here would make `HasInspectHonorData` and
|
||||
// this store disagree about what is held.
|
||||
// Dropped from the resource, so `HasInspectHonorData` and this store agree.
|
||||
inspect_honor.0 = None;
|
||||
}
|
||||
let held = inspect_honor.0.as_ref().map(|r| r.player_guid);
|
||||
@@ -281,18 +188,13 @@ fn feed_honor(
|
||||
}
|
||||
}));
|
||||
memo.last_inspect = held;
|
||||
// Fired on a *clear* as well as on an arrival: the pane's handler re-reads
|
||||
// `GetInspectHonorData`, and a window left showing the previous player's kills is the
|
||||
// failure a silent clear produces.
|
||||
// Fired on a clear too, so the pane stops showing the previous player's numbers.
|
||||
script.fire_event("INSPECT_HONOR_UPDATE", Vec::<ScriptValue>::new());
|
||||
}
|
||||
|
||||
// --- the pane's request ------------------------------------------------------------------
|
||||
//
|
||||
// `RequestInspectHonorData()` takes no argument — the engine holds the inspected player, so
|
||||
// the app resolves it, exactly as `NotifyInspect`'s token is resolved. The token is re-read
|
||||
// now rather than remembered from the notify, so a re-target between opening the window and
|
||||
// opening its Honor tab asks about whoever is actually being inspected.
|
||||
// `RequestInspectHonorData()` takes no argument: it asks about the inspected token's guid now,
|
||||
// so a re-target before the Honor tab opens asks about the new player.
|
||||
let requests = script.take_inspect_honor_requests();
|
||||
if requests > 0 {
|
||||
match inspected {
|
||||
@@ -302,8 +204,7 @@ fn feed_honor(
|
||||
.0
|
||||
.send(ClientCommand::InspectHonorStats { target: guid });
|
||||
}
|
||||
// No inspect target (or it is not streamed): nothing to ask about. The pane will ask
|
||||
// again from its next `OnShow`, which is the reference's only retry.
|
||||
// Nothing to ask about; the pane asks again from its next `OnShow`.
|
||||
None => debug!("honor: inspect-honor request with no resolvable target — not sent"),
|
||||
}
|
||||
}
|
||||
@@ -324,9 +225,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The event split is the whole point of [`events_for`]: a kill must not claim a rank change,
|
||||
/// and a rank must not claim a kill. An addon that registers only one of the two is what makes
|
||||
/// the distinction observable.
|
||||
#[test]
|
||||
fn each_event_fires_only_for_its_own_half() {
|
||||
let base = state();
|
||||
@@ -339,16 +237,12 @@ mod tests {
|
||||
ranked.rank = 9;
|
||||
assert_eq!(events_for(Some(&base), &ranked), (false, true));
|
||||
|
||||
// The bar moving is a rank event too — it is the rank's own progress, and the reference's
|
||||
// pane redraws the bar from the same handler as the title.
|
||||
// The rank bar is `PLAYER_FIELD_BYTES2` byte 0, a rank event.
|
||||
let mut barred = base;
|
||||
barred.rank_bar = 200;
|
||||
assert_eq!(events_for(Some(&base), &barred), (false, true));
|
||||
|
||||
// The unwatched half: a weekly figure, a lifetime total and the highest-lifetime rank all
|
||||
// move in silence, because the real client registers no watch on any of them (`0x467e70` —
|
||||
// the module doc's table). This is the assertion the earlier implementation failed: it
|
||||
// fired `KILLS_CHANGED` for all three.
|
||||
// Unwatched in the reference (`0x467e70`), so these fire nothing.
|
||||
for mutate in [
|
||||
(|h: &mut HonorState| h.last_week_standing = 42) as fn(&mut HonorState),
|
||||
|h: &mut HonorState| h.lifetime_hk = 5_000,
|
||||
@@ -365,8 +259,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A first push has to fire both, or a pane whose numbers arrived before it existed never
|
||||
/// paints — the `ui_unit` "first resolve counts as a transition" rule.
|
||||
/// Otherwise a pane whose numbers arrived before it existed would never paint.
|
||||
#[test]
|
||||
fn the_first_push_fires_both() {
|
||||
assert_eq!(events_for(None, &state()), (true, true));
|
||||
|
||||
@@ -1,43 +1,12 @@
|
||||
//! The app-side **inspect feed** (decision 0631) — the bridge that turns another player's PUBLIC
|
||||
//! descriptor into the slot views the inspect window's Lua reads.
|
||||
//! The inspect feed: another player's public descriptor as the slot views the inspect window's
|
||||
//! Lua reads. Their inventory guids are server-private, so there are no item objects, only the
|
||||
//! `PLAYER_VISIBLE_ITEM_*` entries and their templates, which is why the reference's inspect doll
|
||||
//! shows no counts and no durability.
|
||||
//!
|
||||
//! The `CanInspect`/`CheckInteractDistance` distance map used to be fed from here too, back when it
|
||||
//! held popup tokens only and only for players. It is unit-general now (B304) and lives with the
|
||||
//! other unit-token feeds — `crate::ui_unit::feed_unit_reach`.
|
||||
//!
|
||||
//! This is [`crate::ui_char`]'s pattern turned onto a *foreign* unit, and the difference between
|
||||
//! the two is the whole reason this module exists rather than a flag on that one:
|
||||
//!
|
||||
//! | | the character window | the inspect window |
|
||||
//! |---|---|---|
|
||||
//! | source | `PLAYER_FIELD_INV_SLOT_*` guids (PRIVATE) | `PLAYER_VISIBLE_ITEM_*` entries (PUBLIC) |
|
||||
//! | resolves via | item **objects** → templates | templates only |
|
||||
//! | so it can show | counts, durability, locks, creator | icon, name, quality |
|
||||
//!
|
||||
//! Other players' inventory guids are server-private, so there are no item objects to read — the
|
||||
//! visible-item entry is the only path (`entities::equipment`'s header records the same finding for
|
||||
//! rendering their gear). That is exactly why the reference's inspect paper doll shows no stack
|
||||
//! counts and no durability: the client has none either.
|
||||
//!
|
||||
//! Three jobs, each frame, before the VM ticks:
|
||||
//!
|
||||
//! - **Drain the intents.** `NotifyInspect(unit)` → resolve the token to a player guid → send
|
||||
//! `CMSG_INSPECT` and latch the token as the inspect target. `ClearInspectPlayer()` → drop it.
|
||||
//! The window does not wait for `SMSG_INSPECT` (it carries only the echoed guid, and the ref
|
||||
//! paints immediately) — the request exists because server-side it also sets our selection.
|
||||
//! - **Resolve the view.** While a target is latched: re-resolve the **token** each frame (so the
|
||||
//! window follows a re-target exactly as the ref's does, `InspectFrame_OnEvent`'s
|
||||
//! `PLAYER_TARGET_CHANGED` arm), then read the 19 visible-item entries → the ask-once item
|
||||
//! template cache → icon/name/quality/link, and push it on change. A slot whose template answer
|
||||
//! is still in flight carries its `item_id` with `icon: None` and fills on a later frame, the
|
||||
//! `ui_char` rule.
|
||||
//! - **Point the booth.** The `"inspect"` body booth ([`InspectBooth`]) gets the resolved entity
|
||||
//! and the pane's yaw, so it bakes the inspected player's dressed look (decision 0631 §4).
|
||||
//!
|
||||
//! Events, fired on transitions for the **inspected token** (the ref's own registration set,
|
||||
//! `InspectPaperDollFrame.lua:2-5` + `l.82`): `UNIT_INVENTORY_CHANGED` when any slot view changes,
|
||||
//! and `UNIT_LEVEL` when the unit's level does. A first resolve counts as a transition (the
|
||||
//! `ui_unit` rule).
|
||||
//! `NotifyInspect(unit)` sends `CMSG_INSPECT`, which also sets the server-side selection, and
|
||||
//! latches the token; the window paints without waiting for `SMSG_INSPECT`, a bare guid echo.
|
||||
//! `UNIT_INVENTORY_CHANGED` and `UNIT_LEVEL` fire for the inspected token on change, a first
|
||||
//! resolve included (`InspectPaperDollFrame.lua:2-5`, `82`).
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
@@ -49,26 +18,21 @@ use crate::net::{ClientCommand, GuidIndex, NetCommands, ObjectStore};
|
||||
use crate::portrait::InspectBooth;
|
||||
use crate::ui_script::UiFeed;
|
||||
|
||||
/// Which unit the inspect window is bound to — the ref's `InspectFrame.unit`, mirrored app-side so
|
||||
/// the feed knows whether to resolve anything. The **token** is the identity (not the guid): the
|
||||
/// reference re-reads the token on every retarget, so `"target"` follows the selection.
|
||||
/// The reference's `InspectFrame.unit`. The token, not the guid, is the identity: the reference
|
||||
/// re-reads it on every retarget, so `"target"` follows the selection.
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct InspectTarget {
|
||||
pub(crate) token: Option<String>,
|
||||
}
|
||||
|
||||
/// The last pushed view + the last level seen, so the feed pushes and fires only on real change
|
||||
/// (the `ui_char`/`ui_unit` discipline).
|
||||
///
|
||||
/// Both are claims about what THIS VM holds, so both sit behind a [`crate::ui_script::VmMemo`]
|
||||
/// (1290): against a `/reload`'s replacement VM (1291) the memo reads fresh, and the view is
|
||||
/// re-pushed and its events re-fired for a window the new VM has yet to hear about.
|
||||
/// The last view pushed and level seen, per VM ([`crate::ui_script::VmMemo`]), so a `/reload`'s
|
||||
/// new VM gets the view re-pushed and its events re-fired.
|
||||
#[derive(Resource, Default)]
|
||||
struct InspectFeedState {
|
||||
vm: crate::ui_script::VmMemo<InspectFeedMemo>,
|
||||
}
|
||||
|
||||
/// The per-VM half of [`InspectFeedState`] — the change bases.
|
||||
/// The per-VM change bases.
|
||||
#[derive(Default)]
|
||||
struct InspectFeedMemo {
|
||||
last: Option<InspectView>,
|
||||
@@ -85,18 +49,11 @@ impl Plugin for InspectUiPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
/// One inspected equipment slot, resolved from the target's PUBLIC visible-item entry. `slot0` is
|
||||
/// the 0-based `EQUIPMENT_SLOT_*` index; the live-API id the Lua sees is `slot0 + 1`.
|
||||
///
|
||||
/// Everything an item *object* would supply is absent by construction (see the module doc), so the
|
||||
/// count is the ref's own always-1 and durability/flags/locks/creator/equip-fit stay at their inert
|
||||
/// defaults — an inspected item is not draggable, lockable, or repairable.
|
||||
///
|
||||
/// The **enchant lines are the one exception** (decision 0915): a unit's descriptor broadcasts the
|
||||
/// enchants of what it wears — `PLAYER_VISIBLE_ITEM_<slot>_0 + 1 + j` — so an inspected weapon's
|
||||
/// enchant is readable without any object. 1.12 fills exactly two of those seven slots (PERM,
|
||||
/// TEMP: vmangos `SetVisibleItemSlot`'s `MAX_INSPECTED_ENCHANTMENT_SLOT`), and that is the same
|
||||
/// data the reference's own inspect tooltip has.
|
||||
/// One inspected slot from the target's visible-item entry; `slot0` is the 0-based
|
||||
/// `EQUIPMENT_SLOT_*` index, `slot0 + 1` in Lua. With no item object the count is the reference's
|
||||
/// always-1 and durability, flags, locks and creator stay inert. Only the enchants broadcast, at
|
||||
/// `PLAYER_VISIBLE_ITEM_<slot>_0 + 1 + j`, and 1.12 fills PERM and TEMP alone (vmangos
|
||||
/// `SetVisibleItemSlot`, `MAX_INSPECTED_ENCHANTMENT_SLOT`).
|
||||
fn inspect_slot_view(
|
||||
store: &ObjectStore,
|
||||
items: &Items,
|
||||
@@ -106,13 +63,11 @@ fn inspect_slot_view(
|
||||
slot0: u8,
|
||||
) -> Option<InvSlotView> {
|
||||
let entry = store.0.player_visible_item_entry(slot0)?;
|
||||
// The broadcast roll (`PLAYER_VISIBLE_ITEM_<slot>_PROPERTIES`'s low half — the same `movzx
|
||||
// WORD` the reference's inspect leg feeds its `+0x424` with): the NAME's suffix, and only
|
||||
// that. Its *lines* would come from the visible enchant slots 2..6, which 1.12 servers leave
|
||||
// empty — so an inspected roll names itself and shows no stat lines, on both clients (1547).
|
||||
// The roll, the properties field's low word as the reference's inspect leg reads it into
|
||||
// `+0x424`, names the suffix only: its stat lines would sit in enchant slots 2-6, which 1.12
|
||||
// servers leave empty.
|
||||
let roll = store.0.player_visible_item_properties(slot0);
|
||||
// Template-only ask (`guid` 0 — `Items::template`'s own documented shape): there is no item
|
||||
// object to name, and asking by entry is precisely what the real client's ItemCache does.
|
||||
// By entry alone (guid 0), as the reference's item cache asks: there is no item object.
|
||||
let (name, quality, display) = match items.template(entry, 0, commands) {
|
||||
Some(t) => (
|
||||
Some(rolls.name(&t.name, roll)),
|
||||
@@ -133,17 +88,11 @@ fn inspect_slot_view(
|
||||
quality: quality as i32,
|
||||
name,
|
||||
link,
|
||||
// `already_bound` stays `false` here, deliberately: `0x5da2c0` reads `ITEM_FIELD_FLAGS`
|
||||
// off an item OBJECT, and an inspected player's gear arrives as descriptor fields on
|
||||
// THEM — there is no item object and no flags word to read. So an inspect tooltip prints
|
||||
// the template's own bind line (Binds when equipped), which is what the reference prints
|
||||
// for the same reason. The enchant half below cannot rescue it: those slots name rows,
|
||||
// not the instance's bound state.
|
||||
// `0x5da2c0` reads an item object's `ITEM_FIELD_FLAGS`, which inspected gear lacks, so the
|
||||
// tooltip prints the template's bind line, as the reference's does.
|
||||
already_bound: false,
|
||||
// All 7 slots, exactly as the reference's own inspect leg copies and renders them
|
||||
// (`0x533354`) — a 1.12 server happens to fill only PERM and TEMP. No item object here, so
|
||||
// no charges and no `SMSG_ITEM_ENCHANT_TIME_UPDATE` countdown: the reference's inspect
|
||||
// tooltip has neither either.
|
||||
// All 7 slots, as the reference's inspect leg copies them (`0x533354`), with no charges
|
||||
// and no countdown: there is no item object.
|
||||
enchants: crate::items::enchant_lines(
|
||||
(0..7).map(|j| {
|
||||
let id = store.0.player_visible_item_enchant(slot0, j).unwrap_or(0);
|
||||
@@ -162,8 +111,7 @@ fn feed_inspect(
|
||||
mut booth: ResMut<InspectBooth>,
|
||||
items: Res<Items>,
|
||||
icons: Option<Res<ItemDisplays>>,
|
||||
// `SpellItemEnchantment`'s name column — the inspected item's enchant line (decision 0915) —
|
||||
// and `ItemRandomProperties`, the roll behind its "of the Monkey" name (1547).
|
||||
// `SpellItemEnchantment` names the enchant lines, `ItemRandomProperties` the roll suffix.
|
||||
catalogs: (
|
||||
Option<Res<crate::items::Enchants>>,
|
||||
Option<Res<crate::items::RandomProperties>>,
|
||||
@@ -177,12 +125,10 @@ fn feed_inspect(
|
||||
let Some(mut script) = script else {
|
||||
return;
|
||||
};
|
||||
// Resolved against THIS VM (1290/1291): a `/reload` keeps the latched token, and the fresh
|
||||
// memo re-pushes the view into the VM that replaced the one which last saw it.
|
||||
// A `/reload` keeps the latched token, and the fresh memo re-pushes the view.
|
||||
let memo = feed.vm.get(&script);
|
||||
|
||||
// NotifyInspect(unit) → CMSG_INSPECT + latch the token. Resolving here (not in Lua) is what
|
||||
// lets the window bind to a token while the wire speaks guids.
|
||||
// `NotifyInspect(unit)`: send `CMSG_INSPECT` for the unit's guid and latch its token.
|
||||
for token in script.take_inspect_notifies() {
|
||||
match crate::ui_unit::player_token_guid(&token, &selection, &group) {
|
||||
Some(guid) => {
|
||||
@@ -195,14 +141,13 @@ fn feed_inspect(
|
||||
}
|
||||
}
|
||||
}
|
||||
// ClearInspectPlayer() — the ref's InspectFrame_OnHide. Drop everything so the resolve below
|
||||
// stops and the booth empties.
|
||||
// `ClearInspectPlayer()`, from `InspectFrame_OnHide`: the resolve stops and the booth empties.
|
||||
if script.take_inspect_clear() {
|
||||
target.token = None;
|
||||
}
|
||||
|
||||
// Re-resolve the LATCHED TOKEN every frame: `"target"` now means whoever is selected now, which
|
||||
// is how the reference's retarget arm works (module doc).
|
||||
// Re-resolve the latched token every frame, so the window follows a retarget as the
|
||||
// reference's `PLAYER_TARGET_CHANGED` arm does.
|
||||
let resolved = target.token.as_ref().and_then(|token| {
|
||||
let guid = crate::ui_unit::player_token_guid(token, &selection, &group)?;
|
||||
let entity = *index.0.get(&guid)?;
|
||||
@@ -210,7 +155,7 @@ fn feed_inspect(
|
||||
});
|
||||
|
||||
let Some((token, guid, entity)) = resolved else {
|
||||
// Nothing inspected (or the target isn't streamed): clear the view and empty the booth.
|
||||
// Nothing inspected, or the target is not streamed: clear the view and empty the booth.
|
||||
if memo.last.is_some() {
|
||||
script.set_inspect(None);
|
||||
memo.last = None;
|
||||
@@ -221,9 +166,7 @@ fn feed_inspect(
|
||||
};
|
||||
|
||||
booth.unit = Some(entity);
|
||||
// The stock `InspectPaperDollFrame.lua` turns the doll by writing the PANE
|
||||
// (`InspectModelFrame:SetRotation`), the same way the character sheet, the pet doll and the
|
||||
// stable booth already read theirs (decision 1832).
|
||||
// The stock Lua turns the doll through the pane (`InspectModelFrame:SetRotation`).
|
||||
booth.yaw = script.model_pane_facing("InspectModelFrame");
|
||||
|
||||
let Ok(store) = stores.get(entity) else {
|
||||
@@ -251,8 +194,7 @@ fn feed_inspect(
|
||||
if memo.last.as_ref() != Some(&view) {
|
||||
script.set_inspect(Some(view.clone()));
|
||||
memo.last = Some(view);
|
||||
// The ref's InspectPaperDollItemSlotButton_OnEvent listens for exactly this, filtered on
|
||||
// arg1 == the inspected unit.
|
||||
// `InspectPaperDollItemSlotButton_OnEvent` filters it on `arg1` being the inspected unit.
|
||||
script.fire_event(
|
||||
"UNIT_INVENTORY_CHANGED",
|
||||
vec![ScriptValue::Str(token.clone())],
|
||||
|
||||
@@ -1,111 +1,12 @@
|
||||
//! Instance/raid **lockout messages** — the six server packets that tell you about a saved
|
||||
//! instance, the bookkeeping two of them feed, and the one thing you can do about it
|
||||
//! (decision 1748).
|
||||
//! The instance lockout messages, the bookkeeping behind `CanShowResetInstances()`, and
|
||||
//! `CMSG_RESET_INSTANCES`. No 1.12 Lua handles the lines: the client fills the `GlobalStrings.lua`
|
||||
//! template itself and hands it to the chat composer `0x49a870` as `CHAT_MSG_SYSTEM`; here the net
|
||||
//! handlers queue them and [`feed_instance`] resolves them against the VM.
|
||||
//!
|
||||
//! Four of the six become a `CHAT_MSG_SYSTEM` line, and **the client composes every one of them
|
||||
//! itself**: no Lua handler in 1.12 touches this family (`RAID_INSTANCE_WELCOME`,
|
||||
//! `INSTANCE_RESET_SUCCESS`, `INSTANCE_SAVED` and the rest appear in `GlobalStrings.lua` and
|
||||
//! nowhere else in FrameXML). Each handler resolves the packet's `Map.dbc` id to a display name,
|
||||
//! looks the template up through Lua `GetText`, fills it with `SStrPrintf`, and hands the result
|
||||
//! to the chat chokepoint `0x49a870` with `edx = 0xa` = `CHAT_MSG_SYSTEM`. That whole shape is
|
||||
//! reproduced here; the only piece the app cannot do alone is the GlobalStrings read, which is why
|
||||
//! the lines are QUEUED by the net drain and RESOLVED in [`feed_instance`] where the VM is — the
|
||||
//! questgiver-refusal split of decision 0669.
|
||||
//!
|
||||
//! ## What each packet does (VERIFIED at the bytes, WoW.exe build 5875)
|
||||
//!
|
||||
//! | packet | handler | line |
|
||||
//! |---|---|---|
|
||||
//! | `SMSG_RAID_INSTANCE_MESSAGE` | `0x49e1c0` | one of four `RAID_INSTANCE_*` templates |
|
||||
//! | `SMSG_INSTANCE_RESET` | `0x49e470` | `INSTANCE_RESET_SUCCESS`, and clears the last-dungeon latch first |
|
||||
//! | `SMSG_INSTANCE_RESET_FAILED` | `0x49e540` | one of three `INSTANCE_RESET_FAILED*` templates |
|
||||
//! | `SMSG_INSTANCE_SAVE_CREATED` | `0x4e7e60` | `INSTANCE_SAVED` |
|
||||
//! | `SMSG_UPDATE_LAST_INSTANCE` | `0x49e670` | none — records the dungeon we just left |
|
||||
//! | `SMSG_UPDATE_INSTANCE_OWNERSHIP` | `0x49e6c0` | none — the "I hold a permanent bind" latch |
|
||||
//!
|
||||
//! ## The plural rule is the reference's, not ours
|
||||
//!
|
||||
//! Three of the four warning templates have a `_P1` twin ("…in %d hour." / "…in %d hours."), and
|
||||
//! the client picks between them by calling **Lua `GetText(token, nil, ordinal)`** — the binding
|
||||
//! `0x703bf0` is literally a Lua call into `LocaleProperties.lua`'s `GetText`, pushing the token,
|
||||
//! then `nil` for gender, then the ordinal (or `nil` when the caller passed `-1`, which is what
|
||||
//! the three fill-less templates do). `GetPluralIndex` is "1 or absent → the bare token, anything
|
||||
//! else → `_P1`", and that includes **zero**: `…in 0 hours` is the reference's own output.
|
||||
//! [`plural_token`] is that rule; the `_P1` probe falls back to the bare token exactly as
|
||||
//! `GetText`'s own `if ( not string )` arm does.
|
||||
//!
|
||||
//! ## Two client bugs deliberately not reproduced
|
||||
//!
|
||||
//! `SMSG_INSTANCE_RESET_FAILED` with a reason ≥ 3 and `SMSG_INSTANCE_SAVE_CREATED` with a flag
|
||||
//! ≥ 2 both fall past every arm of their handler's ladder and then call the chat chokepoint with
|
||||
//! an **uninitialized 2 KB stack buffer**. vmangos's own enum names reason 3
|
||||
//! `INSTANCERESET_FAIL_SILENTLY` "as well as any above this", so silence is the server's stated
|
||||
//! intent; printing stack garbage is fidelity to an accident, not to a mechanism. Both print
|
||||
//! nothing here.
|
||||
//!
|
||||
//! ## `CanShowResetInstances()` is a four-term predicate over `Map.dbc`
|
||||
//!
|
||||
//! The reference keeps four globals — `0xb4e370` (when we left), `0xb4e374` (what we left),
|
||||
//! `0xb4e378` (where we are now) and `0xb4e37c` (do we hold a permanent bind) — and
|
||||
//! `CanShowResetInstances 0x495c90` reads all four ([`InstanceState::can_reset`]). Three of them
|
||||
//! live in [`InstanceState`]; the fourth is benilla's own [`benilla_world::world_map::CurrentMap`],
|
||||
//! which is the same value by construction (the reference writes `0xb4e378` from
|
||||
//! `[[0xb41414]+0xcc]`, the world's current map, at every world entry and at every
|
||||
//! `SMSG_UPDATE_LAST_INSTANCE`).
|
||||
//!
|
||||
//! **The predicate is narrower than it looks**: `cmp [rec+8],1` means *party dungeon*, so the row
|
||||
//! is offered only after a 5-man — never after a raid, and never while standing in a 5-man. The
|
||||
//! 25-hour window (`0x15f90` seconds against `time(0)`, `0x495ce6`) is measured from when the
|
||||
//! dungeon was left.
|
||||
//!
|
||||
//! ## Term 1 is the server's job, and vmangos gets it wrong (decision 1754)
|
||||
//!
|
||||
//! `0xb4e37c` is written by exactly one function (`0x495d50`) with exactly one caller
|
||||
//! (`0x49e6d2`, the `SMSG_UPDATE_INSTANCE_OWNERSHIP` handler) — grepped over the whole `.text`,
|
||||
//! so the client has no other source for it. Its real meaning is **"you hold at least one
|
||||
//! instance bind"**, and the binary proves that by the company it keeps: the
|
||||
//! `SMSG_UPDATE_LAST_INSTANCE` handler throws away every map that is not `InstanceType == 1`
|
||||
//! (`0x495d33`), so a server that only ever named RAID binds in that packet would leave a
|
||||
//! handler whose only possible effect is nothing. The packet exists to name **5-man** binds.
|
||||
//!
|
||||
//! vmangos names none. All three of its senders — `Player::SendRaidInfo`,
|
||||
//! `Player::SendSavedInstances`, and the `UpdateLastInstance` loop inside it — filter
|
||||
//! `m_boundInstances` to `itr.second.perm`, and entering a 5-man takes
|
||||
//! `DungeonMap::Add`'s `player->BindToInstance(state, false)` — a **non-permanent** bind. So a
|
||||
//! character without a raid save is told `owns_saved = false` forever, and a client that believes
|
||||
//! it shows the reset row to nobody it was built for. That is a server defect (inherited from
|
||||
//! MaNGOS, identical in cmangos), not the reference's behaviour.
|
||||
//!
|
||||
//! benilla therefore satisfies term 1 from **either** source: the packet, or
|
||||
//! [`InstanceState::saw_own_dungeon`] — our own eyes. When the world-entry writer below records a
|
||||
//! party dungeon, it is recording that *we personally just walked out of one*, and the server
|
||||
//! binds you on entry, so the bind is a fact whether or not vmangos will admit it. This is the
|
||||
//! one deliberate deviation in this module; everything else is the reference's.
|
||||
//!
|
||||
//! ## The last-dungeon latch has TWO writers, and the packet is the lesser one
|
||||
//!
|
||||
//! `0x495d10` — "we just moved from map A to map B; if A was a party dungeon, remember it" — is
|
||||
//! called from two places, and reading only the packet handler gets the feature backwards:
|
||||
//!
|
||||
//! 1. **the client's own world entry** (`0x464ff0`, called from the map-load path at `0x4015f3`
|
||||
//! and `0x401c22`) with `A` = the map we were on and `B` = the map we are entering. This is
|
||||
//! the writer that actually fires when you walk out of the Deadmines;
|
||||
//! 2. **`SMSG_UPDATE_LAST_INSTANCE`** (`0x49e6ac`) with `A` = the packet's map and `B` = the
|
||||
//! world's current map.
|
||||
//!
|
||||
//! Against vmangos, **(2) can never record anything** — its `perm` filter (above) means the only
|
||||
//! maps it ever names are raids, and a raid is `InstanceType == 2`, which `0x495d33`'s
|
||||
//! `cmp [rec+8],1` rejects. (1) is the only live writer here, which is why it is also the one
|
||||
//! that answers term 1. (1) is [`track_instance_state`]'s `CurrentMap` flip; (2) is
|
||||
//! [`net::update_last_instance`]'s queue, drained through the same helper so the two cannot
|
||||
//! drift — [`LatchWriter`] is which of them is speaking.
|
||||
//!
|
||||
//! **(2) is therefore unreachable on our server by construction, and no live probe can cover it**
|
||||
//! — not with any rigging, because `PermBindAllPlayers` (vmangos's only path to a permanent bind)
|
||||
//! runs under `if (IsRaid())` and no GM command creates a bind. Its cover is
|
||||
//! `both_latch_writers_through_the_real_system`, which drives both legs through the real system
|
||||
//! against the real `Map.dbc`. That is the whole of the evidence behind (2): treat it as tested,
|
||||
//! never as *observed*.
|
||||
//! Deviation: walking out of a party dungeon ourselves also counts as holding a bind
|
||||
//! ([`InstanceState::saw_own_dungeon`]), because vmangos reports only permanent binds
|
||||
//! (`Player.cpp:16175`) and makes only raid binds permanent (`Map.cpp:3528`), so the server alone
|
||||
//! would never offer the reset after a 5-man.
|
||||
|
||||
use benilla_protocol::messages::{InstanceResetFailure, RaidInstanceMessage, RaidInstanceWarning};
|
||||
use benilla_ui::script::UiScript;
|
||||
@@ -115,80 +16,62 @@ use crate::net::{ClientCommand, NetCommands};
|
||||
use crate::ui_chat::{ChatEvent, ChatEventKind, ChatLog};
|
||||
use crate::ui_script::{UiFeed, UiInput};
|
||||
|
||||
/// The window `CanShowResetInstances()` offers the row in, in seconds — `cmp eax, 0x15f90` at
|
||||
/// `0x495ce6` against `time(0) - <when we left>`, unsigned, so the boundary is inclusive.
|
||||
/// 90 000 s = 25 hours.
|
||||
/// `CanShowResetInstances()`'s window, 25 hours: `cmp eax, 0x15f90` at `0x495ce6` against
|
||||
/// `time(0)` minus when we left, unsigned, so the boundary is inclusive.
|
||||
const RESET_OFFER_WINDOW_SECS: u64 = 90_000;
|
||||
|
||||
/// The client's lockout bookkeeping — three of the four globals `CanShowResetInstances 0x495c90`
|
||||
/// reads (the fourth, the map we are standing on, is [`benilla_world::world_map::CurrentMap`]).
|
||||
///
|
||||
/// All of it is session-scoped in the reference too: the globals live in zero-initialized `.data`
|
||||
/// and nothing persists them, so a fresh login starts with no offer until the server speaks again.
|
||||
/// The lockout bookkeeping: three of the four globals `CanShowResetInstances` (`0x495c90`) reads;
|
||||
/// the fourth, the map we stand on (`0xb4e378`), is [`benilla_world::world_map::CurrentMap`].
|
||||
/// Session-scoped, like the reference's zero-initialized globals.
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct InstanceState {
|
||||
/// `0xb4e37c` — the server's answer to "do you hold any instance bind at all"
|
||||
/// (`SMSG_UPDATE_INSTANCE_OWNERSHIP`, sent on every successful map change). **vmangos only
|
||||
/// ever says yes to a raid save**, which is the wrong answer for every player this row exists
|
||||
/// for — module doc, decision 1754.
|
||||
/// `0xb4e37c`, "do you hold any bind": `SMSG_UPDATE_INSTANCE_OWNERSHIP`, sent on every map
|
||||
/// change (`Player.cpp:2116`), where vmangos says yes only for a raid save.
|
||||
owns_saved: bool,
|
||||
/// benilla's own half of the same term: we watched the player walk out of a party dungeon, so
|
||||
/// we know they are bound to one without being told (decision 1754).
|
||||
///
|
||||
/// Raised by [`LatchWriter::WorldEntry`], cleared by `SMSG_INSTANCE_RESET` and by a **logout**
|
||||
/// ([`clear_witness_on_logout`]), and deliberately **not** cleared by an `owns_saved = false`
|
||||
/// packet — that packet is the wrong answer this field exists to work around, and vmangos
|
||||
/// sends one on the very teleport that takes us out of the dungeon.
|
||||
/// benilla's half of the same term: we watched the player walk out of a party dungeon. Raised
|
||||
/// by [`LatchWriter::WorldEntry`], cleared by a reset and at logout, and never by an
|
||||
/// `owns_saved = false`, which vmangos sends on the very teleport out of the dungeon.
|
||||
saw_own_dungeon: bool,
|
||||
/// `0xb4e374` — the `Map.dbc` id of the dungeon we were last inside, or `None` for "none
|
||||
/// recorded". Only a **party dungeon** ever lands here (`0x495d33`'s `cmp [rec+8],1`).
|
||||
/// `0xb4e374`: the `Map.dbc` id of the last dungeon left; only a party dungeon lands here
|
||||
/// (`0x495d33`).
|
||||
last_dungeon: Option<u32>,
|
||||
/// `0xb4e370` — when [`Self::last_dungeon`] was recorded, in seconds on the same clock
|
||||
/// [`Self::can_reset`] compares against. The reference uses `time(0)`; ours is the app's
|
||||
/// monotonic real clock, which differs from wall time only across a suspend and cannot run
|
||||
/// backwards over a 25-hour window.
|
||||
/// `0xb4e370`: when [`Self::last_dungeon`] was recorded, in seconds. Deviation: the app's
|
||||
/// monotonic clock, not `time(0)`, because it cannot run backwards; they differ only across a
|
||||
/// suspend.
|
||||
last_dungeon_at: u64,
|
||||
/// `SMSG_UPDATE_LAST_INSTANCE` map ids the net drain has queued for [`track_instance_state`].
|
||||
/// Queued rather than applied inline because the packet's own test is against the map we are
|
||||
/// *currently on*, and benilla writes [`benilla_world::world_map::CurrentMap`] through a
|
||||
/// deferred `Commands::insert_resource` — inside the drain it is still the map we left, which
|
||||
/// is exactly the value that would make the test answer wrong.
|
||||
/// `SMSG_UPDATE_LAST_INSTANCE` map ids for [`track_instance_state`]: the packet tests against
|
||||
/// the current map, which [`benilla_world::world_map::CurrentMap`] reaches only after the
|
||||
/// drain's deferred insert.
|
||||
pending_last_instance: Vec<u32>,
|
||||
/// Lines the net drain has queued for [`feed_instance`] to resolve against the VM's own
|
||||
/// `GlobalStrings.lua` and show. The reference resolves inline because its handler *is* on the
|
||||
/// Lua thread; ours cannot reach the VM from the wire drain (decision 0669's split).
|
||||
/// Lines for [`feed_instance`] to resolve against the VM's `GlobalStrings.lua`; the wire drain
|
||||
/// cannot reach the VM.
|
||||
lines: Vec<LockoutLine>,
|
||||
}
|
||||
|
||||
/// One lockout line, kept the way the reference keeps it between `GetText` and `SStrPrintf`: a
|
||||
/// GlobalStrings token, the ordinal that picks its plural form, and the fills in the template's
|
||||
/// own argument order.
|
||||
/// One lockout line as the reference holds it between `GetText` and `SStrPrintf`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct LockoutLine {
|
||||
/// The GlobalStrings token (`RAID_INSTANCE_WELCOME`, `INSTANCE_RESET_FAILED_OFFLINE`, …).
|
||||
/// The `GlobalStrings.lua` token.
|
||||
token: &'static str,
|
||||
/// `GetText`'s third argument. `Some(n)` selects `_P1` for every `n != 1`; `None` is the
|
||||
/// reference's `or edx,-1` — no ordinal, always the bare token.
|
||||
/// `GetText`'s third argument: `Some(n)` picks `_P1` for every `n != 1`; `None` is the
|
||||
/// reference's `or edx,-1`, always the bare token.
|
||||
ordinal: Option<u32>,
|
||||
/// The `Map.dbc` id whose display name fills the template's `%s`, or `None` for a template
|
||||
/// with no `%s` (`INSTANCE_SAVED`).
|
||||
/// The `Map.dbc` id whose name fills `%s`; `None` for `INSTANCE_SAVED`, which has no `%s`.
|
||||
map: Option<u32>,
|
||||
/// The `%d` fills, in the template's own order (`RAID_INSTANCE_WELCOME` takes three).
|
||||
/// The `%d` fills in template order (`RAID_INSTANCE_WELCOME` takes three).
|
||||
numbers: Vec<u32>,
|
||||
/// The reference's `"(Debug-Only Lock Notice) %s"` wrapper — the one arm of
|
||||
/// `SMSG_INSTANCE_SAVE_CREATED` that does not print its string bare (`0x4e7eb3`).
|
||||
/// The `"(Debug-Only Lock Notice) %s"` wrapper, `SMSG_INSTANCE_SAVE_CREATED`'s flag-1 arm
|
||||
/// (`0x4e7eb3`).
|
||||
debug_notice: bool,
|
||||
}
|
||||
|
||||
/// Which of the last-dungeon latch's two writers is speaking (module doc). The reference shares
|
||||
/// one function between them and does not care which; benilla does, because only the world-entry
|
||||
/// writer is first-hand evidence that we are bound to the dungeon it is recording.
|
||||
/// Which of the last-dungeon latch's two writers is speaking. The reference does not care; benilla
|
||||
/// does, as only the world entry is first-hand evidence of a bind.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum LatchWriter {
|
||||
/// `0x464ff0` — the map-load path. We just walked out of this map ourselves.
|
||||
/// `0x464ff0`, the map-load path: we just walked out of this map.
|
||||
WorldEntry,
|
||||
/// `0x49e6ac` — `SMSG_UPDATE_LAST_INSTANCE` named the map; the server is telling us about a
|
||||
/// bind, and has already said `owns_saved` for it.
|
||||
/// `0x49e6ac`, `SMSG_UPDATE_LAST_INSTANCE`: the server's word, already given as `owns_saved`.
|
||||
Packet,
|
||||
}
|
||||
|
||||
@@ -198,12 +81,9 @@ impl InstanceState {
|
||||
self.owns_saved = owns;
|
||||
}
|
||||
|
||||
/// `SMSG_UPDATE_LAST_INSTANCE` → `0x495d10`: record `map` as the dungeon we just left, if it
|
||||
/// is a party dungeon and is not the map we are standing on.
|
||||
///
|
||||
/// The `map == current` early-out is the reference's own first test (`cmp edx,esi; je`), and
|
||||
/// it matters: vmangos sends one of these per permanent bind on every map change, so the
|
||||
/// instance you are *entering* arrives in the same burst as the ones you are not in.
|
||||
/// The latch `0x495d10`: record `map` as the dungeon just left if it is a party dungeon and
|
||||
/// not the map we stand on, tested first (`cmp edx,esi; je`), as vmangos sends one packet per
|
||||
/// permanent bind on every map change, the entered instance included.
|
||||
pub(crate) fn note_last_instance(
|
||||
&mut self,
|
||||
map: u32,
|
||||
@@ -218,41 +98,34 @@ impl InstanceState {
|
||||
if party_dungeon {
|
||||
self.last_dungeon = Some(map);
|
||||
self.last_dungeon_at = now_secs;
|
||||
// Walking out of a 5-man is first-hand evidence of the bind the server took when we
|
||||
// walked in — the half of term 1 vmangos will not give us (module doc, decision 1754).
|
||||
// Walking out of a 5-man proves the bind the server took on entry.
|
||||
if writer == LatchWriter::WorldEntry {
|
||||
self.saw_own_dungeon = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `SMSG_INSTANCE_RESET` → `0x495d00`, called *before* the body is read: the latch is cleared
|
||||
/// whatever the packet says, so the offer goes away the moment any reset lands.
|
||||
/// `SMSG_INSTANCE_RESET` → `0x495d00`, before the body is read: any reset clears the offer.
|
||||
pub(crate) fn clear_last_instance(&mut self) {
|
||||
self.last_dungeon = None;
|
||||
self.last_dungeon_at = 0;
|
||||
// A reset landed, so whatever we witnessed ourselves is gone with it. The reference has
|
||||
// no equivalent line because its term 1 is the server's to retract.
|
||||
// The witness goes with the reset; the reference's bind term is the server's to retract.
|
||||
self.forget_witness();
|
||||
}
|
||||
|
||||
/// Drop benilla's own half of term 1 — see [`clear_witness_on_logout`]. Kept a method so the
|
||||
/// one piece of state no server will correct has exactly one place that clears it.
|
||||
/// Drop benilla's half of the bind term, the one piece of state no server corrects.
|
||||
pub(crate) fn forget_witness(&mut self) {
|
||||
self.saw_own_dungeon = false;
|
||||
}
|
||||
|
||||
/// `CanShowResetInstances()` — the reference's four terms at `0x495c90`, in its own order:
|
||||
/// `CanShowResetInstances()` (`0x495c90`), its four terms in the reference's order:
|
||||
///
|
||||
/// 1. we hold an instance bind — `0xb4e37c`, **or** benilla's own
|
||||
/// [`InstanceState::saw_own_dungeon`], because vmangos never sets the first one for a
|
||||
/// 5-man (module doc, decision 1754);
|
||||
/// 2. we are **not** standing in a party dungeon;
|
||||
/// 3. the last dungeon we left is a party dungeon that `Map.dbc` knows;
|
||||
/// 4. we left it no more than [`RESET_OFFER_WINDOW_SECS`] ago.
|
||||
/// 1. a bind held: `0xb4e37c`, or [`InstanceState::saw_own_dungeon`];
|
||||
/// 2. not standing in a party dungeon;
|
||||
/// 3. the last dungeon left is a party dungeon `Map.dbc` knows;
|
||||
/// 4. left no more than [`RESET_OFFER_WINDOW_SECS`] ago.
|
||||
///
|
||||
/// `is_party_dungeon` is the caller's `Map.dbc` lookup (`false` for a map id with no row —
|
||||
/// the reference's null-record branch takes the same path as type 0).
|
||||
/// A map with no `Map.dbc` row is not a party dungeon (the reference's null-record branch).
|
||||
pub(crate) fn can_reset(
|
||||
&self,
|
||||
current_map: Option<u32>,
|
||||
@@ -274,29 +147,23 @@ impl InstanceState {
|
||||
now_secs.saturating_sub(self.last_dungeon_at) <= RESET_OFFER_WINDOW_SECS
|
||||
}
|
||||
|
||||
/// Queue a line for the feed to resolve. Per PACKET, never per state edge — two identical
|
||||
/// warnings ten minutes apart are two lines, exactly as the reference prints two.
|
||||
/// Queue a line: one per packet, so two identical warnings print twice, as in the reference.
|
||||
fn push(&mut self, line: LockoutLine) {
|
||||
self.lines.push(line);
|
||||
}
|
||||
|
||||
/// Take everything queued (the feed's drain).
|
||||
fn take_lines(&mut self) -> Vec<LockoutLine> {
|
||||
std::mem::take(&mut self.lines)
|
||||
}
|
||||
|
||||
/// Take the queued `SMSG_UPDATE_LAST_INSTANCE` map ids ([`track_instance_state`]'s drain).
|
||||
fn take_pending_last_instance(&mut self) -> Vec<u32> {
|
||||
std::mem::take(&mut self.pending_last_instance)
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve one queued line to its displayed text — `GetText` + the `%s`/`%d` fills, then the
|
||||
/// debug wrapper if the save-created flag asked for it.
|
||||
///
|
||||
/// `None` = show nothing: a token the player's own `GlobalStrings.lua` does not carry, or carries
|
||||
/// empty, is data-suppression and is answered with silence ([`crate::ui_action::errors::ui_error_text`]'s
|
||||
/// rule, and the reference's own null/empty guards).
|
||||
/// One queued line's text: `GetText`, the `%s` and `%d` fills, then the debug wrapper; `None` for
|
||||
/// a token the player's `GlobalStrings.lua` lacks or leaves empty, as the reference's null and
|
||||
/// empty guards do.
|
||||
fn lockout_text(
|
||||
line: &LockoutLine,
|
||||
map_name: Option<&str>,
|
||||
@@ -304,8 +171,7 @@ fn lockout_text(
|
||||
) -> Option<String> {
|
||||
// `GetText(token, nil, ordinal)`'s own plural pick, through the shared primitive.
|
||||
let template = benilla_ui::strings::plural(line.token, line.ordinal, get)?;
|
||||
// Arguments in the template's own order — every lockout template takes its `%s` before its
|
||||
// `%d`s (checked against all fourteen tokens), which is what makes one ordered list exact.
|
||||
// Every lockout template takes its `%s` before its `%d`s, so one ordered list is exact.
|
||||
let mut args: Vec<benilla_ui::strings::Arg<'_>> = Vec::new();
|
||||
args.extend(map_name.map(benilla_ui::strings::Arg::S));
|
||||
args.extend(
|
||||
@@ -324,17 +190,12 @@ fn lockout_text(
|
||||
})
|
||||
}
|
||||
|
||||
/// The reference's `"(Debug-Only Lock Notice) %s"` (`0x84c36c`), as its literal prefix — a format
|
||||
/// string compiled into `WoW.exe`, not a GlobalString, so there is no token to look it up by. It
|
||||
/// is reachable only from `SMSG_INSTANCE_SAVE_CREATED` with flag 1, which vmangos never sends.
|
||||
/// The reference's `"(Debug-Only Lock Notice) %s"` (`0x84c36c`), a literal in the binary, not a
|
||||
/// GlobalString. Only flag 1 shows it; vmangos sends 0 (`Map.cpp:2289`, `Map.cpp:2389`).
|
||||
const DEBUG_LOCK_NOTICE_PREFIX: &str = "(Debug-Only Lock Notice) ";
|
||||
|
||||
/// The name that fills a lockout template's `%s` — `Map.dbc`'s display name, or the map **id** in
|
||||
/// decimal when the DBC has no row for it.
|
||||
///
|
||||
/// The fallback is the reference's own: `0x49e228` and its three twins print the id through
|
||||
/// `"%d"` (`0x835154`) when `[0xc0daa8][id]` is null or out of range. It is also what the Raid
|
||||
/// Info panel already does with the same ids (decision 1549).
|
||||
/// A template's `%s`: the `Map.dbc` name, or the id in decimal when there is no row, as the
|
||||
/// reference prints it (`0x49e228`, `"%d"` at `0x835154`, when `[0xc0daa8][id]` is null).
|
||||
fn map_name(map: u32, catalog: Option<&benilla_assets::MapCatalogRes>) -> String {
|
||||
catalog
|
||||
.and_then(|c| c.0.name(map))
|
||||
@@ -342,18 +203,10 @@ fn map_name(map: u32, catalog: Option<&benilla_assets::MapCatalogRes>) -> String
|
||||
.map_or_else(|| map.to_string(), str::to_string)
|
||||
}
|
||||
|
||||
/// Build the line for one `SMSG_RAID_INSTANCE_MESSAGE`, or `None` for a type the reference's jump
|
||||
/// table drops (0 and ≥ 5, `dec eax; cmp eax,3; ja` at `0x49e246`).
|
||||
///
|
||||
/// The three countdown templates take one number, the welcome takes three, and every one of the
|
||||
/// four divisions is the reference's own integer arithmetic:
|
||||
///
|
||||
/// - hours `resetTime / 3600` (`0x49e259`, the `0x91a2b3c5`/`sar 11` magic);
|
||||
/// - minutes `resetTime / 60` (`0x49e2c8`/`0x49e337`, `0x88888889`/`sar 5`);
|
||||
/// - welcome `d = t/86400`, `h = (t - 86400d)/3600`, `m = (t - 3600(24d + h))/60` (`0x49e3a6`).
|
||||
///
|
||||
/// Truncating division throughout, so "in 1 hour." covers everything from 3600 s to 7199 s — and
|
||||
/// the *plural* follows the truncated number, not the real duration.
|
||||
/// The line for one `SMSG_RAID_INSTANCE_MESSAGE`, `None` for a type the jump table drops (0 and 5
|
||||
/// up, `0x49e246`). The reference divides with truncation (hours `0x49e259`, minutes `0x49e2c8`
|
||||
/// and `0x49e337`, the welcome's days, hours and minutes `0x49e3a6`), and the plural follows the
|
||||
/// truncated number.
|
||||
fn raid_instance_line(message: &RaidInstanceMessage) -> Option<LockoutLine> {
|
||||
let warning = RaidInstanceWarning::from_wire(message.message_type)?;
|
||||
let t = message.reset;
|
||||
@@ -382,11 +235,7 @@ fn raid_instance_line(message: &RaidInstanceMessage) -> Option<LockoutLine> {
|
||||
})
|
||||
}
|
||||
|
||||
/// The instance/raid lockout family's six packet handlers (decision 1748; in the net handler
|
||||
/// table since 2312), beside the state they drive: four lines the client composes itself out of
|
||||
/// GlobalStrings, and the two-packet latch behind the SELF menu's reset row. The lines are
|
||||
/// QUEUED — resolving them needs the VM, which a packet handler leaves to the feed (decision
|
||||
/// 0669's split).
|
||||
/// The six lockout packet handlers; the feed, which has the VM, resolves the lines they queue.
|
||||
pub(crate) mod net {
|
||||
use super::*;
|
||||
|
||||
@@ -395,7 +244,6 @@ pub(crate) mod net {
|
||||
|
||||
use crate::net::NetHandlerApp;
|
||||
|
||||
/// Register the six handlers — called from [`UiInstancePlugin`].
|
||||
pub(super) fn register(app: &mut App) {
|
||||
use SessionEventKind as K;
|
||||
app.net_handler(K::RaidInstanceMessage, on_packet)
|
||||
@@ -406,7 +254,6 @@ pub(crate) mod net {
|
||||
.net_handler(K::UpdateInstanceOwnership, on_packet);
|
||||
}
|
||||
|
||||
/// One handler for the six: each is a one-line fold into the same state.
|
||||
fn on_packet(In(ev): In<SessionEvent>, mut state: ResMut<InstanceState>) {
|
||||
match ev {
|
||||
SessionEvent::RaidInstanceMessage { message } => {
|
||||
@@ -425,7 +272,7 @@ pub(crate) mod net {
|
||||
}
|
||||
}
|
||||
|
||||
/// `SMSG_RAID_INSTANCE_MESSAGE` — one of four `RAID_INSTANCE_*` lines (`0x49e1c0`).
|
||||
/// `SMSG_RAID_INSTANCE_MESSAGE` (`0x49e1c0`): one of four `RAID_INSTANCE_*` lines.
|
||||
pub(crate) fn raid_instance_message(state: &mut InstanceState, message: RaidInstanceMessage) {
|
||||
match raid_instance_line(&message) {
|
||||
Some(line) => state.push(line),
|
||||
@@ -437,8 +284,8 @@ pub(crate) mod net {
|
||||
}
|
||||
}
|
||||
|
||||
/// `SMSG_INSTANCE_RESET` — clear the last-dungeon latch (`0x495d00`, before the body is even
|
||||
/// read), then print `INSTANCE_RESET_SUCCESS` (`0x49e4ea`).
|
||||
/// `SMSG_INSTANCE_RESET` (`0x49e470`): clear the latch (`0x495d00`) before the body is read,
|
||||
/// then print `INSTANCE_RESET_SUCCESS` (`0x49e4ea`).
|
||||
pub(crate) fn instance_reset(state: &mut InstanceState, map: u32) {
|
||||
state.clear_last_instance();
|
||||
state.push(LockoutLine {
|
||||
@@ -450,7 +297,9 @@ pub(crate) mod net {
|
||||
});
|
||||
}
|
||||
|
||||
/// `SMSG_INSTANCE_RESET_FAILED` — one of three refusals; reason ≥ 3 is silent (module doc).
|
||||
/// `SMSG_INSTANCE_RESET_FAILED` (`0x49e540`): one of three refusals. Deviation: reason 3 and up
|
||||
/// prints nothing, where the reference prints an uninitialized 2 KB stack buffer, because that
|
||||
/// is a bug and vmangos means 3 and up as silent (`MapPersistentStateMgr.h:275`).
|
||||
pub(crate) fn instance_reset_failed(state: &mut InstanceState, failure: InstanceResetFailed) {
|
||||
match InstanceResetFailure::from_wire(failure.reason) {
|
||||
Some(reason) => state.push(LockoutLine {
|
||||
@@ -468,19 +317,20 @@ pub(crate) mod net {
|
||||
}
|
||||
}
|
||||
|
||||
/// `SMSG_UPDATE_LAST_INSTANCE` — queue the map id for [`track_instance_state`] to weigh
|
||||
/// against the map we are actually standing on (module doc, writer 2). No line.
|
||||
/// `SMSG_UPDATE_LAST_INSTANCE` (`0x49e670`): queued for [`track_instance_state`] to weigh
|
||||
/// against the map we end up on. No line.
|
||||
pub(crate) fn update_last_instance(state: &mut InstanceState, map: u32) {
|
||||
state.pending_last_instance.push(map);
|
||||
}
|
||||
|
||||
/// `SMSG_UPDATE_INSTANCE_OWNERSHIP` — one store, no line (`0x495d50`).
|
||||
/// `SMSG_UPDATE_INSTANCE_OWNERSHIP` (`0x49e6c0`): one store (`0x495d50`), no line.
|
||||
pub(crate) fn update_instance_ownership(state: &mut InstanceState, owns: bool) {
|
||||
state.set_ownership(owns);
|
||||
}
|
||||
|
||||
/// `SMSG_INSTANCE_SAVE_CREATED` — `INSTANCE_SAVED`, bare on flag 0 and wrapped on flag 1
|
||||
/// (`0x4e7e60`). Flag ≥ 2 is silent (module doc).
|
||||
/// `SMSG_INSTANCE_SAVE_CREATED` (`0x4e7e60`): `INSTANCE_SAVED`, bare on flag 0 and wrapped on
|
||||
/// flag 1. Deviation: flag 2 and up prints nothing, where the reference prints an
|
||||
/// uninitialized 2 KB stack buffer, because that is a bug, not a mechanism.
|
||||
pub(crate) fn instance_save_created(state: &mut InstanceState, flag: u32) {
|
||||
let debug_notice = match flag {
|
||||
0 => false,
|
||||
@@ -500,15 +350,9 @@ pub(crate) mod net {
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep the client's lockout bookkeeping — the map-change writer and the packet writer of the
|
||||
/// last-dungeon latch (module doc). Deliberately VM-free: the latch is world state, and it has to
|
||||
/// keep running through a `/reload` (and in an engine-only harness) exactly as the reference's
|
||||
/// globals do.
|
||||
///
|
||||
/// The `Local` holds the last **observed** map, so the first observation records nothing — there
|
||||
/// is no map we left at login, which is also what the reference reaches (its `0xb4e378` starts at
|
||||
/// zero, and Azeroth is `InstanceType` 0). `benilla_world::world_map`'s own `announce_map_change`
|
||||
/// uses the identical shape for the same reason.
|
||||
/// Runs both latch writers, VM-free so the latch survives a `/reload` as the reference's globals
|
||||
/// do. The first observed map records nothing: there is no map left at login, as in the reference
|
||||
/// (`0xb4e378` starts at 0, and map 0 is not a dungeon).
|
||||
fn track_instance_state(
|
||||
mut state: ResMut<InstanceState>,
|
||||
maps: Option<Res<benilla_assets::MapCatalogRes>>,
|
||||
@@ -522,7 +366,7 @@ fn track_instance_state(
|
||||
let now = time.elapsed().as_secs();
|
||||
let party_dungeon = |m: u32| maps.as_ref().is_some_and(|c| c.0.is_party_dungeon(m));
|
||||
|
||||
// Writer 1 — the world entry. `prev` is the map we are leaving, which is the candidate.
|
||||
// Writer 1, the world entry: `prev` is the map we are leaving.
|
||||
if let Some(prev) = last_map.replace(here) {
|
||||
state.note_last_instance(
|
||||
prev,
|
||||
@@ -532,19 +376,15 @@ fn track_instance_state(
|
||||
LatchWriter::WorldEntry,
|
||||
);
|
||||
}
|
||||
// Writer 2 — the packet, now that `CurrentMap` has caught up with the transfer.
|
||||
// Writer 2, the packet, now that `CurrentMap` has caught up with the transfer.
|
||||
for map in state.take_pending_last_instance() {
|
||||
let is_dungeon = party_dungeon(map);
|
||||
state.note_last_instance(map, Some(here), is_dungeon, now, LatchWriter::Packet);
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve every queued line against the VM's own `GlobalStrings.lua` and push it as a
|
||||
/// `CHAT_MSG_SYSTEM` event, then publish the two readers' answers (`IsInInstance`,
|
||||
/// `CanShowResetInstances`).
|
||||
///
|
||||
/// The publish is unconditional and idempotent — both setters diff internally — because both
|
||||
/// answers move with the map under us and with the clock, neither of which raises an event.
|
||||
/// Show every queued line as `CHAT_MSG_SYSTEM`, then publish `IsInInstance` and
|
||||
/// `CanShowResetInstances` every frame, as both move with the map and the clock (the setters diff).
|
||||
fn feed_instance(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
mut state: ResMut<InstanceState>,
|
||||
@@ -554,10 +394,8 @@ fn feed_instance(
|
||||
time: Res<Time<Real>>,
|
||||
) {
|
||||
let Some(mut script) = script else {
|
||||
// No VM (an engine-only run, a headless probe): there is nowhere to resolve a template
|
||||
// and nowhere to show it, so DROP what is queued rather than let it accumulate for a
|
||||
// consumer that will never come. Nothing is lost in a real session — the VM is seated
|
||||
// long before any of these packets can arrive, and it survives `/reload`.
|
||||
// No VM (an engine-only run): drop the queue rather than let it grow; a real session seats
|
||||
// the VM before any of these packets can arrive.
|
||||
let dropped = state.take_lines().len();
|
||||
if dropped > 0 {
|
||||
debug!("ui_instance: no VM — dropped {dropped} lockout line(s)");
|
||||
@@ -575,8 +413,7 @@ fn feed_instance(
|
||||
debug!("ui_instance: {} -> {text:?}", line.token);
|
||||
chat.push_event(ChatEvent::text_only(ChatEventKind::System, text));
|
||||
}
|
||||
// The player's own GlobalStrings has no such key (or an empty one) — silence, the way
|
||||
// every other GlobalStrings-driven line in this codebase treats a missing key.
|
||||
// A missing or empty key: silence.
|
||||
None => debug!("ui_instance: {} resolves to nothing — no line", line.token),
|
||||
}
|
||||
}
|
||||
@@ -596,17 +433,9 @@ fn drain_instance(script: Option<NonSendMut<UiScript>>, commands: Res<NetCommand
|
||||
}
|
||||
}
|
||||
|
||||
/// The lockout family: the four chat lines, the bookkeeping behind the SELF menu's reset row, and
|
||||
/// that row's one send.
|
||||
/// Drop the witness at logout — the next login is somebody else's character (`poi_marker`'s own
|
||||
/// shape, and the same reason).
|
||||
///
|
||||
/// Only [`InstanceState::saw_own_dungeon`] is cleared, not the reference's three globals: the
|
||||
/// reference does not clear those either (`0x495d00`'s only caller is the `SMSG_INSTANCE_RESET`
|
||||
/// handler), and it does not need to — its term 1 is the server's, re-advertised on the next
|
||||
/// character's world entry, so a stale latch cannot offer that character a reset on its own. Ours
|
||||
/// is the half no server will correct, so it gets the lifetime the packet would have given it
|
||||
/// (decision 1754).
|
||||
/// Drop the witness at logout, as the next login may be another character. The three globals stay,
|
||||
/// as the reference keeps them (`0x495d00` runs only on `SMSG_INSTANCE_RESET`): its bind term is
|
||||
/// re-sent at the next world entry, where ours has no server to correct it.
|
||||
fn clear_witness_on_logout(
|
||||
mut state: ResMut<InstanceState>,
|
||||
mut logged_out: MessageReader<crate::net::LoggedOutMessage>,
|
||||
@@ -624,8 +453,7 @@ impl Plugin for UiInstancePlugin {
|
||||
app.init_resource::<InstanceState>().add_systems(
|
||||
Update,
|
||||
(
|
||||
// The latch first: `feed_instance` publishes `CanShowResetInstances()` off it, and
|
||||
// a map change and its answer should not be a frame apart.
|
||||
// The latch before the feed, so a map change and its answer share a frame.
|
||||
clear_witness_on_logout.before(track_instance_state),
|
||||
track_instance_state.before(feed_instance),
|
||||
feed_instance.in_set(UiFeed),
|
||||
@@ -639,9 +467,7 @@ impl Plugin for UiInstancePlugin {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The real 1.12 templates, quoted from `Interface\FrameXML\GlobalStrings.lua` (verified
|
||||
/// extract) — the tests resolve against these rather than against whatever the running
|
||||
/// player's chain happens to carry.
|
||||
/// The shipped 1.12 templates from `GlobalStrings.lua`, so these tests need no install.
|
||||
fn globals(key: &str) -> Option<String> {
|
||||
let v = match key {
|
||||
"RAID_INSTANCE_WARNING_HOURS" => "WARNING! %s is scheduled to reset in %d hour.",
|
||||
@@ -688,12 +514,9 @@ mod tests {
|
||||
})
|
||||
}
|
||||
|
||||
/// The welcome line: three truncated fills in `d`/`h`/`m` order, and NO plural — the
|
||||
/// reference passes `-1` for the ordinal, so `RAID_INSTANCE_WELCOME` is used bare (it has no
|
||||
/// `_P1` twin in `GlobalStrings.lua` either).
|
||||
#[test]
|
||||
fn welcome_line_breaks_the_duration_into_d_h_m() {
|
||||
// 3 d 2 h 5 m 30 s — the seconds tail truncates away.
|
||||
// 3 d 2 h 5 m 30 s: the seconds truncate away.
|
||||
let secs = 3 * 86_400 + 2 * 3_600 + 5 * 60 + 30;
|
||||
let line = warning(4, 409, secs).expect("type 4 has a template");
|
||||
assert_eq!(line.token, "RAID_INSTANCE_WELCOME");
|
||||
@@ -705,9 +528,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The three countdown templates take ONE number, and the plural follows that truncated
|
||||
/// number: 1 hour is singular, 2 hours and — the case a hand-rolled rule always gets wrong —
|
||||
/// **0 hours** are plural.
|
||||
#[test]
|
||||
fn countdown_lines_pluralize_on_the_truncated_count() {
|
||||
let one = warning(1, 409, 3_600 + 59).expect("type 1");
|
||||
@@ -723,7 +543,7 @@ mod tests {
|
||||
Some("WARNING! Molten Core is scheduled to reset in 2 hours.")
|
||||
);
|
||||
|
||||
// Under an hour, the HOURS template still truncates to zero — and zero takes `_P1`.
|
||||
// Under an hour truncates to 0, and 0 takes `_P1`.
|
||||
let zero = warning(1, 409, 900).expect("type 1");
|
||||
assert_eq!(zero.numbers, vec![0]);
|
||||
assert_eq!(
|
||||
@@ -747,8 +567,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Types the reference's jump table drops print nothing at all — including 5, which later
|
||||
/// clients call `RAID_INSTANCE_EXPIRED`.
|
||||
/// The jump table drops type 5 too, which later clients call `RAID_INSTANCE_EXPIRED`.
|
||||
#[test]
|
||||
fn dropped_warning_types_make_no_line() {
|
||||
assert!(warning(0, 409, 60).is_none());
|
||||
@@ -756,8 +575,6 @@ mod tests {
|
||||
assert!(warning(u32::MAX, 409, 60).is_none());
|
||||
}
|
||||
|
||||
/// The reset lines: success takes the name, each refusal takes the name, and reason ≥ 3 never
|
||||
/// reaches a line at all (that arm is in [`apply`], tested through the state).
|
||||
#[test]
|
||||
fn reset_lines_fill_the_map_name() {
|
||||
let mut state = InstanceState::default();
|
||||
@@ -797,8 +614,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// INSTANCERESET_FAIL_SILENTLY and above: no line, rather than the reference's
|
||||
// uninitialized-buffer print.
|
||||
// `INSTANCERESET_FAIL_SILENTLY`: no line, where the reference prints a stack buffer.
|
||||
let mut state = InstanceState::default();
|
||||
net::instance_reset_failed(
|
||||
&mut state,
|
||||
@@ -807,8 +623,6 @@ mod tests {
|
||||
assert!(state.take_lines().is_empty());
|
||||
}
|
||||
|
||||
/// `SMSG_INSTANCE_SAVE_CREATED`: flag 0 prints the string bare, flag 1 wraps it in the
|
||||
/// binary's own debug literal, and anything above prints nothing.
|
||||
#[test]
|
||||
fn save_created_has_three_arms() {
|
||||
let mut state = InstanceState::default();
|
||||
@@ -830,8 +644,6 @@ mod tests {
|
||||
assert!(state.take_lines().is_empty());
|
||||
}
|
||||
|
||||
/// A token the player's `GlobalStrings.lua` does not carry is silence, not a raw token or an
|
||||
/// empty chat line.
|
||||
#[test]
|
||||
fn an_unresolvable_token_makes_no_line() {
|
||||
let line = LockoutLine {
|
||||
@@ -844,13 +656,10 @@ mod tests {
|
||||
assert_eq!(line_text(&line, Some("Molten Core")), None);
|
||||
}
|
||||
|
||||
/// The `_P1` probe falls back to the bare token when the twin is missing — `GetText`'s own
|
||||
/// `if ( not string )` arm. Modelled with a locale that has the singular only.
|
||||
/// `GetText`'s own `if ( not string )` arm.
|
||||
#[test]
|
||||
fn a_missing_plural_twin_falls_back_to_the_bare_token() {
|
||||
// Marked stand-ins rather than the shipped wording: the primitive returns a *template*,
|
||||
// so the assertion has to be readable as "which key did it reach", which is the thing
|
||||
// worth asserting (decision 2045).
|
||||
// Stand-ins, so each assertion reads as which key it reached.
|
||||
let sparse = |key: &str| match key {
|
||||
"RAID_INSTANCE_WARNING_HOURS" => Some("<bare>".to_string()),
|
||||
_ => None,
|
||||
@@ -883,8 +692,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A map id `Map.dbc` cannot name prints the id, the way the reference's `"%d"` fallback does
|
||||
/// (and the way the Raid Info panel already does with the same ids).
|
||||
#[test]
|
||||
fn an_unknown_map_id_prints_as_its_number() {
|
||||
assert_eq!(map_name(9999, None), "9999");
|
||||
@@ -895,9 +702,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// `SMSG_UPDATE_LAST_INSTANCE` records only a **party dungeon**, and only one that is not the
|
||||
/// map we are standing on — the reference's `cmp edx,esi; je` early-out, which matters because
|
||||
/// vmangos sends one of these per bind on every map change.
|
||||
#[test]
|
||||
fn last_instance_records_only_a_party_dungeon_we_are_not_in() {
|
||||
let mut state = InstanceState::default();
|
||||
@@ -906,59 +710,47 @@ mod tests {
|
||||
state.note_last_instance(409, Some(0), false, 100, LatchWriter::Packet);
|
||||
assert_eq!(state.last_dungeon, None);
|
||||
|
||||
// The dungeon we are standing IN: the early-out fires before the type test.
|
||||
// The dungeon we stand in: the early-out fires before the type test.
|
||||
state.note_last_instance(36, Some(36), true, 100, LatchWriter::Packet);
|
||||
assert_eq!(state.last_dungeon, None);
|
||||
|
||||
// A dungeon we left: recorded, with the clock.
|
||||
state.note_last_instance(36, Some(0), true, 100, LatchWriter::Packet);
|
||||
assert_eq!(state.last_dungeon, Some(36));
|
||||
assert_eq!(state.last_dungeon_at, 100);
|
||||
|
||||
// …and none of that was first-hand: only the world-entry writer is (next test).
|
||||
// None of it first-hand: only the world-entry writer is.
|
||||
assert!(!state.saw_own_dungeon);
|
||||
}
|
||||
|
||||
/// Term 1 without the server's help — decision 1754, the one deliberate deviation here.
|
||||
///
|
||||
/// vmangos never sends `owns_saved` for a 5-man bind, so walking out of the Deadmines has to
|
||||
/// be enough on its own. It is: the world-entry writer raises
|
||||
/// [`InstanceState::saw_own_dungeon`], and the `owns_saved = false` that vmangos sends on that
|
||||
/// very teleport must not take it away again.
|
||||
/// The module's deviation: the witness alone meets term 1, and outlives `owns_saved = false`.
|
||||
#[test]
|
||||
fn walking_out_of_a_dungeon_satisfies_term_one_by_itself() {
|
||||
let dungeons = |m: u32| m == 36;
|
||||
let mut state = InstanceState::default();
|
||||
|
||||
// The server's answer alone: no. This is every non-raider on vmangos.
|
||||
// The server's answer alone: no, for every non-raider on vmangos.
|
||||
state.set_ownership(false);
|
||||
assert!(!state.can_reset(Some(0), &dungeons, 100));
|
||||
|
||||
// We walk out of the Deadmines ourselves. That is the bind, whoever admits it.
|
||||
// We walk out of the Deadmines ourselves.
|
||||
state.note_last_instance(36, Some(0), true, 100, LatchWriter::WorldEntry);
|
||||
assert!(state.saw_own_dungeon);
|
||||
assert!(state.can_reset(Some(0), &dungeons, 100));
|
||||
|
||||
// vmangos sends `SendSavedInstances` on that same far teleport, saying no. It must not
|
||||
// undo what we saw — if it did, the row would die on the way out of every dungeon.
|
||||
// `SendSavedInstances` says no on that same teleport, and must not undo the witness.
|
||||
state.set_ownership(false);
|
||||
assert!(state.can_reset(Some(0), &dungeons, 100));
|
||||
|
||||
// The other three terms still bind: our own eyes replace term 1, not the predicate.
|
||||
// The other three terms still hold: the witness replaces term 1 only.
|
||||
assert!(!state.can_reset(Some(36), &dungeons, 100)); // 2 · standing back inside
|
||||
assert!(!state.can_reset(Some(0), &dungeons, 100 + RESET_OFFER_WINDOW_SECS + 1)); // 4
|
||||
|
||||
// 3 · a reset landed: the latch AND the witness go together.
|
||||
// 3 · a reset landed: the latch and the witness go together.
|
||||
state.clear_last_instance();
|
||||
assert!(!state.saw_own_dungeon);
|
||||
assert!(!state.can_reset(Some(0), &dungeons, 100));
|
||||
}
|
||||
|
||||
/// The witness does not outlive the character that earned it (`clear_witness_on_logout`).
|
||||
///
|
||||
/// The reference can leave its stale latch lying about because term 1 is the server's and the
|
||||
/// next character's world entry re-answers it. Ours is the half nothing corrects, so a logout
|
||||
/// has to, or character B is offered a reset that character A walked out of.
|
||||
#[test]
|
||||
fn the_witness_does_not_survive_a_logout() {
|
||||
let dungeons = |m: u32| m == 36;
|
||||
@@ -968,26 +760,14 @@ mod tests {
|
||||
|
||||
state.forget_witness();
|
||||
assert!(!state.can_reset(Some(0), &dungeons, 100));
|
||||
// The reference's own three globals are untouched — we clear our addition, not its state.
|
||||
// The reference's three globals stay: only our addition is cleared.
|
||||
assert_eq!(state.last_dungeon, Some(36));
|
||||
assert_eq!(state.last_dungeon_at, 100);
|
||||
}
|
||||
|
||||
/// **Both latch writers, driven through the real system, against the real `Map.dbc`.**
|
||||
///
|
||||
/// This is the only cover writer 2 — `SMSG_UPDATE_LAST_INSTANCE` — can have. vmangos can
|
||||
/// never exercise it: `SendSavedInstances` names only `perm` binds, `DungeonMap::Add` takes a
|
||||
/// non-perm bind for a 5-man, and `PermBindAllPlayers` (the one path that makes a bind
|
||||
/// permanent) runs under `if (IsRaid())`, so the only map ids that packet can ever carry are
|
||||
/// raids — which `0x495d33`'s `cmp [rec+8],1` throws away. No GM command creates a bind
|
||||
/// either (`.instance` offers listbinds/unbind/groupunbind, nothing that binds). So the packet
|
||||
/// leg is unreachable on our server by construction, and a live probe cannot cover it however
|
||||
/// the character is rigged (decision 1754's open thread).
|
||||
///
|
||||
/// It runs both legs through `track_instance_state` itself rather than the methods, because
|
||||
/// the thing worth guarding is the wiring: that the queue drains against the map we ended up
|
||||
/// on rather than the one we left, and that the packet writer does **not** raise the witness
|
||||
/// (which would hand 1754's deviation to a server's say-so). Skips without client data.
|
||||
/// Writer 2 is unreachable against vmangos, which names only permanent binds and makes only
|
||||
/// raid binds permanent (`Map.cpp:3528`), so this is its only cover: the queue drains against
|
||||
/// the map we end up on, and the packet never raises the witness. Skips without client data.
|
||||
#[test]
|
||||
fn both_latch_writers_through_the_real_system() {
|
||||
let data = benilla_formats::wow_data_or_skip!();
|
||||
@@ -999,9 +779,8 @@ mod tests {
|
||||
"Molten Core is a raid, not a dungeon"
|
||||
);
|
||||
|
||||
// The system carries the last observed map in a `Local`, so it has to be the SAME system
|
||||
// across ticks — `run_system_once` hands out a fresh one each call and would never see a
|
||||
// transition at all.
|
||||
// One system across ticks: its `Local` holds the last map, and `run_system_once` would
|
||||
// make a fresh one each call.
|
||||
let mut app = App::new();
|
||||
app.init_resource::<InstanceState>()
|
||||
.init_resource::<Time<Real>>()
|
||||
@@ -1017,8 +796,7 @@ mod tests {
|
||||
net::update_last_instance(&mut app.world_mut().resource_mut::<InstanceState>(), map);
|
||||
};
|
||||
|
||||
// Writer 1 — we walk out of the Deadmines ourselves. The first tick only OBSERVES map 36
|
||||
// (there is no map we left at login), so nothing is recorded until the flip.
|
||||
// Writer 1: the first tick only observes map 36; the flip to 0 records it.
|
||||
app.update();
|
||||
assert_eq!(state(&app), (None, false));
|
||||
|
||||
@@ -1030,8 +808,8 @@ mod tests {
|
||||
"the flip records the dungeon AND our own eyes (1754's term 1)"
|
||||
);
|
||||
|
||||
// Writer 2 — the packet, on a fresh state. A raid id is dropped (the only thing vmangos
|
||||
// could ever send); a party dungeon lands, and lands WITHOUT the witness.
|
||||
// Writer 2, on a fresh state: a raid id is dropped, a party dungeon lands without the
|
||||
// witness.
|
||||
app.insert_resource(InstanceState::default());
|
||||
queue(&mut app, 409);
|
||||
app.update();
|
||||
@@ -1045,7 +823,7 @@ mod tests {
|
||||
"the packet is the server's word, never our own eyes"
|
||||
);
|
||||
|
||||
// …and the packet's own early-out: the instance we are STANDING IN is not one we left.
|
||||
// The packet's early-out: the instance we stand in is not one we left.
|
||||
app.insert_resource(InstanceState::default());
|
||||
app.insert_resource(benilla_world::world_map::CurrentMap(36));
|
||||
app.update();
|
||||
@@ -1071,10 +849,10 @@ mod tests {
|
||||
|
||||
// 2 · standing in a party dungeon.
|
||||
assert!(!state.can_reset(Some(33), &dungeons, 100));
|
||||
// …but a RAID is not a party dungeon, so it does not suppress the row.
|
||||
// A raid is not a party dungeon, so it does not suppress the row.
|
||||
assert!(state.can_reset(Some(409), &dungeons, 100));
|
||||
|
||||
// 1 · no bind at all — neither told nor witnessed (the witness has its own test).
|
||||
// 1 · no bind at all, neither told nor witnessed.
|
||||
state.set_ownership(false);
|
||||
assert!(!state.saw_own_dungeon);
|
||||
assert!(!state.can_reset(Some(0), &dungeons, 100));
|
||||
@@ -1085,15 +863,8 @@ mod tests {
|
||||
assert!(!state.can_reset(Some(0), &dungeons, 100));
|
||||
}
|
||||
|
||||
/// The RUNTIME leg on the real client data (`ui_quest`'s pattern): every token this module can
|
||||
/// emit — the four warnings, their three `_P1` twins, the three refusals, the reset success
|
||||
/// and `INSTANCE_SAVED` — resolves to a non-empty string in the shipped 1.12
|
||||
/// `GlobalStrings.lua`, and carries the fills the composer expects to put in it.
|
||||
///
|
||||
/// This is the guard the module-local tests above cannot be: they resolve against the
|
||||
/// templates QUOTED in this file, so a typo'd token would still pass every one of them and
|
||||
/// degrade a real line to silence in the app. It also re-proves the quoted set is the shipped
|
||||
/// set. Skips without client data.
|
||||
/// Every token this module emits resolves in the shipped `GlobalStrings.lua` with the fills the
|
||||
/// composer expects, and matches the copies quoted above. Skips without client data.
|
||||
#[test]
|
||||
fn every_lockout_token_resolves_in_the_real_global_strings() {
|
||||
let data = benilla_formats::wow_data_or_skip!();
|
||||
@@ -1126,8 +897,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// The three plural twins exist, so the pick really has something to select — asserted by
|
||||
// the twin's own shipped text, which differs from the bare token's.
|
||||
// The three plural twins exist and differ from their bare tokens.
|
||||
for token in [
|
||||
"RAID_INSTANCE_WARNING_HOURS",
|
||||
"RAID_INSTANCE_WARNING_MIN",
|
||||
@@ -1142,7 +912,7 @@ mod tests {
|
||||
);
|
||||
assert_ne!(twin, real(token), "{token}'s two forms differ");
|
||||
}
|
||||
// …and the welcome does not, which is why the reference passes it no ordinal at all.
|
||||
// The welcome has none, which is why the reference passes it no ordinal.
|
||||
assert!(
|
||||
real("RAID_INSTANCE_WELCOME_P1").is_none(),
|
||||
"RAID_INSTANCE_WELCOME has no _P1 in 1.12"
|
||||
@@ -1162,7 +932,7 @@ mod tests {
|
||||
assert_eq!(line.numbers.len(), fills);
|
||||
}
|
||||
|
||||
// Two lines end to end against the real strings — the welcome and a refusal.
|
||||
// Two lines end to end against the real strings: the welcome and a refusal.
|
||||
let welcome = warning(4, 409, 3 * 86_400 + 2 * 3_600 + 5 * 60).expect("type 4");
|
||||
assert_eq!(
|
||||
lockout_text(&welcome, Some("Molten Core"), &real).as_deref(),
|
||||
@@ -1179,8 +949,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The fill is positional and stops at the arguments it has: a template with more specifiers
|
||||
/// than fills keeps the leftovers literally rather than eating the next argument.
|
||||
#[test]
|
||||
fn fill_is_positional_and_never_borrows_the_wrong_argument() {
|
||||
use benilla_ui::strings::{fill, Arg};
|
||||
|
||||
@@ -1,32 +1,13 @@
|
||||
//! The item-text reader session (`ItemTextFrame.xml`) — the one window every *readable* opens in:
|
||||
//! a bag letter, a book in your bags, and a book/plaque lying in the world.
|
||||
//! The item-text reader (`ItemTextFrame.xml`) a bag letter, a book or a readable world object
|
||||
//! opens in, locally with no packet as in the reference: every route ends at
|
||||
//! `0x4e32e0(guid, flag)`, which looks the guid up as any object and asks it for its text
|
||||
//! (`vtbl+0x74`), a mail-made letter's `ITEM_FIELD_ITEM_TEXT_ID` or else a page chain from an
|
||||
//! item template's `PageText` or a `GAMEOBJECT_TYPE_TEXT` object's `data[0]`. vmangos has no use
|
||||
//! arm for a type-9 object and refuses `CMSG_READ_ITEM` without a template `PageText`, which the
|
||||
//! Plain Letter lacks (`ItemHandler.cpp:421`).
|
||||
//!
|
||||
//! **One reader, keyed on an object guid, opened locally with no permission packet.** That is the
|
||||
//! reference's own shape, byte-verified (decision 1105): every route ends at `0x4e32e0(guid, flag)`,
|
||||
//! which looks the guid up with typemask `1` (*any* object — item or GameObject), asks the object
|
||||
//! for its page id through the shared `vtbl+0x74` getter, and pulls the text out of a cache. Which
|
||||
//! of the two text sources applies is the object's own answer, not the caller's:
|
||||
//!
|
||||
//! - **a letter** — an item *instance* carrying `ITEM_FIELD_ITEM_TEXT_ID` (a mail-made permanent
|
||||
//! copy). One body, no pages; it rides the ask-once `CMSG_ITEM_TEXT_QUERY` cache mail letters use
|
||||
//! ([`crate::ui_mail::MailOpen::bodies`] — that map is the client's item-text cache, mail is just
|
||||
//! its first tenant), and its creator line resolves `ITEM_FIELD_CREATOR` through the name cache.
|
||||
//! - **a page chain** — a readable item *template*'s `PageText`, or a `GAMEOBJECT_TYPE_TEXT` world
|
||||
//! object's template `data[0]`. Pages chain by `nextPageId` over the ask-once
|
||||
//! [`PageTexts`] cache (`CMSG_PAGE_TEXT_QUERY`), and the reader's prev/next buttons walk them.
|
||||
//! Authorless — a book has no `From,` tail.
|
||||
//!
|
||||
//! The routes in: `ui_items::drain::drain_container_uses` (the bag click's dispatcher arms #5 and
|
||||
//! #6) and `target::click::act_on_right_click` (the world right-click's GO type-9 arm). None of
|
||||
//! them sends a use/read packet — vmangos has no `GameObject::Use` case for type 9 at all, and its
|
||||
//! `CMSG_READ_ITEM` handler gates on a template `PageText` the Plain Letter doesn't have, so both
|
||||
//! would be answered with silence even if we asked.
|
||||
//!
|
||||
//! The feed mirrors the reference event flow (ItemTextFrame.lua l.10-96): open →
|
||||
//! `ITEM_TEXT_BEGIN` (title known), everything fetched → `ITEM_TEXT_READY`, `CloseItemText()` →
|
||||
//! session cleared + `ITEM_TEXT_CLOSED`. `ITEM_TEXT_TRANSLATION` (the foreign-language progress
|
||||
//! bar) never fires — no translation mechanic on a private server's readables, and the `language`
|
||||
//! column (`data[1]`/`LanguageID`) is carried by neither source here.
|
||||
//! The events follow `ItemTextFrame.lua:10-96`; `ITEM_TEXT_TRANSLATION`, the foreign-language
|
||||
//! bar, never fires, as neither source carries the language column.
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
@@ -43,16 +24,12 @@ use crate::ui_script::{UiFeed, UiInput};
|
||||
/// One page of a book as the wire gave it (`SMSG_PAGE_TEXT_QUERY_RESPONSE`).
|
||||
pub(crate) struct PageText {
|
||||
pub(crate) text: String,
|
||||
/// The next page's id; `0` = this is the last page.
|
||||
/// The next page's id; 0 on the last page.
|
||||
pub(crate) next: u32,
|
||||
}
|
||||
|
||||
/// The ask-once page cache (decision 1105) — the client's `PageText` dbcache (`0xc0e174`). Keyed by
|
||||
/// page id, filled by [`crate::net::apply`]'s `SessionEvent::PageText` arm.
|
||||
///
|
||||
/// vmangos answers a single `CMSG_PAGE_TEXT_QUERY` with the **whole chain** (one response per page),
|
||||
/// so asking for page 1 normally lands the entire book at once and a page turn is a cache hit. The
|
||||
/// per-page fetch is still here for the server that answers one at a time.
|
||||
/// The ask-once page cache, the client's `PageText` dbcache (`0xc0e174`), keyed by page id.
|
||||
/// vmangos answers one query with the whole chain; a missing page is still asked for alone.
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct PageTexts {
|
||||
pages: QueryCache<u32, PageText>,
|
||||
@@ -65,13 +42,11 @@ impl crate::query_cache::AskOnce for PageTexts {
|
||||
}
|
||||
|
||||
impl PageTexts {
|
||||
/// Record a page (and clear its in-flight flag).
|
||||
pub(crate) fn insert(&mut self, page_id: u32, text: String, next: u32) {
|
||||
self.pages.insert(page_id, Some(PageText { text, next }));
|
||||
}
|
||||
|
||||
/// The cached page, asking for it once if this is the first asker. `guid` names the object
|
||||
/// doing the reading (the reference writes it after the page id; vmangos discards it).
|
||||
/// The cached page, asked for once; `guid` is the reading object, which vmangos discards.
|
||||
fn get_or_ask(&self, page_id: u32, guid: u64, commands: &NetCommands) -> Option<&PageText> {
|
||||
self.pages.get_or_ask(page_id, || {
|
||||
debug!("page text: asking page {page_id} (object {guid:#x})");
|
||||
@@ -82,63 +57,45 @@ impl PageTexts {
|
||||
}
|
||||
}
|
||||
|
||||
/// Which text a read session is showing — the reference's two sources, which it picks between by
|
||||
/// asking the *object* (an instance text id, else a page id), not by how the click arrived.
|
||||
/// Which of the reference's two text sources a session shows; the object decides, not the click.
|
||||
pub(crate) enum ReadSource {
|
||||
/// An item instance's `ITEM_FIELD_ITEM_TEXT_ID` — a mail-made letter. Single body, has a
|
||||
/// creator line.
|
||||
/// A mail-made letter's `ITEM_FIELD_ITEM_TEXT_ID`: one body and a creator line.
|
||||
Letter { text_id: u32 },
|
||||
/// A page chain — a readable item template's `PageText`, or a TEXT GameObject's `data[0]`.
|
||||
/// `visited` is the trail of page ids from the first to the one on screen, so **Prev** can walk
|
||||
/// back a chain that only links forwards (the reference keeps the same array, `[0xbc3fd0]`).
|
||||
/// **Empty until the object's template answers**: the reference asks the *object* for its page
|
||||
/// id at paint time (`vtbl+0x74`, with an ask-once template callback that re-enters the whole
|
||||
/// open — `0x5f59d0`), so a click that lands before the template does still opens a reader.
|
||||
/// A page chain. `visited` runs from the first page to the one shown, as pages link only
|
||||
/// forwards (the reference's array at `0xbc3fd0`); it is empty until the object's template
|
||||
/// answers, since the reference asks for the page id at paint time (`0x5f59d0`).
|
||||
Pages { visited: Vec<u32> },
|
||||
}
|
||||
|
||||
/// The open read session; `None` in [`ItemTextOpen::pending`] = no reader open.
|
||||
pub(crate) struct ReadSession {
|
||||
/// The read object's guid — an item's or a GameObject's. Title/creator resolve off it each
|
||||
/// frame until ready, and re-using the *same* object closes the reader (the reference's toggle).
|
||||
/// An item's or a GameObject's guid; reading the same object again closes the reader.
|
||||
pub(crate) object_guid: u64,
|
||||
pub(crate) source: ReadSource,
|
||||
/// What the live VM has been told about this session — keyed on the VM (decisions
|
||||
/// 1290/1291), because the session is world state (the letter is still open) while the
|
||||
/// fires are VM state: a `/reload` with a book up replaces the frame tree, and the fresh
|
||||
/// one needs the `ITEM_TEXT_BEGIN` and repaint the old one already consumed. Before this,
|
||||
/// the reader never came back after a reload while the host still believed it was open.
|
||||
/// Keyed on the VM, so a `/reload` re-fires `ITEM_TEXT_BEGIN` for a reader still open.
|
||||
told: crate::ui_script::VmMemo<ItemTextTold>,
|
||||
}
|
||||
|
||||
/// [`ReadSession::told`]'s payload — the per-VM fire latches.
|
||||
#[derive(Default)]
|
||||
struct ItemTextTold {
|
||||
/// `ITEM_TEXT_BEGIN` fired (the reference fires it once per open, before the text lands).
|
||||
/// `ITEM_TEXT_BEGIN` fired: once per open, before the text lands.
|
||||
begun: bool,
|
||||
/// `ITEM_TEXT_READY` fired — the session is fully painted.
|
||||
ready: bool,
|
||||
}
|
||||
|
||||
/// The reader-session resource ([`ReadSession`]).
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct ItemTextOpen {
|
||||
pub(crate) pending: Option<ReadSession>,
|
||||
/// A [`ItemTextOpen::toggle_closed`] fired and the frame has not been told yet. The click
|
||||
/// routes have no `UiScript` in reach, so the close event is handed to the feed — which owns
|
||||
/// the script — rather than each caller re-implementing it.
|
||||
/// A toggle closed the reader and the frame is not told yet; the feed holds the script.
|
||||
closing: bool,
|
||||
}
|
||||
|
||||
impl ItemTextOpen {
|
||||
/// Open a read for a bag letter (dispatcher arm #6). Re-opening restarts the reference event
|
||||
/// flow from `ITEM_TEXT_BEGIN`.
|
||||
/// Open a bag letter; re-opening restarts from `ITEM_TEXT_BEGIN`.
|
||||
pub(crate) fn open_letter(&mut self, item_guid: u64, text_id: u32) {
|
||||
self.open(item_guid, ReadSource::Letter { text_id });
|
||||
}
|
||||
|
||||
/// Open a read for a page chain — a readable item template (arm #5) or a TEXT GameObject. The
|
||||
/// page head is not passed in: the feed asks the object's template for it, like the reference.
|
||||
/// Open a page chain; the feed asks the object's template for the first page.
|
||||
pub(crate) fn open_pages(&mut self, object_guid: u64) {
|
||||
self.open(
|
||||
object_guid,
|
||||
@@ -148,10 +105,8 @@ impl ItemTextOpen {
|
||||
);
|
||||
}
|
||||
|
||||
/// The reference's **toggle** (`0x4e32e0`'s `arg2 == 0` head, which *every* click route passes —
|
||||
/// the bag readable at `0x5d8e5e` and the TEXT GameObject at `0x5f58e7` both `xor edx,edx`):
|
||||
/// re-clicking the readable whose reader is already open *closes* it. Returns whether it fired,
|
||||
/// so the caller stops there.
|
||||
/// The reference's toggle, `0x4e32e0` with flag 0 from both click routes (`0x5d8e5e`,
|
||||
/// `0x5f58e7`): reading the open object again closes it. Returns whether it closed.
|
||||
pub(crate) fn toggle_closed(&mut self, object_guid: u64) -> bool {
|
||||
let open = self
|
||||
.pending
|
||||
@@ -164,7 +119,6 @@ impl ItemTextOpen {
|
||||
open
|
||||
}
|
||||
|
||||
/// Take the pending toggle-close, for the feed to turn into `ITEM_TEXT_CLOSED`.
|
||||
fn take_closing(&mut self) -> bool {
|
||||
std::mem::take(&mut self.closing)
|
||||
}
|
||||
@@ -178,7 +132,7 @@ impl ItemTextOpen {
|
||||
}
|
||||
}
|
||||
|
||||
/// The book reader's packet handler (decision 1105; in the net handler table since 2313).
|
||||
/// The page-text reply's handler.
|
||||
mod net {
|
||||
use benilla_protocol::{SessionEvent, SessionEventKind};
|
||||
use bevy::prelude::*;
|
||||
@@ -186,13 +140,11 @@ mod net {
|
||||
use super::PageTexts;
|
||||
use crate::net::NetHandlerApp;
|
||||
|
||||
/// Register the handler — called from [`super::UiItemTextPlugin`].
|
||||
pub(super) fn register(app: &mut App) {
|
||||
app.net_handler(SessionEventKind::PageText, on_page_text);
|
||||
}
|
||||
|
||||
/// The book-page cache — one page per packet, the whole chain in answer to the first ask;
|
||||
/// the reader repaints off it on the next feed.
|
||||
/// One page per packet; the reader repaints off the cache on the next feed.
|
||||
fn on_page_text(In(ev): In<SessionEvent>, mut pages: ResMut<PageTexts>) {
|
||||
if let SessionEvent::PageText {
|
||||
page_id,
|
||||
@@ -216,8 +168,8 @@ impl Plugin for UiItemTextPlugin {
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
// Feed before the input pass so an open paints the same frame; drain after it so
|
||||
// a Close click clears the same frame (the ui_mail ordering).
|
||||
// Feed before the input pass so an open paints this frame; drain after it
|
||||
// so a close clears this frame.
|
||||
feed_item_text.in_set(UiFeed),
|
||||
drain_item_text.after(UiInput),
|
||||
),
|
||||
@@ -225,24 +177,19 @@ impl Plugin for UiItemTextPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
/// What the reader's *object* answers, whichever source its text comes from: the window title
|
||||
/// (`ItemTextGetItem 0x4e38f0` → the looked-up object's `vtbl+0x70` name) and the frame material
|
||||
/// (`ItemTextGetMaterial 0x4e39f0` → an item template's `PageMaterial`, a GameObject's `data[2]`),
|
||||
/// plus the page head a page chain starts at. `None` = the template is still in flight.
|
||||
/// What the read object answers: its name for the title (`ItemTextGetItem` `0x4e38f0`), its
|
||||
/// material (`ItemTextGetMaterial` `0x4e39f0`: an item's `PageMaterial`, a GameObject's
|
||||
/// `data[2]`) and its first page.
|
||||
struct Readable {
|
||||
title: String,
|
||||
/// `PageTextMaterial.dbc` id → basename; `None` = the Lua's Parchment default.
|
||||
/// The `PageTextMaterial.dbc` basename; `None` is the Lua's Parchment default.
|
||||
material: Option<String>,
|
||||
/// The first `PageText` id — `0` for a letter (which has no page chain) and for a readable
|
||||
/// whose template carries no page.
|
||||
/// The first `PageText` id; 0 for a letter or a template with no page.
|
||||
page_head: u32,
|
||||
}
|
||||
|
||||
/// The material a quest sourced from `guid` paints on — `GetQuestBackgroundMaterial 0x502230`,
|
||||
/// whose body is `ItemTextGetMaterial`'s: the source object by guid, an item's template
|
||||
/// `PageMaterial` or a GameObject's template data by type, the id named through
|
||||
/// `PageTextMaterial.dbc`; a creature, an uncached template or an id with no row is `None`, which
|
||||
/// is FrameXML's "Parchment". A GameObject whose template is not cached yet is asked for.
|
||||
/// The material a quest from `guid` paints on, `GetQuestBackgroundMaterial` (`0x502230`), the
|
||||
/// body of `ItemTextGetMaterial`; `None` is FrameXML's Parchment. Asks for an uncached GameObject.
|
||||
pub(crate) fn object_material(
|
||||
guid: u64,
|
||||
objects: &Objects,
|
||||
@@ -266,9 +213,7 @@ pub(crate) fn object_material(
|
||||
name(t.page_material)
|
||||
}
|
||||
|
||||
/// Resolve the object's title + material + page head, whether it is an item or a GameObject. The
|
||||
/// reference does exactly this — one guid lookup with typemask 1, then virtual getters — so a
|
||||
/// single resolve serves both sources.
|
||||
/// The object's [`Readable`], item or GameObject alike; `None` while its template is in flight.
|
||||
fn readable(
|
||||
guid: u64,
|
||||
objects: &Objects,
|
||||
@@ -294,12 +239,9 @@ fn readable(
|
||||
})
|
||||
}
|
||||
|
||||
/// Drive the open session to `ITEM_TEXT_BEGIN`/`ITEM_TEXT_READY` as its pieces land. BEGIN holds
|
||||
/// until the object's template resolves — it carries the title *and* the material, and the Lua's
|
||||
/// BEGIN handler picks the page font and text colour off the material, so firing it early would
|
||||
/// paint a Stone plaque in parchment ink. READY holds until the body and, for a letter, the creator
|
||||
/// line are both in. (The window itself only shows on READY — `ItemTextFrame.lua` calls
|
||||
/// `ShowUIPanel` there, not on BEGIN.)
|
||||
/// Drive the open session to `ITEM_TEXT_BEGIN` once the template resolves, as the stock handler
|
||||
/// takes the text colour from the material (`ItemTextFrame.lua:18-23`), then to
|
||||
/// `ITEM_TEXT_READY`, which shows the window, once the body and any creator line are in.
|
||||
fn feed_item_text(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
mut open: ResMut<ItemTextOpen>,
|
||||
@@ -311,15 +253,14 @@ fn feed_item_text(
|
||||
go_templates: Res<GameObjectTemplates>,
|
||||
materials: Option<Res<PageMaterials>>,
|
||||
commands: Res<NetCommands>,
|
||||
// The `$`-macro subject for the page body: the local player, as at every panel seam.
|
||||
// The `$`-macro subject for the page body: the local player.
|
||||
self_q: Query<(&crate::net::ObjectStore, &crate::net::Guid), With<crate::net::SelfPlayer>>,
|
||||
states: Res<crate::world_state::WorldStates>,
|
||||
) {
|
||||
let Some(mut script) = script else {
|
||||
return;
|
||||
};
|
||||
// A re-click closed the reader (the reference's toggle → its `0x128`). The click route cleared
|
||||
// the session; the frame is told here, where the script is.
|
||||
// A toggle closed the reader; the frame is told here, where the script is.
|
||||
if open.take_closing() {
|
||||
script.set_item_text(None);
|
||||
script.fire_event("ITEM_TEXT_CLOSED", vec![]);
|
||||
@@ -338,11 +279,10 @@ fn feed_item_text(
|
||||
materials.as_deref(),
|
||||
&commands,
|
||||
) else {
|
||||
return; // template in flight — the reference re-enters the whole open when it lands
|
||||
return; // template in flight; the reference re-enters the open when it lands
|
||||
};
|
||||
|
||||
// A page read whose template answers with no page has nothing to show — the reference bails
|
||||
// before firing anything (`0x4e341d`, both text sources zero). Drop the session silently.
|
||||
// No page to show: the reference bails before firing anything (`0x4e341d`).
|
||||
if matches!(sess.source, ReadSource::Pages { .. }) && readable.page_head == 0 {
|
||||
debug!(
|
||||
"item text: {:#x} has no page to read — closing",
|
||||
@@ -367,8 +307,6 @@ fn feed_item_text(
|
||||
|
||||
let (creator, text, page, has_next) = match &mut sess.source {
|
||||
ReadSource::Letter { text_id } => {
|
||||
// The creator line: `ITEM_FIELD_CREATOR` → name cache (ask-once). `None` guid =
|
||||
// authorless.
|
||||
let creator_guid = objects
|
||||
.object(sess.object_guid)
|
||||
.and_then(|o| o.item_creator());
|
||||
@@ -379,7 +317,7 @@ fn feed_item_text(
|
||||
None => return, // name query in flight
|
||||
},
|
||||
};
|
||||
// The body: the shared ask-once item-text cache (fetch if this is the first asker).
|
||||
// The body, from the client's one item-text cache, which mail shares.
|
||||
let body = mail
|
||||
.bodies
|
||||
.get_or_ask(*text_id, || {
|
||||
@@ -402,7 +340,7 @@ fn feed_item_text(
|
||||
let Some(page) = pages.get_or_ask(page_id, sess.object_guid, &commands) else {
|
||||
return; // page query in flight
|
||||
};
|
||||
// A book is authorless — the creator leg is the reference's item-instance one.
|
||||
// A book has no creator line.
|
||||
(
|
||||
None,
|
||||
page.text.clone(),
|
||||
@@ -412,8 +350,7 @@ fn feed_item_text(
|
||||
}
|
||||
};
|
||||
|
||||
// Page/book text is server-authored, so it runs the `$`-macro expander — the reference does it
|
||||
// from two sites in `ItemTextFrame.cpp` (decision 0754), subject = the local player.
|
||||
// Server-authored text runs the `$`-macro expander, as in the reference's `ItemTextFrame.cpp`.
|
||||
let subject = crate::npc_text::player_identity(&self_q, &names, &commands);
|
||||
let text = crate::npc_text::substitute(
|
||||
&text,
|
||||
@@ -434,16 +371,12 @@ fn feed_item_text(
|
||||
sess.told.get(&script).ready = true;
|
||||
}
|
||||
|
||||
/// `PageTextMaterial.dbc` as a resource (decision 1105) — the reader frame's material basename,
|
||||
/// loaded once at startup ([`crate::entities`]); absent when the client data is, in which case every
|
||||
/// readable falls to the Lua's Parchment default.
|
||||
/// `PageTextMaterial.dbc`; absent without client data, leaving every reader on Parchment.
|
||||
#[derive(Resource)]
|
||||
pub(crate) struct PageMaterials(pub(crate) benilla_formats::PageTextMaterialCatalog);
|
||||
|
||||
/// Drain the reader's intents: `CloseItemText()` clears the session (+ `ITEM_TEXT_CLOSED`, the
|
||||
/// reference C-side answer the frame's OnHide relies on); a page turn walks the chain — Next
|
||||
/// appends the current page's `nextPageId` to the trail, Prev pops back — and re-runs the feed from
|
||||
/// `ITEM_TEXT_READY` (the reference repaints the same frame, it does not re-fire BEGIN).
|
||||
/// Drain the reader's intents: `CloseItemText()` clears the session and fires `ITEM_TEXT_CLOSED`;
|
||||
/// a page turn re-runs the feed from `ITEM_TEXT_READY`, as the reference does not re-fire BEGIN.
|
||||
fn drain_item_text(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
mut open: ResMut<ItemTextOpen>,
|
||||
@@ -460,8 +393,7 @@ fn drain_item_text(
|
||||
continue; // a letter has no pages; its buttons never show
|
||||
};
|
||||
if turn_page(visited, delta, |id| pages.pages.get(id).map(|p| p.next)) {
|
||||
// Repaint on the next feed, without re-firing BEGIN (within this VM; a fresh VM
|
||||
// re-begins regardless, which is the reload repaint).
|
||||
// Repaint on the next feed without re-firing BEGIN.
|
||||
sess.told.get(&script).ready = false;
|
||||
}
|
||||
}
|
||||
@@ -471,16 +403,12 @@ fn drain_item_text(
|
||||
}
|
||||
}
|
||||
|
||||
/// Walk the visited-page trail one step. A `PageText` chain only links **forwards**, so Prev is a
|
||||
/// pop off the trail rather than a lookup — the reference keeps the same array (`[0xbc3fd0]`).
|
||||
/// Returns whether the page actually changed (a no-op turn must not repaint).
|
||||
///
|
||||
/// `next_of` is the page cache: `None` = the page hasn't landed yet, `Some(0)` = last page. Both
|
||||
/// refuse the turn; neither can normally be clicked, since the Next button only shows while the
|
||||
/// painted page reported a next.
|
||||
/// Walk the trail one step: Next pushes the page's `nextPageId` from `next_of`, the cache, and
|
||||
/// Prev pops (the reference's array at `0xbc3fd0`). A page not landed or a last page refuses;
|
||||
/// returns whether the page changed.
|
||||
fn turn_page(visited: &mut Vec<u32>, delta: i32, next_of: impl Fn(u32) -> Option<u32>) -> bool {
|
||||
let Some(¤t) = visited.last() else {
|
||||
return false; // head not resolved yet — no page is painted to turn from
|
||||
return false; // head not resolved yet
|
||||
};
|
||||
if delta > 0 {
|
||||
match next_of(current) {
|
||||
@@ -499,7 +427,6 @@ fn turn_page(visited: &mut Vec<u32>, delta: i32, next_of: impl Fn(u32) -> Option
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A three-page book: 100 → 101 → 102. Next walks it, Prev walks back, and the ends refuse.
|
||||
#[test]
|
||||
fn page_turns_walk_the_chain_both_ways() {
|
||||
let chain = |id: u32| match id {
|
||||
@@ -524,7 +451,6 @@ mod tests {
|
||||
assert_eq!(visited, [100, 101], "Prev pops the forward-only trail");
|
||||
}
|
||||
|
||||
/// A Next clicked before the page landed is refused rather than pushing a bogus id.
|
||||
#[test]
|
||||
fn a_page_still_in_flight_refuses_the_turn() {
|
||||
let mut visited = vec![7];
|
||||
@@ -532,8 +458,6 @@ mod tests {
|
||||
assert_eq!(visited, [7]);
|
||||
}
|
||||
|
||||
/// The reference's toggle: re-clicking the readable that is already open closes it; a
|
||||
/// *different* one does not.
|
||||
#[test]
|
||||
fn re_reading_the_same_object_toggles_closed() {
|
||||
let mut open = ItemTextOpen::default();
|
||||
@@ -551,8 +475,6 @@ mod tests {
|
||||
assert!(!open.take_closing(), "a no-op toggle closes nothing");
|
||||
}
|
||||
|
||||
/// A page chain opens with an EMPTY trail — the head comes from the object's template as the
|
||||
/// feed paints, so a click that beats the ask-once template query still opens a reader.
|
||||
#[test]
|
||||
fn a_page_read_opens_before_its_template_lands() {
|
||||
let mut open = ItemTextOpen::default();
|
||||
@@ -570,13 +492,10 @@ mod quest_material_tests {
|
||||
(u64::from(benilla_protocol::guid::HIGH_GAMEOBJECT) << 48) | (entry << 24) | 1
|
||||
}
|
||||
|
||||
/// `GetQuestBackgroundMaterial`'s resolve, the reader's twin: a GameObject source answers its
|
||||
/// template data by type through `PageTextMaterial.dbc`; a creature source answers nothing
|
||||
/// (FrameXML's Parchment); an uncached GameObject is asked for and answers nothing meanwhile.
|
||||
/// A GameObject's material is its template data by type; a creature's is Parchment.
|
||||
#[test]
|
||||
fn a_quest_sourced_from_an_object_paints_the_objects_material() {
|
||||
let items = Items::default();
|
||||
// No item source in this case — every one of these is a GameObject or a creature (2334).
|
||||
let mut objs = crate::ui_items::TestObjects::new();
|
||||
let objects = objs.get();
|
||||
let mut gos = GameObjectTemplates::default();
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
//! The outward half of the container seam (see the parent module doc): the per-frame drains that
|
||||
//! turn queued Lua intents (`UseContainerItem`, the cursor pick/place/swap/split moves, the
|
||||
//! delete-confirm popup's destroy) into `ClientCommand`s on the wire, locking the slots each send
|
||||
//! touches ([`crate::pending_item_ops::PendingItemOps`]) along the way.
|
||||
//! The per-frame drains that send the queued Lua container intents as `ClientCommand`s, locking
|
||||
//! the slots each send touches in [`crate::pending_item_ops::PendingItemOps`].
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
@@ -14,22 +12,10 @@ use crate::pending_item_ops::PendingItemOps;
|
||||
|
||||
use super::{slot_guid, slot_guid_count, wire_pos, INVTYPE_AMMO};
|
||||
|
||||
/// **The one auto-equip sender** — the reference has exactly one (`0x5e1480`
|
||||
/// `AutoEquipCursorItem`), reached from several verbs, and until decision 1750 benilla had three
|
||||
/// copies of it. Three copies is what made the soulbind confirm impossible to add correctly: the
|
||||
/// gate would have had to be written, and kept agreeing, in three places.
|
||||
///
|
||||
/// Takes the WIRE position and the item's guid, and answers whether the send happened. The two
|
||||
/// forks it owns:
|
||||
///
|
||||
/// - **ammo**: an ammo-class item loads by entry with `CMSG_SET_AMMO`
|
||||
/// rather than the equip wire — the stack stays in the bag and `PLAYER_AMMO_ID` references it
|
||||
/// (decision 0526). A missing template falls back to `CMSG_AUTOEQUIP_ITEM`, whose refusal is at
|
||||
/// least visible.
|
||||
/// - **the soulbind deferral** (decision 1750, `0x5e163b`): a not-yet-bound, equippable
|
||||
/// `bonding == 2` item raises `AUTOEQUIP_BIND_CONFIRM` and sends NOTHING. `suppress` is the
|
||||
/// reference's own parameter, set on the re-issue `EquipPendingItem` drives — which is what stops
|
||||
/// the accept from asking the same question again forever.
|
||||
/// The one auto-equip sender, as in the reference (`AutoEquipCursorItem`, `0x5e1480`); answers
|
||||
/// whether it sent. An ammo item loads by entry with `CMSG_SET_AMMO` and stays in its bag. An
|
||||
/// unbound, equippable `bonding == 2` item raises `AUTOEQUIP_BIND_CONFIRM` and sends nothing
|
||||
/// (`0x5e163b`) unless `suppress`, the reference's flag, which the accept's re-issue sets.
|
||||
pub(crate) fn send_auto_equip(
|
||||
script: &mut UiScript,
|
||||
gate: &mut crate::ui_bind_confirm::BindGate,
|
||||
@@ -73,21 +59,8 @@ pub(crate) fn send_auto_equip(
|
||||
true
|
||||
}
|
||||
|
||||
/// Drain the `(bag, slot)` sources `AutoEquipCursorItem` queued (decision 0208 phase 1b: the
|
||||
/// model-pane's click-with-payload path) and send `CMSG_AUTOEQUIP_ITEM` — the engine's own
|
||||
/// contract (`cursor::doll::auto_equip_cursor_item`) already guarantees only a whole-stack,
|
||||
/// CONTAINER-sourced Item payload (`bag >= 0`) ever reaches this queue.
|
||||
///
|
||||
/// The same ammo sub-fork as [`drain_container_uses`] (the one auto-equip sender `0x5e1480`
|
||||
/// forks ammo-class → `CMSG_SET_AMMO`): a dropped ammo-class item loads by entry
|
||||
/// instead, which is also the wire for the ammo slot's own drop (the XML routes it here via
|
||||
/// `AutoEquipCursorItem` — decision 0526).
|
||||
///
|
||||
/// No pending-lock recording here (unlike the move/split/destroy drains) — matching this
|
||||
/// codebase's own existing precedent for the SAME wire send: `drain_container_uses`'s
|
||||
/// equip-vs-use fork already sends `AutoEquipItem` for an equippable bag-slot click with no lock
|
||||
/// bookkeeping of its own. A real gap either way (an in-flight autoequip's source slot isn't
|
||||
/// visibly dimmed), pre-existing and out of this slice's scope to fix.
|
||||
/// Sends the auto-equips `AutoEquipCursorItem` queued through [`send_auto_equip`]. It takes no
|
||||
/// pending lock, so the source slot does not dim while the equip is in flight.
|
||||
pub(super) fn drain_container_autoequips(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
items: Res<Items>,
|
||||
@@ -104,9 +77,6 @@ pub(super) fn drain_container_autoequips(
|
||||
debug!("ui_items: autoequip ({bag}, {slot}) out of range — ignored");
|
||||
continue;
|
||||
};
|
||||
// The dropped item, by guid (source is a 0-based inner slot) — the sender needs it for both
|
||||
// of its forks. Unresolved (rare: the bag needed the template for the icon) sends a plain
|
||||
// AUTOEQUIP, whose refusal is at least visible.
|
||||
let slot0 = u8::try_from(slot.saturating_sub(1)).unwrap_or(0);
|
||||
let guid = self_q
|
||||
.iter()
|
||||
@@ -126,22 +96,10 @@ pub(super) fn drain_container_autoequips(
|
||||
}
|
||||
}
|
||||
|
||||
/// Drain the auto-stores `PutItemInBag`/`PutItemInBackpack` queued (`benilla_ui`'s
|
||||
/// `cursor::bag_verbs`) and send them on the wire.
|
||||
///
|
||||
/// **The destination is a BAG, not a slot** — that is the finding this drain exists to carry
|
||||
/// (`0x4c7c00` → `0x5e12e0`): `CMSG_AUTOSTORE_BAG_ITEM` names `(srcbag, srcslot, dstbag)` and
|
||||
/// the server picks where inside it the item lands, which is why an ordinary item dropped on a
|
||||
/// bag BUTTON goes in the bag rather than swapping with it. The destination bag byte is
|
||||
/// [`wire_pos`]'s own answer for that container's first slot (255 for the backpack, the bag's
|
||||
/// player-array slot 19..22 / 63..68 for an equipped or bank bag), so one map serves both ends.
|
||||
///
|
||||
/// A **split carry** takes `CMSG_SPLIT_ITEM` instead — the reference's own fork on `[0xb4b40c]`
|
||||
/// — with the literal `0xFF` where a destination slot would go, because that wire has a slot
|
||||
/// field and this one does not.
|
||||
///
|
||||
/// No pending-lock recording, matching [`drain_container_autoequips`]'s own precedent for the same
|
||||
/// class of send: the destination is not a slot, so there is no second end to lock.
|
||||
/// Sends the auto-stores `PutItemInBag`/`PutItemInBackpack` queued. The destination is a bag, not
|
||||
/// a slot (`0x4c7c00` to `0x5e12e0`): `CMSG_AUTOSTORE_BAG_ITEM` names `(srcbag, srcslot, dstbag)`
|
||||
/// and the server picks the slot. A split carry sends `CMSG_SPLIT_ITEM` instead (the reference's
|
||||
/// fork on `[0xb4b40c]`). Takes no pending lock.
|
||||
pub(super) fn drain_bag_autostores(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
commands: Res<NetCommands>,
|
||||
@@ -178,8 +136,7 @@ pub(super) fn drain_bag_autostores(
|
||||
src_bag,
|
||||
src_slot,
|
||||
dst_bag,
|
||||
// `0xFF` — the reference's own literal for "no destination slot"; the split
|
||||
// wire has the field, the verb has no value for it.
|
||||
// The reference's literal for no destination slot.
|
||||
dst_slot: 0xFF,
|
||||
count,
|
||||
});
|
||||
@@ -188,15 +145,9 @@ pub(super) fn drain_bag_autostores(
|
||||
}
|
||||
}
|
||||
|
||||
/// Drain the inventory-slot ids `UseInventoryItem` queued (decision 0208 phase 1b: the doll
|
||||
/// slot's right-click) and route the equipped position (bag 255 plus the 0-based wire slot —
|
||||
/// `HandleUseItemOpcode` takes equipped positions the same as bag ones, vmangos `ItemHandler.cpp`)
|
||||
/// through the shared use fork (`super::item_use_command`): the reference's doll click lands in
|
||||
/// the same `CGItem::Use` a bag click does (`0x4c7af0`), quest fork included — one of the five
|
||||
/// equippable quest-starters, worn and right-clicked, offers its quest instead of casting nothing.
|
||||
/// Ids outside 1..=19 (ammo, the bag icons) are a no-op — the engine's own queue never receives
|
||||
/// them from the shipped XML (only the 19 named slot buttons wire `UseInventoryItem` this slice),
|
||||
/// but a stray Lua call is still refused rather than sent as nonsense.
|
||||
/// Sends the doll right-clicks `UseInventoryItem` queued through [`super::send_item_use`], as the
|
||||
/// reference's doll click reaches the same `CGItem::Use` a bag click does (`0x4c7af0`): a worn
|
||||
/// quest-starter offers its quest. Ids outside 1..=19 are refused.
|
||||
pub(super) fn drain_inventory_uses(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
self_q: Query<&ObjectStore, With<SelfPlayer>>,
|
||||
@@ -214,8 +165,6 @@ pub(super) fn drain_inventory_uses(
|
||||
continue;
|
||||
}
|
||||
let slot = (id - 1) as u8;
|
||||
// The doll's own slot ids ARE the wire slots (`wire_pos`'s EQUIPMENT_BAG law), so the
|
||||
// equipped instance resolves off the player's INV array directly.
|
||||
let (guid, start_quest, spell_index, use_spell, entry, is_charter) = self_q
|
||||
.iter()
|
||||
.next()
|
||||
@@ -223,8 +172,6 @@ pub(super) fn drain_inventory_uses(
|
||||
.and_then(|guid| {
|
||||
let entry = ladder.objects.object(guid)?.object_entry()?;
|
||||
let t = ladder.items.template(entry, guid, &ladder.commands)?;
|
||||
// The wire's spell byte is a template BLOCK ordinal (decision 0666) — the
|
||||
// template is already in hand here for `start_quest`, so name the real one.
|
||||
Some((
|
||||
Some(guid),
|
||||
t.start_quest,
|
||||
@@ -266,10 +213,8 @@ pub(super) fn drain_container_uses(
|
||||
mut equip_sound: MessageWriter<crate::sound::AutoEquipSound>,
|
||||
mut item_text: ResMut<crate::ui_item_text::ItemTextOpen>,
|
||||
targeting: crate::spell::cast_target::CastTargeting,
|
||||
// The client-side pending ("gray") lock — the right-click-open arm arms it (decision 0916).
|
||||
mut pending_items: ResMut<PendingItemOps>,
|
||||
// The loot-target latch — the right-click-open arm is one of its five arm sites, and the one
|
||||
// that lets `SMSG_LOOT_RESPONSE`'s admission gate recognise an item loot (decision 1531).
|
||||
// The loot latch the open arm sets, so `SMSG_LOOT_RESPONSE` admits an item loot.
|
||||
mut loot_latch: ResMut<crate::ui_loot::LootLatch>,
|
||||
mut ladder: crate::spell::CastLadder,
|
||||
mut ui_errors: ResMut<crate::ui_action::UiErrorKeys>,
|
||||
@@ -278,9 +223,8 @@ pub(super) fn drain_container_uses(
|
||||
let Some(mut script) = script else {
|
||||
return;
|
||||
};
|
||||
// Repair-mode clicks (the engine's pickup intercept — the real client's `0x4f9c7b` route):
|
||||
// resolve the clicked slot's item guid and send its single-item repair. The affordability
|
||||
// pre-check the client does (error 0x25) is left to the server's own refusal.
|
||||
// Repair-mode clicks (the reference's `0x4f9c7b` route) repair the one item. The reference's
|
||||
// affordability check (error 0x25) is not built; the server refuses instead.
|
||||
for (bag, slot) in script.take_container_repairs() {
|
||||
let Some(vendor) = merchant.vendor else {
|
||||
continue;
|
||||
@@ -302,23 +246,16 @@ pub(super) fn drain_container_uses(
|
||||
}
|
||||
}
|
||||
for (bag, slot) in script.take_container_uses() {
|
||||
// **A right-click cancels an armed gift wrap, whatever it then does** (decision 1934):
|
||||
// only a LEFT-click on a container slot spends the paper, and the reference's use path
|
||||
// clears the cursor on its way. Above every affordance below, because a sell or a
|
||||
// deposit is still a right-click. Re-arming is not a special case: a right-click on a
|
||||
// second piece of paper cancels the first here and arms itself in the dispatcher.
|
||||
// Any right-click, a sell or a deposit included, cancels an armed gift wrap: the
|
||||
// reference's use path clears the cursor first (`0x4fa198`).
|
||||
if let Some(w) = script.cancel_gift_wrap() {
|
||||
debug!(
|
||||
"ui_items: right-click cancels the armed gift wrap on bag {} slot {}",
|
||||
w.bag, w.slot
|
||||
);
|
||||
}
|
||||
// Lua (bagID, 1-based slot) → the wire's player-array addressing.
|
||||
let slot0 = u8::try_from(slot.saturating_sub(1)).ok();
|
||||
// Sell affordance (decision 0081 v1): while a merchant is open, a bag-slot click sells the
|
||||
// slot's item instead of using/equipping it (`CMSG_SELL_ITEM`, count 0 = the whole stack —
|
||||
// the item is addressed by its concrete guid, not a bag slot). An empty slot has no guid, so
|
||||
// the click is a harmless no-op.
|
||||
// With a merchant open, the click sells the whole stack (`CMSG_SELL_ITEM`, count 0).
|
||||
if let (true, Some(vendor)) = (merchant.is_open(), merchant.vendor) {
|
||||
let item_guid = self_q
|
||||
.iter()
|
||||
@@ -341,14 +278,10 @@ pub(super) fn drain_container_uses(
|
||||
debug!("ui_items: UseContainerItem({bag}, {slot}) out of range — ignored");
|
||||
continue;
|
||||
};
|
||||
// The deposit/withdraw affordance (decision 0604): while the bank is open, a container
|
||||
// click routes as the reference's at-bank auto-move instead of using/equipping — a bank
|
||||
// position (the vault or a bank bag) withdraws (`CMSG_AUTOSTORE_BANK_ITEM`), a carried
|
||||
// bag's item deposits (`CMSG_AUTOBANK_ITEM`). Which of the two opcodes the reference
|
||||
// fires per direction is INFERRED (0604) — vmangos routes AUTOSTORE by source position,
|
||||
// so either choice lands correctly. An empty slot refuses server-side, harmlessly.
|
||||
// Doll clicks never reach this drain (they flow through `drain_inventory_uses`), so
|
||||
// equipped gear keeps its plain use at the bank, like the reference.
|
||||
// With the bank open, a vault or bank-bag item withdraws (`CMSG_AUTOSTORE_BANK_ITEM`) and
|
||||
// any other deposits (`CMSG_AUTOBANK_ITEM`): the reference's bank flag, set for bags -1
|
||||
// and 5..10 (`0x4f9820`), picks the opcode (`0x4fa2f2`). Doll clicks come through
|
||||
// `drain_inventory_uses`, so worn gear keeps its plain use.
|
||||
if bank.is_open() {
|
||||
let withdrawing = bag == super::BANK_CONTAINER || (5..=10).contains(&bag);
|
||||
if withdrawing {
|
||||
@@ -366,10 +299,7 @@ pub(super) fn drain_container_uses(
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Everything the reference's click law reads off the clicked slot, resolved once. `None` =
|
||||
// an empty slot, or a template still in flight — the click then falls all the way through
|
||||
// to a plain USE, whose refusal is at least visible (the template is all but always cached
|
||||
// by click time; the bag needed it for the icon).
|
||||
// `None` (empty, or the template in flight) falls through to a plain use.
|
||||
let clicked = self_q
|
||||
.iter()
|
||||
.next()
|
||||
@@ -387,8 +317,6 @@ pub(super) fn drain_container_uses(
|
||||
inventory_type: t.inventory_type,
|
||||
display_info_id: t.display_info_id,
|
||||
start_quest: t.start_quest,
|
||||
// The wire's spell byte is a template BLOCK ordinal (decision 0666) — the
|
||||
// template is right here, so send the real one rather than assuming 0.
|
||||
spell_index: t.use_spell_index().unwrap_or(0),
|
||||
use_spell: t.use_spell.map(|u| u.spell_id),
|
||||
unwraps_gift: t.unwraps_gift(inst_flags),
|
||||
@@ -399,24 +327,12 @@ pub(super) fn drain_container_uses(
|
||||
})
|
||||
});
|
||||
|
||||
// The reference's equip-vs-use fork (`0x4fa3b9`/`0x4fa3bd`), with the ammo sub-fork: the
|
||||
// auto-equip sender `0x5e1480` sends `CMSG_SET_AMMO` (the item entry) for an ammo-class
|
||||
// item, `CMSG_AUTOEQUIP_ITEM` for any other equippable (inventoryType != 0 — weapons,
|
||||
// armor, bags). display_id feeds the synthetic pickup→place auto-equip sound (this path
|
||||
// never moves the cursor; a drag already gets that pair via the cursor-payload
|
||||
// transitions).
|
||||
//
|
||||
// The arm carries the reference's own **quest guard** (`0x4fa3bd`–`0x4fa3cc`, decision
|
||||
// 0664): it equips only when `StartQuest` (`[rec+0x1a8]`) is 0, so a quest-starter falls
|
||||
// through *whatever* its inventoryType — the five equippable ones (Pendant of Myzrael,
|
||||
// Arena Master, …) offer their quest on a right-click, they don't put themselves on.
|
||||
//
|
||||
// **Everything below this fork is `0x5d8d00`, the USE dispatcher, in ITS OWN order** — an
|
||||
// equippable item never reaches any of it.
|
||||
// The reference's equip-vs-use fork (`0x4fa3b9`): an equippable item auto-equips unless
|
||||
// its `StartQuest` (`[rec+0x1a8]`) is set (`0x4fa3bd`). Everything below is the use
|
||||
// dispatcher `0x5d8d00`, in its own order.
|
||||
if let Some(c) = clicked.filter(|c| c.start_quest == 0 && c.inventory_type != 0) {
|
||||
// Through the one sender (decision 1750): it owns the ammo fork AND the soulbind
|
||||
// deferral. A deferred equip plays no sound — the reference's own equip kit rides the
|
||||
// arm that sends, and a question is not an equip.
|
||||
// This path never moves the cursor, so it plays the equip sound itself; a deferred
|
||||
// equip plays none, as the reference's sound rides the arm that sends.
|
||||
if send_auto_equip(
|
||||
&mut script,
|
||||
&mut gate,
|
||||
@@ -434,10 +350,8 @@ pub(super) fn drain_container_uses(
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// #2 — the wrapped gift (`0x5d8d92`/`0x5d8d9d` → emitter `0x5edd60`): `CMSG_OPEN_ITEM`
|
||||
// unwraps it. FIRST in the dispatcher, ahead of the quest and readable arms, so a gift
|
||||
// that also carries letter text unwraps rather than reads. (vmangos answers this one with
|
||||
// an entry swap out of `character_gifts`, not a loot window.)
|
||||
// A wrapped gift unwraps with `CMSG_OPEN_ITEM` (`0x5d8d92`, emitter `0x5edd60`), first in
|
||||
// the dispatcher; vmangos swaps the entry back from `character_gifts`.
|
||||
if let Some(c) = clicked.filter(|c| c.unwraps_gift) {
|
||||
debug!(
|
||||
"ui_items: unwrap gift {:#x} (lua bag {bag} → wire {bag_index}/{wire_slot})",
|
||||
@@ -449,26 +363,17 @@ pub(super) fn drain_container_uses(
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// …and arm #2's OTHER side: a piece of wrapping paper (`0x5d8d9d`'s clear branch →
|
||||
// `0x5edea0`). **Purely local — nothing is sent.** The paper's slot locks, the displayed
|
||||
// cursor becomes mode 2, and the next LEFT-click on a container slot is what sends
|
||||
// `CMSG_WRAP_ITEM` (`ui_items::feed`'s wrap drain, through the engine's
|
||||
// `pickup_container_item`). Decision 1934.
|
||||
// Wrapping paper (`0x5d8d9d`'s other branch, `0x5edea0`) sends nothing: its slot locks and
|
||||
// the cursor becomes mode 2 until a left-click on a container slot sends `CMSG_WRAP_ITEM`.
|
||||
if let Some(c) = clicked.filter(|c| c.begins_gift_wrap) {
|
||||
debug!(
|
||||
"ui_items: arm gift wrap with {:#x} (lua bag {bag} slot {slot})",
|
||||
c.guid
|
||||
);
|
||||
// `arm_gift_wrap` queues the slot's own `ITEM_LOCK_CHANGED` — the paper dims from
|
||||
// the lock the arm takes, exactly as a held item's source slot does.
|
||||
script.arm_gift_wrap(bag, slot);
|
||||
continue;
|
||||
}
|
||||
// #3 — the quest-starter (`0x5d8dd2`, decision 0664): the item's own guid is the
|
||||
// questgiver. Placed at the reference's position rather than at the tail, so a starter
|
||||
// that is *also* readable or lootable offers its quest instead of reading/opening. Routed
|
||||
// through the one shared use fork, which is what turns a non-zero StartQuest into the
|
||||
// item-guid query.
|
||||
// A quest-starter (`0x5d8dd2`) offers its quest, ahead of the readable and open arms.
|
||||
if let Some(c) = clicked.filter(|c| c.start_quest != 0) {
|
||||
debug!(
|
||||
"ui_items: quest-starter {:#x} offers quest {}",
|
||||
@@ -495,12 +400,9 @@ pub(super) fn drain_container_uses(
|
||||
);
|
||||
continue;
|
||||
}
|
||||
// #5 — readable: an item TEMPLATE carrying `PageText` (`0x5d8e4c`) — a book — opens the
|
||||
// reader on its page chain, client-side, no permission packet (`0x4e32e0(itemGuid)`;
|
||||
// decision 1105). Above #6 and above the open arm, which is the reference's own order and
|
||||
// the INVERSE of the tooltip's, where OPENABLE wins over READABLE: a template that is both
|
||||
// readable and lootable *shows* `<Right Click to Open>` and *reads* on click —
|
||||
// byte-verified, not a slip of ours.
|
||||
// A book, a template with `PageText` (`0x5d8e4c`), opens the reader with no packet
|
||||
// (`0x4e32e0`). It precedes the open arm, the inverse of the tooltip: an item both
|
||||
// readable and lootable shows `<Right Click to Open>` and reads on click.
|
||||
if let Some(c) = clicked.filter(|c| c.page_text != 0) {
|
||||
if item_text.toggle_closed(c.guid) {
|
||||
debug!("ui_items: re-click closes the book {:#x}", c.guid);
|
||||
@@ -513,10 +415,8 @@ pub(super) fn drain_container_uses(
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// #6 — readable: an item INSTANCE carrying `ITEM_FIELD_ITEM_TEXT_ID` (a mail-made
|
||||
// permanent letter) opens the reader — client-side, no permission packet (vmangos'
|
||||
// `CMSG_READ_ITEM` handler gates on the *template*'s PageText, which is 0 for the Plain
|
||||
// Letter; the text rides the ask-once `CMSG_ITEM_TEXT_QUERY` instead).
|
||||
// A letter from mail (`ITEM_FIELD_ITEM_TEXT_ID`) opens the reader with no packet; vmangos's
|
||||
// `CMSG_READ_ITEM` wants a template `PageText`, so the text comes by item-text query.
|
||||
if let Some(c) = clicked.filter(|c| c.item_text_id != 0) {
|
||||
if item_text.toggle_closed(c.guid) {
|
||||
debug!("ui_items: re-click closes the letter {:#x}", c.guid);
|
||||
@@ -529,48 +429,21 @@ pub(super) fn drain_container_uses(
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// #8 — the open arm (`0x5d8f7c: test al,4` → emitter `0x5edc80`): a **bare** template
|
||||
// LOOTABLE test. `CMSG_OPEN_ITEM`, not `CMSG_USE_ITEM` — the server's
|
||||
// `HandleOpenItemOpcode` is the only handler that answers with
|
||||
// `SendLoot(item guid, LOOT_CORPSE)`, i.e. a loot window over a thing in your bag; sending
|
||||
// USE_ITEM instead casts the item's (absent) on-use spell and nothing happens, which was
|
||||
// exactly the "no way to open clams" symptom.
|
||||
//
|
||||
// Deliberately looser than the tooltip line's predicate (`ItemInfo::shows_open_line`): the
|
||||
// send consults **neither** LockID nor the instance UNLOCKED bit (VERIFIED both ways — no
|
||||
// `[rec+0x1ac]` operand exists anywhere on the send path). So a still-locked junkbox DOES
|
||||
// send, and the server's `EQUIP_ERR_ITEM_LOCKED` is where the player's "Item is locked"
|
||||
// line comes from. Gating locally would eat the click in silence (decision 0896).
|
||||
// A lootable template (`0x5d8f7c`, emitter `0x5edc80`) sends `CMSG_OPEN_ITEM`, which the
|
||||
// server answers with a loot window on the item (`SpellHandler.cpp:227`). Unlike the
|
||||
// tooltip line, the send tests neither the lock id nor the unlocked bit, so a locked box
|
||||
// sends and the server's `EQUIP_ERR_ITEM_LOCKED` is the "Item is locked" line.
|
||||
if let Some(c) = clicked.filter(|c| c.opens_loot) {
|
||||
debug!(
|
||||
"ui_items: open item {:#x} (lua bag {bag} → wire {bag_index}/{wire_slot})",
|
||||
c.guid
|
||||
);
|
||||
// **The loot latch, armed before the send** — arm site four of five (`0x5edcc0`, in
|
||||
// this same emitter `0x5edc80`, immediately ahead of the lock setter and the
|
||||
// `0x5edce5 push 0xac`). The latch is
|
||||
// the **item's own guid** (`[[edi+8]+0]`) because that is what the answer names:
|
||||
// vmangos' `HandleOpenItemOpcode` ends in `SendLoot(pItem->GetObjectGuid(),
|
||||
// LOOT_CORPSE)`, so `SMSG_LOOT_RESPONSE` comes back on the item guid with wire type
|
||||
// **1** — and 1477's admission gate refuses a type-1 answer against a *cold* latch.
|
||||
// Without this arm the window never opens (decision 1531).
|
||||
//
|
||||
// It arms no pose: predicate B `0x612710` answers false for an ITEM, and
|
||||
// [`crate::ui_loot::resolve_loot_kneel`] reaches the same false through a guid the
|
||||
// object manager cannot resolve — we stream no item entities.
|
||||
// The loot latch on the item's guid, before the send (`0x5edcc0`); the admission gate
|
||||
// refuses the type-1 `SMSG_LOOT_RESPONSE` without it. No kneel (`0x612710`).
|
||||
loot_latch.0 = Some(c.guid);
|
||||
// **The gray lock, armed before the send** — the reference's emitter `0x5edc80` calls
|
||||
// the lock setter `0x4953e0` at `0x5edcd9` and only then ships `CMSG_OPEN_ITEM`
|
||||
// (decision 0916). So a clam,
|
||||
// lockbox or loot bag greys the instant you right-click it and stays grey until the
|
||||
// open resolves — the emptied item vanishing (a resolving field update), a refusal
|
||||
// (`EQUIP_ERR_ITEM_LOCKED` on a still-locked junkbox), or the window closed with loot
|
||||
// left, whose `SMSG_LOOT_RELEASE_RESPONSE` unlocks it by guid (`ui_loot::net`'s
|
||||
// `loot_release_response`, the reference's `UnlockItem` at `48f299`).
|
||||
//
|
||||
// Deliberately NOT armed on the gift-unwrap arm above, which sends the same opcode:
|
||||
// its emitter `0x5edd60` contains neither call — no lock setter and no latch write.
|
||||
// That asymmetry is the reference's, verified, and copying it is the point.
|
||||
// The grey lock, also before the send (lock setter `0x4953e0` at `0x5edcd9`); it holds
|
||||
// until the item empties, the server refuses, or `SMSG_LOOT_RELEASE_RESPONSE` unlocks
|
||||
// it by guid. The unwrap arm's emitter (`0x5edd60`) takes neither lock nor latch.
|
||||
let (guid, count) = self_q
|
||||
.iter()
|
||||
.next()
|
||||
@@ -584,8 +457,7 @@ pub(super) fn drain_container_uses(
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// The tail: a plain use (food, potions, hearthstone). The quest arm already fired above,
|
||||
// so the shared fork's own quest leg is inert here by construction.
|
||||
// A plain use (food, potions, the hearthstone); quest-starters took the arm above.
|
||||
debug!("ui_items: use item (lua bag {bag} → wire {bag_index}/{wire_slot})");
|
||||
super::send_item_use(
|
||||
super::ItemUse {
|
||||
@@ -609,60 +481,29 @@ pub(super) fn drain_container_uses(
|
||||
}
|
||||
}
|
||||
|
||||
/// One clicked bag slot, resolved: the live instance's guid and the template scalars the
|
||||
/// reference's fork chain tests, read once so the chain above is a plain ordered cascade rather
|
||||
/// than five repeats of the same lookup. Copy-cheap on purpose — no borrow of [`Items`] outlives
|
||||
/// the resolve.
|
||||
/// One clicked bag slot, resolved once: the fields the reference's fork chain tests.
|
||||
#[derive(Clone, Copy)]
|
||||
struct Clicked {
|
||||
guid: u64,
|
||||
/// `OBJECT_FIELD_ENTRY` — the ammo arm addresses by entry, not by slot (decision 0526).
|
||||
entry: u32,
|
||||
/// Instance `ITEM_FIELD_ITEM_TEXT_ID` (a mail-made permanent letter); 0 = not a letter.
|
||||
/// Instance `ITEM_FIELD_ITEM_TEXT_ID`, nonzero on a letter made from mail.
|
||||
item_text_id: u32,
|
||||
inventory_type: u32,
|
||||
display_info_id: u32,
|
||||
start_quest: u32,
|
||||
spell_index: u8,
|
||||
/// The template's on-use SPELL id — what the cast tail's in-flight guard is keyed on
|
||||
/// (decision 0908); `None` = this item casts nothing.
|
||||
/// The template's on-use spell, the key of the cast tail's in-flight guard.
|
||||
use_spell: Option<u32>,
|
||||
/// `ItemInfo::unwraps_gift` for this instance — dispatcher arm #2.
|
||||
unwraps_gift: bool,
|
||||
/// `ItemInfo::begins_gift_wrap` — arm #2's OTHER side: a piece of wrapping paper. Arms the
|
||||
/// local wrap cursor and sends nothing (decision 1934).
|
||||
begins_gift_wrap: bool,
|
||||
/// `ItemInfo::opens_loot` for this template — dispatcher arm #8.
|
||||
opens_loot: bool,
|
||||
/// The template's `PageText` — dispatcher arm #5's book gate (decision 1105); `0` = not a
|
||||
/// book. The reader re-reads the head (and the material) off the template itself as it paints,
|
||||
/// like the reference, so only the fork's predicate is carried here.
|
||||
/// The template's `PageText`, nonzero on a book.
|
||||
page_text: u32,
|
||||
/// The template's `ITEM_FLAG_CHARTER` — a guild petition (decision 1672).
|
||||
is_charter: bool,
|
||||
}
|
||||
|
||||
/// Drain the pick/place/swap/split moves `PickupContainerItem`/`SplitContainerItem` queued and
|
||||
/// send them on the wire (decision 0216 §6, whole-space since slice 2).
|
||||
///
|
||||
/// `count: None` (a whole-stack move/swap): both ends map through [`wire_pos`]. Both landing on
|
||||
/// [`BAG_PLAYER_INVENTORY`] (the player's own grid — equipment, bag buttons, and the backpack) is
|
||||
/// a `CMSG_SWAP_INV_ITEM` on the two player-array slots, unchanged; otherwise (either end an
|
||||
/// equipped bag 1..4) it's the general `CMSG_SWAP_ITEM` — VERIFIED vmangos
|
||||
/// `Server/Packets/Item.cpp:30-36`: body order is **dstbag, dstslot, srcbag, srcslot** (opcode
|
||||
/// `0x10C`; the builder's arg order and the golden in `messages/items.rs::swap_item_body_destination_first`
|
||||
/// already match this). An empty destination is still a swap on either wire (a move).
|
||||
///
|
||||
/// `count: Some(n)` (a split placement): both ends map through [`wire_pos`] (all five bags valid,
|
||||
/// since `SplitContainerItem`'s pickup already resolved a real slot) → `CMSG_SPLIT_ITEM`, `count`
|
||||
/// clamped to the wire's `u8`.
|
||||
///
|
||||
/// Every send locks the Lua-space slots it touches — both ends (decision 0218 §3: "a send locks
|
||||
/// both ends") — recording each slot's CURRENT item guid as the resolving clear's baseline
|
||||
/// ([`PendingItemOps::add`]) and firing `ITEM_LOCK_CHANGED` immediately, so a bag window's own
|
||||
/// synchronous post-click repaint and every later frame agree (only the SOURCE slot's repaint at
|
||||
/// the exact moment of THIS click can still show briefly stale — see the parent module doc —
|
||||
/// corrected by this same-frame event).
|
||||
/// Sends the gift wraps and the moves `PickupContainerItem`/`SplitContainerItem` queued, the moves
|
||||
/// through [`send_container_move`].
|
||||
pub(super) fn drain_container_moves(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
commands: Res<NetCommands>,
|
||||
@@ -676,10 +517,8 @@ pub(super) fn drain_container_moves(
|
||||
return;
|
||||
};
|
||||
let store = self_q.iter().next();
|
||||
// **The completed gift wraps** (decision 1934): a left-click that spent an armed paper. Both
|
||||
// pairs go out in the wire's own order — the paper first — and the whole eligibility question
|
||||
// is the server's: it answers an ineligible target with one of the six `ERR_CANT_WRAP_*`
|
||||
// reasons through the ordinary `SMSG_INVENTORY_CHANGE_FAILURE` line.
|
||||
// A left-click that spent an armed paper: paper first, then the target. Eligibility is the
|
||||
// server's, refused with an `ERR_CANT_WRAP_*` reason.
|
||||
for (gift_bag, gift_slot, item_bag, item_slot) in script.take_container_wraps() {
|
||||
let (Some((gift_bag_index, gift_wire_slot)), Some((item_bag_index, item_wire_slot))) =
|
||||
(wire_pos(gift_bag, gift_slot), wire_pos(item_bag, item_slot))
|
||||
@@ -716,21 +555,16 @@ pub(super) fn drain_container_moves(
|
||||
}
|
||||
}
|
||||
|
||||
/// **The player-direct slot ranges** the equip deferral elects on (`0x5e0c40`, decision 1750):
|
||||
/// `[0, 22]` — the 19 equipment slots plus the four equipped-bag slots — and `[63, 68]`, the bank
|
||||
/// bag slots. A wire position is player-direct when its bag byte is the player's own array AND its
|
||||
/// slot falls in one of these; the backpack (`23..38`), the keyring and a bag's inner slots are all
|
||||
/// on the same bag byte and are deliberately NOT in the set, which is what makes the election
|
||||
/// pick out exactly "one side of this swap is an equip".
|
||||
/// The equip deferral's positions (`0x5e0c40`): slots 0..=22 (equipment and equipped bags) and
|
||||
/// 63..=68 (bank bags) of the player's own array; the backpack and keyring are not.
|
||||
fn is_equip_position(bag_index: u8, slot: u8) -> bool {
|
||||
bag_index == BAG_PLAYER_INVENTORY && (slot <= 22 || (63..=68).contains(&slot))
|
||||
}
|
||||
|
||||
/// One container move — the whole-stack swap, the split, the pending lock, and (decision 1750) the
|
||||
/// equip soulbind deferral. Split out of the drain so `EquipPendingItem`'s re-issue runs the very
|
||||
/// same body with `suppress` set, rather than a second copy of it that has to be kept agreeing.
|
||||
///
|
||||
/// Returns whether the move was sent (`false` = deferred behind `EQUIP_BIND_CONFIRM`).
|
||||
/// One container move: the soulbind deferral, then the send and a lock on both ends. A whole stack
|
||||
/// is `CMSG_SWAP_INV_ITEM` when both ends are in the player's own array, else `CMSG_SWAP_ITEM`
|
||||
/// (destination first, `Server/Packets/Item.cpp:30-36`); a split is `CMSG_SPLIT_ITEM`. The
|
||||
/// accept's re-issue runs it with `suppress`. False when deferred behind `EQUIP_BIND_CONFIRM`.
|
||||
pub(crate) fn send_container_move(
|
||||
script: &mut UiScript,
|
||||
gate: &mut crate::ui_bind_confirm::BindGate,
|
||||
@@ -750,12 +584,8 @@ pub(crate) fn send_container_move(
|
||||
debug!("ui_items: container move {mv:?} out of range — ignored");
|
||||
return false;
|
||||
};
|
||||
// The equip deferral's ELECTION (`0x5e0c40`): exactly one end is a player-direct equip
|
||||
// position, and the item that would bind is then whatever occupies the OTHER end. That
|
||||
// asymmetry is the whole rule and it falls out right in both directions — dragging a BoE
|
||||
// from a bag onto a worn slot asks about the bag's item, and swapping a worn item out onto
|
||||
// a bag slot that already holds a BoE asks about THAT one, because the swap equips it.
|
||||
// Unequipping onto an empty slot asks about nothing, which is why it never prompts.
|
||||
// The deferral (`0x5e0c40`) asks when exactly one end is an equip position, about the item
|
||||
// at the other end, which the swap equips; an unequip to an empty slot asks nothing.
|
||||
if !suppress
|
||||
&& mv.count.is_none()
|
||||
&& is_equip_position(dst.0, dst.1) != is_equip_position(src.0, src.1)
|
||||
@@ -824,17 +654,14 @@ pub(crate) fn send_container_move(
|
||||
});
|
||||
}
|
||||
}
|
||||
// The pending lock: both ends, baselined on their CURRENT (guid, count) — the resolving
|
||||
// clear then watches for either to move (an empty destination baselines (0, 0) and watches
|
||||
// for an item to land there).
|
||||
// Both ends lock on their current (guid, count), which the resolving clear watches.
|
||||
let (src_guid, src_count) = slot_guid_count(store, mv.src_bag, mv.src_slot, objects);
|
||||
let (dst_guid, dst_count) = slot_guid_count(store, mv.dst_bag, mv.dst_slot, objects);
|
||||
pending.add([
|
||||
(mv.src_bag, mv.src_slot, src_guid, src_count),
|
||||
(mv.dst_bag, mv.dst_slot, dst_guid, dst_count),
|
||||
]);
|
||||
// One per locked end, as the reference's own per-slot unlock does — the slot travels in
|
||||
// how many times it fires, never in an argument (see `feed`'s note).
|
||||
// One argless event per locked end, as the reference fires per slot.
|
||||
for _ in 0..2 {
|
||||
script.fire_event("ITEM_LOCK_CHANGED", Vec::new());
|
||||
}
|
||||
@@ -842,13 +669,8 @@ pub(crate) fn send_container_move(
|
||||
true
|
||||
}
|
||||
|
||||
/// Drain the `(bag, slot, count)` destroys `DeleteCursorItem` queued (the delete-confirm popup's
|
||||
/// accept, decision 0216 §3) and send `CMSG_DESTROYITEM`. `count == 0` is the engine's "whole
|
||||
/// stack" convention — it rides straight onto the wire, which shares the same convention.
|
||||
///
|
||||
/// Locks the one slot touched — baselined on its CURRENT `(guid, count)`, same as
|
||||
/// [`drain_container_moves`] — and fires `ITEM_LOCK_CHANGED` immediately. Unlike a move/split
|
||||
/// there is no second "displaced" slot: a destroy only ever removes from where it's aimed.
|
||||
/// Sends the destroys `DeleteCursorItem` queued as `CMSG_DESTROYITEM` (count 0 is the whole stack
|
||||
/// on both sides) and locks the one slot.
|
||||
pub(super) fn drain_container_destroys(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
commands: Res<NetCommands>,
|
||||
@@ -884,16 +706,15 @@ mod tests {
|
||||
use benilla_protocol::messages::{ItemInfo, ObjectFields, ITEM_FLAG_LOOTABLE};
|
||||
use bevy::ecs::system::RunSystemOnce;
|
||||
|
||||
/// "Small Barnacled Clam", entry 7973 — the director's own case, and the `--open-item` probe's.
|
||||
/// Small Barnacled Clam.
|
||||
const CLAM_ENTRY: u32 = 7973;
|
||||
/// The clam's item guid (`HIGHGUID_ITEM` 0x4000…, as the live probe read it back).
|
||||
const CLAM: u64 = 0x4000_0000_0000_1939;
|
||||
/// `PLAYER_FIELD_PACK_SLOT_1` — backpack slot 1's guid pair, so `slot_guid(0, 0)` resolves.
|
||||
/// `PLAYER_FIELD_PACK_SLOT_1`, backpack slot 1's guid pair.
|
||||
const F_PACK_SLOT_1: u16 = 532;
|
||||
/// `OBJECT_FIELD_ENTRY` on the item object — what `Items::object(…).object_entry()` reads.
|
||||
/// `OBJECT_FIELD_ENTRY` on the item object.
|
||||
const F_OBJECT_ENTRY: u16 = 3;
|
||||
|
||||
/// Right-click backpack slot 1 (holding a LOOTABLE template) and run the click dispatcher.
|
||||
/// Right-clicks backpack slot 1, holding a lootable template, and runs the click dispatcher.
|
||||
fn open_the_clam() -> (App, crossbeam_channel::Receiver<ClientCommand>) {
|
||||
let (tx, rx) = crossbeam_channel::unbounded();
|
||||
let mut app = App::new();
|
||||
@@ -924,7 +745,6 @@ mod tests {
|
||||
.init_resource::<crate::net::GuidIndex>()
|
||||
.insert_resource(NetCommands(tx));
|
||||
|
||||
// The player, holding the clam in backpack slot 1.
|
||||
app.world_mut().spawn((
|
||||
SelfPlayer,
|
||||
ObjectStore(ObjectFields::from_pairs(&[
|
||||
@@ -932,8 +752,7 @@ mod tests {
|
||||
(F_PACK_SLOT_1 + 1, (CLAM >> 32) as u32),
|
||||
])),
|
||||
));
|
||||
// The item object (an entity in the one index, 2334) and its landed template —
|
||||
// LOOTABLE, so the dispatcher's open arm claims the click (`ItemInfo::opens_loot`).
|
||||
// The item object and its lootable template, so the open arm claims the click.
|
||||
crate::items::test_spawn_item(
|
||||
app.world_mut(),
|
||||
CLAM,
|
||||
@@ -958,13 +777,8 @@ mod tests {
|
||||
(app, rx)
|
||||
}
|
||||
|
||||
/// **The clam regression (decision 1531).** Arm site four of five: the `CMSG_OPEN_ITEM` send
|
||||
/// latches the ITEM's own guid (`0x5edcc0`). It is not cosmetic
|
||||
/// and it is not about the pose — vmangos answers this opcode with `SendLoot(item guid,
|
||||
/// LOOT_CORPSE)`, i.e. `SMSG_LOOT_RESPONSE` type **1** on that same guid (live-verified by
|
||||
/// `benilla-world --open-item`), and 1477's admission gate *refuses* a type-1 answer against a
|
||||
/// cold latch. Without the arm the clam greys and no window ever opens, which is exactly what
|
||||
/// the director saw.
|
||||
/// vmangos answers with a type-1 `SMSG_LOOT_RESPONSE` on the item's guid, refused against a
|
||||
/// cold latch: without the latch the clam greys and no window opens.
|
||||
#[test]
|
||||
fn the_open_item_send_arms_the_loot_latch_on_the_items_own_guid() {
|
||||
let (app, rx) = open_the_clam();
|
||||
@@ -983,24 +797,20 @@ mod tests {
|
||||
Some(CLAM),
|
||||
"…having first latched the item's own guid, or the type-1 answer is refused"
|
||||
);
|
||||
// The grey lock is the reference's other pre-send write, and still there (decision 0916).
|
||||
// The grey lock, the reference's other pre-send write.
|
||||
assert!(
|
||||
app.world().resource::<PendingItemOps>().contains(0, 1),
|
||||
"the slot greys at the click"
|
||||
);
|
||||
}
|
||||
|
||||
/// **Right-clicking wrapping paper arms the wrap and sends NOTHING** (decision 1934) — the
|
||||
/// same dispatcher arm the wrapped-gift unwrap takes, on its other side. This is the bug:
|
||||
/// benilla fell through to `CMSG_USE_ITEM`, which casts a spell the paper does
|
||||
/// not have.
|
||||
/// The unwrap arm's other side: wrapping paper arms the wrap cursor and sends nothing.
|
||||
#[test]
|
||||
fn a_wrapper_right_click_arms_the_cursor_and_ships_no_packet() {
|
||||
let (mut app, rx) = open_the_clam();
|
||||
while rx.try_recv().is_ok() {} // drain the clam's own send
|
||||
// Re-dress backpack slot 1 as a piece of wrapping paper: WRAPPER on the template, and no
|
||||
// WRAPPED bit on the instance (that combination is the begin-wrap arm; with the bit set it
|
||||
// would be a present, and would send `CMSG_OPEN_ITEM`).
|
||||
// Slot 1 becomes wrapping paper: WRAPPER on the
|
||||
// template, no WRAPPED bit on the instance.
|
||||
let mut items = app.world_mut().resource_mut::<Items>();
|
||||
items.insert_template(
|
||||
CLAM_ENTRY,
|
||||
@@ -1031,23 +841,13 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// **The bind confirmations' answers** (decision 1750) — `EquipPendingItem`/`CancelPendingEquip`
|
||||
/// and `ConfirmBindOnUse`, drained where the senders they re-issue live.
|
||||
/// The answers to `EquipPendingItem`/`CancelPendingEquip`. Accept re-runs the original sender with
|
||||
/// `suppress` set, as the reference's `0x5e1be0` does: 1.12 has no confirm opcode, and a slot that
|
||||
/// changed under the dialog is judged afresh. Cancel sends nothing and frees the record.
|
||||
///
|
||||
/// Accept is a **re-issue with `suppress` set**, not a confirm packet: 1.12 has no such opcode, and
|
||||
/// the reference's two verbs both land on `0x5e1be0(index, accept)`, whose accept arm re-runs the
|
||||
/// original action. Running the very same sender is what makes the re-issue re-read the world — a
|
||||
/// slot that changed under the open dialog is re-judged rather than sent stale — and `suppress` is
|
||||
/// what stops the gate from asking the same question forever.
|
||||
///
|
||||
/// **Cancel sends nothing and only frees the record.** NAMED DIVERGENCE: the reference's cancel
|
||||
/// also unlocks the src/dst occupants (`UnlockItem 0x495420`), because its deferral *took* those
|
||||
/// locks on the way in. benilla's does not take them — [`send_container_move`]'s
|
||||
/// [`PendingItemOps`] lock is a *pending wire op* lock, and a deferred action has no wire op for it
|
||||
/// to resolve against, so filing one would leave a lock nothing could clear. The visible
|
||||
/// consequence is that an item is not dimmed while its bind question is up; the auto-equip path
|
||||
/// never dimmed at all (a gap this codebase already named), so this keeps the three arms consistent
|
||||
/// instead of half-fixing one of them.
|
||||
/// Deviation: the reference's cancel also unlocks both occupants (`UnlockItem`, `0x495420`), which
|
||||
/// its deferral locked; ours locks nothing while the question is up, because a [`PendingItemOps`]
|
||||
/// lock clears only against a wire op and a deferred action has none.
|
||||
pub(super) fn drain_bind_confirm_answers(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
self_q: Query<&ObjectStore, With<SelfPlayer>>,
|
||||
@@ -1062,8 +862,7 @@ pub(super) fn drain_bind_confirm_answers(
|
||||
};
|
||||
let store = self_q.iter().next();
|
||||
for answer in script.take_pending_equip_answers() {
|
||||
// An index nobody filed is dropped in silence — `0x5e1be0` bounds-checks against the live
|
||||
// element count and returns, so a stray `EquipPendingItem(99)` from an addon does nothing.
|
||||
// An unfiled index does nothing: `0x5e1be0` bounds-checks it and returns.
|
||||
let Some(rec) = gate.equips.take(answer.index) else {
|
||||
debug!(
|
||||
"ui_items: bind answer for index {} — no such pending equip",
|
||||
@@ -1121,11 +920,8 @@ pub(super) fn drain_bind_confirm_answers(
|
||||
}
|
||||
}
|
||||
|
||||
/// `ConfirmBindOnUse()` — arm 290's accept, its own system because it is the only one of the three
|
||||
/// that re-issues through the **cast ladder** (an item use IS a cast, decisions 0908/0914). No
|
||||
/// index and no argument: that arm's pending state is one cell, not an array element. A count
|
||||
/// rather than a bool for the same reason [`UiScript::take_binder_confirms`] is one; the record is
|
||||
/// taken on the first, so a doubled accept re-uses nothing.
|
||||
/// `ConfirmBindOnUse()`: re-issues the pending use through the cast ladder with `suppress` set.
|
||||
/// The pending state is one cell, taken on the first accept, so a doubled accept does nothing.
|
||||
pub(super) fn drain_bind_on_use_confirms(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
targeting: crate::spell::cast_target::CastTargeting,
|
||||
@@ -1164,19 +960,17 @@ mod bind_confirm_tests {
|
||||
use benilla_ui::script::{ContainerSlot, ContainerState};
|
||||
use bevy::ecs::system::RunSystemOnce;
|
||||
|
||||
/// 871 Flurry Axe — a real `item_template` row, quality 4 **bonding 2** (bind on EQUIP): the
|
||||
/// equip arms' whole predicate is `bonding == 2`, with no quality leg at all.
|
||||
/// Flurry Axe, a real `item_template` row: quality 4, bonding 2 (bind on equip).
|
||||
const FLURRY_AXE: u32 = 871;
|
||||
const AXE_GUID: u64 = 0x4000_0000_0000_0871;
|
||||
/// `ITEM_FIELD_FLAGS` (wire field 21) — bit 0 is soulbound, the first half of `0x5da2c0`.
|
||||
/// `ITEM_FIELD_FLAGS` (field 21); bit 0 is soulbound (`0x5da2c0`).
|
||||
const F_ITEM_FLAGS: u16 = 21;
|
||||
/// `PLAYER_FIELD_PACK_SLOT_1` — backpack slot 1's guid pair.
|
||||
/// `PLAYER_FIELD_PACK_SLOT_1`, backpack slot 1's guid pair.
|
||||
const F_PACK_SLOT_1: u16 = 532;
|
||||
const F_OBJECT_ENTRY: u16 = 3;
|
||||
|
||||
fn load_ui(s: &UiScript) {
|
||||
// Through the chain-aware reader: this list names chain files now, and a
|
||||
// reader that joins `assets/ui` cannot resolve one (1838, 1887, 1888).
|
||||
// Chain files, so through the chain-aware reader.
|
||||
for file in [
|
||||
"Interface\\FrameXML\\Fonts.xml",
|
||||
r"Interface\FrameXML\MoneyFrame.lua",
|
||||
@@ -1222,22 +1016,18 @@ mod bind_confirm_tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Place the axe from backpack slot 1 onto the empty MainHand doll slot, then run the move
|
||||
/// drain. Returns the app, the wire receiver, and the script back.
|
||||
/// Places the axe from backpack slot 1 on the empty main hand and runs the move drain.
|
||||
fn place_the_axe_on_the_doll() -> (App, crossbeam_channel::Receiver<ClientCommand>) {
|
||||
place_the_axe_with(2, 4, false)
|
||||
}
|
||||
|
||||
/// The same place with a chosen `bonding` — the one field the equip arm's predicate reads.
|
||||
fn place_the_axe_with_bonding(
|
||||
bonding: u32,
|
||||
) -> (App, crossbeam_channel::Receiver<ClientCommand>) {
|
||||
place_the_axe_with(bonding, 4, false)
|
||||
}
|
||||
|
||||
/// The place, parameterised over everything the gate looks at: the template's `bonding`, its
|
||||
/// `quality` (which this arm must ignore), and whether the instance is already soulbound
|
||||
/// (`ITEM_FIELD_FLAGS & 1`, the `0x5da2c0` bit).
|
||||
/// The place, over everything the gate reads; `quality` must not matter.
|
||||
fn place_the_axe_with(
|
||||
bonding: u32,
|
||||
quality: u32,
|
||||
@@ -1284,7 +1074,7 @@ mod bind_confirm_tests {
|
||||
script.set_container(0, Some(bag_with_the_axe(quality)));
|
||||
let doll: benilla_ui::script::InventorySlots = Default::default();
|
||||
script.set_inventory_slots(doll);
|
||||
// Pick it up, drop it on MainHand — the queued move plus a pending CURSOR_UPDATE.
|
||||
// Pick up and drop on the main hand: a queued move and a pending CURSOR_UPDATE.
|
||||
script.run("PickupContainerItem(0, 1)").unwrap();
|
||||
script.run("PickupInventoryItem(16)").unwrap();
|
||||
app.insert_non_send_resource(script);
|
||||
@@ -1303,15 +1093,9 @@ mod bind_confirm_tests {
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// The equip arm end to end, and **the ordering law that makes it work at all** (decision
|
||||
/// 1750). Placing a bind-on-equip item into a worn slot sends nothing and asks; the question
|
||||
/// is QUEUED, so it lands in the same flush as — and after — the `CURSOR_UPDATE` that same
|
||||
/// place queued, whose `StaticPopup_Hide` would otherwise cancel it before the player saw it.
|
||||
/// A *later* cursor change still retires it, which is what `UIParent.lua:356-360` is for.
|
||||
///
|
||||
/// The first version of this test failed on exactly that cancellation, which is how the
|
||||
/// ordering hazard was found rather than assumed: benilla drains a step behind the input pass
|
||||
/// that fed it, where the reference defers inside the call that consumed the cursor.
|
||||
/// The question is queued behind the place's own `CURSOR_UPDATE`, whose `StaticPopup_Hide`
|
||||
/// (`UIParent.lua:357-359`) would otherwise cancel it unseen: benilla drains a step after the
|
||||
/// input pass, where the reference defers inside the call. A later cursor change retires it.
|
||||
#[test]
|
||||
fn placing_a_boe_on_the_doll_asks_and_survives_its_own_cursor_update() {
|
||||
benilla_formats::wow_data_or_skip!();
|
||||
@@ -1325,8 +1109,7 @@ mod bind_confirm_tests {
|
||||
"the question is queued, so it is not up in the drain's own frame"
|
||||
);
|
||||
|
||||
// The next tick flushes both, in queue order: the place's CURSOR_UPDATE first, then the
|
||||
// question. This is the whole point of queueing it.
|
||||
// The next tick flushes the place's CURSOR_UPDATE, then the question.
|
||||
app.world_mut()
|
||||
.non_send_resource_mut::<UiScript>()
|
||||
.tick(0.01);
|
||||
@@ -1349,8 +1132,7 @@ mod bind_confirm_tests {
|
||||
.tick(0.01);
|
||||
assert!(shown(&mut app, "EQUIP_BIND"), "and stays up");
|
||||
|
||||
// A LATER cursor change — the player picked something else up — retires it, exactly as the
|
||||
// reference's arm intends, and the OnHide cancels the record.
|
||||
// A later cursor change retires it, and its OnHide cancels the record.
|
||||
app.world_mut()
|
||||
.non_send_resource_mut::<UiScript>()
|
||||
.fire_event("CURSOR_UPDATE", vec![]);
|
||||
@@ -1374,10 +1156,7 @@ mod bind_confirm_tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Accept: `EquipPendingItem(index)` **re-issues the original action** — there is no confirm
|
||||
/// opcode in 1.12 — and the re-issue does NOT ask again, because it carries the reference's own
|
||||
/// `suppress` flag. Without that flag the accept would re-enter the same gate and the question
|
||||
/// would be unanswerable.
|
||||
/// The accept re-issues the place (1.12 has no confirm opcode); `suppress` stops a second ask.
|
||||
#[test]
|
||||
fn accepting_re_issues_the_place_and_does_not_ask_again() {
|
||||
benilla_formats::wow_data_or_skip!();
|
||||
@@ -1395,8 +1174,7 @@ mod bind_confirm_tests {
|
||||
.run_system_once(drain_bind_confirm_answers)
|
||||
.unwrap();
|
||||
|
||||
// Backpack slot 1 is wire 255/23, MainHand is wire 255/15 — both player-direct, so the
|
||||
// re-issue is CMSG_SWAP_INV_ITEM on the two player-array slots.
|
||||
// Backpack slot 1 is 255/23 and the main hand 255/15, both in the player's own array.
|
||||
let sent: Vec<_> = rx.try_iter().collect();
|
||||
assert!(
|
||||
matches!(
|
||||
@@ -1424,9 +1202,8 @@ mod bind_confirm_tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Cancel sends nothing and frees the record. The doubled `CancelPendingEquip` the reference's
|
||||
/// own entry causes — `OnCancel` then `OnHide`, both naming it — is harmless by construction:
|
||||
/// the second call finds the element already free.
|
||||
/// The stock popup cancels twice, from `OnCancel` and `OnHide`; the second finds the record
|
||||
/// already free.
|
||||
#[test]
|
||||
fn cancelling_sends_nothing_and_the_doubled_cancel_is_harmless() {
|
||||
benilla_formats::wow_data_or_skip!();
|
||||
@@ -1438,7 +1215,6 @@ mod bind_confirm_tests {
|
||||
.non_send_resource_mut::<UiScript>()
|
||||
.run("StaticPopup_OnClick(StaticPopup_FindVisible(\"EQUIP_BIND\"), 2)")
|
||||
.unwrap();
|
||||
// Two answers for one index — the entry names CancelPendingEquip twice on this path.
|
||||
assert_eq!(
|
||||
app.world_mut()
|
||||
.non_send_resource_mut::<UiScript>()
|
||||
@@ -1453,9 +1229,7 @@ mod bind_confirm_tests {
|
||||
assert!(rx.try_iter().next().is_none(), "a cancel never sends");
|
||||
}
|
||||
|
||||
/// The equip predicate is `bonding == 2` and **nothing else about the item** — no quality leg
|
||||
/// at all (`0x5e0e54`; benilla was about to carry the loot arm's
|
||||
/// `quality >= 2` across). Every other bonding value places straight through.
|
||||
/// The equip predicate is `bonding == 2` alone, with no quality leg (`0x5e0e54`).
|
||||
#[test]
|
||||
fn only_bind_on_equip_defers_the_place() {
|
||||
benilla_formats::wow_data_or_skip!();
|
||||
@@ -1480,8 +1254,6 @@ mod bind_confirm_tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A WHITE bind-on-equip item still asks — the proof that no quality leg exists on this arm.
|
||||
/// (The loot arm's `quality >= 2` would have silenced exactly this case.)
|
||||
#[test]
|
||||
fn a_white_bind_on_equip_item_still_asks() {
|
||||
benilla_formats::wow_data_or_skip!();
|
||||
@@ -1496,8 +1268,7 @@ mod bind_confirm_tests {
|
||||
assert!(rx.try_iter().next().is_none());
|
||||
}
|
||||
|
||||
/// An item that is ALREADY soulbound never asks — `0x5da2c0`, the same predicate the enchant
|
||||
/// cursor and the tooltip's Soulbound override use (decisions 0928, 1562).
|
||||
/// `0x5da2c0`, the predicate the tooltip's Soulbound override also uses.
|
||||
#[test]
|
||||
fn an_already_bound_item_places_without_asking() {
|
||||
benilla_formats::wow_data_or_skip!();
|
||||
@@ -1512,10 +1283,7 @@ mod bind_confirm_tests {
|
||||
assert!(rx.try_iter().next().is_some(), "and it places");
|
||||
}
|
||||
|
||||
/// The AUTO-EQUIP arm (`0x5e1480`, event 289): the same predicate, a different event, and the
|
||||
/// same accept-is-a-re-issue. Driven through `AutoEquipCursorItem`, one of the three verbs
|
||||
/// benilla used to have three separate senders for — decision 1750 funnelled them into one so
|
||||
/// this gate could exist in a single place.
|
||||
/// The auto-equip arm (`0x5e1480`, event 289): the same predicate and re-issue, its own dialog.
|
||||
#[test]
|
||||
fn auto_equipping_a_boe_asks_and_the_accept_re_issues() {
|
||||
benilla_formats::wow_data_or_skip!();
|
||||
@@ -1602,16 +1370,12 @@ mod bind_confirm_tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The `0x5ea930` conjunct: an item the player **cannot** equip is never asked about — the
|
||||
/// question would be confirming an action the server can only refuse. Driven by a required
|
||||
/// level above the player's, the gate's first leg.
|
||||
/// The `0x5ea930` conjunct, driven by its level leg.
|
||||
#[test]
|
||||
fn an_unusable_item_is_never_asked_about() {
|
||||
benilla_formats::wow_data_or_skip!();
|
||||
let (mut app, rx) = place_the_axe_with(2, 4, false);
|
||||
// Re-push the player's requirement state and a template the level leg refuses. (The
|
||||
// fixture's own push leaves `level == 0`, the "decline to judge" state, so this test has
|
||||
// to establish a real player first for the leg to be reachable at all.)
|
||||
// A real player level (the fixture's 0 declines to judge) and a template it refuses.
|
||||
{
|
||||
let mut s = app.world_mut().non_send_resource_mut::<UiScript>();
|
||||
s.set_player_req_state(benilla_ui::script::PlayerReqState {
|
||||
@@ -1653,14 +1417,9 @@ mod bind_confirm_tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// **The USE arm** (`0x5d8d00`, event 290) end to end, and the correction the reference
|
||||
/// forced. Right-clicking a bind-on-**use** item in a bag raises `USE_BIND` and sends nothing;
|
||||
/// `ConfirmBindOnUse()` re-issues the use with `suppress` set.
|
||||
///
|
||||
/// `no_use_spell` drives the case that caught the first version of this code out: `0x5d91d3`
|
||||
/// has five predecessors and four are on-use-spell lookup FAILURES, so the reference asks the
|
||||
/// bind question even for an item with no usable on-use spell. Gating on the plain-cast route
|
||||
/// alone was narrower than the reference; both routes now carry it.
|
||||
/// Right-clicks a bind-on-use item in a bag (the use arm, `0x5d8d00`, event 290);
|
||||
/// `no_use_spell` drops its on-use spell, since four of `0x5d91d3`'s five predecessors are
|
||||
/// failed spell lookups and the reference still asks.
|
||||
fn right_click_a_bind_on_use_item(
|
||||
no_use_spell: bool,
|
||||
) -> (App, crossbeam_channel::Receiver<ClientCommand>) {
|
||||
@@ -1778,9 +1537,6 @@ mod bind_confirm_tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The correction, pinned (`0x5d91d3`): an item with **no usable on-use spell** still raises
|
||||
/// the bind question. Under the first placement (inside the plain-cast route only) this asked
|
||||
/// nothing.
|
||||
#[test]
|
||||
fn a_bind_on_use_item_with_no_on_use_spell_still_asks() {
|
||||
benilla_formats::wow_data_or_skip!();
|
||||
|
||||
@@ -1,44 +1,21 @@
|
||||
//! The wire `InventoryResult` → GlobalStrings-key vocabulary for the red error line — the
|
||||
//! inventory half of the refusal-message law (`ui_action::errors`: every string comes from the
|
||||
//! VM's own loaded `GlobalStrings.lua`, never hardcoded; `mount_result_key` is the twin table).
|
||||
//! The drain lives in [`super::feed::feed_containers`].
|
||||
//! The wire `InventoryResult` to the `GlobalStrings` key of its red error line.
|
||||
//!
|
||||
//! **Not every `SMSG_INVENTORY_CHANGE_FAILURE` is an error to show, and the client has no
|
||||
//! special case that says so.** The packet carries two jobs — clear the item's client-side
|
||||
//! pending ("gray") lock, and *maybe* print a red line. Reason 59 (`EQUIP_ERR_NONE`) is the
|
||||
//! server's pure sentinel for the first: vmangos sends it verbatim as *"free gray item after use
|
||||
//! fail"* (`SpellHandler.cpp:130`, the shapeshifted item-use refusal) and *"just remove gray
|
||||
//! item state"* (`ItemHandler.cpp:838`/`:936`). It rides *alongside* the real message — a
|
||||
//! shapeshifted mount press answers 59 **and** `SPELL_FAILED_NO_ITEMS_WHILE_SHAPESHIFTED` — so
|
||||
//! anything printed for it doubles the refusal (director's report B198).
|
||||
//!
|
||||
//! The reference silences it **through the data, not through control flow**. Handler
|
||||
//! `0x5e3991` maps the reason through the 67-wide errorId jump table `0x622794` into the error
|
||||
//! registry `0xb4b498` and calls `CGGameUI::DisplayError 0x496720` unconditionally. 59's slot is
|
||||
//! populated and non-null — errorId 362, key `ERR_CANT_BE_DISENCHANTED` — but that key is simply
|
||||
//! **absent from the shipped 1.12 `GlobalStrings.lua`**, so `GetText` hands back the pre-seeded
|
||||
//! empty string and the sink's `cmp byte [ecx],0` guard at `0x4945b4` returns before anything
|
||||
//! renders or sounds. So the table below is total, and *silence is what an unresolvable key
|
||||
//! does* — the same law the cast path already runs (`ui_action::cast_fail`). Reason **0** is the
|
||||
//! only coded suppression (`0x5e39a9`), and it is already gated a layer up, at
|
||||
//! `benilla_protocol::events::decode` (`if reason != 0`), so it never reaches this table.
|
||||
//! Reason 59 (`EQUIP_ERR_NONE`) only clears a pending lock (`ItemHandler.cpp:865`, `:963`), and
|
||||
//! after a failed item use rides beside the real refusal (`SpellHandler.cpp:130`). The reference
|
||||
//! maps it like any reason (handler `0x5e3991`, table `0x622794`, registry `0xb4b498`,
|
||||
//! `DisplayError` `0x496720`) to a key with no 1.12 string, which the sink's empty-string guard
|
||||
//! (`0x4945b4`) drops. Reason 0, the one coded suppression (`0x5e39a9`), is dropped earlier, by
|
||||
//! `benilla_protocol::events`.
|
||||
|
||||
/// A wire `InventoryResult` refusal → its GlobalStrings key, resolved to text through the VM's
|
||||
/// own loaded `GlobalStrings.lua` at the drain. **Total** — every reason has a key, because the
|
||||
/// reference's own lookup is (see the module doc); a key with no string displays nothing.
|
||||
///
|
||||
/// The FULL build-5875 set, read key by key off the binary's errorId table `0x622794` — which
|
||||
/// agreed with the vmangos `ItemDefines.h` tag comments on 66 of 67
|
||||
/// entries. Positions come from that enum (sequential with every `#if` band ≤ 5875 included:
|
||||
/// stunned=37, dead=38, INVENTORY_FULL=50; an earlier table had the TBC-era 39/40 for
|
||||
/// stunned/dead — on this wire those are CANT_DO_RIGHT_NOW / INT_BAG_ERROR). Reason 1's string
|
||||
/// carries a `%d` the drain fills with the packet's required level.
|
||||
/// A refusal reason's key, total like the reference's errorId table (`0x622794`). Positions are
|
||||
/// vmangos `ItemDefines.h` with every band up to 5875 (stunned 37, dead 38, inventory full 50).
|
||||
/// Reason 1's string carries a `%d` for the required level.
|
||||
pub(super) fn equip_error_key(reason: u8) -> &'static str {
|
||||
match reason {
|
||||
1 => "ERR_CANT_EQUIP_LEVEL_I",
|
||||
2 => "ERR_CANT_EQUIP_SKILL",
|
||||
3 => "ERR_WRONG_SLOT",
|
||||
// 4 BAG_FULL and the BAG_FULL3/4/6 aliases. **51 is NOT one of them** — see below.
|
||||
// BAG_FULL and its BAG_FULL3/4/6 aliases; 51 is not one.
|
||||
4 | 53 | 56 | 62 => "ERR_BAG_FULL",
|
||||
5 => "ERR_BAG_IN_BAG",
|
||||
6 => "ERR_TRADE_EQUIPPED_BAG",
|
||||
@@ -72,7 +49,7 @@ pub(super) fn equip_error_key(reason: u8) -> &'static str {
|
||||
38 => "ERR_PLAYER_DEAD",
|
||||
39 => "ERR_CLIENT_LOCKED_OUT",
|
||||
40 => "ERR_INTERNAL_BAG_ERROR",
|
||||
// ERR_ONLY_ONE_BOLT's 1.12 string genuinely reads "quiver", same as 33's.
|
||||
// ERR_ONLY_ONE_BOLT's 1.12 string also says "quiver".
|
||||
41 => "ERR_ONLY_ONE_BOLT",
|
||||
42 => "ERR_ONLY_ONE_AMMO",
|
||||
43 => "ERR_CANT_WRAP_STACKABLE",
|
||||
@@ -83,17 +60,12 @@ pub(super) fn equip_error_key(reason: u8) -> &'static str {
|
||||
48 => "ERR_CANT_WRAP_BAGS",
|
||||
49 => "ERR_LOOT_GONE",
|
||||
50 => "ERR_INV_FULL",
|
||||
// 51 `EQUIP_ERR_BANK_FULL` — "Your bank is full", NOT the bag line. vmangos's own enum
|
||||
// annotates this entry `// ERR_BAG_FULL`, and that comment is simply **wrong**: the
|
||||
// binary's pad sets errorId 1 (`0x622661 mov eax,1`), whose registry slot is
|
||||
// `ERR_BANK_FULL` (init `0x484cda`, key ptr `0x842180`). We inherited the bad comment
|
||||
// when this table was built from the tag list; the binary is the authority.
|
||||
// EQUIP_ERR_BANK_FULL: the reference sets errorId 1 (`0x622661`), `ERR_BANK_FULL` (init
|
||||
// `0x484cda`, key `0x842180`), though vmangos tags it `ERR_BAG_FULL`.
|
||||
51 => "ERR_BANK_FULL",
|
||||
52 | 57 => "ERR_VENDOR_SOLD_OUT",
|
||||
58 => "ERR_OBJECT_IS_BUSY",
|
||||
// 59 `EQUIP_ERR_NONE` — the lock-clear sentinel. Its key is real and its registry slot
|
||||
// is populated; the key just has no 1.12 string, so it renders as nothing (module doc,
|
||||
// B198). Mapped rather than special-cased, because that is exactly what the client does.
|
||||
// EQUIP_ERR_NONE, the lock-clear sentinel: a real key with no 1.12 string.
|
||||
59 => "ERR_CANT_BE_DISENCHANTED",
|
||||
60 => "ERR_NOT_IN_COMBAT",
|
||||
61 => "ERR_NOT_WHILE_DISARMED",
|
||||
@@ -101,10 +73,7 @@ pub(super) fn equip_error_key(reason: u8) -> &'static str {
|
||||
64 => "ERR_CANT_EQUIP_REPUTATION",
|
||||
65 => "ERR_TOO_MANY_SPECIAL_BAGS",
|
||||
66 => "ERR_LOOT_CANT_LOOT_THAT_NOW",
|
||||
// Past the enum. The binary's jump table is bounded `cmp ecx,0x42; ja 0x62278d` — 0..=66
|
||||
// indexed, everything above falling to the pad's default **errorId 9 = ERR_BAG_FULL**
|
||||
// ("That bag is full."). The enum's own trailing comment says the same: any greater
|
||||
// value shows as bag full. Not a hole, and not our old hex debug line.
|
||||
// The reference's default past 66 (`0x62278d`): errorId 9, `ERR_BAG_FULL`.
|
||||
_ => "ERR_BAG_FULL",
|
||||
}
|
||||
}
|
||||
@@ -113,10 +82,7 @@ pub(super) fn equip_error_key(reason: u8) -> &'static str {
|
||||
mod tests {
|
||||
use super::equip_error_key;
|
||||
|
||||
/// Pins the build-5875 `InventoryResult` positions against the sender's enum (vmangos
|
||||
/// `ItemDefines.h`, every band ≤ 5875 included). The director's live repro: a full-inventory
|
||||
/// vendor buy arrives as 0x32 = 50 = INVENTORY_FULL. Stunned/dead sit at 37/38 on this wire —
|
||||
/// an earlier table had the TBC-era 39/40, which here mean "right now"/"Internal Bag Error".
|
||||
/// Stunned and dead are 37 and 38 at 5875; 39 and 40 are "right now" and the bag error.
|
||||
#[test]
|
||||
fn equip_error_table_matches_the_5875_enum() {
|
||||
assert_eq!(equip_error_key(50), "ERR_INV_FULL");
|
||||
@@ -127,56 +93,37 @@ mod tests {
|
||||
assert_eq!(equip_error_key(1), "ERR_CANT_EQUIP_LEVEL_I");
|
||||
}
|
||||
|
||||
/// **51 is the bank, not a bag.** The one place the binary's errorId table (`0x622794` →
|
||||
/// errorId 1 → `ERR_BANK_FULL`) disagreed with the vmangos tag comments this table was first
|
||||
/// built from: `ItemDefines.h` annotates `EQUIP_ERR_BANK_FULL` `// ERR_BAG_FULL`, and that
|
||||
/// comment is wrong. A full bank used to say "That bag is full."
|
||||
/// The reference's table maps 51 to `ERR_BANK_FULL`, against vmangos's `ItemDefines.h` tag.
|
||||
#[test]
|
||||
fn a_full_bank_says_bank_not_bag() {
|
||||
assert_eq!(equip_error_key(51), "ERR_BANK_FULL");
|
||||
// Its neighbours really are the bag aliases — the correction is surgical.
|
||||
for alias in [4u8, 53, 56, 62] {
|
||||
assert_eq!(equip_error_key(alias), "ERR_BAG_FULL", "reason {alias}");
|
||||
}
|
||||
}
|
||||
|
||||
/// **B198's regression pin, and the shape of the silence.** Mounting in cat form answers
|
||||
/// `SPELL_FAILED_NO_ITEMS_WHILE_SHAPESHIFTED` *and* an inventory failure 59 —
|
||||
/// `HandleUseItemOpcode`'s "free gray item after use fail" (vmangos `SpellHandler.cpp:130`).
|
||||
/// 59 must print NOTHING or the player gets the refusal twice. The client has no special
|
||||
/// case for it: the key is real and mapped, and it is the *absent GlobalStrings entry* that
|
||||
/// silences it — pinned end-to-end in the resolution test below.
|
||||
/// A mount used in cat form answers `SPELL_FAILED_NO_ITEMS_WHILE_SHAPESHIFTED` and reason 59
|
||||
/// (`SpellHandler.cpp:130`); 59 must print nothing, or the refusal shows twice.
|
||||
#[test]
|
||||
fn the_lock_clear_sentinel_maps_to_the_stringless_key() {
|
||||
assert_eq!(equip_error_key(59), "ERR_CANT_BE_DISENCHANTED");
|
||||
}
|
||||
|
||||
/// Reason 16 keeps the GENERIC key in this table — the substitution is the drain's fork, not
|
||||
/// a second table entry, because choosing it needs the named bag (`feed::bag_family_name`).
|
||||
/// Both 15 and 16 map here; only 16 can be overridden, and only when its bag resolves.
|
||||
/// The drain swaps 16's line for the bag-family one when the bag resolves.
|
||||
#[test]
|
||||
fn the_wrong_bag_reasons_share_the_generic_key() {
|
||||
assert_eq!(equip_error_key(15), "ERR_WRONG_BAG_TYPE");
|
||||
assert_eq!(equip_error_key(16), "ERR_WRONG_BAG_TYPE");
|
||||
}
|
||||
|
||||
/// Past the enum the binary clamps rather than falling through: its jump table is bounded
|
||||
/// `cmp ecx,0x42; ja`, and the default pad is errorId 9 = `ERR_BAG_FULL`. Ours used to print
|
||||
/// a hex debug line here.
|
||||
#[test]
|
||||
fn codes_past_the_enum_clamp_to_the_bag_full_default() {
|
||||
assert_eq!(equip_error_key(67), "ERR_BAG_FULL");
|
||||
assert_eq!(equip_error_key(0xFF), "ERR_BAG_FULL");
|
||||
}
|
||||
|
||||
/// The RUNTIME leg on the real data (the `mount_result_key` test's pattern), and the load-
|
||||
/// bearing one now that the drain has no runtime fallback: every key the table can emit
|
||||
/// resolves to a non-empty string in the shipped 1.12 `GlobalStrings.lua` — **except reason
|
||||
/// 59's**, which must resolve to nothing, because that absence is the entire mechanism by
|
||||
/// which B198's duplicate line stays off the screen. A typo'd key would now silently swallow
|
||||
/// a real refusal; this test is what catches it. Also pins the director's earlier repro
|
||||
/// end-to-end (0x32 → "Inventory is full."), reason 1's `%d` fill, and 51's bank line.
|
||||
/// Skips without client data.
|
||||
/// Every key resolves in the shipped `GlobalStrings.lua` except reason 59's, which must not: a
|
||||
/// typo'd key would swallow a refusal in silence. Skips without client data.
|
||||
#[test]
|
||||
fn every_equip_error_key_resolves_in_the_real_global_strings() {
|
||||
let data = benilla_formats::wow_data_or_skip!();
|
||||
@@ -188,14 +135,12 @@ mod tests {
|
||||
s.run(&String::from_utf8_lossy(&src)).expect("runs clean");
|
||||
let g = |key: &str| s.lua().globals().get::<String>(key).ok();
|
||||
|
||||
// Reason 0 never reaches this table (gated at `events::decode`), so the sweep starts at 1.
|
||||
// Reason 0 never reaches this table (gated at `events::decode`).
|
||||
for reason in 1..=66u8 {
|
||||
let key = equip_error_key(reason);
|
||||
let text = g(key).unwrap_or_default();
|
||||
if reason == 59 {
|
||||
// THE mechanism, asserted on the real shipped data rather than assumed: the key
|
||||
// is mapped and the lookup happens, and it comes back empty. `0x4945b4`'s guard
|
||||
// is what the drain's `is_empty()` continue mirrors.
|
||||
// Empty, which the drain's `is_empty()` skip drops as `0x4945b4`'s guard does.
|
||||
assert!(
|
||||
text.is_empty(),
|
||||
"reason 59's {key} resolved to {text:?} — B198's duplicate line is back"
|
||||
@@ -209,13 +154,9 @@ mod tests {
|
||||
g(equip_error_key(1)).unwrap().replace("%d", "30"),
|
||||
"You must reach level 30 to use that item."
|
||||
);
|
||||
// The 51 correction, in the words the player actually sees.
|
||||
assert_eq!(g(equip_error_key(51)).unwrap(), "Your bank is full");
|
||||
// The past-the-enum clamp resolves too — it is a real line, not a placeholder.
|
||||
assert_eq!(g(equip_error_key(67)).unwrap(), "That bag is full.");
|
||||
// Reason 16's two outcomes, both on real data: the generic line the table gives, and the
|
||||
// substituted one the drain swaps in once `feed::bag_family_name` resolves a bag. The
|
||||
// `%s` fill is a BagFamily name ("Arrows"), pinned in `benilla_formats::itembagfamily`.
|
||||
// Reason 16's generic line, and the bag-family one the drain substitutes.
|
||||
assert_eq!(
|
||||
g(equip_error_key(16)).unwrap(),
|
||||
"That item doesn't go in that container."
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,5 @@
|
||||
//! The item feed's packet handlers (in the net handler table since 2319; the refusal moved out of
|
||||
//! the drain's loot arm file) — `SMSG_INVENTORY_CHANGE_FAILURE` onto the equip-error line, the
|
||||
//! pending item locks, and the loot latch's sixth clear; and `SMSG_OPEN_CONTAINER` onto the
|
||||
//! `BAG_OPEN` queue (decision 2339).
|
||||
//! The item packet handlers: `SMSG_INVENTORY_CHANGE_FAILURE` onto the error line, the pending
|
||||
//! item locks and the loot latch, and `SMSG_OPEN_CONTAINER` onto the `BAG_OPEN` queue.
|
||||
|
||||
use benilla_protocol::{ObjectFields, SessionEvent, SessionEventKind};
|
||||
use bevy::prelude::*;
|
||||
@@ -11,16 +9,14 @@ use crate::net::{NetHandlerApp, ObjectStore, SelfGuid, SelfPlayer};
|
||||
use crate::pending_item_ops::{LockTransitions, PendingItemOps};
|
||||
use crate::ui_loot::LootLatch;
|
||||
|
||||
/// Register the handlers — called from [`super::UiItemsPlugin`].
|
||||
pub(super) fn register(app: &mut App) {
|
||||
app.net_handler(SessionEventKind::InventoryFailure, on_inventory_failure)
|
||||
.net_handler(SessionEventKind::OpenContainer, on_open_container)
|
||||
.net_handler(SessionEventKind::Disconnected, on_session_end);
|
||||
}
|
||||
|
||||
/// The pending item locks die with the socket — a listener on the session end (a second handler on
|
||||
/// the kind, after the bridge's own teardown). The unlock transitions still queued for the feed go
|
||||
/// too: they name the old session's slots, and the next session's VM starts with nothing locked.
|
||||
/// The item locks and the queued unlocks die with the session: the reference's lock is item-object
|
||||
/// state (`item+0x314`), and the objects are rebuilt at every world entry.
|
||||
fn on_session_end(
|
||||
In(_): In<SessionEvent>,
|
||||
mut pending: ResMut<PendingItemOps>,
|
||||
@@ -30,9 +26,8 @@ fn on_session_end(
|
||||
lock_cleared.0.clear();
|
||||
}
|
||||
|
||||
/// The containers the server told us to open (`SMSG_OPEN_CONTAINER`), by container id, waiting
|
||||
/// for the feed ([`super::feed::feed_containers`]) to fire `BAG_OPEN(id)` through the VM — this
|
||||
/// handler has no `UiScript`, the [`LockTransitions`] shape (decision 2339).
|
||||
/// Container ids from `SMSG_OPEN_CONTAINER`, for [`super::feed::feed_containers`] to fire as
|
||||
/// `BAG_OPEN(id)`: this handler has no `UiScript`.
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct BagOpens(pub(crate) Vec<i64>);
|
||||
|
||||
@@ -51,13 +46,9 @@ fn on_open_container(
|
||||
}
|
||||
}
|
||||
|
||||
/// The reference's `BAG_OPEN` raiser `0x4f9410`, transcribed: our own guid names the
|
||||
/// backpack, container **0**; otherwise the guid is looked up in the ten bag slots — the four
|
||||
/// equipped bags (`PLAYER_FIELD_INV_SLOT_HEAD` 19..22 → containers **1..4**) and the six bank
|
||||
/// bags (→ **5..10**), the same numbering `BAG_CLOSED` uses — and a guid in none of them fires
|
||||
/// nothing. vmangos sends the packet from `HandleAutoEquipItemOpcode` when the destination is a
|
||||
/// bag slot, i.e. when a bag gets equipped, which is why the stock `ContainerFrame_OnEvent`
|
||||
/// answers `BAG_OPEN` with `Show()` on the frame carrying that id.
|
||||
/// The reference's `BAG_OPEN` raiser (`0x4f9410`): our own guid is the backpack (0), an equipped
|
||||
/// bag (slots 19..22) is 1..4, a bank bag 5..10, and any other guid fires nothing. vmangos sends
|
||||
/// the packet when an auto-equip puts a bag in a bag slot.
|
||||
pub(super) fn container_of(
|
||||
self_guid: Option<u64>,
|
||||
store: Option<&ObjectFields>,
|
||||
@@ -104,14 +95,9 @@ fn on_inventory_failure(
|
||||
}
|
||||
}
|
||||
|
||||
/// The server refused an inventory operation (`SMSG_INVENTORY_CHANGE_FAILURE` — equip level,
|
||||
/// proficiency, bag full, …): the UI error line's inventory vocabulary, the equip twin of the
|
||||
/// cast-result failure path. Also the pending-lock's failure-driven clear (decision 0216 §4 /
|
||||
/// 0218 §3): every arrival here already has reason ≠ 0 (reason 0 is filtered before this event
|
||||
/// exists at all — `benilla_protocol::events`'s `if reason != 0` guard), so it always tries a
|
||||
/// [`PendingItemOps::clear_by_failure`]. This site has no `UiScript` to fire `ITEM_LOCK_CHANGED`
|
||||
/// through, so the transitioned slots queue in [`LockTransitions`] for the container feed
|
||||
/// ([`super::feed::feed_containers`]) to drain and fire next time it runs.
|
||||
/// A refused inventory operation (reason 0 never arrives: `benilla_protocol::events` drops it):
|
||||
/// queues the error line and clears the item's pending lock, the unlocked slots waiting in
|
||||
/// [`LockTransitions`] for [`super::feed::feed_containers`], since this site has no `UiScript`.
|
||||
fn inventory_failure(
|
||||
reason: u8,
|
||||
required_level: Option<u32>,
|
||||
@@ -129,10 +115,7 @@ fn inventory_failure(
|
||||
bag_slot,
|
||||
});
|
||||
lock_cleared.0.extend(pending.clear_by_failure(item_guid));
|
||||
// The sixth loot-latch clear (`0x5e3a84`, decision 1477): when
|
||||
// the packet's **first item guid** is the object we are looting, the session ends here. It is
|
||||
// how an item-container loot (a lockbox) closes when the move out of it fails — the one clear
|
||||
// the 1471 census was missing. Guid-matched, as the bytes are.
|
||||
// A failure whose first item guid is the looted item (a lockbox) ends the loot (`0x5e3a84`).
|
||||
if item_guid != 0 {
|
||||
latch.clear_for(item_guid);
|
||||
}
|
||||
@@ -142,8 +125,6 @@ fn inventory_failure(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The sixth clear (`0x5e3a84`): an inventory-move failure whose first item guid IS the loot
|
||||
/// target ends that session. Guid-matched, so an unrelated bag failure leaves it alone.
|
||||
#[test]
|
||||
fn an_inventory_failure_on_the_looted_object_clears_the_latch() {
|
||||
const LOCKBOX: u64 = 0x4000_0000_0000_0007;
|
||||
@@ -177,11 +158,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(latch.0, None);
|
||||
}
|
||||
/// **The item locks die with the session.** The lock is item-object state in the reference
|
||||
/// (`item+0x314`), and the objects are rebuilt at every world entry — but ours is a resource,
|
||||
/// and only a resolving field update, a failure or the loot close cleared it. A lockbox opened
|
||||
/// in the frame the socket died never saw any of those, so it stayed greyed and locked for
|
||||
/// the whole next session. Driven through the real registration.
|
||||
/// Driven through the real registration.
|
||||
#[test]
|
||||
fn the_session_end_drops_every_item_lock() {
|
||||
let mut app = App::new();
|
||||
@@ -224,7 +201,7 @@ mod open_container_tests {
|
||||
use super::*;
|
||||
use benilla_protocol::field::FIELD_PLAYER_INV_SLOT_HEAD;
|
||||
|
||||
/// `PLAYER_FIELD_BANK_BAG_SLOT_1` (index 612; private to the fields module, so spelled here).
|
||||
/// `PLAYER_FIELD_BANK_BAG_SLOT_1`, field 612 (private to the fields module).
|
||||
const BANK_BAG_SLOT_1: u16 = 612;
|
||||
|
||||
/// A self descriptor holding the given guids at the given first-dword indices.
|
||||
|
||||
@@ -1,80 +1,21 @@
|
||||
//! The **layout cache** — where the windows the player has moved or resized are, so they are still
|
||||
//! there after a relog.
|
||||
//! The layout cache: the windows the player has moved or resized, the userPlaced bit
|
||||
//! (`frame+0xb4 & 0x1000`), restored at the next login. The engine half (snapshot, restore, dirty
|
||||
//! bit) is `benilla_ui::script`'s `layout_cache`; this is the file and when it is written.
|
||||
//!
|
||||
//! The engine has carried the client's userPlaced bit for a long time
|
||||
//! ([`benilla_ui::script`]'s `movable` cluster — `frame+0xb4 & 0x1000`, set by `SetUserPlaced` and
|
||||
//! by the drag entries themselves) and **nothing read it**: a chat window dragged across the screen
|
||||
//! went back to its authored anchor at the next login. This module is the half that was missing.
|
||||
//! The engine half — enumerate, restore, dirty-bit — is `benilla_ui::script`'s `layout_cache`; this
|
||||
//! is the two ends the VM cannot own: *where the file lives* and *when it is written*.
|
||||
//! The reference writes `WTF/Account/<ACC>/<REALM>/<CHAR>/layout-cache.txt` with `Frame:`,
|
||||
//! `FrameLevel:`, `X:`, `Y:`, `W:` and `H:` lines; ours is
|
||||
//! `benilla-config/layout/<realm>-<character>.txt`, keeping `Frame:`, `W:` and `H:`.
|
||||
//! Deviation: one `Point: <point> <relativeTo> <relativePoint> <x> <y>` line per anchor, `-` for
|
||||
//! the screen root, instead of `X:`/`Y:`, because a benilla drag moves the authored anchors
|
||||
//! rather than re-anchoring to one TOPLEFT, and a pair would lose them. There is no
|
||||
//! `FrameLevel:`: the reference saves an undocked window's raise, and benilla's raise does not
|
||||
//! persist.
|
||||
//!
|
||||
//! ## The file, and how it relates to the reference's
|
||||
//!
|
||||
//! 1.12 writes `WTF/Account/<ACC>/<REALM>/<CHAR>/layout-cache.txt`, and a real one off the pinned
|
||||
//! install reads, in full:
|
||||
//!
|
||||
//! ```text
|
||||
//! Frame: ChatFrame2
|
||||
//! FrameLevel: 4
|
||||
//! X: 32
|
||||
//! Y: -578
|
||||
//! W: 430
|
||||
//! H: 120
|
||||
//! ```
|
||||
//!
|
||||
//! Ours is `benilla-config/layout/<realm>-<character>.txt`
|
||||
//! ([`crate::local_state::layout_character_path`]) — the same scope, one folder flatter, with that
|
||||
//! file's `Frame:`/`W:`/`H:` spellings kept. **Two of its keys are deliberately different, and both
|
||||
//! are about benilla's own move model rather than about taste:**
|
||||
//!
|
||||
//! - **`Point:` lines instead of `X:`/`Y:`.** The reference collapses a dragged frame to a single
|
||||
//! screen-space position and re-seats it from a `TOPLEFT` anchor; benilla's drag pump keeps the
|
||||
//! frame's authored anchor SET and shifts every offset in it (the divergence `movable`'s own doc
|
||||
//! states). Writing an absolute pair would therefore *lose* what the drag produced — ChatFrame1
|
||||
//! is anchored `BOTTOMLEFT` to UIParent, and restoring it as a TOPLEFT position would change how
|
||||
//! it follows a resolution change. So the file carries what `GetPoint` answers, one line per
|
||||
//! anchor: `Point: <point> <relativeTo> <relativePoint> <x> <y>`, with `-` for the screen root
|
||||
//! (which is where `GetPoint` answers `nil`).
|
||||
//! - **No `FrameLevel:`.** The reference persists it because an undocked chat window is raised
|
||||
//! above its neighbours and that raise has to survive; benilla's raise is transient and its
|
||||
//! docked windows share one level. It joins the file the day a window can be raised for good —
|
||||
//! the honest-tree rule (1134 §4), the same one the chat-look file applies next door.
|
||||
//!
|
||||
//! ## The write posture — the reference's shutdown tail, plus a debounce it has not got
|
||||
//!
|
||||
//! **The reference writes this file in exactly one place**: step three of the UI shutdown's
|
||||
//! ordered tail `0x490bd0` — `PLAYER_LEAVING_WORLD` → `PLAYER_LOGOUT` (`0x490c2a`) →
|
||||
//! **`layout-cache.txt` (`0x490c79`)** → the flat saved file (`0x490c7e`) → the per-addon files →
|
||||
//! `AddOns.txt` → the frame teardown. It rides the same five lifecycle roots as SavedVariables —
|
||||
//! logout to the character screen, quit, disconnect, application exit and **`/reload`** — and
|
||||
//! there is no autosave, no dirty bit and no per-frame write.
|
||||
//!
|
||||
//! Ours takes that slot: [`save_now`], called from [`crate::ui_script::shutdown_ui_state`]
|
||||
//! between `PLAYER_LOGOUT` and the flat file. **That is the load-bearing part, and it is what
|
||||
//! B353 was.** The tail is ONE function called from every root, which is the only way a
|
||||
//! `/reload` gets a write at all: a reload never leaves `InWorld` — `run_pending_reload` calls
|
||||
//! the shutdown and the rebuild back to back — so a saver hung off `OnExit(InWorld)` is invisible
|
||||
//! to it, and an unlocked chat window came back on its authored anchors after every `/reload`
|
||||
//! with `benilla-config/layout/` never created. The same edge is *also* not an ordering: two
|
||||
//! unconstrained systems on one state edge are placed by the executor, and measured here (bevy
|
||||
//! 0.18, five systems on one `OnExit`) the placement moves with nothing but the registration
|
||||
//! positions — a saver that needs the dying VM ran *after* the exclusive session-ender in one
|
||||
//! arrangement and before it in another. So this module's `OnExit` saver was reading a VM it was
|
||||
//! racing for. The tail cannot race: it runs inside `end_ui_session`, ahead of the replacement,
|
||||
//! by construction. That is the mistake [`crate::ui_script::shutdown_ui_state`]'s own doc says
|
||||
//! the tail exists to prevent, made once more one module over.
|
||||
//!
|
||||
//! **The one thing we keep that the reference has not got is a debounced autosave** —
|
||||
//! [`save_layout`], dirty flag keyed to the VM ([`VmMemo`]), one quiet second. The reference has
|
||||
//! no crash-path write either (its exception filter reaches no writer) and we would rather not
|
||||
//! lose a session's windows to one; a drag is a slider-shaped gesture, so the quiet second
|
||||
//! coalesces a resize's per-mouse-move writes into one. It is a *second* writer of the same
|
||||
//! file, never the only one: every orderly end goes through the tail.
|
||||
//!
|
||||
//! The VM key on the dirty flag is one-way and load-bearing: the geometry lives in the VM, so a
|
||||
//! plain `bool` surviving a VM replacement would let a save compose the player's file out of a
|
||||
//! fresh tree that has no user-placed frame in it at all — i.e. wipe it. A fresh VM starts
|
||||
//! undirty and cannot write until a drag writes.
|
||||
//! The reference writes the file only in its UI shutdown tail (`0x490bd0`), at `0x490c79`,
|
||||
//! after `PLAYER_LOGOUT` (`0x490c2a`) and before the flat saved file (`0x490c7e`), on logout,
|
||||
//! quit, disconnect, exit and `/reload`; [`save_now`] is that step. Deviation: a debounced
|
||||
//! autosave ([`save_layout`]) also writes a quiet second after a drag, because the reference has
|
||||
//! no crash-path write and a crash would lose the session's windows.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -84,17 +25,13 @@ use benilla_ui::script::{FrameLayout, LayoutPoint, UiScript};
|
||||
|
||||
use crate::ui_script::VmMemo;
|
||||
|
||||
/// How long a moved window sits before the save fires — [`crate::cvars`]'s own constant and its own
|
||||
/// reasoning, which a drag fits exactly: long enough to coalesce one gesture, short enough that a
|
||||
/// crash costs a gesture rather than a session.
|
||||
/// How long a moved window sits before the autosave: one gesture coalesced, at most one lost.
|
||||
const SAVE_QUIET: std::time::Duration = std::time::Duration::from_secs(1);
|
||||
|
||||
/// The screen root's spelling in the file — where `GetPoint` answers `nil`. A literal `-` rather
|
||||
/// than `UIParent`, because benilla has a real frame by that name (`UIParent.xml`) and the two
|
||||
/// would then be indistinguishable on the way back in.
|
||||
/// The screen root in the file, where `GetPoint` answers `nil`; not `UIParent`, a real frame.
|
||||
const SCREEN_TOKEN: &str = "-";
|
||||
|
||||
/// The file's header — what these lines are and where the law lives.
|
||||
/// The file's header, a `#` block the parser skips.
|
||||
const HEADER: &str = "\
|
||||
# benilla window layout — every frame the player has moved or resized (the client's userPlaced
|
||||
# bit). A relative of the reference's layout-cache.txt: same scope, same Frame:/W:/H: keys, but
|
||||
@@ -102,29 +39,20 @@ const HEADER: &str = "\
|
||||
# collapsing it to a screen position. `-` as a Point: target means the screen root.
|
||||
";
|
||||
|
||||
/// Which character's file we are on, where it lives, whether it has been restored into this VM,
|
||||
/// and whether it is owed a write.
|
||||
/// The character's file: its path, the VM it was restored into, and whether a write is owed.
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct LayoutFile {
|
||||
path: Option<PathBuf>,
|
||||
/// The `(realm, character)` [`Self::path`] was built for. Session-keyed (1290) like the chat
|
||||
/// look and the macro loads: the *same* character coming back still meets a fresh VM whose
|
||||
/// frames are all back on their authored anchors.
|
||||
/// The `(realm, character)` of [`Self::path`], keyed on the VM: a fresh VM needs a restore
|
||||
/// even for the same character.
|
||||
identity: VmMemo<Option<(String, String)>>,
|
||||
/// Whether **this VM** has unsaved drags. Session-keyed for the one-way reason in the module
|
||||
/// doc: a `bool` that outlived its VM could compose the file from a tree with nothing placed
|
||||
/// in it.
|
||||
/// Whether this VM has unsaved drags. Keyed on the VM, as a flag that outlived its VM would
|
||||
/// write a fresh tree, with nothing placed, over the player's file.
|
||||
dirty: VmMemo<bool>,
|
||||
last_change: Option<std::time::Instant>,
|
||||
}
|
||||
|
||||
/// Render the cache exactly as [`parse`] reads it, frames in the order the engine hands them
|
||||
/// (name-sorted, so the file is stable across sessions).
|
||||
///
|
||||
/// `pub(crate)` for one reason: `ui_script::chat_resize_tests` drives the *whole* round trip — drag
|
||||
/// the shipped window, snapshot it, write the text, read it back into a fresh VM — and a test that
|
||||
/// stops at the engine seam would not have caught a file that cannot express what the seam
|
||||
/// produced.
|
||||
/// Render the cache as [`parse`] reads it, frames name-sorted as the engine hands them.
|
||||
pub(crate) fn render(frames: &[FrameLayout]) -> String {
|
||||
let mut out = String::from(HEADER);
|
||||
for f in frames {
|
||||
@@ -145,11 +73,8 @@ pub(crate) fn render(frames: &[FrameLayout]) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
/// Parse the cache back. Permissive the way the reference's own readers are, and for the same
|
||||
/// reason — a hand edit and a later build's extra key must each cost at most the line they are on:
|
||||
/// keys match case-insensitively, an unknown key is skipped, and a malformed `Point:` drops that
|
||||
/// anchor rather than the file. A `Point:`/`W:`/`H:` before any `Frame:` has no owner and is
|
||||
/// dropped.
|
||||
/// Parse the cache: keys match case-insensitively, and an unknown key, a malformed `Point:` or a
|
||||
/// value line before any `Frame:` costs only its own line.
|
||||
pub(crate) fn parse(text: &str) -> Vec<FrameLayout> {
|
||||
let mut out: Vec<FrameLayout> = Vec::new();
|
||||
for line in text.lines() {
|
||||
@@ -202,16 +127,9 @@ pub(crate) fn parse(text: &str) -> Vec<FrameLayout> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Seat the player's saved geometry into the VM, once per character per VM.
|
||||
///
|
||||
/// `pub(crate)` so `ui_script::world_entry_tests` can run it against a real post-reload VM:
|
||||
/// the file half of the loop is only worth anything if a window actually comes BACK, and
|
||||
/// that is the half a test of `render`/`parse` alone cannot see.
|
||||
///
|
||||
/// Runs in `Update` under `InWorld`, which is *after* the UI tree is built and after decision
|
||||
/// 0272's load-time `UIParent_ManageFramePositions()` bootstrap — both of which matter: the frames
|
||||
/// have to exist to be looked up by name, and the managed pass has to have had its say first,
|
||||
/// because from here on it skips these frames (`IsUserPlaced`, `UIParent.xml`).
|
||||
/// Seat the saved geometry into the VM, once per character per VM. It runs after the UI tree is
|
||||
/// built and the load-time `UIParent_ManageFramePositions()` pass has run, which skips
|
||||
/// user-placed frames on every later pass (`UIParent.lua:1692`).
|
||||
pub(crate) fn load_layout(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
roster: Res<crate::char_select::Roster>,
|
||||
@@ -222,17 +140,16 @@ pub(crate) fn load_layout(
|
||||
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 into the live VM
|
||||
}
|
||||
file.path = crate::local_state::layout_character_path(&id.0, &id.1);
|
||||
*file.identity.get(&script) = Some(id);
|
||||
// A fresh VM has nothing placed, so a character with no file needs nothing pushed — and the
|
||||
// watcher below must not read a change this load made.
|
||||
// A fresh VM has nothing placed and owes no write.
|
||||
*file.dirty.get(&script) = false;
|
||||
file.last_change = None;
|
||||
|
||||
let Some(path) = file.path.clone() else {
|
||||
return; // hermetic capture, or no state folder — session-only
|
||||
return; // hermetic capture or no state folder: session-only
|
||||
};
|
||||
let text = match std::fs::read_to_string(&path) {
|
||||
Ok(t) => t,
|
||||
@@ -252,13 +169,11 @@ pub(crate) fn load_layout(
|
||||
path.display()
|
||||
);
|
||||
script.restore_user_placed_layouts(frames);
|
||||
// The restore moved anchors the drag pump would otherwise have moved; drain the engine's own
|
||||
// dirty bit so the very first save is not a rewrite of what was just read.
|
||||
// Drain the dirty bit the restore set, so the first save does not rewrite what was just read.
|
||||
script.take_user_placed_change();
|
||||
}
|
||||
|
||||
/// Drain the engine's "a user-placed frame moved" bit into the dirty flag. Cheap on a steady frame
|
||||
/// — the drain is a `take` of a `bool`.
|
||||
/// Drain the engine's user-placed-frame-moved bit into the dirty flag.
|
||||
fn watch_layout(script: Option<NonSendMut<UiScript>>, mut file: ResMut<LayoutFile>) {
|
||||
let Some(mut script) = script else { return };
|
||||
if !script.take_user_placed_change() {
|
||||
@@ -268,9 +183,8 @@ fn watch_layout(script: Option<NonSendMut<UiScript>>, mut file: ResMut<LayoutFil
|
||||
file.last_change = Some(std::time::Instant::now());
|
||||
}
|
||||
|
||||
/// Dirty + one quiet second → rewrite the file atomically. **The crash writer, and only that**
|
||||
/// (see the module doc): every orderly end — logout, disconnect, quit, `/reload` — is the
|
||||
/// shutdown tail's, so this never has to reason about an exit message or an edge.
|
||||
/// The autosave: dirty and one quiet second rewrite the file atomically. Every orderly end is
|
||||
/// [`save_now`]'s.
|
||||
fn save_layout(script: Option<NonSendMut<UiScript>>, mut file: ResMut<LayoutFile>) {
|
||||
let Some(script) = script else { return };
|
||||
if !*file.dirty.get(&script) {
|
||||
@@ -280,42 +194,32 @@ fn save_layout(script: Option<NonSendMut<UiScript>>, mut file: ResMut<LayoutFile
|
||||
return;
|
||||
}
|
||||
let Some(path) = file.path.clone() else {
|
||||
// hermetic/session-only: nothing to write, stop retrying
|
||||
// Hermetic or session-only: nothing to write, stop retrying.
|
||||
*file.dirty.get(&script) = false;
|
||||
return;
|
||||
};
|
||||
let body = render(&script.user_placed_layouts());
|
||||
if let Err(e) = crate::local_state::write_atomic(&path, &body) {
|
||||
// …and don't retry every frame into the same error.
|
||||
// The flag clears below anyway, so a failing write is not retried every frame.
|
||||
warn!("layout: cannot write {}: {e}", path.display());
|
||||
}
|
||||
*file.dirty.get(&script) = false;
|
||||
}
|
||||
|
||||
/// **The reference's own step of the UI shutdown** (`0x490c79`, between `PLAYER_LOGOUT` and the
|
||||
/// flat saved file): write the player's layout cache from the live VM, now. Called from
|
||||
/// [`crate::ui_script::shutdown_ui_state`], so it reaches every root the tail does — logout,
|
||||
/// disconnect, quit and `/reload` — which is what an `OnExit(InWorld)` system could not (module
|
||||
/// doc, and B353).
|
||||
/// The reference's step of the UI shutdown (`0x490c79`, after `PLAYER_LOGOUT`, before the flat
|
||||
/// saved file): write the layout from the live VM now. Called from
|
||||
/// [`crate::ui_script::shutdown_ui_state`], it runs on every end including `/reload`, which never
|
||||
/// leaves `InWorld`; an `OnExit(InWorld)` saver would miss a reload and race the VM's teardown.
|
||||
///
|
||||
/// It takes the **identity the tail is holding** rather than reading [`LayoutFile`], for the
|
||||
/// reason [`crate::ui_script::AddOnIdentity`] exists at all: that is the character the UI
|
||||
/// actually loaded under, remembered because the roster's pick can be gone by the time the
|
||||
/// shutdown runs. It is the same `(realm, character)` [`load_layout`] built its path from —
|
||||
/// both are [`crate::ui_macro::identity`] of the same roster — so the tail writes back to the
|
||||
/// file the load read.
|
||||
///
|
||||
/// Unconditional, like every other resident of the tail: the file is composed whole from the
|
||||
/// live tree, so a session that placed nothing writes back what it restored, and a session that
|
||||
/// un-placed a window writes the row's absence. The one guard is the tail's own — a session
|
||||
/// whose in-game UI never loaded does not run it, so a UI-less VM cannot answer with its
|
||||
/// emptiness.
|
||||
/// It takes the identity the UI loaded under, which the tail holds because the roster's pick can
|
||||
/// be gone by shutdown, and writes unconditionally: the file is composed whole from the live tree,
|
||||
/// and the tail does not run for a session whose UI never loaded.
|
||||
pub(crate) fn save_now(script: &UiScript, identity: Option<&(String, String)>) {
|
||||
let Some((realm, character)) = identity else {
|
||||
return; // no character loaded under — a capture, a scenario, a test world
|
||||
return; // no character loaded under: a capture, a scenario, a test world
|
||||
};
|
||||
let Some(path) = crate::local_state::layout_character_path(realm, character) else {
|
||||
return; // hermetic capture, or no state folder — session-only
|
||||
return; // hermetic capture or no state folder: session-only
|
||||
};
|
||||
let body = render(&script.user_placed_layouts());
|
||||
if let Err(e) = crate::local_state::write_atomic(&path, &body) {
|
||||
@@ -323,25 +227,19 @@ pub(crate) fn save_now(script: &UiScript, identity: Option<&(String, String)>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The layout cache's plugin. **Not the chat look's shape any more** (2029): that module keeps
|
||||
/// its own session edge because its reference is the type-7 chat-cache saver `0x499a80`;
|
||||
/// this file's reference is `0x490bd0`'s ordered tail, so its every orderly write lives in
|
||||
/// [`save_now`] and the plugin has no edge left to own.
|
||||
/// The layout cache's restore, watch and autosave; the plugin owns no session edge, as every
|
||||
/// orderly write is [`save_now`]'s.
|
||||
pub(crate) struct UiLayoutPlugin;
|
||||
|
||||
impl Plugin for UiLayoutPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
// **One chain, and no session edges of its own.** Restore, watch, and the crash-only
|
||||
// debounce; every orderly end is [`crate::ui_script::shutdown_ui_state`]'s [`save_now`],
|
||||
// which is the reference's own single write site and the only one a `/reload` reaches.
|
||||
app.init_resource::<LayoutFile>().add_systems(
|
||||
Update,
|
||||
(
|
||||
// The restore is a push the tick should see the frame it lands: the feed phase.
|
||||
// In the feed phase, so the tick sees the restore the frame it lands.
|
||||
load_layout.in_set(crate::ui_script::UiFeed),
|
||||
// The watcher and the save read what the drag pump — Lua's, in the tick — did:
|
||||
// after it. Load precedes watch through the phases, as the old chain had it,
|
||||
// so the watcher never reads the restore's own move.
|
||||
// After the drag pump in the tick; load precedes watch through the phases, so the
|
||||
// watcher never reads the restore's own move.
|
||||
(watch_layout, save_layout)
|
||||
.chain()
|
||||
.after(crate::ui_script::UiInput),
|
||||
@@ -365,8 +263,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The file round-trips — what `render` writes is exactly what `parse` reads back, the screen
|
||||
/// root's `-` included.
|
||||
#[test]
|
||||
fn the_file_round_trips() {
|
||||
let frames = vec![
|
||||
@@ -395,15 +291,12 @@ mod tests {
|
||||
assert_eq!(parse(&render(&frames)), frames);
|
||||
}
|
||||
|
||||
/// 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 windows on the second launch.
|
||||
#[test]
|
||||
fn the_header_is_skipped_not_parsed() {
|
||||
assert!(render(&[]).starts_with('#'));
|
||||
assert_eq!(parse(HEADER), vec![]);
|
||||
}
|
||||
|
||||
/// Junk costs the line it is on: an orphan value line, a short `Point:`, an unknown key.
|
||||
#[test]
|
||||
fn junk_costs_only_its_own_line() {
|
||||
let got = parse(
|
||||
|
||||
@@ -1,45 +1,17 @@
|
||||
//! **Leaving** — the app half of the game menu's Logout and Exit Game buttons (decision 0674):
|
||||
//! the outbound request, the server's answer, the countdown dialog that narrates it, and the
|
||||
//! process exit.
|
||||
//! Leaving: the game menu's Logout and Exit Game, from the request to the countdown dialog and the
|
||||
//! process exit. The server owns the clock: vmangos answers `CMSG_LOGOUT_REQUEST` with
|
||||
//! `SMSG_LOGOUT_RESPONSE { u32 reason, u8 instant }` (`MiscHandler.cpp:284`):
|
||||
//!
|
||||
//! The whole arc is three packets and one rule: **the server owns the clock, the client only
|
||||
//! narrates it.** `Logout()` sends `CMSG_LOGOUT_REQUEST`; vmangos
|
||||
//! (`WorldSession::HandleLogoutRequestOpcode`, read at MiscHandler.cpp:284) answers
|
||||
//! `SMSG_LOGOUT_RESPONSE {u32 reason, u8 instant}` and that one packet decides everything:
|
||||
//! - `reason != 0`: refused (1 in combat, 3 jumping or falling, 2 GM-frozen).
|
||||
//! - `instant == 1`: out at once (resting, on a taxi, or a GM-level account), with no dialog.
|
||||
//! - otherwise a 20 s server timer runs (`WorldSession.h:284`), which the CAMP dialog counts down
|
||||
//! and `CancelLogout()` calls off.
|
||||
//!
|
||||
//! - `reason != 0` — **refused**, no logout starts (1 in combat, 3 jumping/falling, 2 GM-frozen).
|
||||
//! - `reason == 0, instant == 1` — logged out on the spot (resting in an inn or a city, on a taxi,
|
||||
//! or a GM-level account — vmangos's `CONFIG_UINT32_INSTANT_LOGOUT`); `SMSG_LOGOUT_COMPLETE`
|
||||
//! follows immediately and there is no dialog at all.
|
||||
//! - `reason == 0, instant == 0` — a **20-second server-side timer** now runs, during which the
|
||||
//! character sits and is rooted. This is the case the CAMP dialog counts down, and the case
|
||||
//! `CancelLogout()` (`CMSG_LOGOUT_CANCEL` → `SMSG_LOGOUT_CANCEL_ACK`) calls off.
|
||||
//!
|
||||
//! Quit is Logout plus one bit of local intent: end the *process* on completion instead of falling
|
||||
//! back to character select. It is the same wire, the same clock, the same dialog under a different
|
||||
//! name (QUIT_TIMER "%d %s until exit"), which is why the reference gives QUIT an "Exit now" button
|
||||
//! and CAMP none — a force-quit needs no server round trip, a force-logout does.
|
||||
//!
|
||||
//! **All of it is now pinned** (decision 1821 — this module's three arms were the last INFERRED
|
||||
//! join left in the leaving path). The wire shape is verified twice over: vmangos's packet
|
||||
//! (`WorldPackets::Misc::LogoutResponse` — `u32 reason, u8 instant`) and the reference's own
|
||||
//! handler (`0x5b4630`: `GetInt32` then `GetUInt8`, then a virtual `[vtable+0x54](reason,
|
||||
//! instant)`, which is `[0x80a398+0x54]` = `0x5aaef0`). That override is this decision table byte
|
||||
//! for byte:
|
||||
//!
|
||||
//! - `0x5aaef6 test eax,eax / jne 0x5aaf21` → **any** non-zero reason takes one arm:
|
||||
//! `DisplayError(0x180)` = `ERR_LOGOUT_FAILED`, then clear the pending flag. There is no
|
||||
//! per-reason string — combat, falling and GM-frozen all show the one line.
|
||||
//! - `0x5aaf00 test eax,eax / jne` → `instant != 0` returns having done nothing; the completion
|
||||
//! is already on its way.
|
||||
//! - otherwise `setne cl` on `[this+0x1b1c]`, `add ecx,0x114`, `SignalEvent` → event **276
|
||||
//! `PLAYER_CAMPING`** or **277 `PLAYER_QUITING`**, and `[this+0x1b1c]` is written from the
|
||||
//! logout *request*'s own quit flag at `0x5ab053`. The reference remembers camp-vs-quit in one
|
||||
//! local byte, which is exactly what [`LogoutState::quitting`] is.
|
||||
//!
|
||||
//! The 1.12 FrameXML fixes the rest: the CAMP/QUIT dialogs, their 20 s timeouts, and their
|
||||
//! `PLAYER_CAMPING` / `PLAYER_QUITING` / `LOGOUT_CANCEL` drivers (UIParent.lua l.304-315, event
|
||||
//! ids 276/277/278 in the client's event-name table `0xbe1198`).
|
||||
//! The reference's handler (`0x5b4630`, then `0x5aaef0`) shows `ERR_LOGOUT_FAILED` for every
|
||||
//! refusal, does nothing for an instant logout, as the completion follows, and otherwise fires
|
||||
//! `PLAYER_CAMPING` or `PLAYER_QUITING` from the request's quit byte (stored at `0x5ab053`), which
|
||||
//! the stock dialogs hang off (`UIParent.lua:304-315`). Quit is a logout that exits the process on
|
||||
//! completion; the QUIT dialog has an "Exit now" button and CAMP has none.
|
||||
|
||||
use benilla_ui::script::{SessionRequest, UiScript};
|
||||
use bevy::prelude::*;
|
||||
@@ -47,65 +19,44 @@ use bevy::prelude::*;
|
||||
use crate::net::{ClientCommand, LoggedOutMessage, NetCommands, SelfGuid};
|
||||
use crate::ui_script::{UiFeed, UiInput};
|
||||
|
||||
/// The reference's refusal line, as a **catalog key** rather than a literal — message id `0x180`,
|
||||
/// the sole argument of the `DisplayError` at `0x5aaf26` (decision 1821). VERIFIED, superseding
|
||||
/// this module's old inference: the reference picks this one string for every non-zero reason,
|
||||
/// and `PLAYER_LOGOUT_FAILED_ERROR` — the other candidate the guess weighed — is never reached
|
||||
/// from here. The text comes from the VM's own `GlobalStrings.lua`, so a locale rides for free.
|
||||
/// The refusal line for every non-zero reason, never `PLAYER_LOGOUT_FAILED_ERROR`: message `0x180`
|
||||
/// of the `DisplayError` at `0x5aaf26`, a key so the player's `GlobalStrings.lua` supplies it.
|
||||
const LOGOUT_FAILED_KEY: &str = "ERR_LOGOUT_FAILED";
|
||||
|
||||
/// What the wire told us to say next — drained by [`feed_logout`] into script events. A queue
|
||||
/// rather than a flag: a cancel can land in the same frame as the response it cancels, and the UI
|
||||
/// must see both, in order.
|
||||
/// What the wire told us to say next, queued because a cancel can land in its response's frame.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum LogoutSignal {
|
||||
/// A non-instant logout started — `PLAYER_CAMPING` (the CAMP dialog).
|
||||
/// A timed logout started: `PLAYER_CAMPING`.
|
||||
Camping,
|
||||
/// The same, for a pending Quit — `PLAYER_QUITING` (the QUIT dialog).
|
||||
/// The same for a quit: `PLAYER_QUITING`.
|
||||
Quiting,
|
||||
/// The server dropped the pending logout — `LOGOUT_CANCEL` (both dialogs hide).
|
||||
/// The logout was cancelled: `LOGOUT_CANCEL`.
|
||||
Cancelled,
|
||||
/// The server refused outright — a red error line, no dialog.
|
||||
/// Refused: an error line, no dialog.
|
||||
Refused,
|
||||
}
|
||||
|
||||
/// The pending session-exit, and the signals owed to the UI.
|
||||
///
|
||||
/// `quitting` is the piece of local state the arc's *meaning* needs: the wire cannot tell a logout
|
||||
/// from a quit (both are `CMSG_LOGOUT_REQUEST`), so which dialog to show and whether to exit the
|
||||
/// process at the end is remembered here. It is cleared by every terminal edge — a refusal, a
|
||||
/// cancel, or the exit itself — so a cancelled quit can never leave a client that dies on the next
|
||||
/// unrelated logout.
|
||||
///
|
||||
/// `pending` is the reference's own **`[session+0x1b1d]`** (decision 2092), which this module knew
|
||||
/// about in prose — `apply_cancelled`'s doc has always called it "the same pending byte the
|
||||
/// refusal arm clears" — and never modelled. `0x5ab000` bails on it *silently* when `force == 0`,
|
||||
/// so a second request while one is in flight is not a second packet. That gate was invisible
|
||||
/// while the only caller was a button a human presses once; the idle handler (2092) asks every
|
||||
/// frame past thirty minutes, and without the latch it would put a `CMSG_LOGOUT_REQUEST` on the
|
||||
/// wire sixty times a second until something else stamped the idle clock.
|
||||
/// The pending session exit and the signals owed to the UI. `quitting` is the one bit the wire
|
||||
/// lacks, as a quit is also `CMSG_LOGOUT_REQUEST`; every terminal edge clears it, so a cancelled
|
||||
/// quit cannot exit the process on a later logout.
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct LogoutState {
|
||||
quitting: bool,
|
||||
/// `[session+0x1b1d]` — a logout request is out and unanswered. Set by the non-forced path,
|
||||
/// bypassed *and not set* by `ForceLogout` (`0x5aaff0`, decision 1963), cleared by every
|
||||
/// terminal edge.
|
||||
/// The reference's `[session+0x1b1d]`: a request is out and unanswered. `0x5ab000` drops a
|
||||
/// second request silently while it is set, which the idle logout, asking every frame, relies
|
||||
/// on; `ForceLogout` (`0x5aaff0`) bypasses it and does not set it.
|
||||
pending: bool,
|
||||
signals: Vec<LogoutSignal>,
|
||||
}
|
||||
|
||||
impl LogoutState {
|
||||
/// `SMSG_LOGOUT_RESPONSE` — the whole decision table (module docs). Returns nothing; the
|
||||
/// instant case is silent on purpose (the completion is already on its way).
|
||||
/// `SMSG_LOGOUT_RESPONSE`, the module doc's table; an instant logout signals nothing.
|
||||
pub(crate) fn apply_response(&mut self, reason: u32, instant: bool) {
|
||||
// Announced: this one packet decides the entire user-visible outcome (dialog, no dialog, or
|
||||
// a red line), and the three inputs behind it — combat, resting, account security — are all
|
||||
// invisible from this side. A logout that "did nothing" is diagnosed from this line.
|
||||
// Logged: the server's inputs (combat, resting, account level) are invisible from here.
|
||||
info!("logout: server answered reason={reason} instant={instant}");
|
||||
if reason != 0 {
|
||||
self.quitting = false;
|
||||
// `0x5aaf21`'s arm: `DisplayError(ERR_LOGOUT_FAILED)` **and clear the pending flag**,
|
||||
// which is what lets the player try again once they leave combat.
|
||||
// `0x5aaf21`: the error line, and the pending flag clears so the player can retry.
|
||||
self.pending = false;
|
||||
self.signals.push(LogoutSignal::Refused);
|
||||
return;
|
||||
@@ -120,9 +71,9 @@ impl LogoutState {
|
||||
});
|
||||
}
|
||||
|
||||
/// `SMSG_LOGOUT_CANCEL_ACK` — the countdown is off. Its handler is the vtable slot next door
|
||||
/// (`0x5b4680 call [eax+0x58]` → `0x5aaf60`), and all it does is fire event 278
|
||||
/// `LOGOUT_CANCEL` off the same pending byte the refusal arm clears (decision 1821).
|
||||
/// `SMSG_LOGOUT_CANCEL_ACK`: fires `LOGOUT_CANCEL` on every ack. The reference (`0x5b4680`,
|
||||
/// then `0x5aaf60`) fires it only while the pending byte is set (`0x5aaf6b`), and its own
|
||||
/// `CancelLogout` clears that byte when it sends (`0x5aaf80`).
|
||||
pub(crate) fn apply_cancelled(&mut self) {
|
||||
self.quitting = false;
|
||||
self.pending = false;
|
||||
@@ -130,11 +81,8 @@ impl LogoutState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fire the camp/quit events the dialogs are driven by (GameMenuFrame.xml's `BenillaLogoutDriver`).
|
||||
///
|
||||
/// The refusal is shown one at a time through the message sink rather than collected, because the
|
||||
/// signals are ordered — a cancel can land in the same frame as the response it cancels, and the
|
||||
/// dialogs must see both in the order the wire delivered them.
|
||||
/// Fire the signals in wire order: the events the stock CAMP and QUIT dialogs hang off, and the
|
||||
/// refusal line.
|
||||
fn feed_logout(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
mut logout: ResMut<LogoutState>,
|
||||
@@ -156,7 +104,7 @@ fn feed_logout(
|
||||
}
|
||||
}
|
||||
|
||||
/// Turn the Lua intents into packets — and, for the two force paths, straight into an exit.
|
||||
/// Turn the Lua intents into packets; a forced quit, or a quit with no world session, exits.
|
||||
fn drain_logout(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
mut logout: ResMut<LogoutState>,
|
||||
@@ -172,16 +120,14 @@ fn drain_logout(
|
||||
for request in script.take_session_requests() {
|
||||
match request {
|
||||
SessionRequest::Logout | SessionRequest::Quit => {
|
||||
// `0x5ab000`'s pending-logout bail, and it is **silent** — no error line, no
|
||||
// event, nothing on the wire. It sits ABOVE `0x5ab053`'s store of the quit byte,
|
||||
// so a refused request does not even get to change camp-into-quit.
|
||||
// `0x5ab000` bails silently while a request is pending, before the quit byte is
|
||||
// stored (`0x5ab053`), so a second ask cannot turn a camp into a quit.
|
||||
if logout.pending {
|
||||
continue;
|
||||
}
|
||||
logout.quitting = request == SessionRequest::Quit;
|
||||
// Not in the world (or the socket is gone): there is nothing to log out OF, so a
|
||||
// quit is just an exit and a logout is a no-op. The reference does the same — its
|
||||
// Quit() at the glue screen closes the process outright.
|
||||
// No world session: a quit just exits, as the reference's glue-screen `Quit()`
|
||||
// does, and a logout does nothing.
|
||||
if self_guid.0.is_none() || commands.0.send(ClientCommand::Logout).is_err() {
|
||||
if logout.quitting {
|
||||
info!("logout: quit with no world session — exiting");
|
||||
@@ -192,8 +138,7 @@ fn drain_logout(
|
||||
}
|
||||
} else {
|
||||
logout.pending = true;
|
||||
// Announced because what happens next is entirely the server's call — instant
|
||||
// or a 20 s countdown — and a silent request looks identical to a dropped one.
|
||||
// Logged: instant or timed is the server's call.
|
||||
info!(
|
||||
"logout: requested (quitting={}) — awaiting SMSG_LOGOUT_RESPONSE",
|
||||
logout.quitting
|
||||
@@ -201,10 +146,9 @@ fn drain_logout(
|
||||
}
|
||||
}
|
||||
SessionRequest::CancelLogout => {
|
||||
// The dialog's own OnHide fires this on EVERY early close, including one caused by
|
||||
// a cancel that already landed — so a stray send with nothing pending is normal and
|
||||
// harmless (vmangos ignores it). `quitting` clears here rather than on the ack so a
|
||||
// cancel with a dead socket still can't leave a quit armed.
|
||||
// The stock dialogs' `OnHide` sends this on every early close, even after a cancel
|
||||
// landed, so a stray cancel is normal. `quitting` clears here, not on the ack, so a
|
||||
// dead socket cannot leave a quit armed.
|
||||
logout.quitting = false;
|
||||
logout.pending = false;
|
||||
let _ = commands.0.send(ClientCommand::LogoutCancel);
|
||||
@@ -213,23 +157,18 @@ fn drain_logout(
|
||||
info!("logout: force quit");
|
||||
exit.write(AppExit::Success);
|
||||
}
|
||||
// `CMSG_PLAYER_LOGOUT`, the forced flavour: the dispatcher's own gate is a live
|
||||
// in-world session, and nothing happens without one (decision 1963).
|
||||
// `0x5aaff0` calls the dispatcher `0x5ab000` with `force = 1`, which BYPASSES the
|
||||
// pending bail and does NOT set the latch — so a forced logout is exactly the escape
|
||||
// hatch from a stuck pending one.
|
||||
// `CMSG_PLAYER_LOGOUT`, only with a world session: `0x5aaff0` calls `0x5ab000` with
|
||||
// force 1, which skips the pending bail and does not set the latch.
|
||||
SessionRequest::ForceLogout => {
|
||||
if self_guid.0.is_some() {
|
||||
info!("logout: forced");
|
||||
let _ = commands.0.send(ClientCommand::ForceLogout);
|
||||
}
|
||||
}
|
||||
// Not this module's business beyond routing: the rebuild itself is
|
||||
// [`crate::ui_script::run_pending_reload`]'s, at the top of the next frame.
|
||||
// The rebuild is `ui_script::run_pending_reload`'s, at the top of the next frame.
|
||||
SessionRequest::ReloadUi => reload.0 = true,
|
||||
// ESC out of a cinematic. It rides this queue rather than the binding table because
|
||||
// that is where the reference puts it: `StopCinematic` has no native callers and no
|
||||
// `Bindings.xml` row — `CinematicFrame`'s own `OnKeyDown` is the whole skip path.
|
||||
// Esc out of a cinematic: the reference's `StopCinematic` has no binding and no native
|
||||
// caller, as `CinematicFrame.xml:37-44`'s `OnKeyDown` is the whole skip path.
|
||||
SessionRequest::StopCinematic => {
|
||||
crate::cinematic::stop(&mut cinematic, Some(&commands));
|
||||
}
|
||||
@@ -237,8 +176,7 @@ fn drain_logout(
|
||||
}
|
||||
}
|
||||
|
||||
/// The completion edge: `SMSG_LOGOUT_COMPLETE` normally lands us at character select (decision
|
||||
/// 0193), but a logout that began as Exit Game ends the process instead.
|
||||
/// `SMSG_LOGOUT_COMPLETE` returns to character select, or ends the process for Exit Game.
|
||||
fn exit_on_logout_complete(
|
||||
mut logged_out: MessageReader<LoggedOutMessage>,
|
||||
mut logout: ResMut<LogoutState>,
|
||||
@@ -247,8 +185,7 @@ fn exit_on_logout_complete(
|
||||
if logged_out.read().count() == 0 {
|
||||
return;
|
||||
}
|
||||
// The request is answered — and on a logout that lands us at character select this is the edge
|
||||
// that must disarm it, or the next character's first Logout would be swallowed.
|
||||
// Answered: disarm, or the next character's first logout would be swallowed.
|
||||
logout.pending = false;
|
||||
if logout.quitting {
|
||||
logout.quitting = false;
|
||||
@@ -273,8 +210,7 @@ impl Plugin for UiLogoutPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
/// The logout arc's two narration packets (decision 0674; in the net handler table since 2326)
|
||||
/// — this module owns the decision table; the handler is only the hand-off.
|
||||
/// The logout response and cancel-ack handlers; the decision table is `LogoutState`'s.
|
||||
mod net {
|
||||
use benilla_protocol::{SessionEvent, SessionEventKind};
|
||||
use bevy::prelude::*;
|
||||
@@ -282,7 +218,6 @@ mod net {
|
||||
use super::LogoutState;
|
||||
use crate::net::NetHandlerApp;
|
||||
|
||||
/// Register the pair — called from [`super::UiLogoutPlugin`].
|
||||
pub(super) fn register(app: &mut App) {
|
||||
use SessionEventKind as K;
|
||||
app.net_handler(K::LogoutResponse, on_narration)
|
||||
@@ -306,8 +241,6 @@ mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
/// The response table (module docs): refusal speaks and disarms, instant is silent, and the
|
||||
/// slow path picks its dialog from the pending intent.
|
||||
#[test]
|
||||
fn the_response_decides_which_dialog_if_any() {
|
||||
let mut s = LogoutState::default();
|
||||
@@ -325,8 +258,7 @@ mod tests {
|
||||
s.apply_response(0, false);
|
||||
assert_eq!(s.signals, vec![LogoutSignal::Quiting], "a quit says so");
|
||||
|
||||
// In combat: the red line, and the pending quit is disarmed — otherwise the NEXT logout,
|
||||
// minutes later and deliberate, would silently kill the process instead.
|
||||
// In combat: the error line, and the quit is disarmed so a later logout does not exit.
|
||||
let mut s = LogoutState {
|
||||
quitting: true,
|
||||
pending: true,
|
||||
@@ -342,10 +274,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// **The pending latch, both directions** (decision 2092) — `0x5ab000` bails silently on
|
||||
/// `[session+0x1b1d]`, and every terminal edge clears it. Driven through the real
|
||||
/// [`drain_logout`] rather than the state alone, because the bail lives in the drain and the
|
||||
/// thing under test is that the second ask puts nothing on the wire.
|
||||
/// Driven through [`drain_logout`], where the `0x5ab000` bail lives.
|
||||
#[test]
|
||||
fn a_second_logout_while_one_is_pending_is_silent() {
|
||||
let (tx, rx) = crossbeam_channel::unbounded();
|
||||
@@ -373,13 +302,13 @@ mod tests {
|
||||
assert_eq!(sent(&rx), 1, "the first ask goes out");
|
||||
assert!(app.world().resource::<LogoutState>().pending);
|
||||
|
||||
// The idle handler's shape: ask again, and again, while the server has not answered.
|
||||
// The idle logout's shape: asking every frame while unanswered.
|
||||
for _ in 0..60 {
|
||||
ask(&mut app, SessionRequest::Logout);
|
||||
}
|
||||
assert_eq!(sent(&rx), 0, "and not one more packet, nor any error line");
|
||||
|
||||
// `ForceLogout` bypasses the bail and does not set the latch (`0x5aaff0`, force = 1).
|
||||
// `ForceLogout` bypasses the bail and does not set the latch (`0x5aaff0`, force 1).
|
||||
ask(&mut app, SessionRequest::ForceLogout);
|
||||
assert_eq!(sent(&rx), 1, "the forced flavour is the escape hatch");
|
||||
|
||||
@@ -390,7 +319,7 @@ mod tests {
|
||||
ask(&mut app, SessionRequest::Logout);
|
||||
assert_eq!(sent(&rx), 1);
|
||||
|
||||
// …and so does the completion, so the next character's first Logout is not swallowed.
|
||||
// So does the completion.
|
||||
app.world_mut().resource_mut::<LogoutState>().pending = true;
|
||||
app.world_mut().write_message(LoggedOutMessage);
|
||||
app.world_mut()
|
||||
@@ -399,7 +328,6 @@ mod tests {
|
||||
assert!(!app.world().resource::<LogoutState>().pending);
|
||||
}
|
||||
|
||||
/// The cancel ack disarms a quit too — the same "no armed quit survives a terminal edge" rule.
|
||||
#[test]
|
||||
fn a_cancel_disarms_a_pending_quit() {
|
||||
let mut s = LogoutState {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,5 @@
|
||||
//! The loot window's packet handlers (decision 0084; in the net handler table since 2319, moved
|
||||
//! out of the drain's loot arm file) — the [`LootState`] session and the [`LootLatch`] the loot
|
||||
//! feed ([`super`]) reads, the fishing verdicts, and the item push that prints "You receive …".
|
||||
//! The group rolls are [`crate::ui_loot_roll`]'s and the inventory refusal is
|
||||
//! [`crate::ui_items`]'s.
|
||||
//! The loot window's packet handlers: the [`LootState`] session and the [`LootLatch`] the feed
|
||||
//! reads, the fishing verdicts, and the item push behind "You receive …".
|
||||
|
||||
use benilla_protocol::messages::{ItemPushResult, LootItem};
|
||||
use benilla_protocol::{SessionEvent, SessionEventKind};
|
||||
@@ -13,8 +10,6 @@ use crate::net::{ClientCommand, NetCommands, NetHandlerApp, SelfGuid};
|
||||
use crate::pending_item_ops::{LockTransitions, PendingItemOps};
|
||||
use crate::ui_action::{UiError, UiErrorKeys};
|
||||
|
||||
/// Register the loot handlers — called from [`super::UiLootPlugin`]. One per kind, plus the
|
||||
/// session-end listener.
|
||||
pub(super) fn register(app: &mut App) {
|
||||
use SessionEventKind as K;
|
||||
app.net_handler(K::LootResponse, on_response)
|
||||
@@ -99,10 +94,8 @@ fn on_release_response(
|
||||
}
|
||||
}
|
||||
|
||||
/// **`UnlockItem 0x495420`** as the loot closes call it — on the loot guid, which unlocks
|
||||
/// something only when that guid is an item we locked (a lockbox, clam or loot bag, locked at its
|
||||
/// `CMSG_OPEN_ITEM` send — decision 0916). The unlocked slots queue in [`LockTransitions`] for the
|
||||
/// container feed to fire `ITEM_LOCK_CHANGED`, like the inventory failure's clear.
|
||||
/// `UnlockItem 0x495420` on the loot guid, as the loot closes call it: only an item we locked at
|
||||
/// its `CMSG_OPEN_ITEM` send unlocks, and its slot queues for `ITEM_LOCK_CHANGED`.
|
||||
struct ItemUnlock<'a> {
|
||||
pending: &'a mut PendingItemOps,
|
||||
lock_cleared: &'a mut LockTransitions,
|
||||
@@ -139,8 +132,7 @@ fn on_item_push_result(
|
||||
}
|
||||
}
|
||||
|
||||
/// An open loot window and the kneel latch die with the socket (unconditionally). A listener on
|
||||
/// the session end (a second handler on the kind, after the bridge's own teardown).
|
||||
/// The open window and the latch die with the session, unconditionally.
|
||||
fn on_session_end(
|
||||
In(_): In<SessionEvent>,
|
||||
mut loot: ResMut<LootState>,
|
||||
@@ -150,34 +142,16 @@ fn on_session_end(
|
||||
latch.0 = None;
|
||||
}
|
||||
|
||||
/// The wire `loot_type` values `SMSG_LOOT_RESPONSE`'s **cold-latch** admission accepts
|
||||
/// (`0x5eb94b`/`0x5eb953`/`0x5eb95b`) — vmangos `LootType`'s `PICKPOCKETING(2)` · `FISHING(3)` ·
|
||||
/// `DISENCHANTING(4)`. The alphabet the bytes are drawing is *"the server started this session"*;
|
||||
/// `CORPSE(1)` is the client's own, and it is the one value a cold latch refuses.
|
||||
/// The `loot_type`s a cold latch admits (`0x5eb94b`, `0x5eb953`, `0x5eb95b`): pickpocketing,
|
||||
/// fishing and disenchanting, which the server starts. `CORPSE` (1) answers our own `CMSG_LOOT`
|
||||
/// or `CMSG_OPEN_ITEM` (`SpellHandler.cpp:227`).
|
||||
const SERVER_STARTED_LOOT: [u8; 3] = [2, 3, 4];
|
||||
|
||||
/// A loot window opened (`SMSG_LOOT_RESPONSE`'s normal shape) — the answer to our `CMSG_LOOT`, or
|
||||
/// to anything else that opened a session: a chest, herb node, mining vein or fishing bobber all
|
||||
/// reach the window through an `OPEN_LOCK` cast, never through `CMSG_LOOT` (vmangos
|
||||
/// `Spell::SendLoot` → `Player::SendLoot`).
|
||||
///
|
||||
/// **This handler is an admission gate, not an unconditional open** (decision 1477 correcting
|
||||
/// 1471). `0x5eb924`:
|
||||
///
|
||||
/// ```text
|
||||
/// ACCEPT ⇔ (latch != 0 && latch == pkt.guid) || (latch == 0 && loot_type ∈ {2,3,4})
|
||||
/// ```
|
||||
///
|
||||
/// Everything else is **refused** at `0x5eb963`: no window, a `CMSG_LOOT_RELEASE` bounced back for
|
||||
/// the *packet's* guid, and the latch cleared — **not** guid-matched, so an unsolicited response
|
||||
/// for B drops a live latch on A. The design the bytes state is a two-way handshake: type 1 means
|
||||
/// "I asked for this", and the client pre-armed at its own `CMSG_LOOT` send, so a type-1 answer
|
||||
/// with nothing latched is an answer to a question we never asked.
|
||||
///
|
||||
/// The accepted arm writes the latch verbatim (`0x5ebb60`) and — verified over the whole success
|
||||
/// path — calls **neither** the base-anim recompute nor the Loot-50 force-play. A chest is already
|
||||
/// kneeling by now: its arm was `SMSG_SPELL_GO` (`0x6e831b`, [`super::spells`]), one packet
|
||||
/// earlier.
|
||||
/// `SMSG_LOOT_RESPONSE`'s item shape, behind the admission gate `0x5eb924`: it opens when the
|
||||
/// latch holds the packet's guid, or when the latch is cold and the type is server-started.
|
||||
/// Anything else is refused (`0x5eb963`): no window, a `CMSG_LOOT_RELEASE` for the packet's guid,
|
||||
/// and the latch cleared whatever it held. An open writes the latch (`0x5ebb60`) and plays no
|
||||
/// anim; a chest already knelt at its `SMSG_SPELL_GO` (`0x6e831b`).
|
||||
fn loot_response(
|
||||
guid: u64,
|
||||
loot_type: u8,
|
||||
@@ -187,15 +161,12 @@ fn loot_response(
|
||||
latch: &mut LootLatch,
|
||||
net: &NetCommands,
|
||||
) {
|
||||
// `0x5eb924`–`0x5eb95b`, transcribed.
|
||||
let accept = match latch.0 {
|
||||
Some(latched) => latched == guid,
|
||||
None => SERVER_STARTED_LOOT.contains(&loot_type),
|
||||
};
|
||||
if !accept {
|
||||
// `0x5eb963`: bounce it. On this server nothing produces a refusal — a corpse always
|
||||
// pre-arms and a chest/fishing answer carries type 2/3 — so this arm is inert today and
|
||||
// carried for the faithful shape (and for a server that answers differently).
|
||||
// Inert against vmangos: a corpse always pre-arms, a chest or fishing answer is 2 or 3.
|
||||
debug!(
|
||||
"net: loot response {guid:#x} type {loot_type} REFUSED (latch {:?})",
|
||||
latch.0
|
||||
@@ -203,31 +174,21 @@ fn loot_response(
|
||||
if loot_type != 0 {
|
||||
let _ = net.0.send(ClientCommand::LootRelease { guid });
|
||||
}
|
||||
latch.0 = None; // NOT guid-matched — `0x5eb9d2` clears whatever was there
|
||||
latch.0 = None; // not guid-matched: `0x5eb9d2` clears whatever was there
|
||||
return;
|
||||
}
|
||||
// The loot window opens (decision 0084): fill LootState from the wire; the feed
|
||||
// ([`super`]) resolves rows + fires LOOT_OPENED next frame. `loot_type` rides along
|
||||
// for `IsFishingLoot()` (decision 1086).
|
||||
debug!(
|
||||
"net: loot response {guid:#x} type {loot_type} gold {gold} {} item(s)",
|
||||
items.len()
|
||||
);
|
||||
loot.open(guid, loot_type, gold, items);
|
||||
// `0x5ebb60` — the packet's guid, verbatim. Re-arming a corpse's already-matching latch is a
|
||||
// no-op; a chest re-arms what `SMSG_SPELL_GO` armed; a **fishing** bobber arms here for the
|
||||
// first time and still does not kneel, because the pose is predicate B's call, not the
|
||||
// latch's ([`super::LootKneel`]).
|
||||
// A fishing bobber first arms here and still does not kneel: `LootKneel` decides the pose.
|
||||
latch.0 = Some(guid);
|
||||
}
|
||||
|
||||
/// A fishing verdict with no loot window (`SMSG_FISH_ESCAPED` / `SMSG_FISH_NOT_HOOKED`, both
|
||||
/// empty-bodied; decision 1086): the **yellow** toast by GlobalStrings key — `ERR_FISH_ESCAPED`
|
||||
/// ("Your fish got away!") when the skill roll failed on the click, `ERR_FISH_NOT_HOOKED`
|
||||
/// ("No fish are hooked.") when the bobber expired or was clicked before the splash. Yellow, not
|
||||
/// red: the reference handlers (`0x5e3fc5`/`0x5e3fe2` → `DisplayError` ids `0x13e`/`0x13f`) are
|
||||
/// **type-1** registry entries, which fire `UI_INFO_MESSAGE` (`DisplayError`'s type-1 arm
|
||||
/// `0x49684b`), correcting 1086's shipped guess.
|
||||
/// `SMSG_FISH_ESCAPED` (the skill roll failed) or `SMSG_FISH_NOT_HOOKED` (clicked before the
|
||||
/// splash, or expired): a yellow toast, as the handlers `0x5e3fc5`/`0x5e3fe2` push ids
|
||||
/// `0x13e`/`0x13f`, type-1 entries that `DisplayError` fires as `UI_INFO_MESSAGE` (`0x49684b`).
|
||||
fn fish_verdict(escaped: bool, errors: &mut UiErrorKeys) {
|
||||
let key = if escaped {
|
||||
"ERR_FISH_ESCAPED"
|
||||
@@ -238,73 +199,27 @@ fn fish_verdict(escaped: bool, errors: &mut UiErrorKeys) {
|
||||
errors.0.push(UiError::key(key));
|
||||
}
|
||||
|
||||
/// The **default** arm releases.
|
||||
///
|
||||
/// The out-of-range branch is not merely *adjacent* to code 7's arm, it **is** code 7's arm:
|
||||
///
|
||||
/// ```text
|
||||
/// 5eba11: 0f 87 a6 00 00 00 ja 0x5ebabd ; 0x5eba17 + 0xa6 = 0x5ebabd
|
||||
/// 5ebabd: 68 83 00 00 00 push 0x83 ; 5 bytes, ending exactly at 0x5ebac2
|
||||
/// 5ebac2: e8 59 ac ea ff call 0x496720 ; the release tail, reached by FALL-THROUGH
|
||||
/// ```
|
||||
///
|
||||
/// Jump-table slot 3 (`0x5ebc70`) holds the same `bd ba 5e 00`, a dword that occurs exactly once
|
||||
/// in the image. So every code the table does not name shows `ERR_LOOT_DIDNT_KILL` **and** ends
|
||||
/// the kneel — and the default's reach is wider than the enum suggests: `0x5eba07` is a `movzx`
|
||||
/// and the `ja` is **unsigned**, so 0..3 and 15..255 all land here.
|
||||
/// The default arm is code 7's arm (`0x5eba11 ja 0x5ebabd`), so it releases; the compare is
|
||||
/// unsigned, so codes 0..3 and 15..255 all land there.
|
||||
const DEFAULT_ARM_RELEASES: bool = true;
|
||||
|
||||
/// One arm of the loot-refusal jump table: the message it displays, and whether it falls through
|
||||
/// the **release tail**.
|
||||
///
|
||||
/// Both halves are the reference's, read off the same eleven-entry table — each arm is a
|
||||
/// `push <stringId>` that either falls into the tail at `0x5ebac2` or jumps past it, so "what does
|
||||
/// it say" and "does the kneel end" are one decision in the bytes and one decision here.
|
||||
/// One arm of the loot-refusal jump table: the message it pushes, and whether it falls into the
|
||||
/// release tail at `0x5ebac2`.
|
||||
struct LootRefusal {
|
||||
/// The `GlobalStrings` key of the id the arm hands `CGGameUI::DisplayError` (`0x496720`).
|
||||
key: &'static str,
|
||||
/// Whether the arm falls into the release tail at `0x5ebac2` — zero the pending-loot guid
|
||||
/// (`[player+0x1d28/+0x1d2c]`), `UnlockItem 0x495420`, `RecomputeBaseAnim 0x5fd9e0(-1)`.
|
||||
/// That guid **is** benilla's [`LootLatch`], so this is exactly what ends the
|
||||
/// client-predicted loot kneel.
|
||||
/// The tail zeroes the loot guid (our [`LootLatch`]), runs `UnlockItem 0x495420` and
|
||||
/// `RecomputeBaseAnim 0x5fd9e0(-1)`: it ends the kneel.
|
||||
releases: bool,
|
||||
}
|
||||
|
||||
/// The refusal a `SMSG_LOOT_RESPONSE` error code produces — **the reference's own jump table**,
|
||||
/// transcribed.
|
||||
///
|
||||
/// The error leg is entered on `lootType == 0` (`0x5eb9f4`), reads the code (`0x5eba02`) and
|
||||
/// dispatches `0x5eba0b add eax,-4; cmp eax,0xa; ja <default>; jmp [4*eax + 0x5ebc64]`. The
|
||||
/// eleven-entry table read out of the PE gives:
|
||||
///
|
||||
/// | wire code | stringId | key | releases |
|
||||
/// |---|---|---|---|
|
||||
/// | 4 `TOO_FAR` | `0x82` (130) | `ERR_LOOT_TOO_FAR` | yes |
|
||||
/// | 5 `BAD_FACING` | `0x84` (132) | `ERR_LOOT_BAD_FACING` | yes |
|
||||
/// | 6 `LOCKED` | `0x81` (129) | `ERR_LOOT_LOCKED` | yes |
|
||||
/// | 7 (unnamed server-side) | `0x83` (131) | `ERR_LOOT_DIDNT_KILL` | yes |
|
||||
/// | 8 `NOTSTANDING` | `0x85` (133) | `ERR_LOOT_NOTSTANDING` | yes |
|
||||
/// | 9 `STUNNED` | `0x86` (134) | `ERR_LOOT_STUNNED` | yes |
|
||||
/// | 10 `PLAYER_NOT_FOUND` | `0x19b` (411) | `ERR_LOOT_PLAYER_NOT_FOUND` | **no** |
|
||||
/// | 11 `PLAY_TIME_EXCEEDED` | `0x1bf` (447) | `ERR_PLAY_TIME_EXCEEDED` | yes |
|
||||
/// | 12 `MASTER_INV_FULL` | `0x1cd` (461) | `ERR_LOOT_MASTER_INV_FULL` | **no** |
|
||||
/// | 13 `MASTER_UNIQUE_ITEM` | `0x1ce` (462) | `ERR_LOOT_MASTER_UNIQUE_ITEM` | **no** |
|
||||
/// | 14 `MASTER_OTHER` | `0x1cf` (463) | `ERR_LOOT_MASTER_OTHER` | **no** |
|
||||
///
|
||||
/// **The four that do not release are the four that arrive at an already-open window** — the
|
||||
/// master looter's three `CMSG_LOOT_MASTER_GIVE` refusals (decision 1675) and the dead/absent
|
||||
/// recipient. The corpse stays open and the character stays kneeling, which is why the reference
|
||||
/// jumps past the tail for exactly these.
|
||||
///
|
||||
/// **Everything outside 4..=14 takes the default arm**, `ERR_LOOT_DIDNT_KILL` — including code
|
||||
/// `0` (`DIDNT_KILL` itself, which reaches its own sentence only through the default) and vmangos's
|
||||
/// `ALREADY_PICKPOCKETED` (15) / `NOT_WHILE_SHAPESHIFTED` (16). 1.12 ships **no** string for an
|
||||
/// already-picked pocket or a shapeshifted refusal; the client says "You don't have permission to
|
||||
/// loot that corpse." for both, and inventing a better sentence is not fidelity.
|
||||
/// The reference's refusal jump table for codes 4..=14 (`0x5eba0b`, entries at `0x5ebc64`). The
|
||||
/// four that answer an already-open window (10, 12, 13, 14) skip the release tail. Every other
|
||||
/// code takes the default, `ERR_LOOT_DIDNT_KILL`: 1.12 has no string for vmangos'
|
||||
/// `ALREADY_PICKPOCKETED` (15) or `NOT_WHILE_SHAPESHIFTED` (16).
|
||||
fn loot_refusal(reason: u8) -> LootRefusal {
|
||||
use benilla_protocol::messages::loot_error as e;
|
||||
// `7` has no vmangos name — it is a gap in the server enum — but it is a real arm of the
|
||||
// client's table, and a *releasing* one, which is why it cannot simply fall to the default.
|
||||
// A gap in the vmangos enum, but a real arm of the client's table.
|
||||
const UNNAMED_DIDNT_KILL: u8 = 7;
|
||||
let (key, releases) = match reason {
|
||||
e::TOO_FAR => ("ERR_LOOT_TOO_FAR", true),
|
||||
@@ -323,30 +238,11 @@ fn loot_refusal(reason: u8) -> LootRefusal {
|
||||
LootRefusal { key, releases }
|
||||
}
|
||||
|
||||
/// The server refused to open the loot window (`SMSG_LOOT_RESPONSE`'s error shape — didn't kill
|
||||
/// it, too far, not standing, …): the message by **GlobalStrings key**, never a sentence of ours,
|
||||
/// and the kneel dropped only on the arms that release ([`loot_refusal`]).
|
||||
///
|
||||
/// The key route is not a style preference. The reference displays every one of these through
|
||||
/// `DisplayError(stringId)`, so the *surface* (red `UIErrorsFrame`, from the catalog row's `+0x04`)
|
||||
/// and the *voice line* (`+0x0c` — four of these speak in the player's own race and gender) are
|
||||
/// properties of the message, and a hardcoded English string throws all three away along with
|
||||
/// localization. Before this, benilla composed its own eight sentences here and six of them said
|
||||
/// something the client never says.
|
||||
///
|
||||
/// The arms that release run the reference's release tail `0x5ebac2`, which also calls
|
||||
/// `UnlockItem 0x495420` on the packet's guid: a refused lockbox open drops the item's pending
|
||||
/// lock. For a corpse or chest guid there is no item, and nothing unlocks.
|
||||
///
|
||||
/// **A gap this deliberately does not close** (named here rather than left to a later bug
|
||||
/// report):
|
||||
///
|
||||
/// 1. **A guid-MISMATCHED error takes a different arm in the reference.** The error leg is
|
||||
/// reachable only past the admission gate, so the reference only ever *displays* a refusal for
|
||||
/// the session it asked about; a mismatched one falls to `0x5eb963`, which clears the latch
|
||||
/// unconditionally, shows **nothing**, and (because `lootType == 0`) sends nothing. benilla has
|
||||
/// no admission gate on the error shape — it displays the line and keeps the latch. Reachable
|
||||
/// only under the corpse-switch race, and it belongs with the gate (1477), not with the text.
|
||||
/// `SMSG_LOOT_RESPONSE`'s error shape: the arm's message by key, as `DisplayError(stringId)` takes
|
||||
/// its surface and voice from the record; a releasing arm drops the latch and runs
|
||||
/// `UnlockItem 0x495420` on the packet's guid (the tail `0x5ebac2`). There is no admission gate
|
||||
/// here: a guid-mismatched error still shows and keeps the latch, where the reference refuses it
|
||||
/// at `0x5eb963`, clearing the latch and showing nothing.
|
||||
fn loot_error(
|
||||
guid: u64,
|
||||
error: u8,
|
||||
@@ -358,44 +254,36 @@ fn loot_error(
|
||||
debug!("net: loot error {error} on {guid:#x} → {key} (releases: {releases})");
|
||||
errors.0.push(UiError::key(key));
|
||||
if releases {
|
||||
// The latch armed at the `CMSG_LOOT` send drops (guid-matched — see [`LootLatch`]), or the
|
||||
// character would kneel forever at a corpse whose window never opened (decision 0515).
|
||||
// Guid-matched: the tail's store is unconditional, but only a matching latch reaches it.
|
||||
latch.clear_for(guid);
|
||||
unlock.unlock(guid);
|
||||
}
|
||||
}
|
||||
|
||||
/// One loot-window row was taken, by anyone (`SMSG_LOOT_REMOVED`) — the UI clears that row.
|
||||
/// `SMSG_LOOT_REMOVED`: a row was taken, by anyone; the feed fires `LOOT_SLOT_CLEARED` for it.
|
||||
fn loot_removed(slot: u8, loot: &mut LootState) {
|
||||
// A row was taken (by anyone): drop it; the feed repaints via LOOT_UPDATE.
|
||||
debug!("net: loot slot {slot} removed");
|
||||
loot.remove_slot(slot);
|
||||
}
|
||||
|
||||
/// Our share of the loot's coin pile (`SMSG_LOOT_MONEY_NOTIFY`), answering our `CMSG_LOOT_MONEY`.
|
||||
/// `SMSG_LOOT_MONEY_NOTIFY`, our share of a group's coin: only logged, as the coin row drops on
|
||||
/// `SMSG_LOOT_CLEAR_MONEY` and the purse rides the coinage field. vmangos sends none for a solo
|
||||
/// loot (`LootHandler.cpp:331`).
|
||||
fn loot_money_notify(amount: u32) {
|
||||
// Our share of the coin pile — informational; the coin row drops on CLEAR_MONEY and
|
||||
// the purse rides the ordinary COINAGE flush. Solo looting rarely sends this.
|
||||
debug!("net: loot money {amount}");
|
||||
}
|
||||
|
||||
/// The coin line disappears for every current looter (`SMSG_LOOT_CLEAR_MONEY`).
|
||||
/// `SMSG_LOOT_CLEAR_MONEY`: the coin row goes, for every looter.
|
||||
fn loot_clear_money(loot: &mut LootState) {
|
||||
// The coin line disappears for everyone → drop the coin row.
|
||||
debug!("net: loot coin line cleared");
|
||||
loot.clear_money();
|
||||
}
|
||||
|
||||
/// The loot window closes (`SMSG_LOOT_RELEASE_RESPONSE`), answering our `CMSG_LOOT_RELEASE`.
|
||||
/// Idempotent — a client-side close already cleared. The latch clear is **guid-matched**: under
|
||||
/// the corpse-switch race (loot B requested while A was open) the old window's release response
|
||||
/// must not drop the latch the new request just armed (decision 0515).
|
||||
///
|
||||
/// **An item loot unlocks here.** The handler (`0x5ec090`) ends in `0x48f200(cl=0, dl=0)`, whose
|
||||
/// `48f299` leg calls `UnlockItem 0x495420` when the loot object is an ITEM (`0x48f200` gates
|
||||
/// the unlock on `dl == 0`). It is the only clear an opened lockbox closed with loot left
|
||||
/// ever gets: vmangos' `DoLootRelease` destroys the item only once it is fully looted, so its
|
||||
/// slot never changes and [`PendingItemOps::resolve`] has nothing to see.
|
||||
/// `SMSG_LOOT_RELEASE_RESPONSE`, idempotent after our own close. The latch clear is guid-matched,
|
||||
/// as the reference's (`0x5ec0d4`), so an old window's release keeps a newer request's latch.
|
||||
/// It unlocks an opened item (`0x5ec090` → `0x48f200(cl=0, dl=0)` → `UnlockItem 0x495420`): the
|
||||
/// only clear a lockbox closed with loot left gets, as vmangos destroys only a fully looted item
|
||||
/// (`LootHandler.cpp:558`).
|
||||
fn loot_release_response(
|
||||
guid: u64,
|
||||
loot: &mut LootState,
|
||||
@@ -408,46 +296,27 @@ fn loot_release_response(
|
||||
unlock.unlock(guid);
|
||||
}
|
||||
|
||||
/// The master-loot candidate list (`SMSG_LOOT_MASTER_LIST`, decision 1675) — who the master looter
|
||||
/// may hand a row to. It arrives from inside the server's `SendLoot`, so it lands just AHEAD of the
|
||||
/// `SMSG_LOOT_RESPONSE` it belongs to; `LootState` stages it and the open claims it.
|
||||
/// `SMSG_LOOT_MASTER_LIST`, staged: it lands just ahead of the response it belongs to.
|
||||
fn loot_master_list(candidates: Vec<u64>, loot: &mut LootState) {
|
||||
debug!("net: master-loot candidates: {} eligible", candidates.len());
|
||||
loot.set_master_candidates(candidates);
|
||||
}
|
||||
|
||||
/// Is this push **ours**? The outermost gate in `CGGameUI::OnItemPush` (1.12.1 `WoW.exe` 5875,
|
||||
/// `0x491a60`): `0x491b56`/`0x491b61` compare the packet guid against the active player's and fork,
|
||||
/// and the self arm is the only one that prints "You receive …" *or* animates a bag button. A group
|
||||
/// member's push takes the `0x491d9f` arm and prints `LOOT_ITEM` ("%s receives loot: %s.") with
|
||||
/// *their* name — a line we do not build yet, so a foreign push is dropped rather than mislabelled
|
||||
/// ours.
|
||||
///
|
||||
/// The wire's **`showInChat`** used to be folded in here, which read correctly while the chat line
|
||||
/// was this packet's only output. It is a *later*, narrower gate — `0x491bf3` (self) / `0x491db1`
|
||||
/// (other) skip the chat formatter alone, after the `ITEM_PUSH` fire at `0x491be8` — so it now
|
||||
/// rides into [`LootState::push_receive`] as `PendingReceive::in_chat` and silences the line
|
||||
/// without touching the animation (decision 0887). vmangos always sends 1, so it is inert against
|
||||
/// our server either way; it is the client's own gate, kept where the client keeps it.
|
||||
/// `OnItemPush 0x491a60`'s outermost gate (`0x491b56`/`0x491b61`): only our own push prints or
|
||||
/// animates. Another member's push takes the `0x491d9f` arm and prints `LOOT_ITEM` with their
|
||||
/// name, which is not built, so a foreign push is dropped. `showInChat` is a later gate
|
||||
/// (`0x491bf3`/`0x491db1`), carried into [`LootState::push_receive`].
|
||||
fn is_our_push(p: &ItemPushResult, self_guid: &SelfGuid) -> bool {
|
||||
self_guid.0 == Some(p.player_guid)
|
||||
}
|
||||
|
||||
/// An item landed in our bags — looted or received from an NPC (`SMSG_ITEM_PUSH_RESULT`); drives
|
||||
/// the "You receive loot: …" chat line (gated by [`prints_receive_line`]) **and** the bag-bar drop
|
||||
/// animation (decision 0887), which is NOT so gated — hence the whole packet going through, and the
|
||||
/// self check being the only thing that can stop a push here. The reference's
|
||||
/// `CGGameUI::OnItemPush 0x491a60` emits both from this one packet: it returns early only on a guid
|
||||
/// mismatch, and tests `showInChat` further down, after the `ITEM_PUSH` fire.
|
||||
/// `SMSG_ITEM_PUSH_RESULT`: queues our own push for its chat line and its bag-bar animation.
|
||||
fn item_push_result(
|
||||
p: ItemPushResult,
|
||||
self_guid: &SelfGuid,
|
||||
loot: &mut LootState,
|
||||
tutorials: &mut crate::tutorial::Tutorials,
|
||||
) {
|
||||
// Solo loot never gets a MONEY_NOTIFY from this server (vmangos comments it out;
|
||||
// the purse rides the ordinary COINAGE field flush) — so this push line is the
|
||||
// one reliable "it landed" signal, surfaced as the "You receive loot/item" line.
|
||||
debug!(
|
||||
"net: item push {} x{} → bag {} slot {:#x}",
|
||||
p.item_entry, p.count, p.bag_slot, p.item_slot
|
||||
@@ -455,7 +324,6 @@ fn item_push_result(
|
||||
if !is_our_push(&p, self_guid) {
|
||||
return;
|
||||
}
|
||||
// The item-received handler's tutorial sites (1976): resolved on the tutorial feed.
|
||||
tutorials.item_received(p.item_entry, p.bag_slot, p.item_slot);
|
||||
loot.push_receive(&p);
|
||||
}
|
||||
@@ -467,7 +335,7 @@ mod tests {
|
||||
|
||||
use crate::pending_item_ops::{LockTransitions, PendingItemOps};
|
||||
|
||||
/// An [`ItemUnlock`] over an empty lock set — for the tests whose loot source is no item.
|
||||
/// An [`ItemUnlock`] over no locks, for a loot source that is not an item.
|
||||
macro_rules! no_item {
|
||||
() => {
|
||||
ItemUnlock {
|
||||
@@ -477,10 +345,6 @@ mod tests {
|
||||
};
|
||||
}
|
||||
|
||||
/// Both fish-verdict keys queue as **yellow** (type-1 / `UI_INFO_MESSAGE`) entries — the
|
||||
/// `DisplayError` arm `0x49684b` — and both resolve to the exact 1.12
|
||||
/// strings in the shipped `GlobalStrings.lua` (the equip-error test's runtime pattern —
|
||||
/// a typo'd key would silently swallow the toast). Skips without client data.
|
||||
#[test]
|
||||
fn fish_verdict_keys_resolve_in_the_real_global_strings() {
|
||||
let mut errors = UiErrorKeys::default();
|
||||
@@ -506,14 +370,7 @@ mod tests {
|
||||
assert_eq!(g("ERR_FISH_ESCAPED"), "Your fish got away!");
|
||||
}
|
||||
|
||||
/// **The refusal table is the reference's, by message id.** Each key must be the catalog row
|
||||
/// whose id the binary's own jump table pushes to `DisplayError` (`0x5ebc64`: `4→0x82 ·
|
||||
/// 5→0x84 · 6→0x81 · 7→0x83 · 8→0x85 · 9→0x86 · 10→0x19b · 11→0x1bf · 12→0x1cd · 13→0x1ce ·
|
||||
/// 14→0x1cf`, default `0x83`).
|
||||
///
|
||||
/// Asserting the **id** rather than the spelling is the point: the key text is only a name for
|
||||
/// a number the client pushes, and the catalog is generated from that same table (decision
|
||||
/// 1770). A key that reads plausibly but names the wrong row cannot survive this.
|
||||
/// The ids the reference's jump table (`0x5ebc64`) pushes: a plausible wrong key fails.
|
||||
#[test]
|
||||
fn every_refusal_names_the_message_id_the_reference_pushes() {
|
||||
const TABLE: &[(u8, u16)] = &[
|
||||
@@ -528,8 +385,7 @@ mod tests {
|
||||
(12, 0x1cd),
|
||||
(13, 0x1ce),
|
||||
(14, 0x1cf),
|
||||
// The default arm: code 0 is `DIDNT_KILL` itself, 15 `ALREADY_PICKPOCKETED` and 16
|
||||
// `NOT_WHILE_SHAPESHIFTED` have no string of their own, and 1/2/3 are server-enum gaps.
|
||||
// The default arm: 0 is `DIDNT_KILL`, 15 and 16 have no 1.12 string, 1 is an enum gap.
|
||||
(0, 0x83),
|
||||
(1, 0x83),
|
||||
(15, 0x83),
|
||||
@@ -545,8 +401,7 @@ mod tests {
|
||||
"code {code} → {key} (id {}), but the reference pushes {id:#x}",
|
||||
record.id
|
||||
);
|
||||
// Every one of these is a red `UIErrorsFrame` line, never a chat line — the record's
|
||||
// `+0x04`, which is what benilla threw away by composing its own sentence.
|
||||
// A red `UIErrorsFrame` line: the record's `+0x04`.
|
||||
assert_eq!(
|
||||
record.kind,
|
||||
benilla_ui::messages::MsgKind::Error,
|
||||
@@ -554,10 +409,8 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// **The four commonest refusals are SPOKEN**, in the player's own race and gender — their
|
||||
// `type_tag` (`+0x0c`) is a `VocalUIEnum` line id rather than the `0x44` "play the cue"
|
||||
// sentinel. A hardcoded sentence has no record, so it had no voice either; this is the
|
||||
// third thing the key route gets back, after the wording and the surface.
|
||||
// Four refusals are voiced in the player's race and gender: their `type_tag` (`+0x0c`) is
|
||||
// a `VocalUIEnum` line id, not the `0x44` cue sentinel.
|
||||
let voiced = |key: &str| {
|
||||
usize::from(benilla_ui::messages::by_key(key).expect(key).type_tag)
|
||||
< benilla_formats::VOCAL_UI_LINES
|
||||
@@ -570,18 +423,12 @@ mod tests {
|
||||
] {
|
||||
assert!(voiced(key), "{key} lost its voice line");
|
||||
}
|
||||
// …and the rest are cue-tagged, so the split is asserted from both sides.
|
||||
// The rest are cue-tagged.
|
||||
for key in ["ERR_LOOT_NOTSTANDING", "ERR_LOOT_MASTER_OTHER"] {
|
||||
assert!(!voiced(key), "{key} unexpectedly carries a voice line");
|
||||
}
|
||||
}
|
||||
|
||||
/// **The regression for the invented English.** Every refusal key must resolve in the shipped
|
||||
/// 1.12 `GlobalStrings.lua` to the sentence the real client shows. benilla used to compose
|
||||
/// these itself and six of the eight said something 1.12 never says — "You are too far away to
|
||||
/// loot that." for a string that ends "that corpse.", "You can't loot that from there." for
|
||||
/// "You must be facing the corpse to loot it.", and a wholly invented "Those pockets are
|
||||
/// already empty." for a code that has no string at all. Skips without client data.
|
||||
#[test]
|
||||
fn refusal_keys_resolve_to_the_real_1_12_sentences() {
|
||||
let data = benilla_formats::wow_data_or_skip!();
|
||||
@@ -605,7 +452,7 @@ mod tests {
|
||||
(12, "That player's inventory is full"),
|
||||
(13, "Player has too many of that item already"),
|
||||
(14, "Can't assign item to that player"),
|
||||
// The default arm, including the two vmangos codes 1.12 has no sentence for.
|
||||
// The default arm, including the two vmangos codes 1.12 has no string for.
|
||||
(0, "You don't have permission to loot that corpse."),
|
||||
(15, "You don't have permission to loot that corpse."),
|
||||
(16, "You don't have permission to loot that corpse."),
|
||||
@@ -614,13 +461,10 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// **The four refusals that arrive at an already-open window do not end the kneel.** The
|
||||
/// reference's arms for 10 and the master trio jump *past* the release tail at `0x5ebac2`; the
|
||||
/// rest fall into it. A master looter refused a hand-off keeps kneeling at the open corpse.
|
||||
/// Codes 10 and 12..=14 answer an open window and jump past the release tail `0x5ebac2`.
|
||||
#[test]
|
||||
fn only_the_window_less_refusals_drop_the_kneel() {
|
||||
// The named releasing arms, plus the default — `ja 0x5ebabd` lands *on* code 7's arm and
|
||||
// falls through the tail, and the compare is unsigned, so 0..3 and 15..255 all release.
|
||||
// The releasing arms plus the default, which is code 7's arm (`ja 0x5ebabd`).
|
||||
for code in [4u8, 5, 6, 7, 8, 9, 11, 0, 1, 2, 3, 15, 16, 200, 255] {
|
||||
let mut errors = UiErrorKeys::default();
|
||||
let mut latch = LootLatch(Some(CORPSE));
|
||||
@@ -640,20 +484,8 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The **error leg's** clear stays guid-matched, and this is not the deviation 0515 took it
|
||||
/// for: it is behaviourally *identical* to the reference.
|
||||
///
|
||||
/// The tail's own store is unconditional, but the tail cannot be reached on a mismatch.
|
||||
/// `0x5eb9f1` has exactly one predecessor image-wide — `0x5eb93e je`, which already required
|
||||
/// latch ≠ 0 **and** both GUID dwords equal — and it has no fall-through predecessor
|
||||
/// (`0x5eb9ee` is `ret 8`); the other predecessors of `0x5eb9f4` all require the loot type in
|
||||
/// `{2,3,4}` and so leave at `0x5eb9f8 jne`. A mismatched error never gets here at all.
|
||||
///
|
||||
/// Read this beside [`a_refusal_drops_whatever_latch_was_live_not_just_a_matching_one`], which
|
||||
/// asserts the opposite for a **different arm**: the admission gate `0x5eb963`/`0x5eb9d2`,
|
||||
/// which *is* reached on a mismatch and does clear unconditionally. Not a contradiction — a
|
||||
/// distinction the handler draws, and the reason the mismatch case is a separate open gap
|
||||
/// (see [`loot_error`]).
|
||||
/// The reference's behaviour: the tail's store is unconditional, but the error leg is reached
|
||||
/// only through `0x5eb93e`, which already required a matching latch.
|
||||
#[test]
|
||||
fn the_error_legs_clear_is_guid_matched() {
|
||||
let mut errors = UiErrorKeys::default();
|
||||
@@ -662,12 +494,12 @@ mod tests {
|
||||
assert_eq!(latch.0, Some(CHEST));
|
||||
}
|
||||
|
||||
/// A GameObject guid (HIGHGUID_GAMEOBJECT in the high dword) — a chest.
|
||||
/// A GameObject guid: a chest.
|
||||
const CHEST: u64 = 0xF110_0000_0000_1234;
|
||||
/// A creature guid — a corpse.
|
||||
/// A creature guid: a corpse.
|
||||
const CORPSE: u64 = 0xF130_0000_0000_00AB;
|
||||
|
||||
/// A `NetCommands` plus its receiver, so a test can read what the handler actually sent.
|
||||
/// A `NetCommands` and its receiver, to read what a handler sent.
|
||||
fn net() -> (
|
||||
crate::net::NetCommands,
|
||||
crossbeam_channel::Receiver<ClientCommand>,
|
||||
@@ -676,10 +508,7 @@ mod tests {
|
||||
(crate::net::NetCommands(tx), rx)
|
||||
}
|
||||
|
||||
/// **B84 / decisions 1471 + 1477.** A chest never sends `CMSG_LOOT`, so the corpse branch's
|
||||
/// arm-at-the-send (0515) never fires for it. `SMSG_SPELL_GO` is its real arm; the response's
|
||||
/// own arm is the second half, and on a **cold** latch it admits only the server-started
|
||||
/// types 2/3/4 (`0x5eb94b`–`0x5eb95b`). A chest's answer is wire type 2, so it opens and arms.
|
||||
/// No `CMSG_LOOT` armed the latch; a chest's answer is wire type 2 (`0x5eb94b`-`0x5eb95b`).
|
||||
#[test]
|
||||
fn a_cold_latch_admits_a_server_started_loot_and_arms_on_it() {
|
||||
let (net, rx) = net();
|
||||
@@ -695,28 +524,22 @@ mod tests {
|
||||
"an accepted response bounces nothing"
|
||||
);
|
||||
|
||||
// The ordinary close path still ends it — the latch is guid-matched, and a chest guid is
|
||||
// no different from a corpse one there.
|
||||
loot_release_response(CHEST, &mut loot, &mut latch, no_item!());
|
||||
assert_eq!(latch.0, None, "the release ends the session");
|
||||
}
|
||||
|
||||
/// The corpse path is untouched: the send already armed the latch with the same guid, so the
|
||||
/// response takes the **GUID-match** branch (`0x5eb93e`) and its re-arm is a no-op.
|
||||
/// The `CMSG_LOOT` send armed the same guid: the match branch (`0x5eb93e`).
|
||||
#[test]
|
||||
fn a_matching_latch_admits_any_loot_type() {
|
||||
let (net, _rx) = net();
|
||||
let mut loot = LootState::default();
|
||||
let mut latch = LootLatch(Some(CORPSE)); // the `CMSG_LOOT` send, client-predicted
|
||||
let mut latch = LootLatch(Some(CORPSE)); // the `CMSG_LOOT` send
|
||||
loot_response(CORPSE, 1, 0, Vec::new(), &mut loot, &mut latch, &net);
|
||||
assert_eq!(latch.0, Some(CORPSE));
|
||||
assert_eq!(loot.source(), Some(CORPSE));
|
||||
}
|
||||
|
||||
/// **The refusal arm (`0x5eb963`, decision 1477).** `loot_type == 1` means "you asked for
|
||||
/// this" — and a cold latch says we did not. The real client opens no window, bounces a
|
||||
/// `CMSG_LOOT_RELEASE` for the *packet's* guid, and clears. 1471 shipped an unconditional
|
||||
/// arm, which is exactly this case's divergence.
|
||||
/// The refusal arm (`0x5eb963`): a type-1 answer on a cold latch answers nothing we asked.
|
||||
#[test]
|
||||
fn a_cold_latch_refuses_a_corpse_typed_response_and_bounces_it() {
|
||||
let (net, rx) = net();
|
||||
@@ -736,8 +559,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The refuse arm's clear is **not** guid-matched (`0x5eb9d2`): an unsolicited response for B
|
||||
/// drops a live latch on A, and releases B. Faithfully odd, and byte-verified.
|
||||
/// The refusal's clear is not guid-matched (`0x5eb9d2`): a response for B drops a latch on A.
|
||||
#[test]
|
||||
fn a_refusal_drops_whatever_latch_was_live_not_just_a_matching_one() {
|
||||
let (net, rx) = net();
|
||||
@@ -752,11 +574,10 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A lockbox's item guid (HIGHGUID_ITEM is 0x4000 in the high word).
|
||||
/// An item guid (`HIGHGUID_ITEM` 0x4000 in the high word): a lockbox.
|
||||
const LOCKBOX: u64 = 0x4000_0000_0000_0007;
|
||||
|
||||
/// A World with what the release / error handlers write, and the lockbox's open-item lock
|
||||
/// already armed at bag 0 slot 3 — `ui_items::drain`'s `CMSG_OPEN_ITEM` arm (0916).
|
||||
/// A world with the lockbox at bag 0 slot 3 locked, as its `CMSG_OPEN_ITEM` send leaves it.
|
||||
fn opened_lockbox_world() -> World {
|
||||
let mut world = World::new();
|
||||
world.init_resource::<LootState>();
|
||||
@@ -769,11 +590,7 @@ mod tests {
|
||||
world
|
||||
}
|
||||
|
||||
/// **The release unlocks an opened lockbox.** `SMSG_LOOT_RELEASE_RESPONSE` → `0x5ec090` →
|
||||
/// `0x48f200(cl=0, dl=0)`, whose `48f299` leg calls `UnlockItem 0x495420` when the loot
|
||||
/// object is an ITEM (`0x48f200` gates the unlock on `dl == 0`). Closing a lockbox's window
|
||||
/// with loot left destroys nothing server-side (vmangos `DoLootRelease` destroys only a fully
|
||||
/// looted item), so no field update ever resolves the lock — the release is the clear.
|
||||
/// With loot left, no field update ever resolves the lock: the release is the clear.
|
||||
#[test]
|
||||
fn a_release_for_the_opened_item_unlocks_its_slot() {
|
||||
let mut world = opened_lockbox_world();
|
||||
@@ -794,8 +611,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The control: a release naming some other object (a corpse closing) leaves the lockbox's
|
||||
/// lock exactly where it was.
|
||||
#[test]
|
||||
fn a_release_for_another_guid_leaves_the_lock() {
|
||||
let mut world = opened_lockbox_world();
|
||||
@@ -809,8 +624,7 @@ mod tests {
|
||||
assert!(world.resource::<LockTransitions>().0.is_empty());
|
||||
}
|
||||
|
||||
/// The error leg's release tail `0x5ebac2` calls the same `UnlockItem 0x495420` on the
|
||||
/// packet's guid — on the arms that release, and only those.
|
||||
/// Only a releasing arm reaches `UnlockItem 0x495420`, through the tail `0x5ebac2`.
|
||||
#[test]
|
||||
fn a_releasing_loot_error_on_the_opened_item_unlocks_it() {
|
||||
let mut world = opened_lockbox_world();
|
||||
@@ -857,18 +671,14 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// `OnItemPush`'s outermost gate: the push must be **ours** to produce anything at all. A party
|
||||
/// member's drop must NOT print as "You receive loot" — the real client takes its other-player
|
||||
/// arm and names them instead.
|
||||
#[test]
|
||||
fn only_our_own_pushes_reach_the_receive_queue() {
|
||||
let me = SelfGuid(Some(ME));
|
||||
assert!(is_our_push(&push(ME, true), &me));
|
||||
assert!(!is_our_push(&push(THEM, true), &me));
|
||||
// Before login lands a guid there is no active player to match against.
|
||||
// No guid before login: no active player to match.
|
||||
assert!(!is_our_push(&push(ME, true), &SelfGuid(None)));
|
||||
// `showInChat` is NOT this gate (decision 0887): a silent push still queues, and still
|
||||
// animates — it only loses its chat line, downstream in `drain_receives`.
|
||||
// `showInChat` is not this gate: a silent push still queues and animates.
|
||||
assert!(is_our_push(&push(ME, false), &me));
|
||||
let mut loot = LootState::default();
|
||||
item_push_result(
|
||||
|
||||
@@ -1,87 +1,22 @@
|
||||
//! The app-side **group-loot-roll feed** (decision 0591) — the inward half of the roll seam around
|
||||
//! [`benilla_ui::script`]'s `loot_roll` module, the sibling of [`crate::ui_loot`]'s window seam.
|
||||
//! The group loot roll feed: `SMSG_LOOT_START_ROLL`, `SMSG_LOOT_ROLL`, `SMSG_LOOT_ROLL_WON` and
|
||||
//! `SMSG_LOOT_ALL_PASSED` become the stock `GroupLootFrame`'s events and the roll chat lines, and
|
||||
//! its `RollOnLoot` votes go back out as `CMSG_LOOT_ROLL`.
|
||||
//!
|
||||
//! When the group's loot method is group/need-before-greed and a drop is at or above the quality
|
||||
//! threshold, the server does not put the item in anyone's loot window; it opens a **roll** on it
|
||||
//! and every eligible looter gets a `GroupLootFrame` with Need/Greed/Pass and a one-minute bar.
|
||||
//! A fresh roll's `START_LOOT_ROLL` waits for its item template, as in the reference: `0x61b310`
|
||||
//! ends in the cache lookup `0x55ba30`, whose hit fires the event at once (`0x61b430`) and whose
|
||||
//! miss fires it from the arrival callback `0x61b460`. `GroupLootFrame` paints once, from `OnShow`,
|
||||
//! so an event ahead of the template paints a blank frame. Deviation: a negative answer, or none
|
||||
//! within [`TEMPLATE_HOLD_MAX_MS`], opens the frame unresolved, because a frame that never opens
|
||||
//! cannot even be passed on; the reference's callback fires on success only.
|
||||
//!
|
||||
//! The net bridge ([`crate::net::apply`]) fills [`LootRolls`] from the wire: `SMSG_LOOT_START_ROLL`
|
||||
//! → a fresh roll with a client-allocated id ([`LootRolls::start`]); `SMSG_LOOT_ROLL` → one
|
||||
//! announcement line ([`LootRolls::announce`]); `SMSG_LOOT_ROLL_WON` / `SMSG_LOOT_ALL_PASSED` → the
|
||||
//! resolution line plus the roll's close ([`LootRolls::won`] / [`LootRolls::all_passed`]).
|
||||
//! A held roll's `arg2` is still the wire's countdown (`0x61b430` pushes `node+0x3c`); only the bar
|
||||
//! is short, as `GetLootRollTimeLeft` counts from the packet's arrival.
|
||||
//!
|
||||
//! Each frame [`feed_loot_rolls`] ticks every open roll's remaining time, resolves each to a
|
||||
//! Lua-facing [`LootRollEntry`], pushes the snapshot
|
||||
//! ([`benilla_ui::script::UiScript::set_loot_rolls`]), fires `START_LOOT_ROLL(rollID, rollTime)` for
|
||||
//! each newly opened roll and `CANCEL_LOOT_ROLL(rollID)` for each closed one, and drains the queued
|
||||
//! announcement lines into the chat window once their names resolve. [`drain_loot_rolls`] pulls the
|
||||
//! `RollOnLoot` votes back out as [`ClientCommand::LootRoll`].
|
||||
//!
|
||||
//! ## The late item template, and the hold that answers it
|
||||
//!
|
||||
//! A `GroupLootFrame` paints **once**, from its `OnShow`, off whatever `GetLootRollItemInfo`
|
||||
//! answers at that instant. Ours answers out of a **pushed snapshot**, and a snapshot is at least
|
||||
//! one step behind the thing that changed it — twice over:
|
||||
//!
|
||||
//! 1. A roll is added to [`LootRolls::active`] by [`LootRolls::start`], so the *first* snapshot
|
||||
//! that contains a roll is built in the same pass that would announce it. Push before the
|
||||
//! announce and the OnShow sees it; announce first and it paints a blank.
|
||||
//! 2. `name`/`texture`/`quality`/`bindOnPickUp` come from the ask-once item-template cache and are
|
||||
//! `None` until the query lands, which is typically several frames after the roll opens.
|
||||
//!
|
||||
//! (1) is fixed by ordering — the push precedes the events here, as it does in [`crate::ui_loot`].
|
||||
//!
|
||||
//! (2) is **bug B371**: a reported roll frame came up with an empty icon slot and no name, and
|
||||
//! kept both for the roll's full minute. The fix is decision 2010's, and it is not a seam of ours — **it is the gate the
|
||||
//! reference already has**, which decision 1805 named in one line while looking at the window next
|
||||
//! door and which the disassembly settles:
|
||||
//!
|
||||
//! > `0x61b310`, the `SMSG_LOOT_START_ROLL` handler, allocates and lists the roll node and then
|
||||
//! > ends in the item-template cache lookup `0x55ba30(node+0x1c, node+0x10, 0x61b460, node, 0)`.
|
||||
//! > A **hit** (non-zero return) calls `0x61b430`, which fires `START_LOOT_ROLL (0x1f9, "%d%d")`.
|
||||
//! > A **miss** issues the query and leaves the callback `0x61b460` armed — a trampoline whose
|
||||
//! > worker is that same `0x61b430` — so on a cold cache the event fires from the cache *arrival*,
|
||||
//! > not from the packet. (The reference's `FUN_0061b310`/`FUN_0061b430`, decompiled.)
|
||||
//!
|
||||
//! So `GetLootRollItemInfo`'s cache-miss tail is nearly unreachable in the real client, and was our
|
||||
//! common case. There is no repaint to fall back on — the same finding 1805 landed for
|
||||
//! `LOOT_OPENED` one window over. Our retired `GroupLootFrame` re-entered its paint from a
|
||||
//! benilla-only `UPDATE_LOOT_ROLL(rollID)`; 1838 migrated the frame to the stock one, which has no
|
||||
//! such seam, and 1883 removed the event once nothing was listening for it.
|
||||
//!
|
||||
//! [`feed_loot_rolls`] therefore **holds a fresh roll's `START_LOOT_ROLL` until its template
|
||||
//! answers**. Two releases are ours rather than the reference's, and both are the ones 1805 already
|
||||
//! reasoned through for the loot window:
|
||||
//!
|
||||
//! - a **negative** answer — the server does not know the entry — fires the event anyway. The
|
||||
//! reference's callback is success-gated, but a roll frame that never opens is a roll we cannot
|
||||
//! even *pass* on, and the cache-miss tail is exactly what the reference paints in the
|
||||
//! neighbouring case.
|
||||
//! - a **deadline** ([`TEMPLATE_HOLD_MAX_MS`]) fires it if no answer arrives at all, because unlike
|
||||
//! a pending chat line this wait is paid out of the roll's own minute.
|
||||
//!
|
||||
//! `arg2` stays the **wire's** countdown on a held roll, because that is what `0x61b430` pushes
|
||||
//! (`node+0x3c`, the packet field). Only the bar moves: `GetLootRollTimeLeft` is
|
||||
//! `node+0x28 - now()` off a deadline stamped when the *packet* arrived, so a roll announced late
|
||||
//! opens with its bar already a sliver down — which is [`ActiveRoll::remaining_ms`] ticking from
|
||||
//! [`LootRolls::start`], unchanged.
|
||||
//!
|
||||
//! ## Two client-side behaviours, and why
|
||||
//!
|
||||
//! - **`rollID` is ours.** The wire addresses a roll by `(lootedTarget, itemSlot)`; the FrameXML API
|
||||
//! addresses it by an opaque `rollID`. Nothing on the wire depends on its value, so
|
||||
//! [`LootRolls::next_id`] hands out a monotonic one per roll and the drain maps it back.
|
||||
//! - **Our own vote closes our frame immediately** ([`LootRolls::vote`]) — client-predicted, not
|
||||
//! waiting for the server's resolution, the same shape as the 0515 loot-kneel latch. The server
|
||||
//! echoes our vote as an ordinary `SMSG_LOOT_ROLL` announcement and only resolves the roll once
|
||||
//! *everyone* has voted or the minute expires, so a server-driven close would leave our frame up
|
||||
//! for up to a minute after we clicked. **VERIFIED** in the 5875 binary (decision 0594, resolving
|
||||
//! 0591's deferral): `RollOnLoot` at `0x61bdf0` sends the CMSG and fires `CANCEL_LOOT_ROLL`
|
||||
//! itself, in the same call — the client-side close is the real behaviour, not an approximation.
|
||||
//! - **…except on a bind-on-pickup roll**, where a Need or Greed sends *nothing* and leaves the
|
||||
//! frame up: the seam raises `CONFIRM_LOOT_ROLL` instead, and only the popup's `ConfirmLootRoll`
|
||||
//! re-enters past the gate (decision 0594; the gate is in the real client's C `RollOnLoot`, so it
|
||||
//! lives in our seam too — see [`benilla_ui::script`]'s `loot_roll`). Pass is never gated.
|
||||
//! `rollID` is a client-local serial; the wire addresses a roll by `(lootedTarget, itemSlot)`. Our
|
||||
//! own vote closes our frame at once, as `RollOnLoot` (`0x61bdf0`) sends the CMSG and fires
|
||||
//! `CANCEL_LOOT_ROLL` in one call. On a bind-on-pickup roll, Need or Greed only raises
|
||||
//! `CONFIRM_LOOT_ROLL` and `ConfirmLootRoll` sends, the reference's C `RollOnLoot` gate; Pass is
|
||||
//! never gated.
|
||||
|
||||
use benilla_protocol::messages::{roll_vote, LootAllPassed, LootRoll, LootRollWon, LootStartRoll};
|
||||
use bevy::prelude::*;
|
||||
@@ -95,57 +30,38 @@ use crate::net::{ClientCommand, NetCommands, SelfGuid};
|
||||
use crate::ui_chat::{ChatEvent, ChatEventKind, ChatLog};
|
||||
use crate::ui_script::{UiFeed, UiInput};
|
||||
|
||||
/// Give up re-checking a pending announcement's names after this many frames — the same budget and
|
||||
/// reasoning as [`crate::ui_loot`]'s receive lines (a negative-cached entry never resolves).
|
||||
/// Frames a pending line retries its names before dropping: a negative-cached name never resolves.
|
||||
const LINE_MAX_TRIES: u16 = 120;
|
||||
|
||||
/// How long a fresh roll may wait for its item-template answer before `START_LOOT_ROLL` fires
|
||||
/// anyway, with the frame unresolved (decision 2010 — see the module docs' hold).
|
||||
///
|
||||
/// This is [`LINE_MAX_TRIES`]' budget in the unit that actually matters here. That one counts
|
||||
/// *frames*, because a pending chat line costs nothing but frames; a held roll is paid out of the
|
||||
/// roll's own minute, so its bound has to be wall clock or a stuttering machine turns 120 frames
|
||||
/// into eight seconds of a sixty-second bar. 2 s is the same budget at 60 fps, and 3.3% of a roll.
|
||||
/// How long a fresh roll waits for its item template before `START_LOOT_ROLL` fires anyway: wall
|
||||
/// clock, not frames, as the wait comes out of the roll's own minute.
|
||||
const TEMPLATE_HOLD_MAX_MS: u32 = 2_000;
|
||||
|
||||
/// `item_template.bonding` = *bind on pickup* (VERIFIED vmangos `ItemPrototype.h`'s `ItemBondingType`:
|
||||
/// `NO_BIND` 0, `BIND_WHEN_PICKED_UP` 1, `BIND_WHEN_EQUIPPED` 2, `BIND_WHEN_USE` 3, `QUEST_ITEM` 4).
|
||||
/// Only `1` drives `GroupLootFrame_OnShow`'s gold BoP backdrop — a BoE roll shows the plain plate.
|
||||
/// `item_template.bonding` bind on pickup (vmangos `ItemPrototype.h` `ItemBondingType`); only 1
|
||||
/// gets `GroupLootFrame_OnShow`'s gold backdrop.
|
||||
const BIND_WHEN_PICKED_UP: u32 = 1;
|
||||
|
||||
/// One group loot roll currently open on our screen.
|
||||
struct ActiveRoll {
|
||||
/// The client-internal id the Lua side addresses this roll by (see the module docs).
|
||||
/// The id Lua addresses the roll by.
|
||||
roll_id: u32,
|
||||
/// The roll's **wire** identity — what `CMSG_LOOT_ROLL` addresses it by.
|
||||
looted_target: u64,
|
||||
item_slot: u32,
|
||||
item_id: u32,
|
||||
/// `SMSG_LOOT_START_ROLL`'s `randomPropertyId` — the drop's random-suffix roll (decision
|
||||
/// 1547). The reference's own `SetLootRollItem 0x5364a0` copies exactly this into the
|
||||
/// tooltip's `+0x424` and passes no item object, so — like a loot slot — the roll is the only
|
||||
/// enchant source the roll window's hover can have.
|
||||
/// The drop's random-suffix roll: the reference's `SetLootRollItem` (`0x5364a0`) copies it
|
||||
/// into the tooltip's `+0x424` with no item object, so it is the hover's only enchant source.
|
||||
random_property_id: u32,
|
||||
/// `SMSG_LOOT_START_ROLL`'s `countdownTime` — the roll's **duration**, kept verbatim because it
|
||||
/// is what `START_LOOT_ROLL` carries as `arg2` (`0x61b430` pushes `node+0x3c`, the packet
|
||||
/// field) and what `GroupLootFrame_OpenNewFrame` makes the Timer bar's maximum. A roll held for
|
||||
/// its template still declares its full duration; only [`Self::remaining_ms`] is short.
|
||||
/// The wire's `countdownTime`, kept whole: `START_LOOT_ROLL`'s `arg2` and the bar's maximum.
|
||||
countdown_ms: u32,
|
||||
/// Milliseconds left; ticked down by [`feed_loot_rolls`], saturating at `0`. The roll is *not*
|
||||
/// dropped at zero — the server closes it with `SMSG_LOOT_ROLL_WON`/`SMSG_LOOT_ALL_PASSED` when
|
||||
/// its own timer fires, and the frame stays up (bar empty) until that lands.
|
||||
/// Milliseconds left, saturating at 0; the server's resolution closes the roll, not the tick.
|
||||
remaining_ms: u32,
|
||||
/// How long this roll has been waiting for its item template, ticked alongside
|
||||
/// [`Self::remaining_ms`] and frozen once [`Self::announced`] flips. Bounded by
|
||||
/// [`TEMPLATE_HOLD_MAX_MS`].
|
||||
/// How long the roll has waited for its item template.
|
||||
held_ms: u32,
|
||||
/// Whether `START_LOOT_ROLL` has gone out for this roll yet — the hold in the module docs. A
|
||||
/// roll that resolves while still held simply never announces: it leaves [`LootRolls::active`]
|
||||
/// with the rest of its state.
|
||||
/// Whether `START_LOOT_ROLL` has gone out.
|
||||
announced: bool,
|
||||
}
|
||||
|
||||
/// One queued chat announcement, awaiting the item/player names it needs to render.
|
||||
/// One queued chat announcement, awaiting the names it needs.
|
||||
struct PendingLine {
|
||||
line: RollLine,
|
||||
tries: u16,
|
||||
@@ -153,39 +69,26 @@ struct PendingLine {
|
||||
|
||||
/// Which announcement a [`PendingLine`] renders.
|
||||
enum RollLine {
|
||||
/// `SMSG_LOOT_ROLL` — a vote, or a dice result (the overloaded pair; see [`LootRoll::is_dice`]).
|
||||
Announce(LootRoll),
|
||||
/// `SMSG_LOOT_ROLL_WON` — the roll resolved.
|
||||
Won(LootRollWon),
|
||||
/// `SMSG_LOOT_ALL_PASSED` — nobody wanted it.
|
||||
AllPassed(LootAllPassed),
|
||||
}
|
||||
|
||||
/// Every group loot roll open on our screen, filled by the net bridge and read by
|
||||
/// [`feed_loot_rolls`]. Cleared on disconnect.
|
||||
/// Every group loot roll open on our screen; cleared on disconnect.
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct LootRolls {
|
||||
active: Vec<ActiveRoll>,
|
||||
/// Monotonic client-internal id source (see the module docs). Never reused within a session, so
|
||||
/// a late packet for a closed roll can't collide with a fresh one.
|
||||
/// Never reused within a session, so a late packet for a closed roll cannot hit a fresh one.
|
||||
next_id: u32,
|
||||
/// Announcement lines awaiting their names.
|
||||
pending: Vec<PendingLine>,
|
||||
/// Newly closed rolls → one `CANCEL_LOOT_ROLL(rollID)` each, drained by the feed.
|
||||
/// Closed rolls, one `CANCEL_LOOT_ROLL` each.
|
||||
cancelled: Vec<u32>,
|
||||
}
|
||||
|
||||
impl LootRolls {
|
||||
/// A roll opened (`SMSG_LOOT_START_ROLL`): allocate its client-internal id and list the roll.
|
||||
/// The `START_LOOT_ROLL` the `GroupLootFrame` machinery listens for is **not** queued here —
|
||||
/// [`feed_loot_rolls`] fires it once the roll's item template is in hand, which is the
|
||||
/// reference's own gate (module docs). The node exists from this call either way, so
|
||||
/// `GetLootRollItemInfo`/`GetLootRollTimeLeft` answer for a roll whose frame has not opened.
|
||||
///
|
||||
/// A **duplicate** start for a `(looted_target, item_slot)` we already hold is ignored rather
|
||||
/// than opening a second frame for the same item: the server re-sends every active roll on
|
||||
/// reconnect (`Group::SendLootStartRollsForPlayer`, gated by vmangos'
|
||||
/// `SEND_LOOT_ROLL_UPON_RECONNECT`), which would otherwise stack duplicate frames.
|
||||
/// `SMSG_LOOT_START_ROLL`: list the roll under a fresh id. A duplicate `(looted_target,
|
||||
/// item_slot)` is ignored, as vmangos can re-send every active roll on reconnect
|
||||
/// (`Group::SendLootStartRollsForPlayer`, under `SendLootRollUponReconnect`).
|
||||
pub(crate) fn start(&mut self, p: LootStartRoll) {
|
||||
if self
|
||||
.active
|
||||
@@ -213,7 +116,7 @@ impl LootRolls {
|
||||
});
|
||||
}
|
||||
|
||||
/// One roller's vote or dice result (`SMSG_LOOT_ROLL`) — queue its chat line.
|
||||
/// `SMSG_LOOT_ROLL`: queue the chat line.
|
||||
pub(crate) fn announce(&mut self, p: LootRoll) {
|
||||
self.pending.push(PendingLine {
|
||||
line: RollLine::Announce(p),
|
||||
@@ -221,7 +124,7 @@ impl LootRolls {
|
||||
});
|
||||
}
|
||||
|
||||
/// The roll resolved (`SMSG_LOOT_ROLL_WON`): queue the line and close the frame.
|
||||
/// `SMSG_LOOT_ROLL_WON`: queue the line and close the frame.
|
||||
pub(crate) fn won(&mut self, p: LootRollWon) {
|
||||
self.pending.push(PendingLine {
|
||||
line: RollLine::Won(p),
|
||||
@@ -230,8 +133,7 @@ impl LootRolls {
|
||||
self.close(p.looted_target, p.item_slot);
|
||||
}
|
||||
|
||||
/// Nobody wanted it (`SMSG_LOOT_ALL_PASSED`): queue the line and close the frame. The item
|
||||
/// returns to the corpse and reappears as an ordinary lootable row.
|
||||
/// `SMSG_LOOT_ALL_PASSED`: queue the line and close the frame; the item stays lootable.
|
||||
pub(crate) fn all_passed(&mut self, p: LootAllPassed) {
|
||||
self.pending.push(PendingLine {
|
||||
line: RollLine::AllPassed(p),
|
||||
@@ -240,13 +142,8 @@ impl LootRolls {
|
||||
self.close(p.looted_target, p.item_slot);
|
||||
}
|
||||
|
||||
/// Drop the roll on `(looted_target, item_slot)` if we still hold it, queueing its
|
||||
/// `CANCEL_LOOT_ROLL`. Idempotent — a resolution for a roll we already closed is a no-op.
|
||||
///
|
||||
/// The cancel is queued whether or not the roll ever announced, which is the reference's own
|
||||
/// shape (`0x61b9e0`/`0x61b640` guard only on the node's already-closed byte `+0x3a`) and is
|
||||
/// harmless either way: stock `GroupLootFrame_OnEvent` compares `arg1` against each frame's
|
||||
/// `rollID`, and no frame holds one that never opened.
|
||||
/// Drop the roll if we still hold it and queue its `CANCEL_LOOT_ROLL`, announced or not: the
|
||||
/// reference's `0x61b9e0` and `0x61b640` check only the node's closed byte `+0x3a`.
|
||||
fn close(&mut self, looted_target: u64, item_slot: u32) {
|
||||
if let Some(i) = self
|
||||
.active
|
||||
@@ -258,8 +155,7 @@ impl LootRolls {
|
||||
}
|
||||
}
|
||||
|
||||
/// Our own vote on `roll_id`: close the frame client-side and return the roll's wire identity
|
||||
/// for the outbound `CMSG_LOOT_ROLL`. `None` if no such roll is open (a stale click).
|
||||
/// Our own vote: close the frame and return the roll's wire identity; `None` for a stale click.
|
||||
fn vote(&mut self, roll_id: u32) -> Option<(u64, u32)> {
|
||||
let i = self.active.iter().position(|r| r.roll_id == roll_id)?;
|
||||
let r = self.active.remove(i);
|
||||
@@ -267,9 +163,7 @@ impl LootRolls {
|
||||
Some((r.looted_target, r.item_slot))
|
||||
}
|
||||
|
||||
/// Tick every open roll's bar down by `delta_ms`, saturating at zero (see
|
||||
/// [`ActiveRoll::remaining_ms`] for why a spent roll is not dropped here), and age the template
|
||||
/// hold of every roll whose `START_LOOT_ROLL` has not gone out yet.
|
||||
/// Tick every open roll's bar down, and the template hold of every unannounced roll up.
|
||||
fn tick(&mut self, delta_ms: u32) {
|
||||
for r in &mut self.active {
|
||||
r.remaining_ms = r.remaining_ms.saturating_sub(delta_ms);
|
||||
@@ -279,8 +173,7 @@ impl LootRolls {
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop everything (session teardown) — no `CANCEL_LOOT_ROLL` fan-out: the whole UI is going
|
||||
/// down with the session, and the queues would never be drained.
|
||||
/// Session teardown: no `CANCEL_LOOT_ROLL`, as the UI goes down with the session.
|
||||
pub(crate) fn clear(&mut self) {
|
||||
self.active.clear();
|
||||
self.pending.clear();
|
||||
@@ -288,8 +181,7 @@ impl LootRolls {
|
||||
}
|
||||
}
|
||||
|
||||
/// The group rolls' packet handlers (decision 0591; in the net handler table since 2319, moved out
|
||||
/// of the drain's loot arm file).
|
||||
/// The group rolls' packet handlers.
|
||||
mod net {
|
||||
use benilla_protocol::messages::{LootAllPassed, LootRoll, LootRollWon, LootStartRoll};
|
||||
use benilla_protocol::{SessionEvent, SessionEventKind};
|
||||
@@ -298,8 +190,7 @@ mod net {
|
||||
use super::LootRolls;
|
||||
use crate::net::NetHandlerApp;
|
||||
|
||||
/// Register the roll handlers — called from [`super::UiLootRollPlugin`]. One for the four
|
||||
/// kinds, plus the session-end listener.
|
||||
/// Register the roll handlers, called from [`super::UiLootRollPlugin`].
|
||||
pub(super) fn register(app: &mut App) {
|
||||
use SessionEventKind as K;
|
||||
app.net_handler(K::LootStartRoll, on_packet)
|
||||
@@ -319,14 +210,10 @@ mod net {
|
||||
}
|
||||
}
|
||||
|
||||
/// Open group rolls die with the socket (decision 0591). A listener on the session end
|
||||
/// (a second handler on the kind, after the bridge's own teardown).
|
||||
fn on_session_end(In(_): In<SessionEvent>, mut rolls: ResMut<LootRolls>) {
|
||||
rolls.clear();
|
||||
}
|
||||
|
||||
/// A group roll opened on one drop (`SMSG_LOOT_START_ROLL`) — a `GroupLootFrame` goes up with
|
||||
/// Need/Greed/Pass and the countdown bar (decision 0591).
|
||||
fn loot_start_roll(p: LootStartRoll, rolls: &mut LootRolls) {
|
||||
debug!(
|
||||
"net: loot roll opened on item {} ({:#x} slot {}), {} ms",
|
||||
@@ -335,8 +222,6 @@ mod net {
|
||||
rolls.start(p);
|
||||
}
|
||||
|
||||
/// One roller's vote or dice result (`SMSG_LOOT_ROLL`) — the chat announcement line. The
|
||||
/// `(roll_number, roll_type)` pair is overloaded; `LootRoll::is_dice`/`vote` disentangle it.
|
||||
fn loot_roll(p: LootRoll, rolls: &mut LootRolls) {
|
||||
debug!(
|
||||
"net: loot roll announce — roller {:#x} number {} type {}",
|
||||
@@ -345,7 +230,6 @@ mod net {
|
||||
rolls.announce(p);
|
||||
}
|
||||
|
||||
/// A group roll resolved (`SMSG_LOOT_ROLL_WON`) — the "won" line, and that roll's frame closes.
|
||||
fn loot_roll_won(p: LootRollWon, rolls: &mut LootRolls) {
|
||||
debug!(
|
||||
"net: loot roll won by {:#x} with {} (type {})",
|
||||
@@ -354,8 +238,6 @@ mod net {
|
||||
rolls.won(p);
|
||||
}
|
||||
|
||||
/// Everyone passed (`SMSG_LOOT_ALL_PASSED`) — the frame closes and the item returns to the corpse
|
||||
/// as an ordinary lootable row.
|
||||
fn loot_all_passed(p: LootAllPassed, rolls: &mut LootRolls) {
|
||||
debug!("net: loot roll — everyone passed on item {}", p.item_id);
|
||||
rolls.all_passed(p);
|
||||
@@ -370,9 +252,8 @@ impl Plugin for UiLootRollPlugin {
|
||||
app.init_resource::<LootRolls>().add_systems(
|
||||
Update,
|
||||
(
|
||||
// Same ordering rule as the loot window (ui_loot): push before the input pass so a
|
||||
// freshly opened roll is on screen the same frame, drain after it so a Need/Greed/
|
||||
// Pass click goes out the same frame.
|
||||
// The feed before the input pass shows a new roll the same frame; the drain after
|
||||
// it sends a vote the same frame.
|
||||
feed_loot_rolls.in_set(UiFeed),
|
||||
drain_loot_rolls.after(UiInput),
|
||||
),
|
||||
@@ -380,48 +261,15 @@ impl Plugin for UiLootRollPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
/// Pick and fill the announcement's chat string, given the pieces already resolved: the roller's
|
||||
/// `name` (present whenever the chosen string needs one — see [`render`]), whether the line is about
|
||||
/// *us* (`is_self`), and the item `link`.
|
||||
/// Pick and fill an announcement: the enUS strings of `GlobalStrings.lua:2614-2633`, inlined where
|
||||
/// the reference reads the install's. It branches on `roll_number` first: a Greed vote is
|
||||
/// `(128, 2)`, a Greed dice roll `(1..=100, 2)`. The dice line has no self form: the reference's
|
||||
/// shared tail (`0x61c320`) picks `ROLLED_GREED` for rollType 2, else `ROLLED_NEED`, with no self
|
||||
/// test.
|
||||
///
|
||||
/// The strings are QUOTED from `Interface\FrameXML\GlobalStrings.lua` (verified extract,
|
||||
/// l.2614-2633). The selection follows the four `(roll_number, roll_type)` shapes vmangos emits —
|
||||
/// see [`LootRoll`]'s table — and **must branch on `roll_number` first**: a Greed *vote* is
|
||||
/// `(128, 2)` and a Greed *dice roll* is `(1..=100, 2)`, identical in `roll_type`.
|
||||
///
|
||||
/// **The dice line has NO `_SELF` split** (decision 0594, correcting 0591). `GlobalStrings.lua`
|
||||
/// *does* define `LOOT_ROLL_ROLLED_NEED_SELF`/`_GREED_SELF` — but that file is MPQ **data**, and the
|
||||
/// 5875 **binary** never references them: the shared formatter tail (`0x61c320`) picks
|
||||
/// `ROLLED_GREED` when `rollType == 2` else `ROLLED_NEED`, with no self test anywhere. Of the 20
|
||||
/// `LOOT_ROLL_*` keys in the data file the C++ references only 15; the 5 orphans are exactly those
|
||||
/// two, `LOOT_ROLL_ROLLED`, `_ROLLED_SELF`, and `LOOT_ROLL_START`. So **your own numbered roll
|
||||
/// prints third-person with your own name** — the trap here is that a FrameXML-only pin reaches
|
||||
/// naturally for a string that is present but dead.
|
||||
///
|
||||
/// The *vote* lines keep their `_SELF` split (`LOOT_ROLL_NEED_SELF` &c. are among the referenced
|
||||
/// 15), as does `LOOT_ROLL_YOU_WON`.
|
||||
///
|
||||
/// **The `NO_SPAM` variants are the `showLootSpam == 0` branch**, and since decision 1589 (B246's
|
||||
/// Chat options page) that CVar has a row, so `detailed` is a real argument rather than a constant
|
||||
/// `true`. 0594 §3 recorded the whole gated flow waiting for exactly this; a byte census of the
|
||||
/// CVar `0xb4e2bc` is behind it:
|
||||
///
|
||||
/// | `showLootSpam` | the per-vote / per-dice line (`0x61c0b0`) | the WON line (`0x61b9e0`) |
|
||||
/// |---|---|---|
|
||||
/// | `1` (default) | emitted | `LOOT_ROLL_WON` / `_YOU_WON` — **no roll number** |
|
||||
/// | `0` | suppressed entirely | `*_NO_SPAM_NEED` / `_GREED` — **carries the roll number** |
|
||||
///
|
||||
/// Two things about that table are easy to get backwards and both are VERIFIED: it is the *winner*
|
||||
/// line that changes shape (turning detail OFF makes it say MORE, because it is now the only line
|
||||
/// you get), and **`SMSG_LOOT_ALL_PASSED` is never gated on either side** — `0x61b640` contains no
|
||||
/// read of the CVar at all. The `NO_SPAM` discriminator is `rollType == 1` (need), so anything that
|
||||
/// is not need renders as greed.
|
||||
///
|
||||
/// Returns `None` for the one case the reference drops on the floor: a vote/dice line with detail
|
||||
/// off.
|
||||
///
|
||||
/// Kept free of the resource lookups (`name`/`link` arrive resolved) so the whole table is directly
|
||||
/// testable — it is the piece most likely to be got subtly wrong.
|
||||
/// `detailed` is `showLootSpam` (`0xb4e2bc`). At 0, vote and dice lines drop (`0x61c0b0`, `None`
|
||||
/// here) and the won line (`0x61b9e0`) takes `*_NO_SPAM_NEED`/`_GREED` with the roll number, Need
|
||||
/// on `rollType == 1`; all-passed is never gated (`0x61b640`).
|
||||
fn format_line(
|
||||
line: &RollLine,
|
||||
name: Option<&str>,
|
||||
@@ -438,7 +286,6 @@ fn format_line(
|
||||
Some(format_line_detailed(line, name, is_self, link, detailed))
|
||||
}
|
||||
|
||||
/// [`format_line`]'s body once the suppression fork is out of the way.
|
||||
fn format_line_detailed(
|
||||
line: &RollLine,
|
||||
name: Option<&str>,
|
||||
@@ -447,24 +294,24 @@ fn format_line_detailed(
|
||||
detailed: bool,
|
||||
) -> String {
|
||||
match line {
|
||||
// A real dice result (roll_number in 1..=100) — checked BEFORE the vote shapes.
|
||||
// A dice result, checked before the vote shapes.
|
||||
RollLine::Announce(p) if p.is_dice() => {
|
||||
let n = p.roll_number;
|
||||
let w = name.unwrap_or_default();
|
||||
// LOOT_ROLL_ROLLED_NEED / _GREED (l.2624 / l.2622) — note the trailing "by %s". No
|
||||
// self variant: `is_self` is deliberately unread here (see this fn's doc).
|
||||
// `LOOT_ROLL_ROLLED_NEED`/`_GREED`, with no self form: `is_self` is unread. Need on
|
||||
// type 1, else Greed, where the reference's tail takes Greed on 2, else Need: the two
|
||||
// agree on the 1 and 2 vmangos sends (`Group.cpp:1163,1214`).
|
||||
match p.roll_type {
|
||||
roll_vote::NEED => format!("Need Roll - {n} for {link} by {w}"),
|
||||
_ => format!("Greed Roll - {n} for {link} by {w}"),
|
||||
}
|
||||
}
|
||||
// One of the three vote announcements.
|
||||
RollLine::Announce(p) => match (is_self, p.vote()) {
|
||||
// LOOT_ROLL_NEED_SELF / _GREED_SELF / _PASSED_SELF (l.2618 / l.2616 / l.2620).
|
||||
// `LOOT_ROLL_NEED_SELF`, `_GREED_SELF`, `_PASSED_SELF`.
|
||||
(true, Some(roll_vote::NEED)) => format!("You have selected Need for: {link}"),
|
||||
(true, Some(roll_vote::GREED)) => format!("You have selected Greed for: {link}"),
|
||||
(true, _) => format!("You passed on: {link}"),
|
||||
// LOOT_ROLL_NEED / _GREED / _PASSED (l.2617 / l.2615 / l.2619).
|
||||
// `LOOT_ROLL_NEED`, `_GREED`, `_PASSED`.
|
||||
(false, Some(roll_vote::NEED)) => {
|
||||
format!("{} has selected Need for: {link}", name.unwrap_or_default())
|
||||
}
|
||||
@@ -476,9 +323,7 @@ fn format_line_detailed(
|
||||
}
|
||||
(false, _) => format!("{} passed on: {link}", name.unwrap_or_default()),
|
||||
},
|
||||
// LOOT_ROLL_*_NO_SPAM_NEED / _GREED (l.2629/2630, l.2632/2633) — the detail-off winner
|
||||
// line, which is the ONLY line that roll produces, so it carries the number the suppressed
|
||||
// dice line would have shown. The grey is the GlobalString's own `|cff818181`.
|
||||
// `*_NO_SPAM_*`: with detail off this is the roll's only line, so it carries the number.
|
||||
RollLine::Won(p) if !detailed => {
|
||||
let kind = if p.roll_type == roll_vote::NEED {
|
||||
"Need"
|
||||
@@ -495,16 +340,15 @@ fn format_line_detailed(
|
||||
)
|
||||
}
|
||||
}
|
||||
// LOOT_ROLL_YOU_WON (l.2631) / LOOT_ROLL_WON (l.2628).
|
||||
// `LOOT_ROLL_YOU_WON`, `LOOT_ROLL_WON`.
|
||||
RollLine::Won(_) if is_self => format!("You won: {link}"),
|
||||
RollLine::Won(_) => format!("{} won: {link}", name.unwrap_or_default()),
|
||||
// LOOT_ROLL_ALL_PASSED (l.2614) — names nobody.
|
||||
// `LOOT_ROLL_ALL_PASSED` names nobody.
|
||||
RollLine::AllPassed(_) => format!("Everyone passed on: {link}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Render one queued announcement, or `None` while a name it needs is still in flight. Resolves the
|
||||
/// item template + the roller's name, then defers the whole string choice to [`format_line`].
|
||||
/// Render one queued announcement, or `None` while a name it needs is in flight.
|
||||
fn render(
|
||||
line: &RollLine,
|
||||
self_guid: Option<u64>,
|
||||
@@ -514,15 +358,14 @@ fn render(
|
||||
rolls: crate::items::RollCatalogs,
|
||||
detailed: bool,
|
||||
) -> Option<String> {
|
||||
// Every line embeds the item link, so the template must be in hand before any of them render.
|
||||
// Every line embeds the item link, so the template comes first.
|
||||
let (looted_item, roll, roller) = match line {
|
||||
RollLine::Announce(p) => (p.item_id, p.random_property_id, Some(p.roller)),
|
||||
RollLine::Won(p) => (p.item_id, p.random_property_id, Some(p.winner)),
|
||||
RollLine::AllPassed(p) => (p.item_id, p.random_property_id, None),
|
||||
};
|
||||
let t = items.template(looted_item, 0, commands)?;
|
||||
// The link's name is the ROLLED one (1547) — every announcement names the same drop the roll
|
||||
// window does, so "[Bloodrazor of the Monkey] won by …" has to agree with the frame.
|
||||
// The link names the rolled drop, suffix included, as the roll frame does.
|
||||
let link = crate::ui_items::item_link_full(
|
||||
looted_item,
|
||||
0,
|
||||
@@ -534,12 +377,7 @@ fn render(
|
||||
|
||||
let is_self = roller.is_some() && roller == self_guid;
|
||||
|
||||
// Which strings actually need a name (decision 0594): every third-person one, PLUS our own
|
||||
// *dice* line — the binary's ROLLED_* tail has no self variant, so a roll of ours still prints
|
||||
// "Need Roll - 57 for [Item] by <us>" and must resolve our OWN name. The self *vote* and
|
||||
// *YOU_WON* lines do have _SELF forms and need no lookup; `AllPassed` names nobody.
|
||||
// Resolving our own name is the house pattern (`npc_text::player_identity`) — one ask-once
|
||||
// NameQuery, cached thereafter.
|
||||
// Every third-person line needs a name, and so does our own dice line (no self form).
|
||||
let needs_name = match line {
|
||||
RollLine::Announce(p) => p.is_dice() || !is_self,
|
||||
RollLine::Won(_) => !is_self,
|
||||
@@ -550,14 +388,11 @@ fn render(
|
||||
_ => None,
|
||||
};
|
||||
|
||||
// Never `None` here: the suppression fork is the caller's (a suppressed line must be DROPPED,
|
||||
// where a `None` from this function means "retry, a name is still in flight").
|
||||
// Never `None` here: the caller drops suppressed lines, as a `None` from here means retry.
|
||||
format_line(line, name.as_deref(), is_self, &link, detailed)
|
||||
}
|
||||
|
||||
/// Surface the queued announcement lines in the chat window once their names resolve, colored
|
||||
/// `LOOT` green (the roll lines ride `CHAT_MSG_LOOT` in the real client, like the receive lines).
|
||||
/// Unresolved lines retry up to [`LINE_MAX_TRIES`] frames, then drop.
|
||||
/// Post the queued announcements as `CHAT_MSG_LOOT` lines, as the reference does.
|
||||
fn drain_lines(
|
||||
rolls: &mut LootRolls,
|
||||
self_guid: Option<u64>,
|
||||
@@ -571,9 +406,7 @@ fn drain_lines(
|
||||
let pending = std::mem::take(&mut rolls.pending);
|
||||
let mut still = Vec::new();
|
||||
for mut p in pending {
|
||||
// Detail off drops the vote/dice lines outright — and *drops* them, never retries them,
|
||||
// which is why the fork is here and not inside `render`'s `None` (that one means "a name
|
||||
// is still in flight, come back next frame").
|
||||
// Detail off drops vote and dice lines here, never through `render`'s retrying `None`.
|
||||
if !detailed && matches!(p.line, RollLine::Announce(_)) {
|
||||
continue;
|
||||
}
|
||||
@@ -592,9 +425,7 @@ fn drain_lines(
|
||||
rolls.pending = still;
|
||||
}
|
||||
|
||||
/// Build the Lua-facing snapshot from [`LootRolls`] — the icon straight from the item template's
|
||||
/// display id through the same catalog the bags use, name/quality/bind from the ask-once template
|
||||
/// cache (`None`/`false` while in flight; the frame shows its placeholder and fills in later).
|
||||
/// The Lua-facing snapshot; its template fields are `None` or `false` until the template answers.
|
||||
fn snapshot(
|
||||
rolls: &LootRolls,
|
||||
items: &Items,
|
||||
@@ -614,22 +445,15 @@ fn snapshot(
|
||||
roll_id: r.roll_id,
|
||||
name: t.map(|t| catalogs.name(&t.name, r.random_property_id)),
|
||||
texture,
|
||||
// The roll is always for the whole stack the drop rolled; the wire carries no
|
||||
// count on SMSG_LOOT_START_ROLL, so the frame shows the single-item case (the
|
||||
// reference `GroupLootFrame_OnShow` reads `count` but 1.12 group rolls are
|
||||
// per-item — a stacked drop opens one roll).
|
||||
// The reference's `GetLootRollItemInfo` returns a literal 1 (`0x4c3160`); the
|
||||
// packet carries no count.
|
||||
quantity: 1,
|
||||
quality: t.map(|t| t.quality),
|
||||
bind_on_pickup: t.is_some_and(|t| t.bonding == BIND_WHEN_PICKED_UP),
|
||||
time_left_ms: r.remaining_ms,
|
||||
item_id: r.item_id,
|
||||
// The roll the hover resolves its enchant lines from — `SetLootRollItem`'s own
|
||||
// `+0x424` (1547).
|
||||
random_property_id: r.random_property_id,
|
||||
// The icon button's ctrl/shift arms read this (`GetLootRollItemLink`, decision
|
||||
// 1059). Same builder and same arguments as the announcement lines' own link a few
|
||||
// functions up ([`render`]) — one `item_link` call site per resolved roll, `None`
|
||||
// until the template answers, exactly like `name`/`quality` beside it.
|
||||
// `GetLootRollItemLink`, the chat lines' link; `None` until the template answers.
|
||||
link: t.map(|t| {
|
||||
crate::ui_items::item_link_full(
|
||||
r.item_id,
|
||||
@@ -646,8 +470,7 @@ fn snapshot(
|
||||
LootRollsState { rolls: entries }
|
||||
}
|
||||
|
||||
/// Tick the open rolls, push them into the VM, fire the open/close events, and drain the queued
|
||||
/// announcement lines into chat.
|
||||
/// Tick the open rolls, push them into the VM, fire their events and post their chat lines.
|
||||
fn feed_loot_rolls(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
mut rolls: ResMut<LootRolls>,
|
||||
@@ -659,11 +482,10 @@ fn feed_loot_rolls(
|
||||
mut chat: ResMut<ChatLog>,
|
||||
time: Res<Time>,
|
||||
mut last: Local<crate::ui_script::VmMemo<LootRollsState>>,
|
||||
// The random-suffix roll's catalogs (1547): the rolled name the frame and its chat lines show.
|
||||
props: Option<Res<crate::items::RandomProperties>>,
|
||||
enchants: Option<Res<crate::items::Enchants>>,
|
||||
// `showLootSpam` (1589) — read here, at the moment each line is composed, exactly where the
|
||||
// reference reads it (`0x61ba3a`/`0x61bafe`/`0x61c0b9`, all three inside the composers).
|
||||
// `showLootSpam`, read as each line is composed, as the reference's composers read it
|
||||
// (`0x61ba3a`, `0x61bafe`, `0x61c0b9`).
|
||||
loot: Res<crate::ui_loot::LootConfig>,
|
||||
) {
|
||||
rolls.tick(time.delta().as_millis() as u32);
|
||||
@@ -687,28 +509,20 @@ fn feed_loot_rolls(
|
||||
loot.show_loot_spam,
|
||||
);
|
||||
|
||||
// The snapshot goes in FIRST — a GroupLootFrame claimed by the START_LOOT_ROLL below reads its
|
||||
// item out of the model in its OnShow, and the roll it is about was added to `active` in the
|
||||
// same `start()` call that queued `opened`, so pushing after would hand every fresh roll an
|
||||
// empty lookup. Same order as ui_loot's window feed, for the same reason.
|
||||
// The snapshot goes in before the events: a `GroupLootFrame`'s `OnShow` reads its item from it.
|
||||
let fresh = snapshot(&rolls, &items, icons.as_deref(), &commands, catalogs);
|
||||
if fresh != *last {
|
||||
script.set_loot_rolls(fresh.clone());
|
||||
*last = fresh;
|
||||
}
|
||||
|
||||
// **A fresh roll's `START_LOOT_ROLL` waits for its item template** — the reference's own gate
|
||||
// (`0x61b310`'s cache lookup fires the event from the arrival callback `0x61b460` on a miss;
|
||||
// module docs, decision 2010, bug B371). The snapshot above already asked for every open roll's
|
||||
// template and, crucially, already went into the VM — so a roll released here is one the
|
||||
// `OnShow` below it can actually paint.
|
||||
// The hold: a fresh roll announces once its template is in the snapshot above.
|
||||
for r in &mut rolls.active {
|
||||
if r.announced {
|
||||
continue;
|
||||
}
|
||||
if items.template(r.item_id, 0, &commands).is_none() {
|
||||
// The two releases that are ours rather than the reference's, both 1805's reasoning:
|
||||
// an entry the server cannot describe, and an answer that never comes at all.
|
||||
// The module doc's deviation: an entry the server does not know, or an expired hold.
|
||||
let unknown = items.template_answered_unknown(r.item_id);
|
||||
if !unknown && r.held_ms < TEMPLATE_HOLD_MAX_MS {
|
||||
continue;
|
||||
@@ -742,8 +556,7 @@ fn feed_loot_rolls(
|
||||
}
|
||||
}
|
||||
|
||||
/// Drain the Lua votes: each `RollOnLoot(rollID, rollType)` becomes one `CMSG_LOOT_ROLL` addressed
|
||||
/// by the roll's wire identity, and closes our frame client-side (see the module docs).
|
||||
/// Drain the Lua votes: each `RollOnLoot` is one `CMSG_LOOT_ROLL` and closes our frame.
|
||||
fn drain_loot_rolls(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
mut rolls: ResMut<LootRolls>,
|
||||
@@ -752,9 +565,8 @@ fn drain_loot_rolls(
|
||||
let Some(mut script) = script else {
|
||||
return;
|
||||
};
|
||||
// A Need/Greed on a bind-on-pickup roll sends nothing yet: the seam diverted it here, and the
|
||||
// frame STAYS UP while the popup asks (decision 0594). `CONFIRM_LOOT_ROLL(rollID, rollType)`
|
||||
// is what `UIParent_OnEvent` turns into `StaticPopup_Show("CONFIRM_LOOT_ROLL")`.
|
||||
// A bind-on-pickup Need or Greed sends nothing yet and its frame stays up:
|
||||
// `UIParent_OnEvent` turns `CONFIRM_LOOT_ROLL` into its popup.
|
||||
for (roll_id, roll_type) in script.take_loot_roll_confirms() {
|
||||
debug!("ui_loot_roll: BoP confirm for {roll_type} on roll {roll_id}");
|
||||
script.fire_event(
|
||||
@@ -808,16 +620,14 @@ mod tests {
|
||||
|
||||
const LINK: &str = "|cffa335ee|Hitem:17182:0:0:0|h[Sulfuras]|h|r";
|
||||
|
||||
/// The whole `LOOT_ROLL_*` selection table, both persons. The Greed pair is the case worth
|
||||
/// locking down: `(128, 2)` is a *vote* and `(57, 2)` a *dice roll*, identical in `roll_type`,
|
||||
/// so a table that branched on `roll_type` alone would render the same line for both.
|
||||
/// `(128, 2)` is a Greed vote and `(57, 2)` a Greed dice roll: `roll_type` alone cannot tell.
|
||||
#[test]
|
||||
fn line_table_covers_every_shape() {
|
||||
let other = Some("Bob");
|
||||
let me = Some("Sam");
|
||||
// (packet, name, is_self, expected)
|
||||
let cases: &[(LootRoll, Option<&str>, bool, &str)] = &[
|
||||
// ── The three vote announcements (Group.cpp:970-990) — these DO have _SELF forms ───
|
||||
// The three votes (`Group.cpp:970-990`), which have _SELF forms.
|
||||
(
|
||||
announce(1, 0, 0),
|
||||
other,
|
||||
@@ -844,9 +654,7 @@ mod tests {
|
||||
),
|
||||
(announce(1, 128, 128), other, false, "Bob passed on: {L}"),
|
||||
(announce(1, 128, 128), None, true, "You passed on: {L}"),
|
||||
// ── The dice results (Group.cpp:1163 / :1214) ─────────────────────────────────────
|
||||
// NO _SELF split (0594): the self rows below are third-person WITH OUR OWN NAME, not
|
||||
// "You roll a 57 …". The binary's ROLLED_* tail never tests for self.
|
||||
// The dice results (`Group.cpp:1163,1214`): no _SELF form, so our own rolls name us.
|
||||
(
|
||||
announce(1, 57, roll_vote::NEED),
|
||||
other,
|
||||
@@ -884,11 +692,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The correction 0594 landed, pinned on its own: a dice roll of OURS must never take a
|
||||
/// "You roll a …" form, because the string the 5875 binary would need for that
|
||||
/// (`LOOT_ROLL_ROLLED_NEED_SELF`) is one of the five `LOOT_ROLL_*` keys that exist in
|
||||
/// GlobalStrings.lua but which no C++ path references. Guards against a well-meaning
|
||||
/// re-reading of the data file reintroducing it.
|
||||
/// `LOOT_ROLL_ROLLED_NEED_SELF` is in `GlobalStrings.lua`, but no client code reads it.
|
||||
#[test]
|
||||
fn our_own_dice_roll_prints_third_person() {
|
||||
for roll_type in [roll_vote::NEED, roll_vote::GREED] {
|
||||
@@ -911,8 +715,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The greed vote and the greed dice roll must NOT render the same line — the regression a
|
||||
/// `match roll_type` implementation would introduce.
|
||||
#[test]
|
||||
fn greed_vote_and_greed_roll_differ() {
|
||||
let vote = format_line(
|
||||
@@ -951,7 +753,7 @@ mod tests {
|
||||
format_line(&RollLine::Won(won), Some("Bob"), false, LINK, true),
|
||||
Some(format!("Bob won: {LINK}"))
|
||||
);
|
||||
// LOOT_ROLL_YOU_WON *does* exist and is referenced — the won line keeps its self split.
|
||||
// The client reads `LOOT_ROLL_YOU_WON`: the won line has a self form.
|
||||
assert_eq!(
|
||||
format_line(&RollLine::Won(won), None, true, LINK, true),
|
||||
Some(format!("You won: {LINK}"))
|
||||
@@ -962,18 +764,15 @@ mod tests {
|
||||
item_id: 17182,
|
||||
random_property_id: 0,
|
||||
};
|
||||
// Names nobody — the `name`/`is_self` arguments are irrelevant on this arm.
|
||||
assert_eq!(
|
||||
format_line(&RollLine::AllPassed(passed), Some("Bob"), false, LINK, true),
|
||||
Some(format!("Everyone passed on: {LINK}"))
|
||||
);
|
||||
}
|
||||
|
||||
/// `showLootSpam == 0` (the CVar `0xb4e2bc`) — the whole gated flow 0594 §3 recorded and
|
||||
/// 1589 finally wired, all three of its claims in one place.
|
||||
/// `showLootSpam` 0 (the CVar `0xb4e2bc`).
|
||||
#[test]
|
||||
fn detail_off_suppresses_the_roll_lines_and_reshapes_the_winner() {
|
||||
// 1 · every vote and every dice line is dropped outright.
|
||||
for p in [
|
||||
announce(1, 128, roll_vote::NEED),
|
||||
announce(1, 128, roll_vote::GREED),
|
||||
@@ -990,8 +789,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// 2 · the WINNER line grows the roll number it would otherwise have left to the dice
|
||||
// line, and its NEED/GREED word is decided by `rollType == 1`.
|
||||
// The won line carries the roll number, and Need on `rollType == 1`.
|
||||
let mut won = LootRollWon {
|
||||
looted_target: 0xAA,
|
||||
item_slot: 0,
|
||||
@@ -1014,14 +812,13 @@ mod tests {
|
||||
format_line(&RollLine::Won(won), Some("Bob"), false, LINK, false),
|
||||
Some(format!("Bob won: {LINK} |cff818181(Greed - 84)|r"))
|
||||
);
|
||||
// "anything that is not need renders as greed" — the discriminator is `== 1`, not a
|
||||
// two-way match, so a PASS-typed win (server bookkeeping we never expect) reads Greed.
|
||||
// Anything but Need reads Greed, even a Pass-typed win.
|
||||
won.roll_type = roll_vote::PASS;
|
||||
assert!(format_line(&RollLine::Won(won), None, true, LINK, false)
|
||||
.unwrap()
|
||||
.contains("(Greed - 84)"));
|
||||
|
||||
// 3 · ALL_PASSED is not gated on either side — `0x61b640` reads the CVar not at all.
|
||||
// All-passed is never gated: `0x61b640` does not read the CVar.
|
||||
let passed = LootAllPassed {
|
||||
looted_target: 0xAA,
|
||||
item_slot: 0,
|
||||
@@ -1042,8 +839,7 @@ mod tests {
|
||||
assert_eq!(r.active.len(), 2);
|
||||
assert_eq!(r.active[0].roll_id, 1);
|
||||
assert_eq!(r.active[1].roll_id, 2);
|
||||
// The wire's countdown is kept whole (it is `START_LOOT_ROLL`'s `arg2`) and the bar starts
|
||||
// there; the announce itself waits for the item template (`feed_loot_rolls`).
|
||||
// The countdown stays whole, as `START_LOOT_ROLL`'s `arg2`; the announce waits.
|
||||
for roll in &r.active {
|
||||
assert_eq!(roll.countdown_ms, 60_000);
|
||||
assert_eq!(roll.remaining_ms, 60_000);
|
||||
@@ -1055,8 +851,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// vmangos re-sends every active roll on reconnect (`SendLootStartRollsForPlayer`) — a repeat
|
||||
/// for a `(target, slot)` we already hold must not stack a second frame on the same item.
|
||||
/// vmangos can re-send every active roll on reconnect (`SendLootStartRollsForPlayer`).
|
||||
#[test]
|
||||
fn duplicate_start_is_ignored() {
|
||||
let mut r = LootRolls::default();
|
||||
@@ -1093,12 +888,9 @@ mod tests {
|
||||
});
|
||||
assert_eq!(r.cancelled, vec![1, 2]);
|
||||
assert!(r.active.is_empty());
|
||||
// Both queued their chat line.
|
||||
assert_eq!(r.pending.len(), 2);
|
||||
}
|
||||
|
||||
/// A resolution for a roll we already closed (a duplicate, or one we voted on) is a no-op —
|
||||
/// no second CANCEL_LOOT_ROLL for an id no frame holds any more.
|
||||
#[test]
|
||||
fn resolution_for_a_closed_roll_is_idempotent() {
|
||||
let mut r = LootRolls::default();
|
||||
@@ -1124,13 +916,10 @@ mod tests {
|
||||
assert_eq!(r.vote(1), Some((0xAA, 3)));
|
||||
assert!(r.active.is_empty(), "the frame closes client-predicted");
|
||||
assert_eq!(r.cancelled, vec![1]);
|
||||
// A stale click on an id nothing holds any more yields nothing to send.
|
||||
assert_eq!(r.vote(1), None);
|
||||
assert_eq!(r.vote(99), None);
|
||||
}
|
||||
|
||||
/// The bar ticks down and saturates — a spent roll stays open (the server closes it), so a
|
||||
/// long-running roll must not underflow the countdown.
|
||||
#[test]
|
||||
fn tick_saturates_without_dropping_the_roll() {
|
||||
let mut r = LootRolls::default();
|
||||
@@ -1142,8 +931,6 @@ mod tests {
|
||||
assert_eq!(r.active.len(), 1, "the server closes it, not the tick");
|
||||
}
|
||||
|
||||
/// The template hold ages with the bar, and **stops** at the announce — otherwise a roll that
|
||||
/// opened cleanly would keep accruing a wait nothing is waiting for.
|
||||
#[test]
|
||||
fn tick_ages_the_hold_only_while_the_roll_is_unannounced() {
|
||||
let mut r = LootRolls::default();
|
||||
@@ -1157,7 +944,6 @@ mod tests {
|
||||
assert_eq!(r.active[0].remaining_ms, 60_000 - 1_750, "the bar is not");
|
||||
}
|
||||
|
||||
/// Ids are never reused, so a late packet for a closed roll cannot address a fresh one.
|
||||
#[test]
|
||||
fn ids_are_never_reused() {
|
||||
let mut r = LootRolls::default();
|
||||
@@ -1167,18 +953,14 @@ mod tests {
|
||||
assert_eq!(r.active[0].roll_id, 2);
|
||||
}
|
||||
|
||||
// ── The template hold (decision 2010, bug B371) ──────────────────────────────────────────
|
||||
//
|
||||
// Driven through the real system in a real schedule, because the whole question is what
|
||||
// happens ACROSS frames and `feed_loot_rolls`' snapshot memo is a `Local`: `run_system_once`
|
||||
// builds a new system each call and hands it a fresh memo, which reads every pass as a first
|
||||
// push. Same reason, same shape, as `ui_loot`'s `the_window_waits_for_every_item_template`.
|
||||
// ── The template hold ──
|
||||
// Driven through a real schedule: the snapshot memo is a `Local`, and `run_system_once` would
|
||||
// hand every pass a fresh one, reading each as a first push.
|
||||
|
||||
const ROLLED: u32 = 17182;
|
||||
|
||||
/// A `feed_loot_rolls` app with a listener that records, **at the instant the event fires**,
|
||||
/// what `GetLootRollItemInfo` answers for the roll — the exact call stock
|
||||
/// `GroupLootFrame_OnShow` makes, so a blank here is the director's blank.
|
||||
/// A `feed_loot_rolls` app whose listener records what `GetLootRollItemInfo` answers as
|
||||
/// `START_LOOT_ROLL` fires: the call stock `GroupLootFrame_OnShow` makes.
|
||||
fn hold_app() -> (App, crossbeam_channel::Receiver<ClientCommand>) {
|
||||
let (tx, rx) = crossbeam_channel::unbounded();
|
||||
let mut app = App::new();
|
||||
@@ -1232,17 +1014,9 @@ mod tests {
|
||||
rx.try_iter().collect()
|
||||
}
|
||||
|
||||
/// **The roll waits for its item template** — the reference's own gate, and B371's fix.
|
||||
///
|
||||
/// `0x61b310` ends in `0x55ba30(node+0x1c, node+0x10, callback 0x61b460, node, 0)`: a cache
|
||||
/// **hit** calls `0x61b430` and fires `START_LOOT_ROLL` there and then; a **miss** issues the
|
||||
/// query and fires from the arrival callback instead. So a roll on an entry we have never seen
|
||||
/// must announce nothing on the first pass — and must ask the server — then announce exactly
|
||||
/// once when the answer lands, with the item already readable by the `OnShow`.
|
||||
///
|
||||
/// Both answers are pinned: the real template, and the **negative** one, which releases the
|
||||
/// roll here where the reference's success-gated callback would hold it (1805's divergence — a
|
||||
/// frame that never opens is a roll we cannot even pass on).
|
||||
/// `0x61b310` ends in `0x55ba30(node+0x1c, node+0x10, callback 0x61b460, node, 0)`: a hit fires
|
||||
/// at once (`0x61b430`), a miss asks and fires on arrival. The negative answer releases the
|
||||
/// roll too, the module doc's deviation.
|
||||
#[test]
|
||||
fn the_roll_waits_for_its_item_template() {
|
||||
for (answer, painted) in [
|
||||
@@ -1254,7 +1028,7 @@ mod tests {
|
||||
.resource_mut::<LootRolls>()
|
||||
.start(start(0xAA, 0, ROLLED));
|
||||
|
||||
// Pass one: the template is unknown, so nothing announces — and the ask goes out.
|
||||
// Pass one: the template is unknown, so nothing announces and the ask goes out.
|
||||
app.update();
|
||||
assert!(
|
||||
starts(&mut app).is_empty(),
|
||||
@@ -1266,12 +1040,11 @@ mod tests {
|
||||
),
|
||||
"and the template was asked for"
|
||||
);
|
||||
// A second pass with nothing new changes nothing — the hold is not a one-shot.
|
||||
// The hold is not a one-shot.
|
||||
app.update();
|
||||
assert!(starts(&mut app).is_empty());
|
||||
|
||||
// The answer lands. The roll announces, once, carrying the WIRE's countdown as `arg2`
|
||||
// (`0x61b430` pushes `node+0x3c`) — and the name is already there for the OnShow.
|
||||
// The answer lands: one announce, the wire's countdown as `arg2`, the name readable.
|
||||
app.world_mut()
|
||||
.resource_mut::<Items>()
|
||||
.insert_template(ROLLED, answer.clone());
|
||||
@@ -1282,8 +1055,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A repeat drop costs nothing: the template is already cached, so `0x61b310`'s hit path is
|
||||
/// ours too and the roll announces on the very pass the packet lands.
|
||||
/// `0x61b310`'s hit path: the roll announces on the pass the packet lands.
|
||||
#[test]
|
||||
fn a_cached_template_announces_the_same_pass() {
|
||||
let (mut app, _rx) = hold_app();
|
||||
@@ -1297,10 +1069,7 @@ mod tests {
|
||||
assert_eq!(starts(&mut app), vec!["1:60000:Sulfuras".to_string()]);
|
||||
}
|
||||
|
||||
/// The deadline, which is ours and not the reference's: an entry the server never answers at
|
||||
/// all must not hold the frame shut for the roll's whole minute — we would have no way to
|
||||
/// Need, Greed or even Pass on it. It opens unresolved instead, which is what the reference's
|
||||
/// own cache-miss tail paints in the neighbouring case.
|
||||
/// The module doc's deviation: an unanswered template opens the frame at the deadline.
|
||||
#[test]
|
||||
fn an_unanswered_template_opens_the_roll_at_the_deadline() {
|
||||
let (mut app, _rx) = hold_app();
|
||||
@@ -1319,7 +1088,7 @@ mod tests {
|
||||
TEMPLATE_HOLD_MAX_MS - 2
|
||||
);
|
||||
|
||||
// The step that crosses it opens the frame — blank, but votable.
|
||||
// The step that crosses it opens the frame, blank but votable.
|
||||
advance(&mut app, 2);
|
||||
app.update();
|
||||
assert_eq!(starts(&mut app), vec!["1:60000:<blank>".to_string()]);
|
||||
@@ -1328,9 +1097,8 @@ mod tests {
|
||||
assert_eq!(starts(&mut app).len(), 1, "and only once");
|
||||
}
|
||||
|
||||
/// A roll resolved while it is still held never announces at all — there is no frame to open
|
||||
/// and none to leave up. Its `CANCEL_LOOT_ROLL` still goes out, which is the reference's shape
|
||||
/// (`0x61b9e0` guards only on the node's already-closed byte) and is a no-op in the stock Lua.
|
||||
/// A held roll's `CANCEL_LOOT_ROLL` still goes out, as `0x61b9e0` checks only the node's
|
||||
/// closed byte; the stock Lua matches no frame to it.
|
||||
#[test]
|
||||
fn a_roll_resolved_while_held_never_announces() {
|
||||
let (mut app, _rx) = hold_app();
|
||||
|
||||
@@ -1,19 +1,6 @@
|
||||
//! The **macros** feed (decision 0983) — the app half of `benilla_ui::script::macros`: the icon
|
||||
//! chooser's catalog, persistence under `benilla-config/macros/`, and `UPDATE_MACROS`.
|
||||
//!
|
||||
//! Unlike every other window feed in this tree there is **no wire traffic here at all**. 1.12
|
||||
//! macros are pure client state — no opcode carries them (vmangos has none), and the reference
|
||||
//! client persists them to `WTF/…/macros-cache.txt` itself. So the engine owns the live table
|
||||
//! (`benilla_ui::script::macros`' module doc says why) and this module owns its two ends:
|
||||
//!
|
||||
//! - **In** — the icon list (`SpellIcon.dbc`, filtered by the client's own `Spell_`/`Ability_`
|
||||
//! prefixes: [`benilla_formats::load_macro_icons`]) at startup, and the saved files at world
|
||||
//! entry.
|
||||
//! - **Out** — a save whenever a script mutated the table ([`save_dirty_macros`]), plus the
|
||||
//! reference's own `UPDATE_MACROS` event on the same edge.
|
||||
//!
|
||||
//! The **runner** and the action-bar's **bound spell** live in [`run`]; the file format is
|
||||
//! [`store`] (the reference's own, so a vanilla `macros-cache.txt` drops straight in).
|
||||
//! The app half of `benilla_ui::script::macros`: the icon chooser's catalog, the files under
|
||||
//! `benilla-config/macros/`, the runner, and the bound spell the action bar reads. 1.12 macros are
|
||||
//! client state only: no opcode carries them, and the reference saves them to `macros-cache.txt`.
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
@@ -27,30 +14,20 @@ mod store;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
/// Which files this session's macros live in — resolved once the character is known, since the
|
||||
/// per-character tab is keyed by realm + name exactly as the reference's own folder tree is.
|
||||
/// `None` on either path means "session-only": a hermetic capture, or no resolvable install
|
||||
/// (`crate::local_state`'s law). Macros still work in memory; nothing is written.
|
||||
/// The files this session's macros live in, the per-character one keyed by realm and name as the
|
||||
/// reference's folders are. `None` is session-only (a capture, or no install): nothing is written.
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct MacroFiles {
|
||||
account: Option<std::path::PathBuf>,
|
||||
character: Option<std::path::PathBuf>,
|
||||
/// The `(realm, character)` the [`Self::character`] path was built for — the reload trigger
|
||||
/// when a `/logout` brings a different character back into the world. Session-keyed (1290),
|
||||
/// because the *same* character coming back still meets a fresh VM with an empty macro table.
|
||||
/// The `(realm, character)` the files were loaded for, per VM: a relog meets a fresh VM with an
|
||||
/// empty macro table, even as the same character.
|
||||
identity: crate::ui_script::VmMemo<Option<(String, String)>>,
|
||||
}
|
||||
|
||||
/// Macro index → its bound spell — benilla's `[rec+0x564]` (read by the MACRO arm `0x4e5ba0`),
|
||||
/// the field an action-bar MACRO slot's whole dynamic state reads through, in the
|
||||
/// reference's own three values ([`BoundSpell`]). Stored, not derived at read time, for the
|
||||
/// reference's own reason: it is a **field on the macro record**, recomputed when the macro (or
|
||||
/// the book) changes, so the three per-frame action-bar systems pay a hash lookup instead of a
|
||||
/// body re-parse each.
|
||||
///
|
||||
/// **One entry per macro that exists**, whatever it binds — absence means the index names no
|
||||
/// macro, which is the usable compute's `0x4e5030` verdict and reads differently from a macro
|
||||
/// that merely casts nothing (decision 1636: the former is grey, the latter is not).
|
||||
/// Macro index to its bound spell, the reference's `[rec+0x564]` (read by `0x4e5ba0`), which an
|
||||
/// action-bar macro slot's state reads through. A missing entry names no macro, which `0x4e5030`
|
||||
/// greys, unlike a macro that casts nothing.
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct MacroBoundSpells(pub(crate) std::collections::HashMap<u32, BoundSpell>);
|
||||
|
||||
@@ -65,39 +42,29 @@ impl Plugin for UiMacroPlugin {
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
// Once per **VM** (decision 1290), and in `Update` rather than `PostStartup`:
|
||||
// this needs BOTH the patch chain and the VM, and a login builds a fresh VM
|
||||
// whose icon chooser stays empty until it runs again. `PostStartup` was the
|
||||
// old answer to the ordering half alone — after `AssetSet::Open` this ran
|
||||
// before the VM existed and silently pushed no icon list at all — and a
|
||||
// session-keyed claim answers both halves at once.
|
||||
// Once per VM, in `Update`: it needs both the patch chain and the VM, and each
|
||||
// login builds a fresh VM.
|
||||
load_icon_catalog.in_set(crate::ui_script::UiFeed),
|
||||
// Before the action feeds read it (they run in `UnitFeed`), so a macro edited
|
||||
// this frame reports its new spell's cooldown the same frame.
|
||||
rebind_macro_spells
|
||||
.in_set(crate::ui_script::UiFeed)
|
||||
.before(crate::ui_unit::UnitFeed),
|
||||
// Load runs in-world only: the per-character file needs the character, and the
|
||||
// roster only names it once a login is live. It self-gates on the identity, so
|
||||
// a re-entry with a different character reloads and a re-entry with the same
|
||||
// one is a no-op.
|
||||
// In-world only: the per-character file needs the character.
|
||||
load_macros
|
||||
.in_set(crate::ui_script::UiFeed)
|
||||
.in_set(InWorldGated),
|
||||
// The save edge is checked every frame, in or out of world: the macro window is
|
||||
// `whileDead = 1` and reachable from the game menu, and a `/logout` must not
|
||||
// strand an unsaved edit. After the tick that dirtied it — which also puts it
|
||||
// after the load, in the feed phase.
|
||||
// Every frame, in or out of world, so a `/logout` never strands an edit; after
|
||||
// the script tick that dirtied the table.
|
||||
save_dirty_macros.after(crate::ui_script::UiInput),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the icon chooser's list once at startup — `SpellIcon.dbc` filtered by the client's own
|
||||
/// two prefixes ([`benilla_formats::load_macro_icons`], where the byte citations live). A failed
|
||||
/// load leaves the list empty: the popup then shows no icons and `MacroPopupOkayButton_Update`
|
||||
/// keeps OKAY disabled, which is a visible, diagnosable failure rather than a silent one.
|
||||
/// Build the icon chooser's list once per VM: the `Spell_` and `Ability_` files under
|
||||
/// `Interface\Icons\` ([`benilla_formats::load_macro_icons`]). A failed load leaves it empty, and
|
||||
/// `MacroPopupOkayButton_Update` then keeps OKAY disabled.
|
||||
fn load_icon_catalog(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
assets: Option<Res<WorldAssets>>,
|
||||
@@ -122,9 +89,7 @@ fn load_icon_catalog(
|
||||
}
|
||||
}
|
||||
|
||||
/// Who we are, for the per-character file: `(realm, character)` off the roster's own login pick.
|
||||
/// `None` until the roster and the pick agree — the load simply waits a frame. Shared with the
|
||||
/// bindings load ([`crate::bindings`]), whose per-character file is keyed the same way (0997).
|
||||
/// `(realm, character)` off the roster's login pick, keying the per-character files.
|
||||
pub(crate) fn identity(roster: &crate::char_select::Roster) -> Option<(String, String)> {
|
||||
let guid = roster.pending_pick?;
|
||||
let name = roster.chars.iter().find(|c| c.guid == guid)?.name.clone();
|
||||
@@ -136,7 +101,7 @@ pub(crate) fn identity(roster: &crate::char_select::Roster) -> Option<(String, S
|
||||
Some((realm, name))
|
||||
}
|
||||
|
||||
/// Seed the engine's macro table from disk, once per character per world entry.
|
||||
/// Seed the engine's macro table from disk, once per character per VM.
|
||||
fn load_macros(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
roster: Res<crate::char_select::Roster>,
|
||||
@@ -145,7 +110,7 @@ fn load_macros(
|
||||
let Some(mut script) = script else { return };
|
||||
let Some(id) = identity(&roster) else { return };
|
||||
if files.identity.get(&script).as_ref() == Some(&id) {
|
||||
return; // already loaded for this character, into the VM that is live now
|
||||
return; // already loaded into this VM
|
||||
}
|
||||
let (realm, character) = (&id.0, &id.1);
|
||||
files.account = crate::local_state::macros_account_path();
|
||||
@@ -156,7 +121,7 @@ fn load_macros(
|
||||
let Some(path) = path else { return Vec::new() };
|
||||
match std::fs::read_to_string(path) {
|
||||
Ok(text) => store::parse(&text),
|
||||
// Absent is the normal first-run case, not a failure; anything else is worth a line.
|
||||
// Absent is the first run, not a failure.
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Vec::new(),
|
||||
Err(e) => {
|
||||
warn!("ui_macro: reading {}: {e}", path.display());
|
||||
@@ -174,25 +139,18 @@ fn load_macros(
|
||||
state.character.len()
|
||||
);
|
||||
script.set_macros(state);
|
||||
// The reference's own event for "the macro table changed" (`UPDATE_MACROS`, byte-verified
|
||||
// string at `0x452460`) — the frame redraws off it exactly as it does after an edit.
|
||||
// `UPDATE_MACROS` (string at `0x852460`): the macro frame redraws on it, as after an edit.
|
||||
script.fire_event("UPDATE_MACROS", vec![]);
|
||||
}
|
||||
|
||||
/// Persist on the engine's dirty edge, and fire `UPDATE_MACROS`.
|
||||
///
|
||||
/// Deliberately **not** coalesced behind a timer the way `config.toml`'s save is (decision 0954
|
||||
/// §3): a macro edit is a discrete, deliberate act (an OKAY click, a tab switch, a window close),
|
||||
/// not a slider drag, and the file is a few hundred bytes. The dirty flag is already the
|
||||
/// coalescer — a whole `MacroFrame_SaveMacro` + `MacroFrame_Update` round raises it once.
|
||||
fn save_dirty_macros(script: Option<NonSendMut<UiScript>>, files: Res<MacroFiles>) {
|
||||
let Some(mut script) = script else { return };
|
||||
if !script.take_macros_dirty() {
|
||||
return;
|
||||
}
|
||||
let state = script.macros();
|
||||
// Fire first: the event is the UI's redraw trigger and must not depend on the write landing
|
||||
// (a read-only `benilla/` folder still gets a live macro list for the session).
|
||||
// Fire first: the redraw must not depend on the write landing.
|
||||
script.fire_event("UPDATE_MACROS", vec![]);
|
||||
for (path, macros) in [
|
||||
(&files.account, &state.account),
|
||||
@@ -205,10 +163,8 @@ fn save_dirty_macros(script: Option<NonSendMut<UiScript>>, files: Res<MacroFiles
|
||||
}
|
||||
}
|
||||
|
||||
/// Recompute every macro's bound spell when the macro table or the spell book moves ([`run`]'s
|
||||
/// module doc for what "bound" means and where it is byte-verified). Change-gated on the engine's
|
||||
/// macro generation plus Bevy's own change detection over the action store (the book derives from
|
||||
/// its known-spell set), so a steady frame does nothing at all.
|
||||
/// Recompute every macro's bound spell when the macro table or the spell book changes: gated on
|
||||
/// the engine's macro generation and on change detection over the action store.
|
||||
fn rebind_macro_spells(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
actions: Res<crate::ui_action::PlayerActions>,
|
||||
@@ -219,8 +175,7 @@ fn rebind_macro_spells(
|
||||
let (Some(script), Some(table)) = (script, table) else {
|
||||
return;
|
||||
};
|
||||
// Session-keyed (1290): a fresh VM restarts its macro generation at 0, so a bare memo could
|
||||
// hold a *higher* number than the live VM will ever reach and gate this off for the session.
|
||||
// Per VM: a fresh VM restarts its generation at 0, which a bare memo would gate off for good.
|
||||
let last_generation = last_generation.get(&script);
|
||||
let generation = script.macros_generation();
|
||||
if *last_generation == Some(generation) && !actions.is_changed() {
|
||||
@@ -249,22 +204,14 @@ fn rebind_macro_spells(
|
||||
}
|
||||
}
|
||||
|
||||
/// The engine event a macro line is delivered as — `0x188` in the reference's runtime event
|
||||
/// registry, resolved to its name inside the binary (`0xbe1198 + 4*0x188` is written exactly once,
|
||||
/// at `0x51b4ff`, with `0x852470` = these bytes).
|
||||
/// The event each macro line is fired as: id `0x188`, whose registry slot `0xbe17b8` is written
|
||||
/// once, at `0x51b4ff`, with the string at `0x852470`.
|
||||
const EXECUTE_CHAT_LINE: &str = "EXECUTE_CHAT_LINE";
|
||||
|
||||
/// Run a macro by its 1-based index — the action bar's MACRO arm (`crate::ui_action::drain`) and
|
||||
/// nothing else today, which is also the reference's shape (`0x4f14e0`'s only caller is
|
||||
/// `UseAction`'s core at `0x4e6098`). Returns whether anything ran.
|
||||
///
|
||||
/// **Each body line is FIRED AS AN EVENT, not handed to the drain directly** (0996). The reference's
|
||||
/// runner names no Lua function and walks no command table — per non-empty line it fires
|
||||
/// `FrameScript_SignalEvent(EXECUTE_CHAT_LINE, "%s", line)` and the Lua side does everything else,
|
||||
/// which is exactly why a scan of `WoW.exe` finds no FrameXML function name and no chat-frame name.
|
||||
/// benilla's ChatFrame1 registers it (ChatFrame.xml) and calls `SubmitChatInput`, so the line lands
|
||||
/// in the same queue a typed line does — but through the reference's own door, which means **an
|
||||
/// addon that registers `EXECUTE_CHAT_LINE` sees macro lines**, as it would in 1.12.
|
||||
/// Run a macro by its 1-based index; returns whether anything ran. Like the reference's runner
|
||||
/// (`0x4f14e0`, reached only from `UseAction` at `0x4e6098`), it fires `EXECUTE_CHAT_LINE` per
|
||||
/// non-empty line and nothing else: the stock `ChatFrame1` sends each through its edit box
|
||||
/// (`ChatFrame.lua:1343`), and an addon registered for the event sees every macro line.
|
||||
pub(crate) fn run_macro(script: &mut UiScript, index: u32) -> bool {
|
||||
let Some(body) = script.macros().get(index as usize).map(|m| m.body.clone()) else {
|
||||
return false;
|
||||
|
||||
@@ -1,141 +1,60 @@
|
||||
//! **Running** a macro, and the one derivation the action bar needs from a macro body: its
|
||||
//! **bound spell** (decision 0983).
|
||||
//! A macro body's runnable lines, and the spell it binds for the action bar.
|
||||
//!
|
||||
//! ## Running: each line is fired as `EXECUTE_CHAT_LINE` (VERIFIED, 0996)
|
||||
//! A macro slot shows the macro's icon but the cooldown, usability, range and checked state of its
|
||||
//! bound spell, the record's `[rec+0x564]`, which the slot resolver `0x4e5a50` returns for a
|
||||
//! macro. `0x4efe00` derives it from the first line that is a `/cast` alias or holds
|
||||
//! `CastSpellByName(`, through the resolver the `CastSpellByName` binding (`0x4b4ab0`) uses
|
||||
//! (`0x4b3950` → `0x4b3a10`), so a bare name binds the highest known rank.
|
||||
//!
|
||||
//! 0983 shipped this as "push the line onto the chat-input queue", with the engine's route to Lua
|
||||
//! recorded as an open question. The reference settles it, and the answer is better than the guess:
|
||||
//! the runner `0x4f14e0` fires **`FrameScript_SignalEvent(EXECUTE_CHAT_LINE, "%s", line)`** per
|
||||
//! non-empty line and does nothing else. It names no Lua function and holds no command table — which
|
||||
//! is precisely why a scan of `WoW.exe` finds no FrameXML function name but `GetText`, no
|
||||
//! `SLASH_%s%d` walk, and no chat-frame name. (`0x188` → the name is pinned inside the binary: the
|
||||
//! registry slot `0xbe17b8` has exactly one writer, `0x51b4ff`, storing `0x852470`.)
|
||||
//!
|
||||
//! So [`super::run_macro`] fires the event and `ChatFrame1` handles it — the reference's own
|
||||
//! division, with benilla's Rust drain (`crate::ui_chat::input`, the whole slash grammar in one
|
||||
//! place) standing in for `ChatEdit_SendText`. Two things follow that the old shape did not give:
|
||||
//! **an addon registering `EXECUTE_CHAT_LINE` sees macro lines**, and the dependency is the
|
||||
//! reference's real one — ChatFrame1's registration is the *only* one in the default UI, so a macro
|
||||
//! line runs through that frame's box whichever chat frame has focus.
|
||||
//!
|
||||
//! The chat type of a plain line is decided nowhere in the engine: the runner has no
|
||||
//! `SendChatMessage` call and no type constant. It is `ChatEdit_SendText`'s
|
||||
//! `editBox.chatType`, which the reference's trailing `ChatEdit_OnEscapePressed` resets to
|
||||
//! `stickyType` — benilla's drain sends as the box's current type, the same observable.
|
||||
//!
|
||||
//! ## The bound spell (`[rec+0x564]`, VERIFIED)
|
||||
//!
|
||||
//! A macro action-bar slot shows **the macro's own icon** but reports the **cooldown, usability,
|
||||
//! range and checked state of the spell it casts** — `0x4e5a50`'s macro arm resolves the macro
|
||||
//! record through `0x4f0f40` and returns `[rec+0x564]` as the slot's spell id, and every
|
||||
//! `Is*Action`/`GetActionCooldown` binding reads through that resolver. [`bound_spell`] is how
|
||||
//! that field is filled: the body's first `/cast` line, or a `CastSpellByName("…")` call in a
|
||||
//! `/script` line.
|
||||
//!
|
||||
//! 0983 called this derivation INFERRED (it was reasoned from the two string literals in the
|
||||
//! reference's `UIMacros.cpp` block). **`0x4efe00` confirms it at the bytes**, with three
|
||||
//! refinements now implemented here:
|
||||
//!
|
||||
//! - **Arm A** builds `"SLASH_CAST%d"` for n = 1, 2, … and reads each as a **Lua global's value**
|
||||
//! (`FrameScript_GetText 0x703bf0`), stopping at the first nil/empty — never a hardcoded
|
||||
//! `"/cast"`, which is 0881's law arrived at independently. The compare is case-insensitive and
|
||||
//! the separator must be a literal `' '`.
|
||||
//! - **Arm B** is a case-SENSITIVE substring search for `CastSpellByName(`, tried on the same line
|
||||
//! after the alias loop ends. So `/CAST x` matches; `castspellbyname("x")` does not.
|
||||
//! - The first line matching **either** arm wins and the walk stops there.
|
||||
//!
|
||||
//! **The shared resolver is faithful — do not split it.** `CastSpellByName`'s own binding
|
||||
//! (`0x4b4ab0`) calls the *identical* name resolver this derive does (`0x4b3950` → `0x4b3a10`); the
|
||||
//! two differ only downstream, in what they do with the `(index, bookFlag)` pair. And a bare name
|
||||
//! binds the **highest** rank: `0x4b3a10` walks its list strictly descending (`edi` from count,
|
||||
//! post-decremented) over a list `0x4b2fd0` qsorts rank-ascending immediately before every re-derive.
|
||||
//! So [`bound_spell`] and the press going through one [`resolve_spell_by_name`] is the reference's
|
||||
//! shape, not a convenience.
|
||||
//!
|
||||
//! **The field is three-valued, and the third value shows on the bar** (decision 1636): a `/cast`
|
||||
//! line whose name did not resolve stores **-1**; a `CastSpellByName(` line whose name did not
|
||||
//! resolve stores **0** — the same 0 a body with no cast line at all stores. The usable compute
|
||||
//! `0x4e5050` reads the difference: a 0 takes its spell-less leg, which answers usable=1 for any
|
||||
//! macro that exists (`0x4e5030`), while a -1 falls into the spell path and fails its `jl` at
|
||||
//! `0x4e518b` — the 0.4 grey. So `/cast Pyroblast` before you know Pyroblast is a grey button and
|
||||
//! `/script CastSpellByName("Pyroblast")` is a full-colour one; [`BoundSpell`] carries all three
|
||||
//! so the bar can tell them apart. The reference's incremental re-derive retries only negatives
|
||||
//! (a failed `CastSpellByName` is never retried, a failed `/cast` is); benilla recomputes the
|
||||
//! whole table off two change signals instead (`rebind_macro_spells`), which retries both —
|
||||
//! strictly more forgiving, and no observable differs.
|
||||
//!
|
||||
//! **`[rec+0x568]` is NOT what 0983 said it was.** It is the SPELLBOOK the bound spell resolved
|
||||
//! from — 0 = the player's list, 1 = the PET's (Lua's `BOOKTYPE_SPELL`/`BOOKTYPE_PET`) — not "the
|
||||
//! cast is not self-targeted". benilla does not model it: `bound_spell` resolves against the player
|
||||
//! book only, so a macro that casts a pet spell binds nothing. See decision 0996 for what that
|
||||
//! costs (pet autocast state and pet-aura checked state on a macro slot).
|
||||
//! `[rec+0x568]` is the book the spell resolved from, 0 the player's and 1 the pet's. benilla
|
||||
//! resolves against the player's book only, so a macro that casts a pet spell binds nothing.
|
||||
|
||||
use benilla_ui::script::{resolve_spell_by_name, SpellBookState};
|
||||
|
||||
use crate::ui_chat::commands::{Command, SlashCommands, SlashIndex};
|
||||
|
||||
/// The literal the reference matches a `/script`-style body line against — `0x84cab0`, searched
|
||||
/// case-SENSITIVELY anywhere in the line (`0x4efed5` → `0x64b4f0` → a `rep cmpsb` `strncmp`).
|
||||
/// Arm B's literal (`0x84cab0`), searched case-sensitively anywhere in the line (`0x4efed5`).
|
||||
const CAST_BY_NAME_CALL: &str = "CastSpellByName(";
|
||||
|
||||
/// Which of `0x4efe00`'s two arms matched the cast line — load-bearing because the two store
|
||||
/// different values for a name that does not resolve (arm A writes `-1` at `0x4eff48`, arm B
|
||||
/// stores the resolver's `0` verbatim at `0x4eff81`).
|
||||
/// Which of `0x4efe00`'s arms matched: an unresolved name stores -1 from arm A (`0x4eff48`) and
|
||||
/// 0 from arm B (`0x4eff81`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum CastArm {
|
||||
/// A `/cast`-alias line (`SLASH_CAST%d`, read as Lua globals).
|
||||
/// A `/cast`-alias line (`SLASH_CAST%d`, read as Lua globals at `0x703bf0`).
|
||||
Slash,
|
||||
/// A `CastSpellByName("…")` call anywhere in the line.
|
||||
ByName,
|
||||
}
|
||||
|
||||
/// The reference's `[rec+0x564]` — a macro record's cached bound spell — as the three values it
|
||||
/// actually takes (written by `0x4efe00`; the usable consequence is decision 1636). The action
|
||||
/// bar reads this through `ui_action::state`'s slot resolve.
|
||||
/// A macro record's cached bound spell, the reference's `[rec+0x564]` (written by `0x4efe00`).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub(crate) enum BoundSpell {
|
||||
/// `0` — no line matched either arm, or a `CastSpellByName(` name that did not resolve. The
|
||||
/// bar treats the slot as a bare macro: usable, no spell state.
|
||||
/// `0`: no cast line, or an unresolved `CastSpellByName(`: a usable macro with no spell state.
|
||||
#[default]
|
||||
None,
|
||||
/// `-1` — a `/cast` line whose name did not resolve. The bar greys the slot (the spell path
|
||||
/// of `0x4e5050` refuses a negative id at `0x4e518b`).
|
||||
/// `-1`: an unresolved `/cast`; the bar greys the slot (`0x4e5050` refuses it at `0x4e518b`).
|
||||
Unresolved,
|
||||
/// A live spell id: from here down the slot IS that spell.
|
||||
/// A spell id: the slot shows that spell's state.
|
||||
Spell(u32),
|
||||
}
|
||||
|
||||
/// A macro body's runnable lines. The reference's tokenizer (`0x64ae50`) takes `"\r\n"` as a
|
||||
/// **delimiter SET** — either character splits — and skips empty tokens, which is why an interior
|
||||
/// blank line never even produces one. So do we; a `macros-cache.txt` hand-copied off a Windows
|
||||
/// install is a real input, and so is a lone `\r`.
|
||||
///
|
||||
/// One friendly divergence: we **trim** each line. The reference hands the line over verbatim, so
|
||||
/// its `" /say hi"` is not a slash command and its `"/cast Fireball"` (two spaces) binds a name
|
||||
/// with a leading space that resolves to nothing. Both are ways a player's macro silently does
|
||||
/// nothing, and neither is a behaviour worth reproducing. (The reference does fire the event for a
|
||||
/// whitespace-only line and drop it a layer up in `ChatEdit_SendText`'s length gate; dropping it
|
||||
/// here is the same observable.)
|
||||
/// A macro body's runnable lines: the reference's tokenizer (`0x64ae50`) splits on either `\r` or
|
||||
/// `\n` and skips empty tokens. Deviation: each line is trimmed, because the reference sends an
|
||||
/// indented `" /say hi"` as plain chat, never as the command meant. A whitespace-only line is fired
|
||||
/// there and dropped by `ChatEdit_SendText`, the same observable.
|
||||
pub(crate) fn macro_lines(body: &str) -> impl Iterator<Item = &str> {
|
||||
body.split(['\r', '\n'])
|
||||
.map(str::trim)
|
||||
.filter(|l| !l.is_empty())
|
||||
}
|
||||
|
||||
/// The spell name a macro body casts, if any — [`bound_spell`]'s parse half, split out because it
|
||||
/// carries the whole of the reference's `0x4efe00` and deserves its own test.
|
||||
///
|
||||
/// Walks the body in order and returns the FIRST match of either form:
|
||||
/// - a line whose command resolves to [`SlashIndex::Cast`] through the boot-built alias table
|
||||
/// (never a hardcoded `"/cast"` — decision 0881's law, and the reference's own: it reads
|
||||
/// `SLASH_CAST1`, `SLASH_CAST2`, … out of the Lua globals), argument taken whole; or
|
||||
/// - a line containing `CastSpellByName(` with a quoted first argument.
|
||||
/// The spell name a macro body casts (`0x4efe00`): the first `/cast`-alias line, or line holding
|
||||
/// `CastSpellByName(` with a quoted argument. Deviation: the `/cast` argument is trimmed, because
|
||||
/// the reference binds `/cast Fireball` (two spaces) to a name that never resolves.
|
||||
pub(crate) fn cast_name(table: &SlashCommands, body: &str) -> Option<(CastArm, String)> {
|
||||
for line in macro_lines(body) {
|
||||
// Arm A. The separator after the alias must be a literal `' '` — the reference compares
|
||||
// `[ebp+ebx-0x108]` against `0x20` exactly (`0x4efe96`), so a TAB does not match and a
|
||||
// bare `/cast` at end-of-line does not either. The alias itself is compared with an
|
||||
// ASCII-folding `_strnicmp` (`0x414310`), which `SlashCommands::lookup` already is.
|
||||
// Arm A: the alias folds ASCII case (`_strnicmp`, `0x414310`, as `SlashCommands::lookup`
|
||||
// does), and the next byte must be a space (`0x4efe96`), so a tab or a bare `/cast` misses.
|
||||
if let Some(rest) = line.strip_prefix('/') {
|
||||
let (cmd, args) = rest.split_once(' ').unwrap_or((rest, ""));
|
||||
if table.lookup(cmd) == Some(Command::Slash(SlashIndex::Cast)) {
|
||||
@@ -143,11 +62,11 @@ pub(crate) fn cast_name(table: &SlashCommands, body: &str) -> Option<(CastArm, S
|
||||
if !args.is_empty() {
|
||||
return Some((CastArm::Slash, args.to_string()));
|
||||
}
|
||||
// The ref falls through to arm B on this same line, then to the next line.
|
||||
// The reference tries arm B on this line, then the next line.
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Arm B, second and on the same line (`0x4efed5`).
|
||||
// Arm B, second, on the same line (`0x4efed5`).
|
||||
if let Some(name) = quoted_call_argument(line) {
|
||||
return Some((CastArm::ByName, name));
|
||||
}
|
||||
@@ -155,9 +74,9 @@ pub(crate) fn cast_name(table: &SlashCommands, body: &str) -> Option<(CastArm, S
|
||||
None
|
||||
}
|
||||
|
||||
/// `… CastSpellByName("Fireball" …) …` → `Fireball`. Only a double-quoted first argument is read:
|
||||
/// a computed one (`CastSpellByName(spell)`) has no name to bind at parse time, and the reference
|
||||
/// — matching a bare literal with no format specifier — cannot read one either.
|
||||
/// `… CastSpellByName("Fireball" …) …` gives `Fireball`: only a non-empty, double-quoted first
|
||||
/// argument is read. The reference reads from the first quote anywhere after the call (`0x4efef0`)
|
||||
/// and binds even an empty name, which ends its walk on that line.
|
||||
fn quoted_call_argument(line: &str) -> Option<String> {
|
||||
let after = line.find(CAST_BY_NAME_CALL)? + CAST_BY_NAME_CALL.len();
|
||||
let rest = line.get(after..)?.trim_start();
|
||||
@@ -167,11 +86,8 @@ fn quoted_call_argument(line: &str) -> Option<String> {
|
||||
(!name.is_empty()).then(|| name.to_string())
|
||||
}
|
||||
|
||||
/// The macro's bound spell — [`cast_name`] resolved against the player's book by the same law
|
||||
/// `CastSpellByName` itself uses ([`resolve_spell_by_name`]), so the icon's cooldown swirl and the
|
||||
/// press always agree about which rank is meant. The miss is arm-dependent, exactly as the
|
||||
/// reference stores it (the module doc): a `/cast` of a name this character does not know is
|
||||
/// [`BoundSpell::Unresolved`], a `CastSpellByName(` of one is [`BoundSpell::None`].
|
||||
/// The macro's bound spell: [`cast_name`] resolved as `CastSpellByName` resolves
|
||||
/// ([`resolve_spell_by_name`]), so the cooldown swirl and the press agree on the rank.
|
||||
pub(crate) fn bound_spell(table: &SlashCommands, body: &str, book: &SpellBookState) -> BoundSpell {
|
||||
let Some((arm, name)) = cast_name(table, body) else {
|
||||
return BoundSpell::None;
|
||||
@@ -188,8 +104,7 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::ui_chat::commands::SlashCommands;
|
||||
|
||||
/// A table with just the aliases these tests need, built the way boot builds it (through the
|
||||
/// global-string reader), so the parse is exercised against a real lookup and never a literal.
|
||||
/// The aliases these tests need, built through the global-string reader as boot builds it.
|
||||
fn table() -> SlashCommands {
|
||||
SlashCommands::build(
|
||||
|name| match name {
|
||||
@@ -210,8 +125,7 @@ mod tests {
|
||||
assert_eq!(lines, ["/cast Fireball", "/say pew"]);
|
||||
}
|
||||
|
||||
/// The `/cast` form, through the ALIAS table — `/spell` is `SLASH_CAST2` in the shipped
|
||||
/// strings, so it must bind exactly as `/cast` does.
|
||||
/// `/spell` is `SLASH_CAST2` in the shipped strings.
|
||||
#[test]
|
||||
fn cast_name_reads_the_first_cast_line_through_the_alias_table() {
|
||||
let t = table();
|
||||
@@ -223,7 +137,7 @@ mod tests {
|
||||
cast_name(&t, "/spell Frostbolt"),
|
||||
Some((CastArm::Slash, "Frostbolt".into()))
|
||||
);
|
||||
// The whole argument, subtext included — `resolve_spell_by_name` owns that grammar.
|
||||
// The whole argument, rank included; `resolve_spell_by_name` parses it.
|
||||
assert_eq!(
|
||||
cast_name(&t, "/cast Fireball(Rank 1)"),
|
||||
Some((CastArm::Slash, "Fireball(Rank 1)".into()))
|
||||
@@ -241,28 +155,24 @@ mod tests {
|
||||
assert_eq!(cast_name(&t, "/say hello\n/target Bob"), None);
|
||||
}
|
||||
|
||||
/// The separator after the alias is a **literal space** — the reference compares that one byte
|
||||
/// against `0x20` (`0x4efe96`), so a tab is not a match and the line is simply not a cast line.
|
||||
/// The alias itself folds case, which is the same `_strnicmp` the ref uses.
|
||||
#[test]
|
||||
fn the_alias_separator_is_a_literal_space_and_the_alias_folds_case() {
|
||||
let t = table();
|
||||
let fireball = Some((CastArm::Slash, "Fireball".into()));
|
||||
assert_eq!(cast_name(&t, "/CAST Fireball"), fireball);
|
||||
assert_eq!(cast_name(&t, "/Cast Fireball"), fireball);
|
||||
// A tab does not separate — the ref reads it as "not a /cast line" and walks on.
|
||||
// A tab does not separate, so the walk goes on.
|
||||
assert_eq!(
|
||||
cast_name(&t, "/cast\tFireball\n/cast Frostbolt"),
|
||||
Some((CastArm::Slash, "Frostbolt".into()))
|
||||
);
|
||||
// Arm B is case-SENSITIVE (a `rep cmpsb` strncmp), so the lowercased spelling misses.
|
||||
// Arm B is case-sensitive.
|
||||
assert_eq!(
|
||||
cast_name(&t, r#"/script castspellbyname("Fireball")"#),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
/// The `CastSpellByName(` form — the other half of the reference's own two-literal parse.
|
||||
#[test]
|
||||
fn cast_name_reads_a_quoted_cast_spell_by_name_call() {
|
||||
let t = table();
|
||||
@@ -275,13 +185,11 @@ mod tests {
|
||||
cast_name(&t, r#"/script CastSpellByName( "Healing Touch", 1 )"#),
|
||||
Some((CastArm::ByName, "Healing Touch".into()))
|
||||
);
|
||||
// A computed argument has no name to bind — neither here nor in the reference.
|
||||
// A computed argument has no name to bind.
|
||||
assert_eq!(cast_name(&t, "/script CastSpellByName(spell)"), None);
|
||||
assert_eq!(cast_name(&t, r#"/script CastSpellByName("")"#), None);
|
||||
}
|
||||
|
||||
/// The bound spell is resolved by the SAME law the press uses, so the swirl and the cast can
|
||||
/// never disagree about the rank.
|
||||
#[test]
|
||||
fn bound_spell_resolves_through_the_book() {
|
||||
use benilla_ui::script::SpellSlotView;
|
||||
@@ -320,10 +228,8 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The miss is arm-dependent, as the reference stores it (`0x4eff48`/`0x4eff81` +
|
||||
/// decision 1636): a `/cast` of a spell this character does not know is the `-1` the bar
|
||||
/// greys; a body with no cast line, or a `CastSpellByName(` of an unknown name, is the `0`
|
||||
/// the bar draws full-colour with no spell state.
|
||||
/// An unknown `/cast` is -1 (`0x4eff48`), which the bar greys; no cast line, or an unknown
|
||||
/// `CastSpellByName(`, is 0 (`0x4eff81`).
|
||||
#[test]
|
||||
fn an_unresolved_cast_is_arm_dependent() {
|
||||
let book = SpellBookState::default();
|
||||
|
||||
@@ -1,49 +1,29 @@
|
||||
//! The macro **file format** — the reference client's own, byte-for-byte (decision 0983).
|
||||
//!
|
||||
//! The 1.12 client writes each macro with the format string **`MACRO %d "%s" %s`** (byte-read at
|
||||
//! `0x44cb60`, inside the `UIMacros.cpp` string block) followed by the body's lines and a bare
|
||||
//! `END`, into `macros-cache.txt`/`macros-local.txt` (`0x45de74`/`0x45de88`). A real file off the
|
||||
//! director's own install:
|
||||
//!
|
||||
//! ```text
|
||||
//! MACRO 1 "f" Ability_Ambush
|
||||
//! .cheat fly on
|
||||
//! .modify aspeed 10
|
||||
//! END
|
||||
//! ```
|
||||
//!
|
||||
//! benilla writes exactly that. Not nostalgia: it means **a player can drop their vanilla
|
||||
//! `macros-cache.txt` into `benilla-config/macros/` and their macros are there** — and it keeps the file
|
||||
//! hand-editable, which a serialized blob would not be. The icon column is the texture's *basename*
|
||||
//! (`Ability_Ambush`); the client prepends `Interface\Icons\` (`0x44ca64`), which is why the stored
|
||||
//! token survives an icon list changing under it where a chooser index would silently re-point.
|
||||
//!
|
||||
//! Two liberties, both taken because the reference's own writer can produce files its own reader
|
||||
//! must survive: an index is trusted only as an *ordering key* (the lists are dense — see
|
||||
//! `benilla_ui::script::macros`), and a name containing a `"` round-trips (the reference's `"%s"`
|
||||
//! is unescaped, so we take the span between the FIRST and LAST quote on the line, which is what
|
||||
//! its own writer would have produced).
|
||||
//! The macro file format, the reference's own: a `MACRO %d "%s" %s` header (`0x84cb60`), the body
|
||||
//! lines and a bare `END`, in `macros-cache.txt` and `macros-local.txt` (`0x85de74`, `0x85de88`).
|
||||
//! benilla ends lines with `\n` where the reference writes `\r\n`, and reads either, so a 1.12
|
||||
//! `macros-cache.txt` drops into `benilla-config/macros/`. The index only orders the records, and
|
||||
//! a name holding a `"` runs from the first quote to the last, since the reference's `"%s"` is
|
||||
//! unescaped.
|
||||
|
||||
use benilla_ui::script::{MacroView, MAX_MACROS};
|
||||
|
||||
/// The path prefix the client prepends to a stored icon token (`0x44ca64`).
|
||||
/// The path prefix the client prepends to a stored icon basename (`0x84ca64`).
|
||||
pub(super) const ICON_PREFIX: &str = "Interface\\Icons\\";
|
||||
|
||||
/// Parse one scope's file into a dense macro list, longest-effort: a malformed record is skipped
|
||||
/// with the rest kept, because losing every macro to one bad line is the worse failure. Records
|
||||
/// beyond [`MAX_MACROS`] are dropped (the tab cannot show them).
|
||||
/// Parse one scope's file into a dense macro list: a malformed record is skipped, and records past
|
||||
/// [`MAX_MACROS`] are dropped.
|
||||
pub(super) fn parse(text: &str) -> Vec<MacroView> {
|
||||
let mut out: Vec<(u32, MacroView)> = Vec::new();
|
||||
let mut lines = text.lines().peekable();
|
||||
while let Some(line) = lines.next() {
|
||||
let Some(rest) = line.trim_start().strip_prefix("MACRO ") else {
|
||||
continue; // stray text between records (or a leading blank) — skipped
|
||||
continue; // stray text between records
|
||||
};
|
||||
let Some((index, name, icon)) = parse_header(rest) else {
|
||||
continue;
|
||||
};
|
||||
// Body: every line up to the bare END (or the next MACRO / EOF, so a file truncated
|
||||
// mid-record still yields the macro it was writing).
|
||||
// The body runs to a bare END, or to the next MACRO or EOF, so a truncated file keeps its
|
||||
// last macro.
|
||||
let mut body: Vec<&str> = Vec::new();
|
||||
loop {
|
||||
match lines.peek() {
|
||||
@@ -70,12 +50,11 @@ pub(super) fn parse(text: &str) -> Vec<MacroView> {
|
||||
out.into_iter().map(|(_, m)| m).take(MAX_MACROS).collect()
|
||||
}
|
||||
|
||||
/// `<index> "<name>" <icon>` — the tail of a `MACRO ` header line.
|
||||
/// The tail of a `MACRO ` header line: `<index> "<name>" <icon>`.
|
||||
fn parse_header(rest: &str) -> Option<(u32, String, Option<String>)> {
|
||||
let (index, rest) = rest.split_once('"')?;
|
||||
let index: u32 = index.trim().parse().ok()?;
|
||||
// The name runs to the LAST quote on the line: the reference's `"%s"` is unescaped, so a name
|
||||
// holding a quote is only recoverable from the outside in.
|
||||
// To the last quote on the line: the reference's `"%s"` is unescaped.
|
||||
let close = rest.rfind('"')?;
|
||||
let name = rest[..close].to_string();
|
||||
let icon = rest[close + 1..].trim();
|
||||
@@ -97,8 +76,7 @@ pub(super) fn write(macros: &[MacroView]) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
/// A stored icon token → the full texture path. A token that already carries a path separator is
|
||||
/// taken whole (a hand-edited file naming art outside `Interface\Icons`).
|
||||
/// A stored icon token to its texture path; a token with a path separator is taken whole.
|
||||
fn icon_path(token: String) -> String {
|
||||
if token.contains('\\') || token.contains('/') {
|
||||
token
|
||||
@@ -107,8 +85,7 @@ fn icon_path(token: String) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// The inverse: a full texture path → the stored token (the basename, when it sits in the icons
|
||||
/// folder; the whole path otherwise, so a hand-edited entry round-trips).
|
||||
/// A texture path to its stored token: the basename in the icons folder, else the whole path.
|
||||
fn icon_token(path: &str) -> &str {
|
||||
path.strip_prefix(ICON_PREFIX).unwrap_or(path)
|
||||
}
|
||||
@@ -117,9 +94,8 @@ fn icon_token(path: &str) -> &str {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The **real file** off the director's 1.12 install (`WTF/Account/WINUSER/macros-cache.txt`),
|
||||
/// verbatim — the one input that proves this reader against the reference's own writer. Note
|
||||
/// the out-of-order indices: the file is a dump, the index column is the ordering key.
|
||||
/// A real 1.12 `macros-cache.txt`, transcribed with `\n` line ends; its indices are out of
|
||||
/// order.
|
||||
const REAL_112_FILE: &str = "MACRO 1 \"f\" Ability_Ambush\n\
|
||||
.cheat fly on\n\
|
||||
.modify aspeed 10\n\
|
||||
@@ -136,7 +112,7 @@ mod tests {
|
||||
fn a_real_1_12_macros_cache_file_parses_in_index_order() {
|
||||
let macros = parse(REAL_112_FILE);
|
||||
assert_eq!(macros.len(), 3);
|
||||
// Sorted by the index COLUMN, not by file position (the real file is 1, 3, 2).
|
||||
// Sorted by the index column, not by file position (the file is 1, 3, 2).
|
||||
let names: Vec<&str> = macros.iter().map(|m| m.name.as_str()).collect();
|
||||
assert_eq!(names, ["f", "w", "ns"]);
|
||||
assert_eq!(
|
||||
@@ -152,7 +128,7 @@ mod tests {
|
||||
fn write_then_parse_round_trips_and_matches_the_reference_shape() {
|
||||
let macros = parse(REAL_112_FILE);
|
||||
let text = write(¯os);
|
||||
// The reference's own line shape, with the list re-densified 1..n.
|
||||
// The reference's line shape, the list renumbered 1..n.
|
||||
assert!(text.starts_with("MACRO 1 \"f\" Ability_Ambush\n"));
|
||||
assert!(text.contains("\nMACRO 2 \"w\" Ability_Creature_Cursed_05\n"));
|
||||
assert!(text.ends_with("END\n"));
|
||||
@@ -172,8 +148,7 @@ mod tests {
|
||||
assert_eq!(parse(&text), m);
|
||||
}
|
||||
|
||||
/// Longest-effort parsing: a malformed record loses only itself, and a file truncated
|
||||
/// mid-record (a crash between the body and its `END`) still yields what was written.
|
||||
/// A file truncated before its last `END` still yields that macro.
|
||||
#[test]
|
||||
fn malformed_records_lose_only_themselves() {
|
||||
let text = "garbage line\n\
|
||||
@@ -191,8 +166,6 @@ mod tests {
|
||||
assert_eq!(macros[1].body, "/say bye", "no END is still a macro");
|
||||
}
|
||||
|
||||
/// A name holding a quote round-trips, because the reference's unescaped `"%s"` means the
|
||||
/// only recoverable reading is first-quote-to-last-quote.
|
||||
#[test]
|
||||
fn a_quoted_name_round_trips_the_way_the_reference_would_write_it() {
|
||||
let m = vec![MacroView {
|
||||
@@ -204,7 +177,6 @@ mod tests {
|
||||
assert_eq!(parse(&write(&m)), m);
|
||||
}
|
||||
|
||||
/// A hand-edited entry naming art outside `Interface\Icons` keeps its whole path.
|
||||
#[test]
|
||||
fn a_full_path_icon_token_is_taken_whole() {
|
||||
let text = "MACRO 1 \"custom\" Interface\\Buttons\\UI-Panel-Button\nEND\n";
|
||||
@@ -216,8 +188,7 @@ mod tests {
|
||||
assert_eq!(write(¯os), text, "and writes back unchanged");
|
||||
}
|
||||
|
||||
/// A file with more records than a tab can hold is truncated, never wrapped into the other
|
||||
/// tab (the two scopes are separate files).
|
||||
/// Never wrapped into the other tab: the two scopes are separate files.
|
||||
#[test]
|
||||
fn a_file_longer_than_the_tab_is_truncated() {
|
||||
let text: String = (1..=25)
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
//! The app half of the macro system (decision 0983): persistence under `benilla-config/macros/`, the
|
||||
//! runner's route into the chat drain, and the seed/dirty contract the plugin's systems rely on.
|
||||
//!
|
||||
//! The file FORMAT has its own tests in [`super::store`] (including the director's real 1.12
|
||||
//! `macros-cache.txt`); the API's own round trip is `benilla_ui::script::macros`'; the window's is
|
||||
//! `crate::ui_script::macro_tests`. What is only testable here is the wiring.
|
||||
//! The macro wiring: persistence under `benilla-config/macros/`, the runner's route through the
|
||||
//! stock chat frame, and the seed and dirty contract the plugin's systems rely on.
|
||||
|
||||
use benilla_ui::script::{MacroState, MacroView, UiScript};
|
||||
|
||||
@@ -18,8 +14,7 @@ fn macro_view(name: &str, body: &str) -> MacroView {
|
||||
}
|
||||
}
|
||||
|
||||
/// A save writes the reference's own format under `benilla-config/macros/`, and a load brings the same
|
||||
/// macros back — the whole persistence loop over the real `local_state` law.
|
||||
/// A save writes the reference's format under `benilla-config/macros/`, and a load reads it back.
|
||||
#[test]
|
||||
fn a_saved_macro_table_round_trips_through_benilla_macros() {
|
||||
let _l = ENV_LOCK
|
||||
@@ -40,8 +35,7 @@ fn a_saved_macro_table_round_trips_through_benilla_macros() {
|
||||
crate::local_state::write_atomic(&account, &super::store::write(&state.account)).unwrap();
|
||||
crate::local_state::write_atomic(&character, &super::store::write(&state.character)).unwrap();
|
||||
|
||||
// The file on disk is the reference's own shape — readable, hand-editable, and the exact
|
||||
// format a vanilla `macros-cache.txt` already has.
|
||||
// The reference's format, with `\n` line ends.
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&account).unwrap(),
|
||||
"MACRO 1 \"Ambush\" Ability_Ambush\n/cast Ambush\n/say pew\nEND\n"
|
||||
@@ -55,8 +49,7 @@ fn a_saved_macro_table_round_trips_through_benilla_macros() {
|
||||
std::fs::remove_dir_all(&tmp).ok();
|
||||
}
|
||||
|
||||
/// A capture run is hermetic (decision 0954): both paths resolve to `None`, so a macro edit during
|
||||
/// a capture is session-only and nothing is written under anyone's install.
|
||||
/// A capture resolves both paths to `None`, so its macros are session-only.
|
||||
#[test]
|
||||
fn a_capture_run_persists_nothing() {
|
||||
let _l = ENV_LOCK
|
||||
@@ -68,17 +61,13 @@ fn a_capture_run_persists_nothing() {
|
||||
assert_eq!(crate::local_state::macros_character_path("R", "C"), None);
|
||||
}
|
||||
|
||||
/// The real UI, so the WHOLE route is under test: `run_macro` fires `EXECUTE_CHAT_LINE`, the
|
||||
/// shipped ChatFrame1 is registered for it, and its handler calls `SubmitChatInput`. A bare VM
|
||||
/// would pass this by accident under the old direct-push shape and silently prove nothing under
|
||||
/// this one.
|
||||
/// The real UI, so the whole route is under test: `run_macro` fires `EXECUTE_CHAT_LINE`, and the
|
||||
/// stock `ChatFrame1` sends the line through its edit box.
|
||||
fn ui() -> UiScript {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
// The in-game UI materializes on world entry (1051), so a player always exists by the time the
|
||||
// manifest loads — and the stock macro window's character tab formats `UnitName("player")`
|
||||
// into its label inside its own OnLoad. A manifest load with no player is a state the client
|
||||
// never reaches (decision 1848).
|
||||
// The in-game UI loads on world entry, so a player exists: the stock macro window's OnLoad
|
||||
// formats `UnitName("player")` into its character tab.
|
||||
s.set_unit(
|
||||
"player",
|
||||
Some(benilla_ui::script::UnitState {
|
||||
@@ -93,12 +82,9 @@ fn ui() -> UiScript {
|
||||
s
|
||||
}
|
||||
|
||||
/// The runner delivers every body line through the reference's own door — one
|
||||
/// `EXECUTE_CHAT_LINE` event per non-empty line, in order (0996, `0x4f14e0`) — and the
|
||||
/// reference's own `ChatFrame_OnEvent` arm does the rest: `SetText(arg1)`,
|
||||
/// `ChatEdit_SendText`, `ChatEdit_OnEscapePressed` (ChatFrame.lua l.1343-1347). So a macro line
|
||||
/// lands wherever a typed line lands — a chat type in the send queue, an emote in the emote
|
||||
/// queue, a roll in the roll queue — without the runner knowing any of them.
|
||||
/// One `EXECUTE_CHAT_LINE` per non-empty line, in order (`0x4f14e0`), which `ChatFrame_OnEvent`
|
||||
/// sends through the edit box (`ChatFrame.lua:1343-1347`): a macro line lands where a typed one
|
||||
/// does.
|
||||
#[test]
|
||||
fn running_a_macro_runs_its_lines_through_the_references_edit_box() {
|
||||
benilla_formats::wow_data_or_skip!();
|
||||
@@ -139,8 +125,7 @@ fn running_a_macro_runs_its_lines_through_the_references_edit_box() {
|
||||
assert!(s.take_chat_sends().is_empty());
|
||||
}
|
||||
|
||||
/// A CHARACTER-range macro runs by its own index — the second half of the space is not a special
|
||||
/// case anywhere in the runner.
|
||||
/// Index 19 is the first per-character macro (18 per tab).
|
||||
#[test]
|
||||
fn a_character_macro_runs_by_its_own_index() {
|
||||
benilla_formats::wow_data_or_skip!();
|
||||
@@ -159,8 +144,7 @@ fn a_character_macro_runs_by_its_own_index() {
|
||||
);
|
||||
}
|
||||
|
||||
/// The seed→dirty→save contract the plugin's two systems rest on: the app's own load must not look
|
||||
/// like a change (or every login would rewrite the file), and every script mutation must.
|
||||
/// The app's own load is not an edit, or every login would rewrite the file; a script mutation is.
|
||||
#[test]
|
||||
fn the_dirty_edge_distinguishes_a_load_from_an_edit() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
@@ -179,8 +163,6 @@ fn the_dirty_edge_distinguishes_a_load_from_an_edit() {
|
||||
assert_eq!(s.macros().account[0].body, "/cast Backstab");
|
||||
}
|
||||
|
||||
/// The generation counter is the per-frame consumers' gate (the action bar's identity feed): it
|
||||
/// moves on a seed AND on every mutation, and is never consumed by reading it.
|
||||
#[test]
|
||||
fn the_generation_moves_on_every_write_and_is_not_drained() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
@@ -198,10 +180,8 @@ fn the_generation_moves_on_every_write_and_is_not_drained() {
|
||||
assert_ne!(s.macros_generation(), after_seed);
|
||||
}
|
||||
|
||||
/// **An addon that registers `EXECUTE_CHAT_LINE` sees macro lines** — the behaviour benilla gained
|
||||
/// by firing the reference's event instead of calling its own drain (0996). In 1.12 this is not a
|
||||
/// courtesy: the event is the entire mechanism, and ChatFrame1's registration is just the default
|
||||
/// UI's use of it.
|
||||
/// In 1.12 the event is the whole mechanism; `ChatFrame1`'s registration is only the default UI's
|
||||
/// use of it.
|
||||
#[test]
|
||||
fn a_registered_frame_sees_every_macro_line_as_an_event() {
|
||||
benilla_formats::wow_data_or_skip!();
|
||||
@@ -229,7 +209,7 @@ fn a_registered_frame_sees_every_macro_line_as_an_event() {
|
||||
("/wave".into(), "/say Incoming!".into(), 2),
|
||||
"each line arrives as arg1 of its own event, in body order"
|
||||
);
|
||||
// …and the same lines still reach the chat grammar: the spy is an observer, not a diversion.
|
||||
// The same lines still reach the chat frame.
|
||||
assert_eq!(
|
||||
s.take_chat_sends()
|
||||
.iter()
|
||||
@@ -241,9 +221,7 @@ fn a_registered_frame_sees_every_macro_line_as_an_event() {
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
/// The reference's tokenizer takes `"\r\n"` as a delimiter SET — either character splits a line
|
||||
/// (`0x64ae50`). A body carrying lone `\r`s (an old-Mac hand edit, or a file round-tripped through
|
||||
/// one) is therefore three lines, not one long one.
|
||||
/// The reference's tokenizer (`0x64ae50`) splits on either `\r` or `\n`.
|
||||
#[test]
|
||||
fn either_line_ending_splits_a_body() {
|
||||
benilla_formats::wow_data_or_skip!();
|
||||
@@ -263,33 +241,14 @@ fn either_line_ending_splits_a_body() {
|
||||
);
|
||||
}
|
||||
|
||||
/// **Every icon the chooser offers RESOLVES to real art, and the catalog is the archive's** — the
|
||||
/// tripwire for B221, where four pages of the picker each showed a solid WHITE cell.
|
||||
///
|
||||
/// This is `ui_script::shipped_xml_tests`' resolve sweep for the paths that sweep structurally
|
||||
/// cannot see. That one walks static `file=` attributes in our own XML; a macro icon never appears
|
||||
/// in XML — it arrives at runtime as `SetTexture(GetMacroIconInfo(i))`. Nothing checked that those
|
||||
/// resolve, and an unresolvable path drew as an opaque white rectangle, so the picker shipped with
|
||||
/// white squares in it and every gate green.
|
||||
///
|
||||
/// The catalog is now the archive enumeration the reference itself does
|
||||
/// ([`benilla_formats::load_macro_icons`]), not a `SpellIcon.dbc` scan, so a name with no file
|
||||
/// behind it can no longer enter the list at all — what this guards is the other half: that the
|
||||
/// **resolution rule** still finds every enumerated name. `Ability_Druid_Mangle.tga` is the entry
|
||||
/// that matters (the chooser stores names extension-stripped, and `…Mangle.tga.blp` ships): it only
|
||||
/// resolves via the reference's second `.blp` candidate, and the old rule had no second candidate.
|
||||
///
|
||||
/// Resolution goes through the renderer's own [`benilla_assets::sprite_candidates`], never a copy of
|
||||
/// it: a sweep re-implementing the rule could agree with itself while disagreeing with what draws.
|
||||
///
|
||||
/// Needs client data; skips without it, like the XML sweep.
|
||||
/// Every chooser icon resolves through the renderer's own [`benilla_assets::sprite_candidates`]:
|
||||
/// one that does not draws as a white square. `Ability_Druid_Mangle.tga` resolves only through the
|
||||
/// second `.blp` candidate, since `Ability_Druid_Mangle.tga.blp` is what ships.
|
||||
#[test]
|
||||
fn every_macro_chooser_icon_resolves_in_the_client_archives() {
|
||||
/// Icons on a stock 5875 install: `patch.MPQ` 77 + `interface.MPQ` 443 = 520 raw names under
|
||||
/// `Interface\Icons\` matching `Spell_`/`Ability_`, less 3 that differ only by case or
|
||||
/// extension — independently counted off the binary's own enumeration
|
||||
/// (`BuildMacroIconList 0x4f0090`). The DBC scan this replaced served 521, a
|
||||
/// different set: it included five names with no file and missed art the archive has.
|
||||
/// A stock 5875 install: `patch.MPQ` 77 + `interface.MPQ` 443 = 520 `Spell_`/`Ability_` names
|
||||
/// under `Interface\Icons\`, less 3 that differ only by case or extension
|
||||
/// (`BuildMacroIconList`, `0x4f0090`).
|
||||
const CHOOSER_ICONS_5875: usize = 517;
|
||||
|
||||
let data = benilla_formats::wow_data_or_skip!();
|
||||
@@ -316,8 +275,7 @@ fn every_macro_chooser_icon_resolves_in_the_client_archives() {
|
||||
"chooser icons that resolve to nothing (each draws as a white square): {missing:#?}"
|
||||
);
|
||||
|
||||
// Sorted, not archive order: the reference `qsort`s case-insensitively before deduping, so the
|
||||
// order the player scrolls is alphabetical.
|
||||
// Alphabetical: the reference `qsort`s without case before deduping.
|
||||
let mut sorted = icons.clone();
|
||||
sorted.sort_by_key(|p| p.to_ascii_lowercase());
|
||||
assert_eq!(
|
||||
|
||||
@@ -1,27 +1,12 @@
|
||||
//! The auction house's mail seam — how a mail from the auction house stops being
|
||||
//! `4428:0:1` / `6C:10000:10000` and becomes "Auction won: Small Blue Pouch" over a receipt.
|
||||
//! The auction house's mail: a notice's subject and body are colon-separated text the client
|
||||
//! parses back, the subject in `0x4ace70` and the body in `GetInboxInvoiceInfo` (`0x4af360`).
|
||||
//!
|
||||
//! **The invoice is TEXT.** Nothing structured rides the wire: the auction house writes its numbers
|
||||
//! into the mail's *subject* and *body* as colon-separated fields, and the client parses them back
|
||||
//! out. That is not an emulator shortcut — it is what the real 1.12 client does (`0x4ace70` for
|
||||
//! the subject, `GetInboxInvoiceInfo 0x4af360` for the body).
|
||||
//!
|
||||
//! Two halves, and they answer different questions from different places:
|
||||
//!
|
||||
//! * **The subject says WHAT HAPPENED.** `<itemEntry>:<randomProperty>:<resultCode>` — five outcomes
|
||||
//! over six codes. The client parses it, formats the matching `AUCTION_*_MAIL_SUBJECT`
|
||||
//! GlobalString with the item's name, and writes the result back over the *displayed* subject, so
|
||||
//! the inbox list and the open letter both read it. The raw triplet is never shown.
|
||||
//! * **The body says HOW MUCH**, and only for the two outcomes that move money: won (`1`) and sold
|
||||
//! (`2`). The other four carry no body at all, which is why an outbid notice has no invoice and
|
||||
//! why [`parse_body`] is only ever asked about those two.
|
||||
//!
|
||||
//! The identity comes from the subject and the numbers come from the body — so a mail can be
|
||||
//! recognisably "Auction won: X" in the inbox list while its invoice is still unanswerable, because
|
||||
//! the body is fetched lazily (`CMSG_ITEM_TEXT_QUERY`) when the letter is opened.
|
||||
//! The subject `<itemEntry>:<randomProperty>:<resultCode>` names the outcome and is displayed
|
||||
//! through the matching `AUCTION_*_MAIL_SUBJECT` string, never raw. Only won and sold notices carry
|
||||
//! a body, the invoice's numbers, fetched when the letter is opened.
|
||||
|
||||
/// The mail's `resultCode` — the third subject field. vmangos writes these as `MailAuctionAnswers`;
|
||||
/// the client bounds the field to `0..=5` and jumps a six-entry table (`0x4acf62`/`0x4acfec`).
|
||||
/// The subject's `resultCode` (vmangos `Mail.h:102`); the client bounds it to `0..=5` for a
|
||||
/// six-entry table (`0x4acf62`, `0x4acfec`).
|
||||
pub(crate) mod auction_mail {
|
||||
pub(crate) const OUTBID: u32 = 0;
|
||||
pub(crate) const WON: u32 = 1;
|
||||
@@ -35,21 +20,15 @@ pub(crate) mod auction_mail {
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) struct AuctionSubject {
|
||||
pub(crate) entry: u32,
|
||||
/// The item's random-property roll, for the suffixed name ("of the Bear"). Carried because the
|
||||
/// reference composes the displayed name from **both** (`[rec+0x250]` and `[rec+0x254]`);
|
||||
/// benilla's item names are unsuffixed today, so nothing reads it yet.
|
||||
/// The random-property roll. Parsed, not applied: the reference names the item with it
|
||||
/// (`[rec+0x250]`, `[rec+0x254]`), and the displayed subject here does not.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) random_property: i32,
|
||||
pub(crate) result: u32,
|
||||
}
|
||||
|
||||
/// Parse `"<itemEntry>:<randomProperty>:<resultCode>"`.
|
||||
///
|
||||
/// Strict on purpose: a mail whose subject is a player's own free text must NOT be mistaken for an
|
||||
/// auction notice just because it happens to contain colons, so every field has to parse and the
|
||||
/// result code has to be in range. The type byte already gated us to `MAIL_AUCTION`, but the
|
||||
/// reference applies this second test on top of it and so do we — the subject is the only thing
|
||||
/// that distinguishes the five outcomes, and an unparseable one has no outcome at all.
|
||||
/// Parses `<itemEntry>:<randomProperty>:<resultCode>` strictly: on top of the type byte, the
|
||||
/// reference also refuses a subject whose fields do not all parse in range.
|
||||
pub(crate) fn parse_subject(subject: &str) -> Option<AuctionSubject> {
|
||||
let mut parts = subject.trim().split(':');
|
||||
let entry: u32 = parts.next()?.trim().parse().ok()?;
|
||||
@@ -65,10 +44,8 @@ pub(crate) fn parse_subject(subject: &str) -> Option<AuctionSubject> {
|
||||
})
|
||||
}
|
||||
|
||||
/// The `AUCTION_*_MAIL_SUBJECT` GlobalStrings key one result code displays under — the reference's
|
||||
/// six-entry dispatch table at `0x4acfec`, where the two cancel codes share a key. Every one of
|
||||
/// these strings is `"<something>: %s"` with the item name as the fill, and all five ship in the
|
||||
/// player's own GlobalStrings.lua (l.83-99), so nothing here carries Blizzard's text.
|
||||
/// The `AUCTION_*_MAIL_SUBJECT` key a result code displays under (`0x4acfec`); the two cancel
|
||||
/// codes share one. The strings are the install's own (`GlobalStrings.lua:83-99`).
|
||||
pub(crate) fn subject_key(result: u32) -> Option<&'static str> {
|
||||
Some(match result {
|
||||
auction_mail::OUTBID => "AUCTION_OUTBID_MAIL_SUBJECT",
|
||||
@@ -85,24 +62,19 @@ pub(crate) fn subject_key(result: u32) -> Option<&'static str> {
|
||||
/// The numbers an invoice body carries.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub(crate) struct InvoiceNumbers {
|
||||
/// The counterparty's guid — who bought your auction, or who sold you the one you won. Written
|
||||
/// as **hex**, right-aligned in a 16-wide field, so it can arrive with leading spaces.
|
||||
/// The counterparty's guid, hex right-aligned in a 16-wide field, so it may lead with spaces.
|
||||
pub(crate) player_guid: u64,
|
||||
pub(crate) bid: u32,
|
||||
pub(crate) buyout: u32,
|
||||
/// Seller bodies only.
|
||||
pub(crate) deposit: u32,
|
||||
/// Seller bodies only — the auction house's cut.
|
||||
/// Seller bodies only: the auction house's cut.
|
||||
pub(crate) consignment: u32,
|
||||
}
|
||||
|
||||
/// Parse an invoice body: five fields for a `seller` invoice, three for a buyer's.
|
||||
///
|
||||
/// The reference picks the `sscanf` format off the invoice type rather than counting fields
|
||||
/// (`"%16I64X:%d:%d:%d:%d"` vs `"%16I64X:%d:%d"`), which is why the count is checked here and not
|
||||
/// inferred: a seller body that arrived three-field would leave the reference reading two
|
||||
/// uninitialised numbers, and we would rather answer "no invoice" than a deposit of whatever was on
|
||||
/// the stack.
|
||||
/// Parses an invoice body with the reference's `sscanf` formats, `%16I64X:%d:%d:%d:%d` for a
|
||||
/// seller and `%16I64X:%d:%d` for a buyer. Deviation: a wrong field count is no invoice, because
|
||||
/// the reference would read the missing fields uninitialised.
|
||||
pub(crate) fn parse_body(body: &str, seller: bool) -> Option<InvoiceNumbers> {
|
||||
let fields: Vec<&str> = body.trim().split(':').map(str::trim).collect();
|
||||
if fields.len() != if seller { 5 } else { 3 } {
|
||||
@@ -129,7 +101,7 @@ pub(crate) fn parse_body(body: &str, seller: bool) -> Option<InvoiceNumbers> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The two subjects the director photographed, byte for byte off the live server.
|
||||
/// Two subjects as the live server sends them.
|
||||
#[test]
|
||||
fn the_subject_triplet_names_the_outcome() {
|
||||
let won = parse_subject("4428:0:1").expect("won");
|
||||
@@ -140,15 +112,13 @@ mod tests {
|
||||
assert_eq!((sold.entry, sold.result), (5529, auction_mail::SOLD));
|
||||
assert_eq!(subject_key(sold.result), Some("AUCTION_SOLD_MAIL_SUBJECT"));
|
||||
|
||||
// Both cancel codes land on one key (the reference's own table shares the entry).
|
||||
// Both cancel codes share one key, as in the reference's table.
|
||||
assert_eq!(
|
||||
subject_key(auction_mail::CANCELLED_TO_BIDDER),
|
||||
subject_key(auction_mail::CANCELLED)
|
||||
);
|
||||
}
|
||||
|
||||
/// A player's own subject line must never be mistaken for an auction notice — the type byte
|
||||
/// gates us here, but a hand-typed "3:4:5" would otherwise sail straight through.
|
||||
#[test]
|
||||
fn a_subject_that_is_not_the_triplet_is_not_an_auction_notice() {
|
||||
assert_eq!(parse_subject("Hi there"), None);
|
||||
@@ -158,8 +128,7 @@ mod tests {
|
||||
assert_eq!(parse_subject(""), None);
|
||||
}
|
||||
|
||||
/// The two bodies the director photographed. The seller's five fields carry the arithmetic the
|
||||
/// receipt shows; the buyer's three do not, and asking for five is what keeps them apart.
|
||||
/// Two bodies as the live server sends them.
|
||||
#[test]
|
||||
fn the_body_carries_hex_guid_then_the_money() {
|
||||
let sold = parse_body("6C:10000:10000:25:500", true).expect("seller body");
|
||||
@@ -177,13 +146,13 @@ mod tests {
|
||||
"not in a buyer body"
|
||||
);
|
||||
|
||||
// The right shape, asked the wrong way round, answers nothing rather than guessing.
|
||||
// A body asked for as the other kind answers nothing.
|
||||
assert_eq!(parse_body("6C:10000:10000", true), None);
|
||||
assert_eq!(parse_body("6C:10000:10000:25:500", false), None);
|
||||
}
|
||||
|
||||
/// `strm.width(16) << right << hex` pads with spaces, so the guid can arrive with a run of them
|
||||
/// in front. The reference's `sscanf` skips leading whitespace; so do we.
|
||||
/// vmangos pads the guid to 16 with spaces (`AuctionHouseMgr.cpp:171`); the reference's
|
||||
/// `sscanf` skips them.
|
||||
#[test]
|
||||
fn a_space_padded_guid_still_parses() {
|
||||
let b = parse_body(" 6C:10000:10000", false).expect("padded");
|
||||
|
||||
@@ -1,49 +1,14 @@
|
||||
//! The app-side **mail feed** (decision 0544 P1/P2) — the inward half of the mail seam around
|
||||
//! [`benilla_ui::script`]'s `mail` module, the mailbox twin of [`crate::ui_merchant`]'s vendor seam.
|
||||
//! The mail feed: the app half of [`benilla_ui::script`]'s `mail` module.
|
||||
//!
|
||||
//! The mailbox window is entirely client-side (there is no `SMSG_SHOW_MAILBOX` on 5875): a
|
||||
//! right-click on a MAILBOX GameObject ([`crate::target`]) opens the session here — it stores the
|
||||
//! mailbox guid and requests the window's show; it sends **no** packet (the MAILBOX use handler
|
||||
//! `0x5f6820` overrides the shared use-sender to a local open — never `CMSG_GAMEOBJ_USE`).
|
||||
//! The window's own `MAIL_SHOW` handler then calls `CheckInbox()`, whose intent this module drains
|
||||
//! into the first `CMSG_GET_MAIL_LIST` — one request, Lua-driven, exactly the reference flow
|
||||
//! (MailFrame.lua l.42).
|
||||
//! The mailbox window is client-side, with no `SMSG_SHOW_MAILBOX`: a mailbox right-click opens the
|
||||
//! session and sends nothing (the MAILBOX use handler `0x5f6820` opens locally, never
|
||||
//! `CMSG_GAMEOBJ_USE`), and the window's `MAIL_SHOW` handler calls `CheckInbox()`, the first
|
||||
//! `CMSG_GET_MAIL_LIST` (`MailFrame.lua:42`).
|
||||
//!
|
||||
//! The mailbox's packet handlers ([`net`]) fill [`MailOpen`] from the wire
|
||||
//! (`SMSG_MAIL_LIST_RESULT` → the inbox rows; `SMSG_ITEM_TEXT_QUERY_RESPONSE` → the body cache;
|
||||
//! `SMSG_SEND_MAIL_RESULT` → the send/take result queues). Each frame [`feed_mail`] resolves each
|
||||
//! wire [`MailListEntry`] to a Lua-facing [`MailInboxRow`] (sender via the ask-once name cache, item
|
||||
//! name/quality/icon via the ask-once item-template cache + `ItemDisplayInfo.dbc`, body from the
|
||||
//! cache, stationery basename from `Stationery.dbc`), pushes the snapshot
|
||||
//! ([`benilla_ui::script::UiScript::set_mail`]), and fires the events the reference Lua drives —
|
||||
//! `MAIL_SHOW` on open, `MAIL_INBOX_UPDATE` on a list/body change, `MAIL_CLOSED` on clear, and the
|
||||
//! send-result trio (see [`feed_mail`]). [`drain_mail`] pulls the Lua intents back out into the mail
|
||||
//! `CMSG`s. The standardized NPC-session range guard ([`crate::ui_session`]) client-side-closes the
|
||||
//! window when the player leaves the mailbox's interaction range (the client's generic
|
||||
//! interaction-target range gate `0x493230` — past it every mail `CMSG` silently fails its
|
||||
//! `CheckMailBox`, `MailHandler.cpp:65`).
|
||||
//!
|
||||
//! **The arrival layer ([`MailPending`], decisions 0544 P3 / 0904 / 0913).** `HasNewMail()` mirrors
|
||||
//! the real client's own model: a countdown float (`0x845eac`) read as **`|countdown| < ε`** —
|
||||
//! *near zero*, not `<= 0` (the sign matters, decision 0904: vmangos's "no mail" reply is
|
||||
//! `-86400.0`, which a `<= 0` predicate reads as "you have mail" at every login). vmangos only ever
|
||||
//! sends `0.0` ("waiting now") or `-86400.0` ("none") — never a positive value — but the countdown
|
||||
//! is implemented in full since that's the client's real model, not a two-value special case.
|
||||
//!
|
||||
//! **The icon's whole life is that float**, because `MiniMapMailFrame` is the *only* listener of
|
||||
//! `UPDATE_PENDING_MAIL` in all of 1.12 FrameXML and its handler is a bare idempotent
|
||||
//! `if HasNewMail() then Show() else Hide()` (`Minimap.xml` l.278-289) — it never listens for
|
||||
//! `MAIL_INBOX_UPDATE`/`MAIL_CLOSED` despite the icon logically depending on the inbox state. So
|
||||
//! **checking your mail clears the icon by a route that never touches the inbox** (decision 0913):
|
||||
//! opening a letter arms the deferred-refresh flag ([`MailPending::arm_refresh`], the reference's
|
||||
//! `[0xb6efcc]`), and the mailbox *close* re-asks the server — the sender stamping the countdown to
|
||||
//! [`MAIL_TIME_NO_MAIL`] and the reply settling it. The same stamp-then-ask runs at every
|
||||
//! world-enter ([`send_query_next_mail_time_on_enter`]), which is why the reference icon also blinks
|
||||
//! off across a loading screen.
|
||||
//!
|
||||
//! [`feed_mail`] steps the float every frame ([`MailPending::step`]), keeps
|
||||
//! [`benilla_ui::script::UiScript::set_has_new_mail`] current, and fires `UPDATE_PENDING_MAIL` at
|
||||
//! the three sites the reference fires it (see [`MailPending::notify`]).
|
||||
//! [`MailPending`] is the arrival layer behind `HasNewMail()` and the minimap icon, whose only
|
||||
//! listener, `MiniMapMailFrame` (`Minimap.xml:278-289`), never reads the inbox. Reading mail clears
|
||||
//! the icon because opening a letter arms a deferred refresh (`[0xb6efcc]`) and the mailbox close
|
||||
//! re-asks the server.
|
||||
|
||||
use benilla_protocol::messages::{mail_error, mail_message_type, MailListEntry};
|
||||
use bevy::prelude::*;
|
||||
@@ -65,71 +30,53 @@ mod pending;
|
||||
|
||||
pub(crate) use pending::MailPending;
|
||||
|
||||
/// `checked` mask bits (VERIFIED vmangos `Mail.h`, decision 0544): READ, RETURNED, COPIED.
|
||||
/// `checked` mask bits (vmangos `Mail.h`): READ, RETURNED, COPIED.
|
||||
const CHECKED_READ: u32 = 0x1;
|
||||
const CHECKED_RETURNED: u32 = 0x2;
|
||||
const CHECKED_COPIED: u32 = 0x4;
|
||||
|
||||
/// `MAIL_STATIONERY_GM` (`Stationery.dbc` row 61) — `isGM` is `stationery == 61` (MailFrame.lua
|
||||
/// l.108: a GM row shows its stationery icon even when it carries a package).
|
||||
/// `MAIL_STATIONERY_GM` (`Stationery.dbc` row 61): a GM row shows its stationery icon even when it
|
||||
/// carries a package (`MailFrame.lua:108`).
|
||||
const STATIONERY_GM: u32 = 61;
|
||||
|
||||
/// The `CheckInbox` → `CMSG_GET_MAIL_LIST` client-side rate limit, in seconds (`0x4aeab0`: the
|
||||
/// real client drops repeat inbox queries inside this window).
|
||||
/// `CheckInbox`'s client-side rate limit on `CMSG_GET_MAIL_LIST`, in seconds (`0x4aeab0`).
|
||||
const CHECK_INBOX_THROTTLE_SECS: f64 = 60.0;
|
||||
|
||||
/// The letter-row icon by kind (the wire carries only a stationery id; the reference derives the
|
||||
/// row's `stationeryIcon` from it). VERIFIED via the data chain `Stationery.dbc` → stationery
|
||||
/// item (41 → 9311 "Default Stationery", 61 → 18154 "Blizzard Stationery") → the item's
|
||||
/// `ItemDisplayInfo` icon (displays 7798 / 30658) — and the default's pixels match the reference
|
||||
/// screenshot's red-stamped letter (`INV_Letter_15`, our old guess, is the tan open envelope).
|
||||
/// The letter-row icon: the stationery's item icon (`Stationery.dbc` 41 → item 9311, display
|
||||
/// 7798; 61 → item 18154, display 30658).
|
||||
const STATIONERY_ICON_NORMAL: &str = "Interface\\Icons\\INV_Misc_Note_01";
|
||||
const STATIONERY_ICON_GM: &str = "Interface\\Icons\\Mail_GMIcon";
|
||||
|
||||
/// The `Stationery.dbc` id → texture-basename catalog, loaded with the entity catalogs
|
||||
/// ([`crate::entities`]). Optional resource — absent, every mail falls to the default backdrop
|
||||
/// ([`benilla_formats::StationeryCatalog::DEFAULT_TEXTURE`]) and the window still works.
|
||||
/// The `Stationery.dbc` catalog; without it every mail gets the default backdrop.
|
||||
#[derive(Resource)]
|
||||
pub(crate) struct Stationery(pub(crate) benilla_formats::StationeryCatalog);
|
||||
|
||||
/// One `SMSG_SEND_MAIL_RESULT` for `action == SEND`, queued by the net bridge for [`feed_mail`] to
|
||||
/// fire (the feed owns the `UiScript`, the apply site doesn't). `MAIL_FAILED` fires on **every**
|
||||
/// SEND result — success included — because the real client overloads it as "send resolved, unblock
|
||||
/// the UI" (`0x4ad15f`; MailFrame.lua's `MAIL_FAILED` handler only re-enables the button).
|
||||
/// `MAIL_SEND_SUCCESS` fires additionally when `error == OK`.
|
||||
/// A `SEND` result queued for [`feed_mail`]. `MAIL_FAILED` fires on every one, success included:
|
||||
/// the reference uses it as "the send resolved" (`0x4ad15f`), and the stock handler only
|
||||
/// re-enables the button.
|
||||
pub(crate) struct MailSendAck {
|
||||
pub(crate) ok: bool,
|
||||
/// The message key for a non-OK, non-equip send failure (else `None`).
|
||||
/// The message key of a failure other than an equip error.
|
||||
pub(crate) refusal: Option<&'static str>,
|
||||
}
|
||||
|
||||
/// The open mailbox, filled by the packet handlers ([`net`]) and read by
|
||||
/// [`feed_mail`]. Holds the mailbox guid and the inbox rows exactly as the wire delivered them
|
||||
/// (`SMSG_MAIL_LIST_RESULT`), the ask-once letter-body cache, and the send-result queues; the feed
|
||||
/// resolves each row to a display row and the drain maps a clicked 1-based row to its wire mail id.
|
||||
/// Cleared on a client-side close and on disconnect.
|
||||
/// The open mailbox session: the guid, the inbox rows as the wire sent them, the letter-body cache
|
||||
/// and the result queues. Cleared on close and on disconnect.
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct MailOpen {
|
||||
/// The mailbox whose window is open; `None` = no mailbox open.
|
||||
pub(crate) mailbox: Option<u64>,
|
||||
/// The inbox rows (wire order = 1-based display order).
|
||||
/// Wire order, which is the 1-based display order.
|
||||
pub(crate) mails: Vec<MailListEntry>,
|
||||
/// Ask-once letter-body cache: `item_text_id` → body text, through [`QueryCache`] (2288).
|
||||
/// Letter bodies by `item_text_id`, asked once.
|
||||
pub(crate) bodies: QueryCache<u32, String>,
|
||||
/// Fire `MAIL_SHOW` next feed — set on every mailbox click (the reference opens the window per
|
||||
/// use, `0x4acd10`). Consumed by [`feed_mail`].
|
||||
/// `MAIL_SHOW` owed: every mailbox use re-shows the window (`0x4acd10`).
|
||||
show_requested: bool,
|
||||
/// Session-scoped throttle: the `Time::elapsed_secs_f64` of the last `CMSG_GET_MAIL_LIST`, or
|
||||
/// `None` when never queried this session (a fresh open queries immediately).
|
||||
/// When the last `CMSG_GET_MAIL_LIST` went out, in `Time::elapsed_secs_f64`.
|
||||
last_list_query: Option<f64>,
|
||||
/// SEND-action results the net bridge queued for the feed to fire ([`MailSendAck`]).
|
||||
pub(crate) send_acks: Vec<MailSendAck>,
|
||||
/// 1-based inbox rows a take just emptied and purged ([`net`]) — the feed fires
|
||||
/// `CLOSE_INBOX_ITEM(index)` for each, ahead of the `MAIL_INBOX_UPDATE` the purge causes.
|
||||
/// 1-based rows a take emptied and purged, each owed a `CLOSE_INBOX_ITEM`.
|
||||
pub(crate) close_inbox: Vec<u32>,
|
||||
/// Refusals (a take/return/delete failure) the feed shows, as message keys — resolved at the
|
||||
/// feed, which is where the VM's `GlobalStrings` can be read
|
||||
/// ([`crate::ui_action::keyed_line`]).
|
||||
/// Take, return and delete refusals, as message keys.
|
||||
pub(crate) errors: Vec<&'static str>,
|
||||
}
|
||||
|
||||
@@ -140,10 +87,8 @@ impl crate::query_cache::AskOnce for MailOpen {
|
||||
}
|
||||
|
||||
impl MailOpen {
|
||||
/// A mailbox right-click (decision 0544): open (or re-show) the window's session and request its
|
||||
/// `MAIL_SHOW`. A *different* mailbox resets the session (rows + throttle); the same one keeps
|
||||
/// them and just re-shows (the reference re-fires `MAIL_SHOW` on every use — `CheckInbox`'s own
|
||||
/// throttle stops the refresh from spamming the wire).
|
||||
/// A mailbox right-click. A different mailbox resets the rows and the throttle; the same one
|
||||
/// only re-shows, as the reference fires `MAIL_SHOW` on every use.
|
||||
pub(crate) fn click(&mut self, mailbox: u64) {
|
||||
if self.mailbox != Some(mailbox) {
|
||||
self.mailbox = Some(mailbox);
|
||||
@@ -153,7 +98,6 @@ impl MailOpen {
|
||||
self.show_requested = true;
|
||||
}
|
||||
|
||||
/// The wire mail id at a 1-based display row — what the mail `CMSG`s address.
|
||||
fn mail_id_at(&self, index_1based: u32) -> Option<u32> {
|
||||
index_1based
|
||||
.checked_sub(1)
|
||||
@@ -161,8 +105,7 @@ impl MailOpen {
|
||||
.map(|e| e.message_id)
|
||||
}
|
||||
|
||||
/// Flip a row's local `checked` READ bit — the no-reply `CMSG_MAIL_MARK_AS_READ` has no server
|
||||
/// answer (decision 0544 P1), so the read state is client-authoritative until the next list.
|
||||
/// Sets a row's READ bit locally: `CMSG_MAIL_MARK_AS_READ` gets no reply.
|
||||
fn mark_read(&mut self, index_1based: u32) {
|
||||
if let Some(e) = index_1based
|
||||
.checked_sub(1)
|
||||
@@ -172,8 +115,7 @@ impl MailOpen {
|
||||
}
|
||||
}
|
||||
|
||||
/// Close the open window (a client-side close, no packet — vanilla). Keeps nothing; a re-open
|
||||
/// re-lists.
|
||||
/// A client-side close, sending nothing as 1.12 does; a re-open re-lists.
|
||||
pub(crate) fn clear(&mut self) {
|
||||
self.mailbox = None;
|
||||
self.mails.clear();
|
||||
@@ -184,17 +126,14 @@ impl MailOpen {
|
||||
self.errors.clear();
|
||||
}
|
||||
|
||||
/// Disconnect: drop the open window (mirrors the merchant/gossip session clears).
|
||||
pub(crate) fn clear_session(&mut self) {
|
||||
self.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// The mailbox window is an NPC-less interaction session: the standardized range guard
|
||||
/// ([`crate::ui_session`]) client-side-closes it — the exact `CloseMail` clear — when the player
|
||||
/// leaves the mailbox's interaction range (the client's generic interaction-target range gate
|
||||
/// `0x493230`, the same one the mail `CMSG`s' server-side `CheckMailBox` (`MailHandler.cpp:65`)
|
||||
/// enforces at 5 yd).
|
||||
/// The range guard ([`crate::ui_session`]) closes the window, the `CloseMail` clear, past the
|
||||
/// client's own interaction gate (`0x493230`, 5.5556 yd); the server's `CheckMailBox`
|
||||
/// (`MailHandler.cpp:69`) checks 5 yd on each mailbox `CMSG`.
|
||||
impl NpcSession for MailOpen {
|
||||
fn npc(&self) -> Option<u64> {
|
||||
self.mailbox
|
||||
@@ -216,30 +155,20 @@ impl Plugin for UiMailPlugin {
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
// Range-close before the feed so the clear turns into MAIL_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 (CheckInbox, a take, a send) goes
|
||||
// out the same frame (the ui_merchant ordering exactly). After the UnitFeed set
|
||||
// so the SetInboxItem tooltip reads a landed item-template store.
|
||||
// Range-close, feed, then drain after input, so each edge lands the same
|
||||
// frame; the feed runs after `UnitFeed` so `SetInboxItem` reads the template.
|
||||
close_npc_session_out_of_range::<MailOpen>.before(feed_mail),
|
||||
feed_mail.after(crate::ui_unit::UnitFeed).in_set(UiFeed),
|
||||
drain_mail.after(UiInput),
|
||||
// The world-enter one-shot (decision 0548 §7: "once at UI/login load") — its
|
||||
// own system, ordering-independent of the feed/drain pair above.
|
||||
send_query_next_mail_time_on_enter,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `MSG_QUERY_NEXT_MAIL_TIME`, sent once per **world-enter** — VERIFIED faithful (decision 0913):
|
||||
/// the query's two callers are the mail module's init tail and the close-with-pending path, and
|
||||
/// that init hangs off the world-enter cascade `0x4908c0` (`PLAYER_ENTERING_WORLD` + 37 subsystem
|
||||
/// inits), whose guard is re-armed by the paired teardown — so it runs per world-enter (login,
|
||||
/// worldport, instance transfer, `ReloadUI`), not once per process as an earlier note had it.
|
||||
///
|
||||
/// The init stamps the countdown to [`MAIL_TIME_NO_MAIL`] before asking, which is why the reference
|
||||
/// icon blinks off across a loading screen and comes back with the reply.
|
||||
/// `MSG_QUERY_NEXT_MAIL_TIME` at every world enter, from the mail module's init in the world-enter
|
||||
/// cascade (`0x4908c0`). The init stamps "no mail" first, so the icon goes dark across a loading
|
||||
/// screen until the reply.
|
||||
fn send_query_next_mail_time_on_enter(
|
||||
mut entered: MessageReader<EnteredWorldMessage>,
|
||||
commands: Res<NetCommands>,
|
||||
@@ -251,46 +180,32 @@ fn send_query_next_mail_time_on_enter(
|
||||
}
|
||||
}
|
||||
|
||||
/// The message a `SMSG_SEND_MAIL_RESULT` code shows — **always** a catalog key, which is the whole
|
||||
/// correction decision 1821 brought here (superseding 0544's hand-written pair).
|
||||
///
|
||||
/// The reference's handler `0x4ad050` indexes `[0x4ad1a0 + error]` into a nine-entry jump table at
|
||||
/// `0x4ad17c`, every arm a `push <id>; call CGGameUI::DisplayError`. This module used to claim
|
||||
/// *"No 1.12 GlobalString for either"* about codes 5 and 14 and inline near-miss English for them;
|
||||
/// both are real rows, and their real strings differ from what we were showing. And the arm this
|
||||
/// module used to spell `"Mail failed (n)."` is the reference's own `ja 0x4ad147` — every code
|
||||
/// from 6 to 13 and everything past 15 lands on `ERR_MAIL_DATABASE_ERROR` with them.
|
||||
///
|
||||
/// `OK` and `EQUIP_ERROR` never reach here: the first is not a refusal (it has
|
||||
/// [`MAIL_SENT_KEY`] instead) and the second resolves through the equip-error table
|
||||
/// (`0x4ad12e` → `0x622630`), which is [`crate::ui_items`]' job.
|
||||
/// The message key a `SMSG_SEND_MAIL_RESULT` error shows: the handler `0x4ad050` indexes
|
||||
/// `[0x4ad1a0 + error]` into the nine-arm jump table at `0x4ad17c`, and codes 6 to 13 and past 15
|
||||
/// share `ERR_MAIL_DATABASE_ERROR` (`0x4ad147`). `OK` shows [`MAIL_SENT_KEY`] and `EQUIP_ERROR`
|
||||
/// goes through the equip-error table (`0x4ad12e` → `0x622630`).
|
||||
pub(crate) fn mail_refusal(error: u32) -> &'static str {
|
||||
match error {
|
||||
mail_error::CANNOT_SEND_TO_SELF => "ERR_MAIL_TO_SELF", // 0x164
|
||||
mail_error::NOT_ENOUGH_MONEY => "ERR_NOT_ENOUGH_MONEY", // 0x25 — speaks, line 0x28
|
||||
mail_error::NOT_ENOUGH_MONEY => "ERR_NOT_ENOUGH_MONEY", // 0x25, voiced (speech 0x28)
|
||||
mail_error::RECIPIENT_NOT_FOUND => "ERR_MAIL_TARGET_NOT_FOUND", // 0x165
|
||||
mail_error::NOT_YOUR_TEAM => "ERR_PLAYER_WRONG_FACTION", // 0xff
|
||||
mail_error::TRIAL_ACCOUNT => "ERR_RESTRICTED_ACCOUNT", // 0x1be
|
||||
mail_error::TOO_MANY_ATTACHMENTS => "ERR_MAIL_REACHED_CAP", // 0x1c4
|
||||
// INTERNAL_ERROR (6) and every other code, by the same `0x4ad147`.
|
||||
// INTERNAL_ERROR (6) and every other code (`0x4ad147`).
|
||||
_ => "ERR_MAIL_DATABASE_ERROR", // 0x166
|
||||
}
|
||||
}
|
||||
|
||||
/// The line a **successful** `SEND` shows: `ERR_MAIL_SENT` — id `0x167`, and a `kind 1` row, so it
|
||||
/// is the yellow info line and not the red one. `0x4ad0b1` reaches it before the form reset
|
||||
/// (`0x4acdc0`), gated on the action being `SEND`; a successful take/return/delete says nothing.
|
||||
/// The yellow info line (id `0x167`) a successful `SEND` shows before the form reset (`0x4ad0b1`,
|
||||
/// `0x4acdc0`); a successful take, return or delete says nothing.
|
||||
pub(crate) const MAIL_SENT_KEY: &str = "ERR_MAIL_SENT";
|
||||
|
||||
mod invoice;
|
||||
|
||||
use invoice::auction_mail;
|
||||
|
||||
/// Resolve one wire [`MailListEntry`] into the Lua-facing [`MailInboxRow`]: sender through the ask-
|
||||
/// once name cache (player guids only), the enclosed item's name/quality/icon through the ask-once
|
||||
/// item-template cache + `ItemDisplayInfo.dbc`, the body from the cache, the stationery basename
|
||||
/// from `Stationery.dbc`. `None`s stay `None` while a query is in flight — the row shows a
|
||||
/// placeholder and fills in when the answer lands (the merchant/loot pattern).
|
||||
/// One wire row as the Lua-facing row; anything still in flight stays `None` until it lands.
|
||||
fn resolve_row(
|
||||
entry: &MailListEntry,
|
||||
bodies: &QueryCache<u32, String>,
|
||||
@@ -301,29 +216,22 @@ fn resolve_row(
|
||||
commands: &NetCommands,
|
||||
rolls: crate::items::RollCatalogs,
|
||||
macros: &crate::npc_text::MacroContext,
|
||||
// The VM's own GlobalStrings, for the auction notice's subject template. A resolver rather than
|
||||
// a table because the strings are the player's install's, and Rust must never carry a copy of
|
||||
// them (decision 0669's shape).
|
||||
// The VM's GlobalStrings, for the auction subject: the strings are the install's, never ours.
|
||||
get_text: &dyn Fn(&str) -> Option<String>,
|
||||
) -> MailInboxRow {
|
||||
let is_gm = entry.stationery == STATIONERY_GM;
|
||||
let returned = entry.checked & CHECKED_RETURNED != 0;
|
||||
// Reply is allowed only to a not-yet-returned mail from a player (MailFrame.lua l.281).
|
||||
// Reply only to an unreturned mail from a player (`MailFrame.lua:281` reads it).
|
||||
let can_reply = entry.sender_guid.is_some() && !returned;
|
||||
// Delete-vs-return (INTERIM, decision 0548): a mail RETURNS on delete/expiry only when it still
|
||||
// carries attachments (item or money) from a not-yet-returned player sender — that is the
|
||||
// server's own expiry behavior (vmangos `ReturnOrDeleteOldMails`). Everything else — plain
|
||||
// letters, system mail, already-returned mail — deletes. The exact client predicate behind
|
||||
// `InboxItemCanDelete` is not read from the reference; this mirrors the observable law.
|
||||
// An unreturned player mail still carrying money or an item returns rather than deletes; the
|
||||
// reference's `InboxItemCanDelete` predicate is untraced.
|
||||
let can_delete =
|
||||
returned || entry.sender_guid.is_none() || (entry.item.is_none() && entry.money == 0);
|
||||
|
||||
// The enclosed item, resolved ask-once from its template.
|
||||
let (item_id, item_roll, item_count, item_name, item_texture, item_quality) = match &entry.item
|
||||
{
|
||||
Some(att) => {
|
||||
let template = items.template(att.entry, 0, commands);
|
||||
// The rolled name (1547) — the same join the loot window and the auction rows make.
|
||||
let name = template.map(|t| rolls.name(&t.name, att.random_prop_id));
|
||||
let quality = template.map(|t| t.quality);
|
||||
let display_id = template.map(|t| t.display_info_id).unwrap_or(0);
|
||||
@@ -350,28 +258,22 @@ fn resolve_row(
|
||||
.map(|s| s.0.texture(entry.stationery).to_string())
|
||||
.unwrap_or_else(|| benilla_formats::StationeryCatalog::DEFAULT_TEXTURE.to_string());
|
||||
|
||||
// The letter body, raw. Hoisted because the auction invoice parses THIS and not the
|
||||
// `$`-substituted text below: the invoice is machine-written colon fields, and running a macro
|
||||
// expander over them would be expanding the auction house's own bookkeeping.
|
||||
// The raw body: the invoice parses it before any `$`-macro expansion.
|
||||
let raw_body = (entry.item_text_id != 0)
|
||||
.then(|| bodies.get(entry.item_text_id))
|
||||
.flatten();
|
||||
|
||||
// ── The auction house's mail (`ui_mail::invoice`, `0x4ace70`/`0x4af360`) ─────────────────
|
||||
// Two independent resolves off one subject: the DISPLAYED subject, which every auction notice
|
||||
// gets, and the INVOICE, which only a won/sold notice can answer and only once its body has
|
||||
// been fetched.
|
||||
// ── The auction house's mail (`0x4ace70`, `0x4af360`) ──
|
||||
// Every notice gets its displayed subject; only a won or sold one has an invoice, once its
|
||||
// body is fetched.
|
||||
let auction = (entry.message_type == mail_message_type::AUCTION)
|
||||
.then(|| invoice::parse_subject(&entry.subject))
|
||||
.flatten();
|
||||
// The item the notice is ABOUT — resolved from the subject's own entry, not from the
|
||||
// attachment: a "sold" notice encloses the money, and names an item it does not carry.
|
||||
// The notice's item comes from the subject: a sold notice names an item it does not carry.
|
||||
let notice_item_name = auction
|
||||
.and_then(|a| items.template(a.entry, 0, commands))
|
||||
.map(|t| t.name.clone());
|
||||
// `<key>: %s` + the name. Until the item template lands the raw triplet stands — a subject that
|
||||
// reads `4428:0:1` for one frame is better than one that reads `Auction won: ` forever if the
|
||||
// query never answers, and the row re-resolves (and fires MAIL_INBOX_UPDATE) when it does.
|
||||
// Until the item template lands, the raw triplet stands.
|
||||
let subject = match (auction, ¬ice_item_name) {
|
||||
(Some(a), Some(name)) => invoice::subject_key(a.result)
|
||||
.and_then(get_text)
|
||||
@@ -384,9 +286,7 @@ fn resolve_row(
|
||||
.and_then(|a| {
|
||||
let seller = a.result == auction_mail::SOLD;
|
||||
let n = invoice::parse_body(raw_body?, seller)?;
|
||||
// An unresolved counterparty takes the miss tail this pass and fills in when the name
|
||||
// query lands — the reference does exactly this, and fires MAIL_INBOX_UPDATE on the
|
||||
// late arrival, which our snapshot diff does for free.
|
||||
// An unresolved counterparty fills in when its name lands, as in the reference.
|
||||
let player_name = names.resolve(n.player_guid, commands)?.to_string();
|
||||
Some(benilla_ui::script::MailInvoice {
|
||||
seller,
|
||||
@@ -420,16 +320,11 @@ fn resolve_row(
|
||||
text_created: entry.checked & CHECKED_COPIED != 0,
|
||||
can_reply,
|
||||
is_gm,
|
||||
// The letter body is server-authored text, so it runs the `$`-macro expander — the
|
||||
// reference does it from two sites in `GetInboxText` (decision 0754). Subject is the
|
||||
// local player, as it is for every panel seam.
|
||||
// The body runs the `$`-macro expander for the local player, as `GetInboxText` does.
|
||||
body: raw_body.map(|b| crate::npc_text::substitute(b, macros)),
|
||||
stationery_texture,
|
||||
// NOT "is an auction mail": the reference's `isInvoice` reads the fields its subject
|
||||
// parser persisted, and it persists them only for won(1)/sold(2) — so an outbid or expiry
|
||||
// notice answers false here (decision 1527). This is also what nils the body: the two
|
||||
// answers come off the same record state, which is exactly why MailFrame can lay the
|
||||
// invoice pane over the letter page.
|
||||
// Won and sold notices only, not every auction mail: the reference's `isInvoice` reads
|
||||
// what its subject parser persists for those two alone.
|
||||
is_invoice: auction
|
||||
.is_some_and(|a| matches!(a.result, auction_mail::WON | auction_mail::SOLD)),
|
||||
invoice: auction_invoice,
|
||||
@@ -443,13 +338,9 @@ fn resolve_row(
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the Lua-facing snapshot from [`MailOpen`] — `None` when no mailbox is open.
|
||||
/// The usable stationery list (`0x4ad970`, 1970): every
|
||||
/// `Stationery.dbc` row that is always available (`Flags & 1`) or whose item the player carries
|
||||
/// (bags, not the bank — the client's `0x622270`), and whose item template is cached — the name,
|
||||
/// icon and price come off the template — sorted by BuyPrice ascending, the client's comparator
|
||||
/// (`0x4ada90`). A carried paper costs nothing to use (`cost` nil). Templates are asked once here
|
||||
/// so the list fills from the second frame after world enter, mailbox or no mailbox.
|
||||
/// The usable stationery list (`0x4ad970`): rows always available (`Flags & 1`) or whose item the
|
||||
/// player carries in bags (`0x622270`), with a cached template, sorted by buy price (`0x4ada90`).
|
||||
/// A carried paper has no `cost`.
|
||||
fn stationeries(
|
||||
catalog: &Stationery,
|
||||
self_q: &Query<(&ObjectStore, &crate::net::Guid), With<SelfPlayer>>,
|
||||
@@ -524,12 +415,7 @@ fn snapshot(
|
||||
})
|
||||
}
|
||||
|
||||
/// Push the current mail into the VM and fire the show/update/close + send-result events on a
|
||||
/// transition or content change (an async sender/item/body landing, a mark-read flip, a fresh
|
||||
/// list). Diffed against a `Local` memory, exactly like the merchant/gossip feeds.
|
||||
/// The feed's tail, bundled — the signature sits at the 16-SystemParam ceiling: the random-suffix
|
||||
/// roll's catalogs (1547), where a refusal lands (1815), and the usable stationery list's memo
|
||||
/// (1970), pushed on change like the inbox.
|
||||
/// The feed's remaining parameters, bundled under Bevy's 16-parameter limit.
|
||||
#[derive(bevy::ecs::system::SystemParam)]
|
||||
struct MailFeedExtras<'w, 's> {
|
||||
props: Option<Res<'w, crate::items::RandomProperties>>,
|
||||
@@ -552,10 +438,9 @@ fn feed_mail(
|
||||
mut last: Local<crate::ui_script::VmMemo<Option<MailState>>>,
|
||||
mut last_mailbox: Local<crate::ui_script::VmMemo<Option<u64>>>,
|
||||
mut last_has_new_mail: Local<crate::ui_script::VmMemo<bool>>,
|
||||
// The `$`-macro subject for the letter body: the local player, as at every panel seam.
|
||||
// The local player, the letter body's `$`-macro subject.
|
||||
self_q: Query<(&crate::net::ObjectStore, &crate::net::Guid), With<crate::net::SelfPlayer>>,
|
||||
states: Res<crate::world_state::WorldStates>,
|
||||
// The random-suffix roll's catalogs (1547): an enclosed item's rolled name.
|
||||
extras: MailFeedExtras,
|
||||
) {
|
||||
let Some(mut script) = script else {
|
||||
@@ -575,26 +460,19 @@ fn feed_mail(
|
||||
let last_mailbox = last_mailbox.get(&script);
|
||||
let last_has_new_mail = last_has_new_mail.get(&script);
|
||||
|
||||
// Take/return/delete failures go to the surface — and the voice — their message record names.
|
||||
// Take, return and delete refusals, on the surface and voice their message row names.
|
||||
let refusals: Vec<_> = std::mem::take(&mut mail.errors)
|
||||
.into_iter()
|
||||
.filter_map(|key| crate::ui_action::keyed_line(&script, key))
|
||||
.collect();
|
||||
crate::ui_action::show_messages(&mut script, &mut sink, "ui_mail", refusals);
|
||||
// SEND acks: MAIL_FAILED on EVERY send result (unblocks the button — `0x4ad15f`), plus
|
||||
// MAIL_SEND_SUCCESS on OK (resets the form) or the refusal's own line on a failure. The
|
||||
// success also SAYS so — `ERR_MAIL_SENT`, the yellow info line `0x4ad0b1` shows before it
|
||||
// resets the form (decision 1821).
|
||||
// A take emptied and purged a row: `CLOSE_INBOX_ITEM(index)` — the stock handler hides the
|
||||
// open letter when it is that row — ahead of the `MAIL_INBOX_UPDATE` the changed list fires
|
||||
// below, the client's own order (`0x4ad772` before `0x4ad7a3`, 1970).
|
||||
// A purged row fires `CLOSE_INBOX_ITEM(index)` before the list's `MAIL_INBOX_UPDATE`, as both
|
||||
// take legs do (money `0x4ad772` before `0x4ad7a3`, item `0x4ad87d` before `0x4ad8ae`).
|
||||
for index in std::mem::take(&mut mail.close_inbox) {
|
||||
script.fire_event("CLOSE_INBOX_ITEM", vec![ScriptValue::Int(i64::from(index))]);
|
||||
}
|
||||
// `SMSG_SEND_MAIL_RESULT 0x4ad050`, in its own order: the GlobalString toast, then — on
|
||||
// `action 0 / reason 0` only — the compose-tab reset `0x4acdc0(1)` with its three events, and
|
||||
// **last, on every path, `MAIL_FAILED`** (`0x4ad15f`, unconditional: a successful send fires it
|
||||
// too, as the generic "the send resolved, unblock the form" signal).
|
||||
// `0x4ad050`'s order: the message line, then on a successful send the compose reset
|
||||
// (`0x4acdc0`) with its three events, then `MAIL_FAILED` on every path (`0x4ad15f`).
|
||||
for ack in std::mem::take(&mut mail.send_acks) {
|
||||
let key = if ack.ok {
|
||||
Some(MAIL_SENT_KEY)
|
||||
@@ -609,9 +487,7 @@ fn feed_mail(
|
||||
script.fire_event("MAIL_FAILED", vec![]);
|
||||
}
|
||||
|
||||
// The macro subject, resolved before the row walk borrows the name cache again. `None` until
|
||||
// the player is streamed and named — the feed diffs on the substituted text, so a letter opened
|
||||
// that early re-substitutes as soon as it lands.
|
||||
// `None` until the player is named; the diff re-substitutes an open letter when it lands.
|
||||
let subject = crate::npc_text::player_identity(&self_q, &names, &commands);
|
||||
let macros = crate::npc_text::MacroContext {
|
||||
subject: subject.as_ref(),
|
||||
@@ -639,20 +515,9 @@ fn feed_mail(
|
||||
if changed {
|
||||
script.set_mail(fresh.clone());
|
||||
}
|
||||
// The send tab's usable stationery (1970): `Stationery.dbc` rows the player may use — the
|
||||
// always-available one, or one whose item they carry — whose item template is cached, priced
|
||||
// by BuyPrice ascending. The templates are asked ahead of any mailbox (the catalog is five
|
||||
// rows) so the list is whole the frame the window opens: the stock `SendMailFrame_Reset`
|
||||
// selects row 1 on show, and an empty list then would leave every send silently unsent.
|
||||
//
|
||||
// **Only once there is a player.** `feed_mail` carries no run condition, so it runs from the
|
||||
// first frame of the process — the login screen, before the socket exists. The five asks went
|
||||
// out there, the io thread dropped them ("not connected"), and `Items::template` had already
|
||||
// latched all five in its ask-once `pending` set, which is cleared on DISCONNECT and never on
|
||||
// connect. The templates were therefore never re-asked, the list stayed empty for the whole
|
||||
// session, and the failure this very comment describes — every send silently unsent — is what
|
||||
// it caused. Gating on the self player is also what the list means: "what may I use", which is
|
||||
// not a question until there is a me.
|
||||
// The usable stationery is asked ahead of any mailbox so the list is whole when
|
||||
// `SendMailFrame_Reset` selects row 1 on show; an empty list leaves every send unsent. Only
|
||||
// with a player: an ask sent before the socket exists is dropped and never re-asked.
|
||||
let usable = stationery
|
||||
.as_deref()
|
||||
.filter(|_| !self_q.is_empty())
|
||||
@@ -673,34 +538,26 @@ fn feed_mail(
|
||||
script.set_mail_stationeries(usable);
|
||||
}
|
||||
if opened {
|
||||
// The selection is cleared on open AND close (`0x4ace07`, 1970); the stock tab's reset
|
||||
// on MAIL_SHOW re-selects row 1.
|
||||
// The selection clears on open and close (`0x4ace07`); `MAIL_SHOW`'s reset picks row 1.
|
||||
script.clear_stationery();
|
||||
// The open core's own order (`0x4acd10`): store the mailbox, register
|
||||
// the interaction target, **reset the compose tab** — a fresh window carries no stale
|
||||
// attachment or money — and only THEN `MAIL_SHOW`. The reset's `MAIL_SEND_SUCCESS` is the
|
||||
// byte-verified open side-effect, so the page-turn sound on open is faithful, not a bug.
|
||||
// The open core (`0x4acd10`) resets the compose tab before `MAIL_SHOW`; the reset's
|
||||
// `MAIL_SEND_SUCCESS`, page-turn sound and all, is the reference's too.
|
||||
script.reset_compose_tab();
|
||||
script.fire_event("MAIL_SHOW", vec![]);
|
||||
} else if closed {
|
||||
script.clear_stationery();
|
||||
script.fire_event("MAIL_CLOSED", vec![]);
|
||||
// The close core's tail (`0x4acdad`/`0x4acdb1`, decision 0913): a session where a
|
||||
// mail was read — or one arrived while the window was open — re-asks the server, and the
|
||||
// sender stamps the countdown to "no mail" on the way out. This is the whole mechanism by
|
||||
// which checking your mail clears the minimap icon; nothing on the inbox path touches it.
|
||||
// Both close paths converge here (explicit `CloseMail` and the walk-away range close), as
|
||||
// they do in the reference through the same generic dispatcher.
|
||||
// The close core's tail (`0x4acdad`, `0x4acdb1`): after a read, or an arrival while open,
|
||||
// re-ask the server, stamping "no mail". `CloseMail` and the range close both land here.
|
||||
if pending.take_refresh() {
|
||||
pending.on_query_sent();
|
||||
let _ = commands.0.send(ClientCommand::QueryNextMailTime);
|
||||
}
|
||||
} else if mail.mailbox.is_some() {
|
||||
// A re-click re-shows the (already open) window; MAIL_SHOW's CheckInbox refreshes the list.
|
||||
// A re-click re-shows the open window; `MAIL_SHOW`'s `CheckInbox` refreshes the list.
|
||||
if show_requested {
|
||||
script.fire_event("MAIL_SHOW", vec![]);
|
||||
}
|
||||
// A content change (async name/body landed, list replaced, mark-read flip) → repaint.
|
||||
if changed {
|
||||
script.fire_event("MAIL_INBOX_UPDATE", vec![]);
|
||||
}
|
||||
@@ -708,14 +565,8 @@ fn feed_mail(
|
||||
*last = fresh;
|
||||
*last_mailbox = mail.mailbox;
|
||||
|
||||
// The arrival layer (decisions 0544 P3 / 0904 / 0913): step the client-local countdown, keep
|
||||
// the VM's `HasNewMail()` answer current, and fire `UPDATE_PENDING_MAIL` only where the
|
||||
// reference fires it. The two are deliberately separate: the *push* keeps the Lua getter
|
||||
// truthful whenever it is asked, while the *event* is edge-triggered at three sites only (the
|
||||
// query reply, a near-zero arrival, the step crossing ε) — so, exactly as in the reference, the
|
||||
// query sender's "no mail" stamp does not move the icon until the reply lands.
|
||||
// `MiniMapMailFrame` is the sole listener in all of 1.12 FrameXML and its handler is an
|
||||
// idempotent Show/Hide (`Minimap.xml` l.278-289, quoted in the module doc).
|
||||
// `HasNewMail()` is pushed on change, but `UPDATE_PENDING_MAIL` fires only at the reference's
|
||||
// three sites, so a query's "no mail" stamp does not move the icon until the reply lands.
|
||||
pending.step(time.delta_secs());
|
||||
let has_new_mail = pending.has_new_mail();
|
||||
if has_new_mail != *last_has_new_mail {
|
||||
@@ -727,11 +578,7 @@ fn feed_mail(
|
||||
}
|
||||
}
|
||||
|
||||
/// Drain the Lua intents into the mail `CMSG`s (decision 0544): `CheckInbox` → a throttled
|
||||
/// `CMSG_GET_MAIL_LIST`; an opened mail → `CMSG_MAIL_MARK_AS_READ` (once, unread) + the ask-once
|
||||
/// `CMSG_ITEM_TEXT_QUERY` for its body; take/return/delete → their per-mail `CMSG`s; `SendMail` →
|
||||
/// `CMSG_SEND_MAIL` (the attachment's `(bag, slot)` resolved to its item guid here); `CloseMail` →
|
||||
/// a local clear (no packet, vanilla).
|
||||
/// The Lua intents out as the mail `CMSG`s; `CloseMail` is a local clear that sends nothing.
|
||||
fn drain_mail(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
mut mail: ResMut<MailOpen>,
|
||||
@@ -755,7 +602,7 @@ fn drain_mail(
|
||||
return;
|
||||
};
|
||||
|
||||
// CheckInbox → a session-scoped, 60s-throttled inbox query (`0x4aeab0`).
|
||||
// `CheckInbox`: the list query, throttled to one per 60 s (`0x4aeab0`).
|
||||
if script.take_mail_check_inbox() {
|
||||
let now = time.elapsed_secs_f64();
|
||||
if mail
|
||||
@@ -767,7 +614,7 @@ fn drain_mail(
|
||||
}
|
||||
}
|
||||
|
||||
// Opened mails: mark read (once) then ask-once the body (`0x4af110`: read FIRST, body second).
|
||||
// An opened mail: mark read, then ask for the body, in `0x4af110`'s order.
|
||||
for index in script.take_mail_opens() {
|
||||
let Some((mail_id, text_id, read)) = index
|
||||
.checked_sub(1)
|
||||
@@ -776,12 +623,8 @@ fn drain_mail(
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
// Arm the deferred refresh on **every** open, read or not: the reference's `GetInboxText`
|
||||
// calls the mark-as-read sender unconditionally, and the `[0xb6efcc] = 1` inside it is
|
||||
// unconditional too (`0x4adda6`) — so opening any letter is what makes the close re-ask the
|
||||
// server, and that is what clears the minimap icon (decision 0913). Arming it on an
|
||||
// already-read mail costs at most one redundant query on close; failing to arm it would
|
||||
// strand the icon lit, so the eager side is the safe side.
|
||||
// Every open arms the refresh, read or not: `GetInboxText` calls the mark-as-read sender
|
||||
// unconditionally, which sets `[0xb6efcc]` (`0x4adda6`).
|
||||
pending.arm_refresh();
|
||||
if !read {
|
||||
mail.mark_read(index);
|
||||
@@ -798,7 +641,6 @@ fn drain_mail(
|
||||
}
|
||||
}
|
||||
|
||||
// The per-mail row picks addressing the wire mail id.
|
||||
for index in script.take_mail_take_items() {
|
||||
if let Some(mail_id) = mail.mail_id_at(index) {
|
||||
let _ = commands
|
||||
@@ -835,8 +677,7 @@ fn drain_mail(
|
||||
}
|
||||
}
|
||||
|
||||
// SendMail: resolve the attachment's (bag, slot) to the wire item guid at send time (the
|
||||
// reference re-reads the slot when the send fires — a lazy resolve, decision 0216/0544).
|
||||
// `SendMail`: the attachment's slot is read at send time, as in the reference.
|
||||
if let Some(req) = script.take_mail_send() {
|
||||
let item_guid = req.item.and_then(|(bag, slot)| {
|
||||
self_q.iter().next().and_then(|s| {
|
||||
@@ -844,8 +685,8 @@ fn drain_mail(
|
||||
})
|
||||
});
|
||||
if req.item.is_some() && item_guid.is_none() {
|
||||
// The attached item is gone from its slot: `ERR_ITEM_NOT_FOUND`, no packet, the
|
||||
// attachment dropped and `MAIL_SEND_INFO_UPDATE` fired (`0x4ae98d`, 1970).
|
||||
// The item left its slot: `ERR_ITEM_NOT_FOUND`, no packet, the attachment dropped and
|
||||
// `MAIL_SEND_INFO_UPDATE` fired (`0x4ae98d`).
|
||||
mail.errors.push("ERR_ITEM_NOT_FOUND");
|
||||
script.drop_send_mail_item();
|
||||
} else {
|
||||
@@ -872,9 +713,8 @@ mod tests {
|
||||
use super::*;
|
||||
use benilla_protocol::messages::MailAttachment;
|
||||
|
||||
/// **The mail-result table, welded to the message ids it was read from** (decision 1821) —
|
||||
/// the reference's jump table at `0x4ad17c`, reached through the byte index at `0x4ad1a0`.
|
||||
/// The id, not the key, is what was disassembled, so the id is what this asserts.
|
||||
/// The result table at `0x4ad17c` (indexed at `0x4ad1a0`), asserted as the message ids its
|
||||
/// arms push.
|
||||
#[test]
|
||||
fn the_mail_result_table_is_the_references_own() {
|
||||
let id = |key: &str| {
|
||||
@@ -893,13 +733,11 @@ mod tests {
|
||||
] {
|
||||
assert_eq!(id(mail_refusal(code)), want, "mail error {code}");
|
||||
}
|
||||
// `ja 0x4ad147` and the index table's `08` run: 7..13 and everything past 15 share the
|
||||
// database-error line, and there is no "(n)" arm for them to take.
|
||||
// `ja 0x4ad147` and the index table's `08` run: 7..13 and past 15 share the database error.
|
||||
for code in [7, 8, 13, 16, 99, u32::MAX] {
|
||||
assert_eq!(id(mail_refusal(code)), 0x166, "mail error {code}");
|
||||
}
|
||||
// A success SAYS so — and on the YELLOW line, which is the half a red-only path would
|
||||
// have got wrong.
|
||||
// A success shows on the yellow info line.
|
||||
assert_eq!(id(MAIL_SENT_KEY), 0x167);
|
||||
assert_eq!(
|
||||
benilla_ui::messages::by_key(MAIL_SENT_KEY)
|
||||
@@ -909,9 +747,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The no-subject macro context these row tests run under: none of them exercises a `$` token,
|
||||
/// and a subject-less context leaves plain text untouched (the expander only rewrites at a `$`).
|
||||
/// The macro path's own behaviour is covered in [`crate::npc_text`]'s tests.
|
||||
/// A subject-less macro context: no row test uses a `$` token.
|
||||
fn no_macros(states: &crate::world_state::WorldStates) -> crate::npc_text::MacroContext<'_> {
|
||||
crate::npc_text::MacroContext {
|
||||
subject: None,
|
||||
@@ -951,12 +787,12 @@ mod tests {
|
||||
m.mails.push(entry(1, Some(0xA), 0, 30.0));
|
||||
m.last_list_query = Some(5.0);
|
||||
m.show_requested = false;
|
||||
// Re-click the SAME mailbox keeps the rows + throttle, just re-requests the show.
|
||||
// The same mailbox keeps the rows and throttle, and re-requests the show.
|
||||
m.click(0x100);
|
||||
assert_eq!(m.mails.len(), 1);
|
||||
assert_eq!(m.last_list_query, Some(5.0));
|
||||
assert!(m.show_requested);
|
||||
// A DIFFERENT mailbox resets the session.
|
||||
// A different mailbox resets the session.
|
||||
m.click(0x200);
|
||||
assert!(m.mails.is_empty());
|
||||
assert_eq!(m.last_list_query, None);
|
||||
@@ -972,7 +808,6 @@ mod tests {
|
||||
assert_eq!(m.mail_id_at(2), Some(43));
|
||||
assert_eq!(m.mail_id_at(3), None);
|
||||
assert_eq!(m.mail_id_at(0), None);
|
||||
// Mark row 1 read: its READ bit flips locally.
|
||||
assert_eq!(m.mails[0].checked & CHECKED_READ, 0);
|
||||
m.mark_read(1);
|
||||
assert_eq!(m.mails[0].checked & CHECKED_READ, CHECKED_READ);
|
||||
@@ -990,8 +825,7 @@ mod tests {
|
||||
assert!(m.bodies.is_empty());
|
||||
}
|
||||
|
||||
/// No GlobalStrings in a unit test — the auction-subject rewrite is exercised where the
|
||||
/// strings are (`tests/mail_frame.rs`), and here every key simply misses.
|
||||
/// No GlobalStrings in a unit test: every key misses.
|
||||
fn no_strings(_key: &str) -> Option<String> {
|
||||
None
|
||||
}
|
||||
@@ -1005,7 +839,7 @@ mod tests {
|
||||
let names = NameCache::default();
|
||||
let bodies = QueryCache::default();
|
||||
|
||||
// A plain letter from a player (no attachments) → replyable AND deletable.
|
||||
// A plain letter from a player: replyable and deletable.
|
||||
let from_player = entry(1, Some(0xA), 0, 30.0);
|
||||
let row = resolve_row(
|
||||
&from_player,
|
||||
@@ -1023,8 +857,7 @@ mod tests {
|
||||
assert!(row.can_delete);
|
||||
assert_eq!(row.stationery_texture, "STATIONERYTEST");
|
||||
|
||||
// A player mail still carrying money → returnable, NOT deletable (the 0548 INTERIM law:
|
||||
// attachments return; the same holds for an enclosed item).
|
||||
// A player mail still carrying money returns, so it is not deletable; so does an item.
|
||||
let mut with_money = entry(4, Some(0xA), 0, 30.0);
|
||||
with_money.money = 500;
|
||||
let row = resolve_row(
|
||||
@@ -1042,7 +875,7 @@ mod tests {
|
||||
assert!(row.can_reply);
|
||||
assert!(!row.can_delete);
|
||||
|
||||
// A returned mail → not replyable, deletable.
|
||||
// A returned mail: not replyable, deletable.
|
||||
let returned = entry(2, Some(0xA), CHECKED_RETURNED, 30.0);
|
||||
let row = resolve_row(
|
||||
&returned,
|
||||
@@ -1059,7 +892,7 @@ mod tests {
|
||||
assert!(!row.can_reply);
|
||||
assert!(row.can_delete);
|
||||
|
||||
// A system mail (no sender guid) → deletable.
|
||||
// A system mail, with no sender guid: deletable.
|
||||
let system = entry(3, None, 0, 30.0);
|
||||
let row = resolve_row(
|
||||
&system,
|
||||
@@ -1114,7 +947,7 @@ mod tests {
|
||||
assert_eq!(row.item_count, 5);
|
||||
assert_eq!(row.body.as_deref(), Some("the letter body"));
|
||||
assert!(row.has_body);
|
||||
// The item name/texture are in flight (no template answer yet).
|
||||
// The item template has not answered yet.
|
||||
assert!(row.item_name.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
//! The mailbox's packet handlers (decision 0544 P1/P2/P3; in the net handler table since 2306) —
|
||||
//! the inbox/body/send-result kinds fill the [`MailOpen`] session the feed reads, the arrival
|
||||
//! pair feeds [`MailPending`] (`HasNewMail()`/the minimap icon). The `UiScript` events these
|
||||
//! ultimately drive are fired by [`super::feed_mail`] (the feed owns the VM), so nothing here
|
||||
//! touches it: the handlers only mutate resources + send the wire re-syncs.
|
||||
//! The mailbox's packet handlers: they fill [`MailOpen`] and [`MailPending`] and send the wire
|
||||
//! re-syncs; the events fire from [`super::feed_mail`].
|
||||
|
||||
use benilla_protocol::messages::{mail_action, mail_error, mail_message_type, MailListEntry};
|
||||
use benilla_protocol::{SessionEvent, SessionEventKind};
|
||||
@@ -12,8 +9,6 @@ use super::{mail_refusal, MailOpen, MailPending, MailSendAck};
|
||||
use crate::net::{ClientCommand, NetCommands, NetHandlerApp};
|
||||
use crate::ui_items::{EquipError, EquipErrors};
|
||||
|
||||
/// Register the mailbox's handlers — called from [`super::UiMailPlugin`]. One per kind, plus the
|
||||
/// session-end listener.
|
||||
pub(super) fn register(app: &mut App) {
|
||||
use SessionEventKind as K;
|
||||
app.net_handler(K::MailList, on_mail_list)
|
||||
@@ -80,10 +75,8 @@ fn on_next_mail_time(In(ev): In<SessionEvent>, mut pending: ResMut<MailPending>)
|
||||
}
|
||||
}
|
||||
|
||||
/// An open mailbox dies with the socket, and the arrival countdown is login-scoped (decision
|
||||
/// 0544 P3): a fresh login re-queries `MSG_QUERY_NEXT_MAIL_TIME` at world-enter, so nothing
|
||||
/// carries over across a reconnect. A listener on the session end (a second handler on
|
||||
/// `Disconnected`, after the bridge's own teardown, `net::session::on_disconnected`).
|
||||
/// The open mailbox and the arrival countdown end with the session; the next world enter
|
||||
/// re-queries.
|
||||
fn on_session_end(
|
||||
In(_): In<SessionEvent>,
|
||||
mut mail: ResMut<MailOpen>,
|
||||
@@ -93,18 +86,9 @@ fn on_session_end(
|
||||
*pending = MailPending::default();
|
||||
}
|
||||
|
||||
/// `SessionEvent::MailList` (`SMSG_MAIL_LIST_RESULT`) — replace the session's rows + fire the inbox
|
||||
/// repaint (via the feed's diff). The inbox handler auto-purges expired mail: any row whose timer ran
|
||||
/// out (`expire_days <= 0`) is deleted server-side (`CMSG_MAIL_DELETE`) and dropped here
|
||||
/// (`0x4ad1b0`).
|
||||
///
|
||||
/// **It does not touch [`MailPending`]** — and that is a positive fact, not an
|
||||
/// omission (decision 0913). This arm used to clear the countdown when the surviving list had
|
||||
/// nothing unread, on the inferred grounds that "checking your mail clears the icon" had to be the
|
||||
/// list's doing. A full write-xref of the countdown float `0x845eac` says otherwise: nothing on the
|
||||
/// inbox path writes it. The icon clears because **opening a letter arms the deferred-refresh flag
|
||||
/// and the mailbox *close* re-asks the server** — modelled in [`super`], where the close
|
||||
/// edge lives.
|
||||
/// `SMSG_MAIL_LIST_RESULT` replaces the rows; an expired row (`expire_days <= 0`) is deleted with
|
||||
/// `CMSG_MAIL_DELETE` and dropped, as the handler `0x4ad1b0` does. It never touches
|
||||
/// [`MailPending`]: nothing on the inbox path writes the countdown `0x845eac`.
|
||||
fn mail_list(mails: Vec<MailListEntry>, mail: &mut MailOpen, commands: &NetCommands) {
|
||||
let mailbox = mail.mailbox;
|
||||
mail.mails = mails
|
||||
@@ -125,22 +109,16 @@ fn mail_list(mails: Vec<MailListEntry>, mail: &mut MailOpen, commands: &NetComma
|
||||
.collect();
|
||||
}
|
||||
|
||||
/// `MAIL_CHECK_MASK_COD_PAYMENT` — the `checked` bit (`byte[rec+0x148] & 8`) that marks a mail as
|
||||
/// the money a COD taker paid; taking that money empties the mail (`0x4ad6b0`, 1970).
|
||||
/// `MAIL_CHECK_MASK_COD_PAYMENT` (`byte[rec+0x148] & 8`): the money a COD taker paid; taking it
|
||||
/// empties the mail (`0x4ad6b0`).
|
||||
const CHECKED_COD_PAYMENT: u32 = 8;
|
||||
|
||||
/// The two take legs' "this mail is now empty" decision, off the client's own bytes
|
||||
/// (1970): a take that empties the mail sends `CMSG_MAIL_DELETE`
|
||||
/// itself, then `CLOSE_INBOX_ITEM(index)`, then `MAIL_INBOX_UPDATE`. The money leg (`0x4ad6b0`)
|
||||
/// purges iff the mail is a COD payment, or an auction notice with no item; the item leg
|
||||
/// (`0x4ad7b0`) purges iff the money is gone and the mail is an auction notice or carries no
|
||||
/// letter text. A plain letter you took the money from stays open, husk and all — that is the
|
||||
/// reference too (the stock `OpenMailFrame_OnHide` deletes a copied, emptied letter on close).
|
||||
///
|
||||
/// The item leg's second conjunct reads `[rec+0x114] == 0 && [rec+0x25c] == 0`; `+0x114` is the
|
||||
/// letter's text id and `+0x25c` a second no-text field (inferred to be the
|
||||
/// fetched body). This takes the text id alone, which can only purge a mail the client also would
|
||||
/// when that second field is zero whenever the first is.
|
||||
/// Whether a take leaves the mail empty, which the client then deletes itself: `CMSG_MAIL_DELETE`,
|
||||
/// then `CLOSE_INBOX_ITEM(index)`, then `MAIL_INBOX_UPDATE`. The money leg (`0x4ad6b0`) purges a
|
||||
/// COD payment or an auction notice with no item. The item leg (`0x4ad7b0`) purges once the money
|
||||
/// is gone and the mail is an auction notice or has neither a text id (`[rec+0x114]`) nor a mail
|
||||
/// template id (`[rec+0x25c]`); this reads the text id alone, so it also purges a template mail
|
||||
/// the reference keeps.
|
||||
fn take_empties(entry: &MailListEntry, action: u32) -> bool {
|
||||
let auction = entry.message_type == mail_message_type::AUCTION;
|
||||
match action {
|
||||
@@ -152,13 +130,8 @@ fn take_empties(entry: &MailListEntry, action: u32) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// `SessionEvent::SendMailResult` (`SMSG_SEND_MAIL_RESULT`) — route per action/error (decision 0544
|
||||
/// P2). action == SEND queues a [`MailSendAck`] for the feed (MAIL_FAILED always, MAIL_SEND_SUCCESS
|
||||
/// on OK, else the red error line). A successful take applies to the local row and, when it
|
||||
/// empties the mail, deletes it the way the client does ([`take_empties`]); every successful
|
||||
/// take/return/delete then re-syncs the inbox with a fresh `CMSG_GET_MAIL_LIST` (the reference
|
||||
/// client's inbox-refresh moment); an EQUIP_ERROR routes to the existing inventory-error surface;
|
||||
/// any other failure surfaces the red error line.
|
||||
/// `SMSG_SEND_MAIL_RESULT`: a `SEND` result is queued for the feed; a successful take updates the
|
||||
/// row and purges it when empty, and every successful take, return or delete re-lists the inbox.
|
||||
fn send_mail_result(
|
||||
mail_id: u32,
|
||||
action: u32,
|
||||
@@ -169,17 +142,15 @@ fn send_mail_result(
|
||||
commands: &NetCommands,
|
||||
equip_errors: &mut EquipErrors,
|
||||
) {
|
||||
// ITEM_TAKEN's OK tail carries (entry, count) for a "received" line; vmangos does NOT also send
|
||||
// SMSG_ITEM_PUSH_RESULT for a mail take, and no mail-received-line precedent exists yet, so we do
|
||||
// nothing extra with it here (the GetMailList re-sync below reflects the emptied row). [_item]
|
||||
// ITEM_TAKEN's `(entry, count)` tail is unused; vmangos sends no `SMSG_ITEM_PUSH_RESULT` for a
|
||||
// mail take (`MailHandler.cpp:675`).
|
||||
if action == mail_action::SEND {
|
||||
if error == mail_error::EQUIP_ERROR {
|
||||
equip_errors.0.push(EquipError {
|
||||
reason: equip_error.unwrap_or(0) as u8,
|
||||
required_level: None,
|
||||
// SMSG_SEND_MAIL_RESULT carries only the code, never a bag slot. 255 is the
|
||||
// wire's own "the player's own array" sentinel — reason 16's substitution
|
||||
// correctly declines to name a container it was never told about.
|
||||
// The result carries no bag slot: 255 is the wire's "own array" sentinel, so
|
||||
// reason 16 names no container.
|
||||
bag_slot: 255,
|
||||
});
|
||||
}
|
||||
@@ -193,9 +164,8 @@ fn send_mail_result(
|
||||
// A take/return/delete result.
|
||||
match error {
|
||||
mail_error::OK => {
|
||||
// The take landed on the local row first (the client clears `[rec+0x140]` /
|
||||
// `[rec+0x120]` before deciding), and an emptied mail is purged client-side:
|
||||
// `CMSG_MAIL_DELETE`, then `CLOSE_INBOX_ITEM(index)` from the feed (1970).
|
||||
// The take clears the local row first (`[rec+0x140]` money, `[rec+0x120]` item), then
|
||||
// an emptied mail is purged: `CMSG_MAIL_DELETE`, then the feed's `CLOSE_INBOX_ITEM`.
|
||||
if let Some(pos) = mail.mails.iter().position(|e| e.message_id == mail_id) {
|
||||
match action {
|
||||
mail_action::MONEY_TAKEN => mail.mails[pos].money = 0,
|
||||
@@ -226,21 +196,16 @@ fn send_mail_result(
|
||||
}
|
||||
}
|
||||
|
||||
/// `SessionEvent::MailItemText` (`SMSG_ITEM_TEXT_QUERY_RESPONSE`) — land the letter body in the
|
||||
/// ask-once cache + clear its pending flag; the feed repaints (MAIL_INBOX_UPDATE) on the change.
|
||||
/// `SMSG_ITEM_TEXT_QUERY_RESPONSE`: the body lands in the cache and the feed repaints.
|
||||
fn mail_item_text(text_id: u32, text: String, mail: &mut MailOpen) {
|
||||
mail.bodies.insert(text_id, Some(text));
|
||||
}
|
||||
|
||||
/// `SessionEvent::ReceivedMail` (`SMSG_RECEIVED_MAIL`) — mail just arrived. `seconds` is the wire's
|
||||
/// delay float (vmangos always sends `0.0` = "now"); it runs the countdown's set-value ladder,
|
||||
/// which takes the **busy** branch when a mailbox window is open — arming the deferred refresh
|
||||
/// instead of moving the icon under the player's nose (`0x4ad620`, decision 0913).
|
||||
/// `SMSG_RECEIVED_MAIL` (`0x4ad620`): with a mailbox open it arms the deferred refresh instead of
|
||||
/// moving the icon.
|
||||
///
|
||||
/// The list re-sync is ours, not the reference's, and stays: a server push bypasses `CheckInbox`'s
|
||||
/// 60 s client-side throttle (decision 0544 P3), so a mail arriving while you stand at the mailbox
|
||||
/// shows up. The reference reaches the same place by its close-time re-query; leaving a mail you
|
||||
/// were just told about invisible for up to a minute is the worse client, and this costs one packet.
|
||||
/// Deviation: an open mailbox also re-lists at once, past `CheckInbox`'s 60 s throttle, because a
|
||||
/// mail announced while the player stands there should show; the reference waits for the close.
|
||||
fn received_mail(seconds: f32, pending: &mut MailPending, mail: &MailOpen, commands: &NetCommands) {
|
||||
pending.apply_received_mail(seconds, mail.mailbox.is_some());
|
||||
if let Some(mailbox) = mail.mailbox {
|
||||
@@ -248,11 +213,8 @@ fn received_mail(seconds: f32, pending: &mut MailPending, mail: &MailOpen, comma
|
||||
}
|
||||
}
|
||||
|
||||
/// `SessionEvent::NextMailTime` (`MSG_QUERY_NEXT_MAIL_TIME`'s reply, one `f32`) — store the
|
||||
/// server's float verbatim and signal `UPDATE_PENDING_MAIL` **unconditionally** (`0x4ad5f0`,
|
||||
/// signal site `0x4ad605`; decision 0913). `0.0` = mail waiting now, negative (vmangos always sends
|
||||
/// `-86400.0`) = none, a positive value counts down per frame in `crate::ui_mail`'s `feed_mail` and
|
||||
/// flips `HasNewMail()` true as it lands inside ε.
|
||||
/// The `MSG_QUERY_NEXT_MAIL_TIME` reply: stored as sent, and `UPDATE_PENDING_MAIL` always fires
|
||||
/// (`0x4ad5f0`, `0x4ad605`).
|
||||
fn next_mail_time(seconds: f32, pending: &mut MailPending) {
|
||||
pending.apply_query_reply(seconds);
|
||||
}
|
||||
@@ -261,9 +223,6 @@ fn next_mail_time(seconds: f32, pending: &mut MailPending) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// End to end through the real registration: the wire's "mail is waiting" reaches the
|
||||
/// countdown, a mailbox click survives until the session ends, and the session end — the
|
||||
/// bridge's own teardown beside this listener — resets both.
|
||||
#[test]
|
||||
fn the_table_routes_the_arrival_and_the_session_end_to_the_mailbox() {
|
||||
let (tx, _rx) = crossbeam_channel::unbounded();
|
||||
|
||||
@@ -1,60 +1,33 @@
|
||||
//! The **arrival layer** of the mail arc (decisions 0544 P3 / 0904 / 0913) — the real client's
|
||||
//! pending-mail countdown, modelled field for field.
|
||||
//!
|
||||
//! This is the half of [`super`] that has nothing to do with the mailbox *window*: it survives the
|
||||
//! window closing, it is what `HasNewMail()` answers from, and it is the entire life of the minimap
|
||||
//! mail icon. The window session lives next door in [`super`]; the seam between them is narrow on
|
||||
//! purpose — the two only meet at the mailbox close edge, where a read letter turns into the
|
||||
//! re-query that clears the icon.
|
||||
//!
|
||||
//! Every rule here is the reference's, and so is the load-bearing *negative*: **nothing on the
|
||||
//! inbox path writes the countdown** (`0x845eac`).
|
||||
//! The mail arrival layer: the reference's pending-mail countdown behind `HasNewMail()` and the
|
||||
//! minimap icon. It outlives the mailbox window, and nothing on the inbox path writes it.
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
/// The `HasNewMail()` / countdown-step epsilon — the real client's `[0x8029d4]` = `2^-22`, the
|
||||
/// `fabs(x)`-vs-this threshold both `0x4afea0` (`HasNewMail`) and `0x4ade60` (the per-frame step)
|
||||
/// compare against.
|
||||
const MAIL_TIME_EPSILON: f32 = f32::from_bits(0x3480_0000); // 2^-22, the reference's `[0x8029d4]`
|
||||
/// The epsilon `HasNewMail()` (`0x4afea0`) and the per-frame step (`0x4ade60`) compare `fabs`
|
||||
/// against: `[0x8029d4]`, 2^-22.
|
||||
const MAIL_TIME_EPSILON: f32 = f32::from_bits(0x3480_0000);
|
||||
|
||||
/// The "no mail waiting" stamp: the literal **`-1.0f`** the mail module's init (`0x4acb87`) and the
|
||||
/// `MSG_QUERY_NEXT_MAIL_TIME` **sender** (`0x4ade25`) both write into the countdown,
|
||||
/// unconditionally (decision 0913). Any value outside ε of zero reads "no mail";
|
||||
/// `-1.0` is simply the one the reference picks, and it is what the countdown holds between asking
|
||||
/// the server and hearing back.
|
||||
/// The "no mail" stamp the module init (`0x4acb87`) and the query sender (`0x4ade25`) write; the
|
||||
/// countdown holds it until the reply.
|
||||
const MAIL_TIME_NO_MAIL: f32 = -1.0;
|
||||
|
||||
/// The client's own pending-mail model (decisions 0544 P3 / 0904 / 0913) — the countdown float
|
||||
/// `0x845eac` plus the deferred-refresh flag `[0xb6efcc]`, mirrored field for field.
|
||||
///
|
||||
/// **Why a countdown and not a bool.** `HasNewMail()` is `|countdown| < ε`; the countdown is
|
||||
/// seeded by `MSG_QUERY_NEXT_MAIL_TIME`'s reply and `SMSG_RECEIVED_MAIL`, stepped down per frame,
|
||||
/// and stamped back to [`MAIL_TIME_NO_MAIL`] whenever the client (re-)asks the server. Independent
|
||||
/// of [`MailOpen`] (that is session-scoped to one open window; this survives it closing).
|
||||
///
|
||||
/// **The five writers** (decision 0913): the module init and the query sender stamp `-1.0`
|
||||
/// ([`Self::on_query_sent`]), the query reply stores the server's float
|
||||
/// ([`Self::apply_query_reply`]), `SMSG_RECEIVED_MAIL` runs the set-value ladder
|
||||
/// ([`Self::apply_received_mail`]), and the per-frame step counts down ([`Self::step`]).
|
||||
/// **Nothing on the inbox path writes it** — that negative is the load-bearing half.
|
||||
/// The reference's pending-mail state: the countdown `0x845eac` and the deferred-refresh flag
|
||||
/// `[0xb6efcc]`.
|
||||
#[derive(Resource)]
|
||||
pub(crate) struct MailPending {
|
||||
/// The countdown float `0x845eac`, in seconds until mail is "waiting".
|
||||
/// Seconds until mail is waiting (`0x845eac`).
|
||||
countdown: f32,
|
||||
/// The deferred-refresh flag `[0xb6efcc]`: "re-ask the server on close". Set by the
|
||||
/// mark-as-read sender (`0x4adda6`) and by `SMSG_RECEIVED_MAIL` arriving while the mailbox is
|
||||
/// open (`0x4ad642`); read at exactly one site — the close core (`0x4acda8`).
|
||||
/// Re-ask the server on close (`[0xb6efcc]`): set by the mark-as-read sender (`0x4adda6`) and
|
||||
/// by an arrival while the mailbox is open (`0x4ad642`), read only by the close core
|
||||
/// (`0x4acda8`).
|
||||
refresh_pending: bool,
|
||||
/// A queued `UPDATE_PENDING_MAIL`, drained by [`feed_mail`]. The event is **edge-triggered at
|
||||
/// three sites only** (`0x4ad605` the reply, `0x4ad66b` the near-zero arrival, `0x4adeba` the
|
||||
/// step crossing under ε) — not per frame, and not on every value change.
|
||||
/// A queued `UPDATE_PENDING_MAIL`, which fires at three sites only: the reply (`0x4ad605`), a
|
||||
/// near-zero arrival (`0x4ad66b`) and the step crossing under ε (`0x4adeba`).
|
||||
notify: bool,
|
||||
}
|
||||
|
||||
impl Default for MailPending {
|
||||
/// The reference's own resting state: no mail waiting, nothing deferred. (The module init
|
||||
/// stamps exactly this at every world-enter, so it is also the post-`PLAYER_ENTERING_WORLD`
|
||||
/// value before the reply lands.)
|
||||
/// No mail waiting and nothing deferred, as the module init stamps at every world enter.
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
countdown: MAIL_TIME_NO_MAIL,
|
||||
@@ -65,56 +38,40 @@ impl Default for MailPending {
|
||||
}
|
||||
|
||||
impl MailPending {
|
||||
/// `HasNewMail()` (`0x4afea0`: `fld [0x845eac]; fabs; fcomp [0x8029d4]`,
|
||||
/// emitted `jp`) — true iff the countdown is **within ε of zero**. Strict `<`, symmetric, and
|
||||
/// false at exact equality and for NaN.
|
||||
///
|
||||
/// Near-zero, *not* `<= 0`: the sign carries meaning. vmangos answers
|
||||
/// `MSG_QUERY_NEXT_MAIL_TIME` with `-86400.0` when nothing is unread (`MailHandler.cpp`
|
||||
/// `HandleQueryNextMailTime`: `HasUnreadMail() ? 0.0f : -float(DAY)`), so a `<= 0` predicate
|
||||
/// lights the minimap icon on every login for a character with no mail (decision 0904).
|
||||
/// `HasNewMail()` (`0x4afea0`): `|countdown| < ε`, false at equality and for NaN. Not `<= 0`:
|
||||
/// vmangos answers "no unread mail" with `-86400.0` (`MailHandler.cpp:912`).
|
||||
pub(super) fn has_new_mail(&self) -> bool {
|
||||
self.countdown.abs() < MAIL_TIME_EPSILON
|
||||
}
|
||||
|
||||
/// The per-frame countdown step (`0x4ade60`): a **non-positive countdown is left alone**
|
||||
/// (the "no mail" stamp must never drift toward zero, and a reached-zero countdown must not
|
||||
/// sail past it into negative — either would flip [`Self::has_new_mail`] the wrong way), and a
|
||||
/// positive one steps down floor-clamped at `0.0`. The subtraction runs the client's x87 chain
|
||||
/// (f64 under PC_53, narrowed at the store). Signals once, on the step that lands inside ε.
|
||||
/// The per-frame step (`0x4ade60`): a non-positive countdown stays put; a positive one steps
|
||||
/// down to a floor of `0.0`, in f64 as the x87 chain runs it (PC_53), and signals on the step
|
||||
/// that lands inside ε.
|
||||
pub(super) fn step(&mut self, delta_secs: f32) {
|
||||
if self.countdown > 0.0 {
|
||||
let diff = f64::from(self.countdown) - f64::from(delta_secs); // fsub, then fcom vs 0.0
|
||||
self.countdown = if diff > 0.0 { diff as f32 } else { 0.0 };
|
||||
if self.has_new_mail() {
|
||||
self.notify = true; // 0x4adeba — the crossing edge, fired once
|
||||
self.notify = true; // 0x4adeba: the crossing edge, fired once
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The `MSG_QUERY_NEXT_MAIL_TIME` **reply** (`0x4ad5f0`): store the server's float
|
||||
/// verbatim and signal — **unconditionally**, whether or not `HasNewMail()` changed.
|
||||
/// The `MSG_QUERY_NEXT_MAIL_TIME` reply (`0x4ad5f0`): stored as sent, and it signals whether
|
||||
/// or not `HasNewMail()` changed.
|
||||
pub(crate) fn apply_query_reply(&mut self, seconds: f32) {
|
||||
self.countdown = seconds;
|
||||
self.notify = true; // 0x4ad605 — unconditional
|
||||
self.notify = true; // 0x4ad605, unconditional
|
||||
}
|
||||
|
||||
/// Sending `MSG_QUERY_NEXT_MAIL_TIME` (the sender `0x4ade25` and the module init
|
||||
/// `0x4acb87` both stamp `-1.0f`): the countdown reads "no mail" from the moment we ask until
|
||||
/// the reply lands. **No signal** — the sender does not fire `UPDATE_PENDING_MAIL`, so the icon
|
||||
/// keeps its old face for the round trip and updates when the reply arrives.
|
||||
/// Sending the query stamps "no mail" (`0x4ade25`) with no signal, so the icon keeps its face
|
||||
/// until the reply.
|
||||
pub(super) fn on_query_sent(&mut self) {
|
||||
self.countdown = MAIL_TIME_NO_MAIL;
|
||||
}
|
||||
|
||||
/// `SMSG_RECEIVED_MAIL` (`0x4ad620`).
|
||||
/// `mailbox_open` is the busy-flag pair `[0xb6ef88]|[0xb6ef8c]`, the open mailbox's guid:
|
||||
///
|
||||
/// * **busy** → arm the deferred refresh and leave the countdown alone (the icon does not move
|
||||
/// while you stand at the mailbox; the close re-query settles it);
|
||||
/// * `|delay| < ε` → store it and signal ("you have mail *now*");
|
||||
/// * else if the current countdown is negative → store it (no signal);
|
||||
/// * else store the *smaller* of the two (tightening the estimate), no signal.
|
||||
/// `SMSG_RECEIVED_MAIL` (`0x4ad620`); `mailbox_open` is its busy test on the open mailbox's
|
||||
/// guid (`[0xb6ef88]|[0xb6ef8c]`).
|
||||
pub(crate) fn apply_received_mail(&mut self, seconds: f32, mailbox_open: bool) {
|
||||
if mailbox_open {
|
||||
self.refresh_pending = true; // 0x4ad642
|
||||
@@ -128,19 +85,16 @@ impl MailPending {
|
||||
}
|
||||
}
|
||||
|
||||
/// Arm the deferred refresh `[0xb6efcc]` — the mark-as-read sender does this unconditionally
|
||||
/// (`0x4adda6`), which is *the* mechanism by which checking your mail clears the icon: the
|
||||
/// close core sees the flag and re-asks the server (decision 0913).
|
||||
/// The mark-as-read sender's unconditional arm (`0x4adda6`).
|
||||
pub(crate) fn arm_refresh(&mut self) {
|
||||
self.refresh_pending = true;
|
||||
}
|
||||
|
||||
/// The close core's read of `[0xb6efcc]` (`0x4acda8`) — the flag's only reader, consumed.
|
||||
/// The close core's read of the flag (`0x4acda8`), its only reader.
|
||||
pub(super) fn take_refresh(&mut self) -> bool {
|
||||
std::mem::take(&mut self.refresh_pending)
|
||||
}
|
||||
|
||||
/// Drain a queued `UPDATE_PENDING_MAIL`.
|
||||
pub(super) fn take_notify(&mut self) -> bool {
|
||||
std::mem::take(&mut self.notify)
|
||||
}
|
||||
@@ -150,7 +104,6 @@ impl MailPending {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A [`MailPending`] holding `countdown`, as if the server had just answered with it.
|
||||
fn pending_at(countdown: f32) -> MailPending {
|
||||
MailPending {
|
||||
countdown,
|
||||
@@ -158,18 +111,14 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// `HasNewMail()` is `|countdown| < ε`, not `countdown <= 0` (decision 0904). The regression
|
||||
/// this pins: vmangos's "no unread mail" answer is `-86400.0`, and the old `<= 0` predicate
|
||||
/// read it as "you have mail" — the phantom minimap icon on every login.
|
||||
#[test]
|
||||
fn has_new_mail_is_near_zero_not_non_positive() {
|
||||
// The resting state — the reference's own `-1.0` init stamp.
|
||||
// The resting state, the reference's `-1.0` init stamp.
|
||||
assert!(!MailPending::default().has_new_mail());
|
||||
// vmangos's two real answers.
|
||||
assert!(pending_at(0.0).has_new_mail());
|
||||
assert!(!pending_at(-86400.0).has_new_mail());
|
||||
// The threshold itself: inside ε counts, ε and beyond does not — either sign. Strict `<`,
|
||||
// so exact equality is false (the emitted `jp` at `0x4afeb3` over the x87 C-bits).
|
||||
// Inside ε counts and ε itself does not, either sign (the `jp` at `0x4afeb3`).
|
||||
assert!(pending_at(MAIL_TIME_EPSILON / 2.0).has_new_mail());
|
||||
assert!(pending_at(-MAIL_TIME_EPSILON / 2.0).has_new_mail());
|
||||
assert!(!pending_at(MAIL_TIME_EPSILON).has_new_mail());
|
||||
@@ -180,9 +129,6 @@ mod tests {
|
||||
assert!(!pending_at(30.0).has_new_mail());
|
||||
}
|
||||
|
||||
/// The per-frame step (`0x4ade60`): non-positive is untouched, positive steps down and floors
|
||||
/// at exactly `0.0` — so a countdown that expires flips `HasNewMail()` true and *stays* true,
|
||||
/// signalling exactly once on the crossing.
|
||||
#[test]
|
||||
fn step_leaves_non_positive_alone_and_floors_at_zero() {
|
||||
// The "no mail" stamp never drifts toward zero, however long the session runs.
|
||||
@@ -197,18 +143,16 @@ mod tests {
|
||||
"a dormant countdown never signals"
|
||||
);
|
||||
|
||||
// A reached-zero countdown stays at zero rather than sailing past into negative — and does
|
||||
// not re-signal every frame (the reference's edge is one-shot).
|
||||
// A reached-zero countdown stays at zero and does not re-signal.
|
||||
let mut expired = pending_at(0.0);
|
||||
expired.step(1.0 / 60.0);
|
||||
assert_eq!(expired.countdown, 0.0);
|
||||
assert!(expired.has_new_mail());
|
||||
assert!(!expired.take_notify());
|
||||
|
||||
// A positive countdown steps down, then floors (never overshoots past 0), signalling on the
|
||||
// step that lands it inside ε and not before.
|
||||
// A positive countdown steps down, floors at 0 and signals on the step that lands it.
|
||||
let mut counting = pending_at(0.5);
|
||||
counting.step(0.125); // exact in binary — the step's value is pinned, not approximated
|
||||
counting.step(0.125); // exact in binary, so the step's value is pinned
|
||||
assert_eq!(counting.countdown, 0.375);
|
||||
assert!(!counting.has_new_mail());
|
||||
assert!(!counting.take_notify());
|
||||
@@ -221,8 +165,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The query reply stores verbatim and signals **unconditionally** — even when `HasNewMail()`
|
||||
/// did not change (`0x4ad605`). A transition-only fire would be a silent divergence.
|
||||
#[test]
|
||||
fn query_reply_stores_and_always_signals() {
|
||||
let mut p = MailPending::default();
|
||||
@@ -236,8 +178,6 @@ mod tests {
|
||||
assert!(p.take_notify());
|
||||
}
|
||||
|
||||
/// Sending the query stamps "no mail" but does **not** signal — so the icon keeps its face for
|
||||
/// the round trip and moves when the reply lands (`0x4ade25` stamps, `0x4ad605` fires).
|
||||
#[test]
|
||||
fn sending_the_query_stamps_without_signalling() {
|
||||
let mut p = pending_at(0.0);
|
||||
@@ -248,7 +188,6 @@ mod tests {
|
||||
assert!(!p.take_notify());
|
||||
}
|
||||
|
||||
/// `SMSG_RECEIVED_MAIL`'s set-value ladder (`0x4ad620`), all four branches.
|
||||
#[test]
|
||||
fn received_mail_ladder() {
|
||||
// Busy (a mailbox window is open): arm the deferred refresh, leave the countdown alone.
|
||||
@@ -265,7 +204,7 @@ mod tests {
|
||||
"and the flag is consumed by that one read"
|
||||
);
|
||||
|
||||
// Not busy, |delay| < eps: store and signal — vmangos's only case.
|
||||
// Not busy, |delay| < ε: store and signal, vmangos's only case.
|
||||
let mut now = pending_at(-86400.0);
|
||||
now.apply_received_mail(0.0, false);
|
||||
assert_eq!(now.countdown, 0.0);
|
||||
@@ -278,7 +217,7 @@ mod tests {
|
||||
assert_eq!(seeded.countdown, 120.0);
|
||||
assert!(!seeded.take_notify());
|
||||
|
||||
// Not busy, a real delay, current countdown positive: keep the *smaller* (tighten), silent.
|
||||
// Not busy, a real delay, current countdown positive: keep the smaller, silently.
|
||||
let mut tighten = pending_at(120.0);
|
||||
tighten.apply_received_mail(60.0, false);
|
||||
assert_eq!(tighten.countdown, 60.0);
|
||||
@@ -287,8 +226,6 @@ mod tests {
|
||||
assert!(!tighten.take_notify());
|
||||
}
|
||||
|
||||
/// The mechanism that actually clears the icon (decision 0913): opening a letter arms the
|
||||
/// deferred refresh, and the close consumes it into a re-query whose stamp darkens the icon.
|
||||
#[test]
|
||||
fn reading_mail_arms_the_refresh_that_the_close_consumes() {
|
||||
let mut p = pending_at(0.0);
|
||||
|
||||
@@ -1,24 +1,6 @@
|
||||
//! The app-side **merchant feed** (decision 0081 phase 4) — the inward half of the merchant seam
|
||||
//! around [`benilla_ui::script`]'s `merchant` module, the twin of [`crate::ui_gossip`]'s gossip seam
|
||||
//! and [`crate::ui_items`]'s container seam.
|
||||
//!
|
||||
//! The net bridge fills [`MerchantOpen`] from the wire (`SMSG_LIST_INVENTORY` → the vendor's rows;
|
||||
//! `SMSG_BUY_ITEM` → a stock update; the two refusals → [`MerchantErrors`]). Each frame
|
||||
//! [`feed_merchant`] resolves each wire [`VendorItem`] to a Lua-facing [`MerchantItem`] (name via the
|
||||
//! ask-once item-template cache, icon straight from the wire `display_id` through
|
||||
//! `ItemDisplayInfo.dbc` — the same catalog the bags use), pushes the snapshot
|
||||
//! ([`benilla_ui::script::UiScript::set_merchant`]), and fires `MERCHANT_SHOW` on open /
|
||||
//! `MERCHANT_UPDATE` on a content change / `MERCHANT_CLOSED` on clear. It also pushes the player's
|
||||
//! purse (`PLAYER_FIELD_COINAGE` via `set_money`) each frame it changes, firing `PLAYER_MONEY` with
|
||||
//! it (the money displays repaint on the event). [`drain_merchant`] pulls the Lua intents back out:
|
||||
//! `BuyMerchantItem(index)` → [`ClientCommand::BuyItem`] (mapped from the 1-based row to the item
|
||||
//! *entry* the wire addresses — buy is by entry, not the vendor `muid`), and `CloseMerchant` → a
|
||||
//! local clear (vanilla's client-side close sends no packet). The standardized NPC-session range
|
||||
//! guard ([`crate::ui_session`]) applies the same client-side close when the player walks out of
|
||||
//! the NPC-service range (or the vendor despawns).
|
||||
//!
|
||||
//! The sell affordance (decision 0081 v1) lives one module over in [`crate::ui_items`]: while a
|
||||
//! merchant is open, a bag-slot click sells the slot's item instead of using/equipping it.
|
||||
//! The merchant window's app side: [`MerchantOpen`] holds the `SMSG_LIST_INVENTORY` rows,
|
||||
//! [`feed_merchant`] pushes them with the buyback and repair rows, the purse and the refusals, and
|
||||
//! [`drain_merchant`] sends the Lua intents. A bag click sells through [`crate::ui_items`].
|
||||
|
||||
use benilla_protocol::messages::{buy_result, sell_result, VendorItem};
|
||||
use bevy::prelude::*;
|
||||
@@ -34,48 +16,39 @@ use crate::ui_script::{UiFeed, UiInput};
|
||||
use crate::ui_session::{close_npc_session_out_of_range, npc_switched, NpcSession};
|
||||
use benilla_protocol::messages::BAG_PLAYER_INVENTORY;
|
||||
|
||||
/// The wire's "unlimited stock" sentinel for a vendor row's `current_count` (vmangos
|
||||
/// `ItemHandler.cpp`); the Lua side sees it as `numAvailable == -1`.
|
||||
/// A vendor row's unlimited `current_count` (vmangos `ItemHandler.cpp:795`), `-1` to Lua.
|
||||
const STOCK_UNLIMITED: u32 = 0xFFFF_FFFF;
|
||||
|
||||
/// `UNIT_NPC_FLAG_REPAIR` — the vendor-can-repair service bit `CanMerchantRepair` tests
|
||||
/// (`0x4fadb0`: a merchant-frame gate, deliberately never in the cursor ladder).
|
||||
/// `UNIT_NPC_FLAG_REPAIR`, the bit `CanMerchantRepair` tests (`0x4fadb0`).
|
||||
const NPC_FLAG_REPAIR: u32 = 0x4000;
|
||||
|
||||
/// The wire's first absolute buyback inventory slot (`BUYBACK_SLOT_START`; slots 69–80).
|
||||
/// The first absolute buyback slot (`BUYBACK_SLOT_START`, slots 69-80).
|
||||
const BUYBACK_SLOT_FIRST: u32 = 69;
|
||||
|
||||
/// The client-side repair-cost tables (`DurabilityCosts.dbc` + `DurabilityQuality.dbc`), loaded
|
||||
/// with the entity catalogs ([`crate::entities`]). Optional resource — absent, every repair cost
|
||||
/// displays 0 and the repair-all button stays disabled (the wire still works).
|
||||
/// `DurabilityCosts.dbc` and `DurabilityQuality.dbc`, the client's repair prices; absent, every
|
||||
/// cost shows 0.
|
||||
#[derive(Resource)]
|
||||
pub(crate) struct RepairTables(pub(crate) benilla_formats::DurabilityTables);
|
||||
|
||||
/// The open merchant, filled by the net bridge ([`crate::net`]) and read by [`feed_merchant`]. Holds
|
||||
/// the vendor guid and its rows exactly as the wire delivered them (`SMSG_LIST_INVENTORY`); the feed
|
||||
/// resolves each to a display row and the drain maps a clicked 1-based row to its item entry. Cleared
|
||||
/// on a client-side close and on disconnect.
|
||||
/// The open vendor and its `SMSG_LIST_INVENTORY` rows, until a close or a disconnect.
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct MerchantOpen {
|
||||
/// The vendor whose window is open; `None` = no vendor open.
|
||||
pub(crate) vendor: Option<u64>,
|
||||
/// The vendor's rows (wire order = 1-based display order).
|
||||
/// In wire order, which is the 1-based display order.
|
||||
pub(crate) items: Vec<VendorItem>,
|
||||
}
|
||||
|
||||
impl MerchantOpen {
|
||||
/// Open (or replace) the window with a vendor's freshly-listed stock.
|
||||
pub(crate) fn open(&mut self, vendor: u64, items: Vec<VendorItem>) {
|
||||
self.vendor = Some(vendor);
|
||||
self.items = items;
|
||||
}
|
||||
|
||||
/// Whether a vendor window is currently open.
|
||||
pub(crate) fn is_open(&self) -> bool {
|
||||
self.vendor.is_some()
|
||||
}
|
||||
|
||||
/// The item **entry** at a 1-based display row — what `CMSG_BUY_ITEM` addresses.
|
||||
/// The item entry at a 1-based row: `CMSG_BUY_ITEM` buys by entry, not by vendor slot.
|
||||
pub(crate) fn entry_at(&self, index_1based: u32) -> Option<u32> {
|
||||
index_1based
|
||||
.checked_sub(1)
|
||||
@@ -83,45 +56,39 @@ impl MerchantOpen {
|
||||
.map(|it| it.entry)
|
||||
}
|
||||
|
||||
/// Apply a post-purchase stock update (`SMSG_BUY_ITEM`): the row's remaining count changes. The
|
||||
/// wire keys it by the 1-based vendor `slot`; the purchased item itself lands via the normal
|
||||
/// item-create path (already handled), so only the count display moves here.
|
||||
/// `SMSG_BUY_ITEM`'s new count for the row at 1-based vendor `slot`; the bought item itself
|
||||
/// arrives as an object create.
|
||||
pub(crate) fn update_stock(&mut self, slot: u32, new_count: u32) {
|
||||
if let Some(it) = self.items.iter_mut().find(|it| it.slot == slot) {
|
||||
it.current_count = new_count;
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark every row of an item **sold out** — the `SMSG_BUY_FAILED` `ITEM_ALREADY_SOLD` arm
|
||||
/// (`0x5dcdbf mov DWORD PTR [esi],0x0`), so a limited-stock row that just refused the click
|
||||
/// stops showing the count that tempted it. Unconditional, as the reference's write is: only a
|
||||
/// limited row can raise this refusal, and the reference does not break on the first match
|
||||
/// either (`0x5dcdcd` walks all 128 cache rows).
|
||||
/// Zero every row of `entry`, as the reference's `ITEM_ALREADY_SOLD` arm writes 0 (`0x5dcdbf`)
|
||||
/// into each matching row of its 128-row cache (`0x5dcdcd`).
|
||||
pub(crate) fn sold_out(&mut self, entry: u32) {
|
||||
for it in self.items.iter_mut().filter(|it| it.entry == entry) {
|
||||
it.current_count = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Close the open window (a client-side close). Keeps nothing — a re-open re-lists.
|
||||
/// A client-side close; a re-open re-lists.
|
||||
pub(crate) fn clear(&mut self) {
|
||||
self.vendor = None;
|
||||
self.items.clear();
|
||||
}
|
||||
|
||||
/// Disconnect: drop the open window (mirrors the gossip/item session clears).
|
||||
/// The disconnect clear.
|
||||
pub(crate) fn clear_session(&mut self) {
|
||||
self.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// A merchant refusal (`SMSG_BUY_FAILED` / `SMSG_SELL_ITEM`'s error path) queued by the net bridge for
|
||||
/// the UI error line — the merchant twin of [`crate::ui_items::EquipErrors`]. A *successful* buy/sell
|
||||
/// never lands here (a sell is silent, a buy answers `SMSG_BUY_ITEM`); only the error path does.
|
||||
/// Buy and sell refusals waiting for the feed's message line.
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct MerchantErrors(pub Vec<MerchantRefusal>);
|
||||
|
||||
/// One refusal: a buy or a sell, carrying the wire's `u8` reason code.
|
||||
/// A refused buy or sell and its wire reason code.
|
||||
pub(crate) enum MerchantRefusal {
|
||||
Buy(u8),
|
||||
Sell(u8),
|
||||
@@ -139,14 +106,9 @@ impl Plugin for UiMerchantPlugin {
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
// Range-close before the feed so the clear turns into MERCHANT_CLOSED the same
|
||||
// frame; push before the input pass so an open/close is on screen the same
|
||||
// frame; drain after it so a buy's intent goes out the same frame (mirrors
|
||||
// ui_gossip/ui_items). After the UnitFeed set: the paint MERCHANT_SHOW
|
||||
// triggers reads the engine's item-template + player-req stores
|
||||
// (GetMerchantItemInfo's isUsable), so this frame's pushes must land first —
|
||||
// unordered, the first paint races feed_item_stats/feed_player_req and the
|
||||
// usable reds drop until the next content repaint.
|
||||
// Range-close first so the clear fires `MERCHANT_CLOSED` the same frame. The
|
||||
// feed follows `UnitFeed`: `MERCHANT_SHOW`'s paint reads the item and
|
||||
// player-requirement stores for `GetMerchantItemInfo`'s `isUsable`.
|
||||
close_npc_session_out_of_range::<MerchantOpen>.before(feed_merchant),
|
||||
feed_merchant.after(crate::ui_unit::UnitFeed).in_set(UiFeed),
|
||||
drain_merchant.after(UiInput),
|
||||
@@ -155,44 +117,30 @@ impl Plugin for UiMerchantPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
/// The one `BuyResult` code the reference deliberately says **nothing** for. It has no vmangos
|
||||
/// name because vmangos never sends it: `0x5dcde7`'s jump table sends code 6 to the handler's own
|
||||
/// `ret`, past the `DisplayError` its neighbours land on.
|
||||
/// The one `BuyResult` code the reference shows nothing for: `0x5dcde7`'s jump table sends 6 to
|
||||
/// the handler's `ret`. vmangos never sends it.
|
||||
const BUY_SILENT: u8 = 6;
|
||||
|
||||
/// The GlobalStrings key a `BuyResult` refusal (`SMSG_BUY_FAILED`) shows — the reference's switch
|
||||
/// at `0x5dcdd8`, each arm a `CGGameUI::DisplayError(msgId)` call, read off the binary for
|
||||
/// decision 1821. `None` is silence the reference chose, not a hole in the table.
|
||||
///
|
||||
/// **The `default` arm is a real message, not an invented fallback**: every code outside the
|
||||
/// table — 0, 3, 9, 10 and everything from 13 up — shows `ERR_ITEM_NOT_FOUND` (`0x5dce81`, the
|
||||
/// `ja` target of `cmp eax,0xc`).
|
||||
/// The message a `SMSG_BUY_FAILED` code shows, from the reference's switch at `0x5dcdd8`; its
|
||||
/// default arm (`0x5dce81`) shows `ERR_ITEM_NOT_FOUND` for 0, 3, 9, 10 and 13 up.
|
||||
fn buy_error_key(reason: u8) -> Option<&'static str> {
|
||||
Some(match reason {
|
||||
// One arm (`0x5dcdee`) for both: vmangos calls 1 ALREADY_SOLD and 7 SOLD_OUT, and the
|
||||
// reference tells them apart only by code 1 also zeroing the row
|
||||
// ([`crate::ui_merchant::net::vendor_buy_failed`]).
|
||||
// One arm (`0x5dcdee`) for both; only code 1 also zeroes the row.
|
||||
buy_result::ITEM_ALREADY_SOLD | buy_result::ITEM_SOLD_OUT => "ERR_VENDOR_SOLD_OUT", // 0x23
|
||||
buy_result::NOT_ENOUGH_MONEY => "ERR_NOT_ENOUGH_MONEY", // 0x25 — speaks, line 0x28
|
||||
buy_result::NOT_ENOUGH_MONEY => "ERR_NOT_ENOUGH_MONEY", // 0x25, speaks line 0x28
|
||||
buy_result::SELLER_DONT_LIKE_YOU => "ERR_VENDOR_HATES_YOU", // 0x22
|
||||
buy_result::DISTANCE_TOO_FAR => "ERR_VENDOR_TOO_FAR", // 0x24
|
||||
BUY_SILENT => return None,
|
||||
buy_result::CANT_CARRY_MORE => "ERR_ITEM_MAX_COUNT", // 0x12 — speaks, line 0x1e
|
||||
buy_result::CANT_CARRY_MORE => "ERR_ITEM_MAX_COUNT", // 0x12, speaks line 0x1e
|
||||
buy_result::RANK_REQUIRE => "ERR_CANT_EQUIP_RANK", // 0x05
|
||||
buy_result::REPUTATION_REQUIRE => "ERR_CANT_EQUIP_REPUTATION", // 0x06
|
||||
_ => "ERR_ITEM_NOT_FOUND", // 0x17 — the switch's own default
|
||||
_ => "ERR_ITEM_NOT_FOUND", // 0x17, the switch's default
|
||||
})
|
||||
}
|
||||
|
||||
/// The GlobalStrings key a `SellResult` refusal (`SMSG_SELL_ITEM`'s error path) shows — the
|
||||
/// reference's switch at `0x5dd22c` (decision 1821).
|
||||
///
|
||||
/// **Silence is the default here**, the opposite of [`buy_error_key`]: code 0 returns before the
|
||||
/// switch (`0x5dd21e`), and 5 and everything from 7 up jump past the `DisplayError` to the
|
||||
/// handler's tail. vmangos's own header agrees line for line — its comment on `SELL_ERR_UNK = 5`
|
||||
/// is *"nothing appears…"*, and its comments on 2 and 3 (*"merchant doesn't like that item"* /
|
||||
/// *"merchant doesn't like you"*) name the two strings the reference picks, which its `CANT_SELL`
|
||||
/// / `CANT_FIND_VENDOR` identifiers do not.
|
||||
/// The message a `SMSG_SELL_ITEM` error code shows, from the reference's switch at `0x5dd22c`.
|
||||
/// Its default is silence: 0 returns before the switch (`0x5dd21e`), and 5 and 7 up skip the
|
||||
/// `DisplayError`.
|
||||
fn sell_error_key(reason: u8) -> Option<&'static str> {
|
||||
Some(match reason {
|
||||
sell_result::CANT_FIND_ITEM => "ERR_ITEM_NOT_FOUND", // 0x17
|
||||
@@ -204,10 +152,8 @@ fn sell_error_key(reason: u8) -> Option<&'static str> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolve one wire [`VendorItem`] into the Lua-facing [`MerchantItem`]: the icon comes straight from
|
||||
/// the wire `display_id` (no template wait — the row shows its icon immediately), the name + the
|
||||
/// tooltip stat head from the ask-once template cache (`None` while in flight — the row shows a
|
||||
/// placeholder and fills in when the answer lands, exactly like a bag slot).
|
||||
/// One wire row as a Lua row: the icon from the wire `display_id` at once, the name and stats once
|
||||
/// the item template answers.
|
||||
fn resolve_item(
|
||||
item: &VendorItem,
|
||||
items: &Items,
|
||||
@@ -237,11 +183,8 @@ fn resolve_item(
|
||||
} else {
|
||||
item.current_count as i32
|
||||
};
|
||||
// The row's link (`GetMerchantItemLink`, decision 1059) — what the row click's ctrl/shift arms
|
||||
// hand on. Off the SAME one template answer as `name`/`stats`, through the one shared builder
|
||||
// ([`crate::ui_items::item_link`], our transcription of the client's own `0x52adb0`): a vendor
|
||||
// row carries no enchant and no random property on the wire, so the no-ids form is the right
|
||||
// one here.
|
||||
// `GetMerchantItemLink`'s link, built as the reference's `0x52adb0` builds it; a vendor row
|
||||
// has no enchant or random property.
|
||||
let link = template.map(|t| item_link(item.entry, &t.name, t.quality));
|
||||
MerchantItem {
|
||||
name,
|
||||
@@ -256,10 +199,9 @@ fn resolve_item(
|
||||
}
|
||||
}
|
||||
|
||||
/// The occupied buyback slots' player-descriptor indices (0–11) in the client's display order —
|
||||
/// timestamp-ascending, oldest first (`0x4fafd0`: scan slots 69–80 for a non-zero
|
||||
/// guid+price pair, sort by the timestamp fields). Index `i` here is Lua's `GetBuybackItemInfo(i+1)`;
|
||||
/// the wire's absolute slot for an entry is `BUYBACK_SLOT_FIRST + index`.
|
||||
/// The occupied buyback slots (0-11) in the reference's display order, oldest first: `0x4fafd0`
|
||||
/// takes the slots with a guid and a price and sorts them by timestamp. Entry `i` is
|
||||
/// `GetBuybackItemInfo(i + 1)`.
|
||||
fn buyback_order(store: &benilla_protocol::ObjectFields) -> Vec<u8> {
|
||||
let mut v: Vec<(u8, u32)> = (0..12u8)
|
||||
.filter_map(|i| {
|
||||
@@ -272,9 +214,8 @@ fn buyback_order(store: &benilla_protocol::ObjectFields) -> Vec<u8> {
|
||||
v.into_iter().map(|(i, _)| i).collect()
|
||||
}
|
||||
|
||||
/// Resolve one buyback slot to its Lua-facing row: identity from the parked item object (it stays
|
||||
/// streamed while in the buyback slots), name/stats/icon from its template, the price from the
|
||||
/// player's BUYBACK_PRICE field.
|
||||
/// One buyback slot as a Lua row: the item from its object, which stays streamed while it sits in
|
||||
/// the slot, and the price from the player's buyback price field.
|
||||
fn resolve_buyback(
|
||||
idx: u8,
|
||||
store: &benilla_protocol::ObjectFields,
|
||||
@@ -314,20 +255,17 @@ fn resolve_buyback(
|
||||
num_available: 0,
|
||||
item_id: entry,
|
||||
stats,
|
||||
// No link on a buyback row: 1.12 has no `GetBuybackItemLink`, and the reference's buyback
|
||||
// click carries no ctrl/shift branch at all — it is a bare `BuybackItem(this:GetID())`
|
||||
// (`MerchantFrame.lua:358-361`). Nothing reads it, so nothing builds it (decision 1059).
|
||||
// No link: 1.12 has no `GetBuybackItemLink`, and the buyback click is a bare
|
||||
// `BuybackItem(this:GetID())` (`MerchantFrame.lua:358-361`).
|
||||
link: None,
|
||||
// …and no max stack, for the same shape of reason: `GetMerchantItemMaxStack` indexes the
|
||||
// MERCHANT list, and a buyback row is bought whole rather than by the stackful. Nothing
|
||||
// asks, so nothing is answered.
|
||||
// No max stack: `GetMerchantItemMaxStack` indexes the merchant list, and a buyback row
|
||||
// is bought whole.
|
||||
max_stack: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// One item's displayed repair cost — durability off the item object, the head off its template,
|
||||
/// the arithmetic in [`benilla_formats::DurabilityTables`] (the client's own `0x4faf30` chain).
|
||||
/// No reputation model yet → discount 0.
|
||||
/// One item's repair cost, by the reference's `0x4faf30` arithmetic
|
||||
/// ([`benilla_formats::DurabilityTables`]); its reputation discount is not applied (passed as 0).
|
||||
fn item_repair_cost(
|
||||
guid: u64,
|
||||
objects: &Objects,
|
||||
@@ -354,9 +292,8 @@ fn item_repair_cost(
|
||||
.repair_cost(points, level, quality, class, subclass, 0.0)
|
||||
}
|
||||
|
||||
/// The repair-all total: the client's three sweeps (`0x4fbd60`) —
|
||||
/// equipped 0–18, the backpack, and the CONTENTS of the 4 equipped bags (never bank/keyring/
|
||||
/// buyback).
|
||||
/// The repair-all total over the reference's three sweeps (`0x4fbd60`): equipped slots 0-18, the
|
||||
/// backpack and the four bags' contents; never the bank, keyring or buyback.
|
||||
fn repair_all_cost(
|
||||
store: &benilla_protocol::ObjectFields,
|
||||
objects: &Objects,
|
||||
@@ -395,8 +332,6 @@ fn repair_all_cost(
|
||||
total.min(u64::from(u32::MAX)) as u32
|
||||
}
|
||||
|
||||
/// Build the Lua-facing snapshot from [`MerchantOpen`] + the player/vendor descriptors — `None`
|
||||
/// when no vendor is open.
|
||||
fn snapshot(
|
||||
open: &MerchantOpen,
|
||||
objects: &Objects,
|
||||
@@ -433,10 +368,8 @@ fn snapshot(
|
||||
})
|
||||
}
|
||||
|
||||
/// Push the current merchant into the VM and fire the show/update/close events on a transition (or a
|
||||
/// content change — the async name landing, a post-buy stock update). Also surfaces refusals on the
|
||||
/// red error line and pushes the player's purse each frame it changes. Diffed against a `Local`
|
||||
/// memory, exactly like the gossip/container feeds.
|
||||
/// Push the merchant into the VM and fire its events on a change; show the refusals and push the
|
||||
/// purse.
|
||||
fn feed_merchant(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
open: Res<MerchantOpen>,
|
||||
@@ -462,9 +395,7 @@ fn feed_merchant(
|
||||
let last_money = last_money.get(&script);
|
||||
let last_name = last_name.get(&script);
|
||||
let last_vendor = last_vendor.get(&script);
|
||||
// Refusals go to the surface — and the voice — their message record names (decision 1815):
|
||||
// the vendor's "not enough money" carries error-speech line 0x28, "you can't carry any more"
|
||||
// line 0x1e. A code the reference says nothing for resolves to no key and reaches nothing.
|
||||
// Refusals show, and speak, as their message rows say; a silent code has no key.
|
||||
let refusals: Vec<_> = errors
|
||||
.0
|
||||
.drain(..)
|
||||
@@ -477,10 +408,8 @@ fn feed_merchant(
|
||||
})
|
||||
.collect();
|
||||
crate::ui_action::show_messages(&mut script, &mut sink, "ui_merchant", refusals);
|
||||
// The purse: push it only when it changes (u64 copper straight from PLAYER_FIELD_COINAGE),
|
||||
// and fire the real client's PLAYER_MONEY event with it — the money displays (bag + merchant
|
||||
// purses) repaint on the event rather than riding some window-content repaint that happens to
|
||||
// coincide (a sell repainted the bag purse only because it also moved bag contents).
|
||||
// The purse (`PLAYER_FIELD_COINAGE`): on a change, push it and fire `PLAYER_MONEY`, which the
|
||||
// money frames repaint on.
|
||||
if let Some(store) = self_q.iter().next() {
|
||||
if let Some(copper) = store.0.player_money() {
|
||||
let copper = u64::from(copper);
|
||||
@@ -509,18 +438,15 @@ fn feed_merchant(
|
||||
vendor_npc_flags,
|
||||
tables.as_deref(),
|
||||
);
|
||||
// The vendor's name resolves through the NameCache (a creature-name query, ask-once — the gossip
|
||||
// feed's pattern). `None`/empty while in flight; the title shows "Merchant" until it lands, then
|
||||
// re-fires MERCHANT_UPDATE with the name (the diff below tracks the name too, so a name-only
|
||||
// change still repaints the title). It rides an event arg rather than a `MerchantState` field so
|
||||
// no benilla-ui engine change is needed for the title.
|
||||
// The vendor's name rides `MERCHANT_SHOW` and `MERCHANT_UPDATE` as arg1, and its landing alone
|
||||
// re-fires `MERCHANT_UPDATE`; the reference fires both bare (`0x4fad92`, `0x4facaa`), and
|
||||
// stock `MerchantFrame.lua:67` titles with `UnitName("NPC")`.
|
||||
let vendor_name = open
|
||||
.vendor
|
||||
.and_then(|g| names.resolve(g, &commands).map(str::to_string));
|
||||
let name_changed = *last_name != vendor_name;
|
||||
// A different vendor while the window is already open is a real close+open (the client's
|
||||
// ShowUIPanel early-returns when visible, so the open sound only re-plays after a hide —
|
||||
// decision 0096 / [`crate::ui_session::npc_switched`]).
|
||||
// A different vendor while open is a close then an open: `ShowUIPanel` returns early on a
|
||||
// visible frame, so only a hide replays the open sound.
|
||||
let switched = npc_switched(*last_vendor, open.vendor);
|
||||
if fresh == *last && !name_changed && !switched {
|
||||
return;
|
||||
@@ -528,17 +454,15 @@ fn feed_merchant(
|
||||
script.set_merchant(fresh.clone());
|
||||
let name_arg = || vec![ScriptValue::Str(vendor_name.clone().unwrap_or_default())];
|
||||
if switched {
|
||||
// Close the old vendor, open the new: the frame hides then shows, playing the close then
|
||||
// open kits (and closing/reopening the bag). The MERCHANT_CLOSED routes through the window's
|
||||
// OnHide → CloseMerchant (decision 0095), which queues a close intent — consume it here so
|
||||
// the drain does NOT clear the vendor we just re-opened to.
|
||||
// `MERCHANT_CLOSED` queues a close intent through OnHide; consume it so the drain keeps
|
||||
// the new vendor.
|
||||
script.fire_event("MERCHANT_CLOSED", vec![]);
|
||||
script.fire_event("MERCHANT_SHOW", name_arg());
|
||||
let _ = script.take_merchant_close();
|
||||
} else {
|
||||
match (&*last, &fresh) {
|
||||
(None, Some(_)) => script.fire_event("MERCHANT_SHOW", name_arg()),
|
||||
// A content change while open (async item/vendor name landed, stock moved) → repaint.
|
||||
// A change while open: an item or the name landed, or the stock moved.
|
||||
(Some(_), Some(_)) => script.fire_event("MERCHANT_UPDATE", name_arg()),
|
||||
(Some(_), None) => script.fire_event("MERCHANT_CLOSED", vec![]),
|
||||
(None, None) => {}
|
||||
@@ -549,9 +473,8 @@ fn feed_merchant(
|
||||
*last_vendor = open.vendor;
|
||||
}
|
||||
|
||||
/// The merchant window is an NPC session: the standardized range guard
|
||||
/// ([`crate::ui_session`]) client-side-closes it — the exact `CloseMerchant` clear — when the
|
||||
/// player walks out of the vendor's service range or the vendor despawns.
|
||||
/// The range guard closes the window, as `CloseMerchant` does, when the vendor is out of range or
|
||||
/// gone.
|
||||
impl NpcSession for MerchantOpen {
|
||||
fn npc(&self) -> Option<u64> {
|
||||
self.vendor
|
||||
@@ -562,10 +485,9 @@ impl NpcSession for MerchantOpen {
|
||||
}
|
||||
}
|
||||
|
||||
/// Drain the Lua intents: a bought row → `CMSG_BUY_ITEM` (mapped to the row's item entry; buy is by
|
||||
/// entry, not the vendor `muid` — decision 0081); a buyback → `CMSG_BUYBACK_ITEM` (the clicked
|
||||
/// 1-based, timestamp-sorted list index mapped to its ABSOLUTE slot 69–80); a repair-all →
|
||||
/// `CMSG_REPAIR_ITEM` with guid 0; a close → a local clear (no packet, vanilla).
|
||||
/// Drain the Lua intents: a buy sends `CMSG_BUY_ITEM` by item entry, a buyback
|
||||
/// `CMSG_BUYBACK_ITEM` with the absolute slot (69-80), a repair-all `CMSG_REPAIR_ITEM` with guid
|
||||
/// 0, and a close clears locally, as there is no close packet.
|
||||
fn drain_merchant(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
mut open: ResMut<MerchantOpen>,
|
||||
@@ -622,14 +544,9 @@ fn drain_merchant(
|
||||
None => debug!("ui_merchant: BuyMerchantItem({index}) out of range — ignored"),
|
||||
}
|
||||
}
|
||||
// `PickupMerchantItem`'s SELL arm — a bag item dropped on the vendor window. Resolved the
|
||||
// same way the bag-click sell route resolves it: the wire addresses the item by its concrete
|
||||
// guid, not by a slot, so a slot that emptied under us is a no-op rather than a wrong sell.
|
||||
//
|
||||
// **No merchant-open gate**, deliberately: `0x4fb760`'s sell arm runs before the vendor check
|
||||
// (`0x4fb7f7` gates only the grab). The vendor guid is still needed to address the packet, so
|
||||
// in practice a closed window drops it — but the ORDER matters, because it is why dropping an
|
||||
// item on a vendor window that is closing still sells.
|
||||
// `PickupMerchantItem`'s sell arm, a bag item dropped on the window, sold by its item guid so
|
||||
// an emptied slot is a no-op. It runs before the close below, as `0x4fb760`'s sell arm runs
|
||||
// before its vendor check (`0x4fb7f7`), so a drop on a closing window still sells.
|
||||
for (bag, slot) in script.take_merchant_cursor_sells() {
|
||||
let Some(vendor) = open.vendor else { continue };
|
||||
let slot0 = u8::try_from(slot.saturating_sub(1)).unwrap_or(0);
|
||||
@@ -645,17 +562,16 @@ fn drain_merchant(
|
||||
None => debug!("ui_merchant: cursor sell on an empty slot ({bag}, {slot}) — ignored"),
|
||||
}
|
||||
}
|
||||
// A held vendor row dropped into a slot — `CMSG_BUY_ITEM_IN_SLOT`, count 1 (the reference's
|
||||
// own hardcoded stack count on all three drop paths).
|
||||
// A held vendor row dropped into a slot: `CMSG_BUY_ITEM_IN_SLOT` with count 1, the
|
||||
// reference's fixed count on all three drop paths.
|
||||
for (bag, slot, entry) in script.take_merchant_slot_buys() {
|
||||
let Some(vendor) = open.vendor else { continue };
|
||||
let (Some(store), Some((bag_index, bag_slot))) = (self_store, wire_pos(bag, slot)) else {
|
||||
debug!("ui_merchant: slot buy to an unaddressable slot ({bag}, {slot}) — ignored");
|
||||
continue;
|
||||
};
|
||||
// The wire wants the destination CONTAINER'S GUID, not its slot index: the player's own
|
||||
// for the backpack, the keyring, the bank and the equipment slots (all of which live in
|
||||
// the player's array, `BAG_PLAYER_INVENTORY`), and the bag object's for a real bag.
|
||||
// The wire wants the container's guid: the player's for a slot in the player's own
|
||||
// array (`BAG_PLAYER_INVENTORY`), the bag's for a real bag.
|
||||
let bag_guid = if bag_index == BAG_PLAYER_INVENTORY {
|
||||
self_pair.map(|(_, g)| g.0)
|
||||
} else {
|
||||
@@ -700,9 +616,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// **The two refusal tables, welded to the message ids they were read from** (decision 1821).
|
||||
/// Asserting the key alone would let a plausible-looking rename through; asserting the id it
|
||||
/// resolves to is asserting the `push <id>; call 0x496720` that was actually disassembled.
|
||||
/// The ids are asserted, not just the keys: each is its arm's `push <id>; call 0x496720`.
|
||||
#[test]
|
||||
fn the_refusal_tables_are_the_references_own() {
|
||||
let id = |key: &str| {
|
||||
@@ -710,8 +624,8 @@ mod tests {
|
||||
.unwrap_or_else(|| panic!("{key} is not a catalog row"))
|
||||
.id
|
||||
};
|
||||
// `SMSG_BUY_FAILED` — the switch at `0x5dcdd8`. Note 1 and 7 share `0x23`, 6 is silent,
|
||||
// and the arms outside the table fall to `0x17` rather than to nothing.
|
||||
// `SMSG_BUY_FAILED`, the switch at `0x5dcdd8`: 1 and 7 share `0x23`, 6 is silent, and
|
||||
// codes outside the table fall to `0x17`.
|
||||
for (code, want) in [
|
||||
(0u8, Some(0x17u16)),
|
||||
(1, Some(0x23)),
|
||||
@@ -731,7 +645,7 @@ mod tests {
|
||||
] {
|
||||
assert_eq!(buy_error_key(code).map(id), want, "buy code {code}");
|
||||
}
|
||||
// `SMSG_SELL_ITEM` — the switch at `0x5dd22c`, where the default is silence.
|
||||
// `SMSG_SELL_ITEM`, the switch at `0x5dd22c`, where the default is silence.
|
||||
for (code, want) in [
|
||||
(0u8, None),
|
||||
(1, Some(0x17u16)),
|
||||
@@ -747,8 +661,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The two vendor refusals that **speak** — the reason the keys had to replace the hand-written
|
||||
/// English at all (decision 1815's join). Everything else in the two tables is a silent row.
|
||||
/// Only these two vendor refusals speak.
|
||||
#[test]
|
||||
fn the_purse_refusals_carry_their_voice_lines() {
|
||||
let tag = |key: &str| benilla_ui::messages::by_key(key).expect("row").type_tag;
|
||||
@@ -769,8 +682,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// `ITEM_ALREADY_SOLD` zeroes the row it refused, and only that row — the reference's own
|
||||
/// cache write (`0x5dcdbf`), keyed by entry because vmangos puts the entry in that field.
|
||||
/// The reference's cache write (`0x5dcdbf`), keyed by entry as vmangos fills the field.
|
||||
#[test]
|
||||
fn a_sold_out_refusal_zeroes_only_that_rows_count() {
|
||||
let mut open = MerchantOpen::default();
|
||||
@@ -809,13 +721,13 @@ mod tests {
|
||||
let items = Items::default();
|
||||
let (tx, _rx) = crossbeam_channel::unbounded();
|
||||
let commands = NetCommands(tx);
|
||||
// Unlimited stock → numAvailable -1; a finite count passes through.
|
||||
// Unlimited stock is `numAvailable` -1; a finite count passes through.
|
||||
let unlimited = resolve_item(&row(159, 1, STOCK_UNLIMITED), &items, None, &commands);
|
||||
assert_eq!(unlimited.num_available, -1);
|
||||
assert_eq!(unlimited.item_id, 159);
|
||||
let finite = resolve_item(&row(4540, 2, 5), &items, None, &commands);
|
||||
assert_eq!(finite.num_available, 5);
|
||||
// No template answer yet → name + tooltip stats in flight (nil), the rest present.
|
||||
// No template answer yet: no name or stats, the rest present.
|
||||
assert!(finite.name.is_none());
|
||||
assert!(finite.stats.is_none());
|
||||
assert_eq!(finite.price, 500);
|
||||
@@ -903,7 +815,7 @@ mod tests {
|
||||
open.open(0x42, vec![row(159, 1, STOCK_UNLIMITED), row(4540, 2, 5)]);
|
||||
open.update_stock(2, 4);
|
||||
assert_eq!(open.items[1].current_count, 4);
|
||||
open.update_stock(99, 0); // no such slot — no-op
|
||||
open.update_stock(99, 0); // no such slot: a no-op
|
||||
assert_eq!(open.items[0].current_count, STOCK_UNLIMITED);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
//! The vendor window's packet handlers (decision 0081 phase 4; in the net handler table since
|
||||
//! 2318, moved out of the drain's npc arm file) — each fills the [`MerchantOpen`] session or the
|
||||
//! [`MerchantErrors`] line queue the merchant feed ([`super`]) reads.
|
||||
//! The vendor window's packet handlers: they fill [`MerchantOpen`] and [`MerchantErrors`].
|
||||
|
||||
use benilla_protocol::messages::VendorItem;
|
||||
use benilla_protocol::{SessionEvent, SessionEventKind};
|
||||
@@ -9,8 +7,7 @@ use bevy::prelude::*;
|
||||
use super::{MerchantErrors, MerchantOpen, MerchantRefusal};
|
||||
use crate::net::NetHandlerApp;
|
||||
|
||||
/// Register the vendor handlers — called from [`super::UiMerchantPlugin`]. One per kind, plus the
|
||||
/// session-end listener.
|
||||
/// Register the vendor handlers and the session-end listener.
|
||||
pub(super) fn register(app: &mut App) {
|
||||
use SessionEventKind as K;
|
||||
app.net_handler(K::VendorInventory, on_inventory)
|
||||
@@ -59,43 +56,30 @@ fn on_sell_failed(In(ev): In<SessionEvent>, mut errors: ResMut<MerchantErrors>)
|
||||
}
|
||||
}
|
||||
|
||||
/// An open vendor window dies with the socket. A listener on the session end
|
||||
/// (a second handler on the kind, after the bridge's own teardown).
|
||||
/// The vendor window closes with the connection.
|
||||
fn on_session_end(In(_): In<SessionEvent>, mut merchant: ResMut<MerchantOpen>) {
|
||||
merchant.clear_session();
|
||||
}
|
||||
|
||||
/// A vendor's stock (`SMSG_LIST_INVENTORY`): fill the [`MerchantOpen`] the merchant feed
|
||||
/// ([`super`]) reads. A successful buy updates the stock display via
|
||||
/// [`vendor_buy_result`] (the item itself lands via item-create); a successful sell is silent
|
||||
/// (never a packet here — only the error path is).
|
||||
/// `SMSG_LIST_INVENTORY` opens the window on the vendor's rows.
|
||||
fn vendor_inventory(vendor: u64, items: Vec<VendorItem>, merchant: &mut MerchantOpen) {
|
||||
debug!("net: vendor {vendor:#x} listed {} items", items.len());
|
||||
merchant.open(vendor, items);
|
||||
}
|
||||
|
||||
/// A purchase updated the vendor's stock (`SMSG_BUY_ITEM`). Only touch stock for the open
|
||||
/// vendor (a late answer for a closed window is stale).
|
||||
/// `SMSG_BUY_ITEM`'s new stock count, applied only to the open vendor.
|
||||
fn vendor_buy_result(vendor: u64, slot: u32, new_count: u32, merchant: &mut MerchantOpen) {
|
||||
if merchant.vendor == Some(vendor) {
|
||||
merchant.update_stock(slot, new_count);
|
||||
}
|
||||
}
|
||||
|
||||
/// A purchase was refused (`SMSG_BUY_FAILED`) — the merchant window's error line, and for the
|
||||
/// out-of-stock code the refusing row's own count.
|
||||
/// `SMSG_BUY_FAILED`: queue the message line, and for `ITEM_ALREADY_SOLD` zero the refused row of
|
||||
/// the open vendor, as the reference does (`0x5dcda7`..`0x5dcdd6`). vmangos sends that code only
|
||||
/// for a limited row short of stock (`Player.cpp:18541`).
|
||||
///
|
||||
/// **`ITEM_ALREADY_SOLD` zeroes the row** (`0x5dcda7`..`0x5dcdd6`, decision 1821): the reference
|
||||
/// walks its 128-row vendor cache, writes 0 into the matching row's count word and repaints —
|
||||
/// gated, as every other stale-answer path here is, on the packet naming the vendor still open.
|
||||
///
|
||||
/// **NAMED DIVERGENCE in the key.** The reference matches the row's `+0x00`, the vendor *slot* —
|
||||
/// the same word `SMSG_BUY_ITEM` keys its stock update by. vmangos puts the item *entry* in that
|
||||
/// field (`Player::SendBuyError`, `Player.cpp:11637`: `packet->itemEntry = item`), so matching by
|
||||
/// slot would find nothing on the server we actually talk to; matching by entry is the same row.
|
||||
/// vmangos raises this code from exactly the condition the zeroing models —
|
||||
/// `GetVendorItemCurrentCount(crItem) < totalCount` under `crItem->maxcount != 0`
|
||||
/// (`Player.cpp:18508`).
|
||||
/// Deviation: the row is matched by item entry, where the reference matches its vendor slot word
|
||||
/// (`+0x00`), because vmangos writes the entry in that field (`Player.cpp:11718`).
|
||||
fn vendor_buy_failed(
|
||||
vendor: u64,
|
||||
item_entry: u32,
|
||||
@@ -112,7 +96,7 @@ fn vendor_buy_failed(
|
||||
errors.0.push(MerchantRefusal::Buy(reason));
|
||||
}
|
||||
|
||||
/// A sell was refused (`SMSG_SELL_ITEM`'s error path) — the merchant window's error line.
|
||||
/// `SMSG_SELL_ITEM`'s error path: queue the message line.
|
||||
fn vendor_sell_failed(reason: u8, errors: &mut MerchantErrors) {
|
||||
debug!("net: sell failed (reason {reason})");
|
||||
errors.0.push(MerchantRefusal::Sell(reason));
|
||||
|
||||
@@ -1,20 +1,11 @@
|
||||
//! The mirror-timer feed: breath / fatigue / feign-death off the wire → FrameXML events
|
||||
//! (decision 0874).
|
||||
//! The mirror-timer feed: breath, fatigue and feign-death edges off the wire, fired as
|
||||
//! `MIRROR_TIMER_START`, `_PAUSE` and `_STOP` (`UIParent.lua:97`, `MirrorTimer.lua:73-74`). The
|
||||
//! stock bars integrate `value + scale * elapsed` every `OnUpdate`, so a packet every few seconds
|
||||
//! paints a smooth countdown.
|
||||
//!
|
||||
//! The net bridge queues [`MirrorTimerEdge`]s and the drain fires the reference client's
|
||||
//! FrameScript events into the script VM — `MIRROR_TIMER_START` / `_PAUSE` / `_STOP`, the exact
|
||||
//! contract stock `Interface\FrameXML\MirrorTimer.xml` (`MirrorTimer1/2/3`) registers for.
|
||||
//! The bars themselves are the reference's: the frame stores the value and integrates
|
||||
//! `value + scale * elapsed` every OnUpdate, so a packet every few seconds is enough to paint a
|
||||
//! smooth countdown.
|
||||
//!
|
||||
//! **The client computes nothing here.** Breath and fatigue are server state — vmangos's
|
||||
//! `Player::UpdateMirrorTimers` runs them off its own liquid checks (`IsUnderwater`,
|
||||
//! `IsInHighSea`) and ships a value + a signed rate. That is why there is no local "am I
|
||||
//! underwater" predicate in this module and should not be one: a second, disagreeing authority
|
||||
//! is exactly how a bar ends up drifting from the drowning damage that follows it.
|
||||
//!
|
||||
//! The `ui_cast::CastBarFeed` pattern throughout — one queue, one drain, no per-frame work.
|
||||
//! The client computes nothing: vmangos's `Player::UpdateMirrorTimers` runs the timers off its own
|
||||
//! liquid checks and ships a value and a signed rate, so there is no local underwater predicate to
|
||||
//! disagree with the drowning damage that follows.
|
||||
|
||||
use benilla_ui::script::{ScriptValue, UiScript};
|
||||
use bevy::prelude::*;
|
||||
@@ -27,28 +18,22 @@ use crate::ui_unit::UnitFeed;
|
||||
/// One mirror-timer edge off the wire, queued by the net bridge for the bars.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(crate) enum MirrorTimerEdge {
|
||||
/// `SMSG_START_MIRROR_TIMER` — start, or wholly re-state, one timer. The server re-sends this
|
||||
/// on every change (direction, remaining, frozen), so it arrives repeatedly for one bar.
|
||||
/// `SMSG_START_MIRROR_TIMER`: starts or wholly restates a timer; vmangos re-sends it on every
|
||||
/// change of direction, remaining time or freeze.
|
||||
Start(MirrorTimerStart),
|
||||
/// `SMSG_PAUSE_MIRROR_TIMER` — freeze/unfreeze. vmangos never sends it (it substitutes a full
|
||||
/// `Start`), but a server that does must not be ignored.
|
||||
/// `SMSG_PAUSE_MIRROR_TIMER`: vmangos never sends it, restating with a `Start` instead.
|
||||
Pause { kind: u32, paused: bool },
|
||||
/// `SMSG_STOP_MIRROR_TIMER` — that timer is over; its bar hides.
|
||||
/// `SMSG_STOP_MIRROR_TIMER`: the bar hides.
|
||||
Stop { kind: u32 },
|
||||
}
|
||||
|
||||
/// The net bridge's mirror-timer queue (the [`crate::ui_cast::CastBarFeed`] pattern).
|
||||
/// The net bridge's mirror-timer queue.
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct MirrorTimerFeed(pub(crate) Vec<MirrorTimerEdge>);
|
||||
|
||||
/// The FrameScript name the reference passes as **arg1** for each timer type — the key its
|
||||
/// `MirrorTimerColors` table is indexed by, and the stem of the caption lookup below.
|
||||
///
|
||||
/// The client holds these as a 3-entry table indexed by the wire's `timerType`
|
||||
/// (`WoW.exe`: `"EXHAUSTION"` @`0x460520`, `"BREATH"` @`0x46052c`, `"FEIGNDEATH"` @`0x460534`,
|
||||
/// contiguous and in the server's `MirrorTimer::Type` order). Note the type-0 name is
|
||||
/// `EXHAUSTION`, not the server's own word for it (`FATIGUE`) — the two ends disagree on the
|
||||
/// name of the same timer, and it is the *client's* word that the Lua is keyed by.
|
||||
/// The timer's arg1 and `MirrorTimerColors` key, from the client's 3-entry table by wire type
|
||||
/// (`"EXHAUSTION"` `0x460520`, `"BREATH"` `0x46052c`, `"FEIGNDEATH"` `0x460534`). Type 0 is
|
||||
/// `EXHAUSTION` on the client and `FATIGUE` on the server; the Lua keys on the client's word.
|
||||
fn script_name(kind: MirrorTimerKind) -> &'static str {
|
||||
match kind {
|
||||
MirrorTimerKind::Fatigue => "EXHAUSTION",
|
||||
@@ -57,17 +42,10 @@ fn script_name(kind: MirrorTimerKind) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
/// The static caption for a timer with **no owning spell** — the `GlobalStrings.lua` value the
|
||||
/// reference's `<NAME>_LABEL` lookup resolves to.
|
||||
///
|
||||
/// The 1.12 `GlobalStrings.lua` defines exactly two of the three: `BREATH_LABEL = "Breath"` and
|
||||
/// `EXHAUSTION_LABEL = "Fatigue"` (each commented "Used as the label for the … status bar"), and
|
||||
/// **no** `FEIGNDEATH_LABEL`. The reference's `GetGlobalString 0x703bf0` never returns NULL — it
|
||||
/// pre-seeds a static empty string (`0x882748`) — so the missing one is the **empty caption**, not
|
||||
/// a nil the Lua would trip over.
|
||||
///
|
||||
/// Inlined here rather than looked up through the VM for the same reason `CastingBar.xml` inlines
|
||||
/// `FAILED`/`INTERRUPTED`: benilla loads no `GlobalStrings.lua` yet.
|
||||
/// A spell-less timer's caption, the reference's `<NAME>_LABEL` lookup: the 1.12
|
||||
/// `GlobalStrings.lua` defines `BREATH_LABEL` and `EXHAUSTION_LABEL` and no `FEIGNDEATH_LABEL`,
|
||||
/// which `GetGlobalString` (`0x703bf0`) answers with its static empty string (`0x882748`), never
|
||||
/// nil. These are the enUS values inlined; the reference reads the install's.
|
||||
fn global_string_label(kind: MirrorTimerKind) -> &'static str {
|
||||
match kind {
|
||||
MirrorTimerKind::Fatigue => "Fatigue",
|
||||
@@ -76,23 +54,11 @@ fn global_string_label(kind: MirrorTimerKind) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
/// The bar's caption — **arg6** of `MIRROR_TIMER_START`, and it is **not** a fixed word.
|
||||
///
|
||||
/// Handler `0x5e7990` and its label helper `0x5e7b10`: the client tries the **owning spell's
|
||||
/// localized name first** — `Spell.dbc` `SpellRec + 0x1e0 + 4*locale`, indexed by the START
|
||||
/// packet's `spellId` — and only falls back to the `"<NAME>_LABEL"` global string when there is
|
||||
/// no spell (`spellId == 0`).
|
||||
///
|
||||
/// That correction matters in play, and 0874 had it wrong: a water-breathing effect owns the
|
||||
/// breath timer for its duration (vmangos `UpdateMirrorTimers` starts the timer from
|
||||
/// `GetMirrorTimerBuff(type)` and passes `buff->GetId()`), so while it is up the bar is captioned
|
||||
/// with **the spell's own name**, not "Breath". The bar's colour still keys off the timer name,
|
||||
/// so only the word changes.
|
||||
///
|
||||
/// `spell_name` is the already-resolved catalog lookup (the `ui_cast` idiom: the script VM has no
|
||||
/// spell-catalog binding, so the drain resolves it — one lookup face, decision 0107). `None`
|
||||
/// covers both "no owning spell" and "spell not in the catalog"; the reference's fallback chain
|
||||
/// ends at the global string either way.
|
||||
/// `MIRROR_TIMER_START`'s arg6 (handler `0x5e7990`, label helper `0x5e7b10`): the owning spell's
|
||||
/// localized name (`SpellRec + 0x1e0 + 4*locale`, by the packet's `spellId`), else the
|
||||
/// `<NAME>_LABEL` string. A water-breathing buff owns the breath timer (vmangos
|
||||
/// `UpdateMirrorTimers`, `GetMirrorTimerBuff`), so the bar reads its name; the colour still keys
|
||||
/// off the timer. `spell_name` is `None` for no spell and for one not in the catalog.
|
||||
fn caption(kind: MirrorTimerKind, spell_name: Option<&str>) -> String {
|
||||
spell_name
|
||||
.filter(|n| !n.is_empty())
|
||||
@@ -100,14 +66,11 @@ fn caption(kind: MirrorTimerKind, spell_name: Option<&str>) -> String {
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Drain the queue into the script VM, one FrameScript event per edge.
|
||||
/// Drains the queue into the VM, one event per edge.
|
||||
///
|
||||
/// A `kind` the client has no bar for is **dropped**. The reference does fire the event for one —
|
||||
/// its type→name switch (`0x5e7ae0`) answers `"UNKNOWN"` for anything outside 0..2 — but
|
||||
/// `MirrorTimerColors["UNKNOWN"]` is nil, so `MirrorTimer_Show` errors at the colour read
|
||||
/// *before* `dialog:Show()` and no bar ever appears. Dropping it is the same observable without
|
||||
/// the Lua error, and vanilla never sends one anyway (the server's `NUM_CLIENT_TIMERS` gate keeps
|
||||
/// its fourth timer, `ENVIRONMENTAL`, off the wire entirely).
|
||||
/// Deviation: a type with no bar is dropped, because no bar appears either way: the reference
|
||||
/// fires it as `"UNKNOWN"` (`0x5e7ae0`) and `MirrorTimer_Show` errors on its nil colour before
|
||||
/// `Show()`. vmangos's `NUM_CLIENT_TIMERS` keeps its fourth timer off the wire.
|
||||
fn feed_mirror_timers(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
mut feed: ResMut<MirrorTimerFeed>,
|
||||
@@ -115,12 +78,11 @@ fn feed_mirror_timers(
|
||||
mut tutorials: Option<MessageWriter<crate::tutorial::TutorialEvent>>,
|
||||
) {
|
||||
let Some(mut script) = script else {
|
||||
// No VM (a capture/headless run): drop the edges rather than let them pile up unbounded.
|
||||
// No VM (a capture or headless run): drop the edges so the queue stays bounded.
|
||||
feed.0.clear();
|
||||
return;
|
||||
};
|
||||
// The owning spell's name, resolved here because the script VM has no spell-catalog binding
|
||||
// (the `ui_cast` idiom, decision 0107). `0` = no spell, which is the common case.
|
||||
// The VM has no spell-catalog binding, so the owning spell's name resolves here; 0 is none.
|
||||
let spell_name = |id: u32| -> Option<String> {
|
||||
(id != 0)
|
||||
.then(|| spells.as_ref()?.catalog.get(id).map(|d| d.name.clone()))
|
||||
@@ -134,7 +96,7 @@ fn feed_mirror_timers(
|
||||
let Some(kind) = MirrorTimerKind::from_wire(raw) else {
|
||||
continue;
|
||||
};
|
||||
// The handler's two tutorial arms (`0x5e7ab0` type 0, `0x5e7acd` type 1; 1976).
|
||||
// The handler's two tutorial arms: type 0 (`0x5e7ab0`) and type 1 (`0x5e7acd`).
|
||||
if matches!(edge, MirrorTimerEdge::Start(_)) {
|
||||
match kind {
|
||||
MirrorTimerKind::Fatigue => {
|
||||
@@ -167,33 +129,23 @@ fn feed_mirror_timers(
|
||||
ScriptValue::Str(caption(kind, spell_name(start.spell_id).as_deref())),
|
||||
],
|
||||
),
|
||||
// **This edge reaches a reference body that raises**, and it is left that way on
|
||||
// purpose (1751 window 7). `MirrorTimerFrame_OnEvent` reads `arg1` as the timer name
|
||||
// and then as a number (`arg1 > 0`), comparing a string to a number — 1.12's own bug.
|
||||
// vmangos never sends the packet: it substitutes a full START and says so in
|
||||
// `Player::SendMirrorTimers`, which is presumably how the bug survived. If a server
|
||||
// ever does send one, the raise is the correct loud failure (1203) and the repair
|
||||
// belongs in an adapter over the reference's body, not in a second vocabulary here.
|
||||
// The stock handler raises on this: it matches `arg1` as the timer name, then tests
|
||||
// `arg1 > 0` (`MirrorTimer.lua:84-88`), 1.12's own bug. vmangos never sends it
|
||||
// (`Player::SendMirrorTimers`); a repair belongs over the stock body, not here.
|
||||
MirrorTimerEdge::Pause { paused, .. } => (
|
||||
"MIRROR_TIMER_PAUSE",
|
||||
vec![name, ScriptValue::Int(i64::from(paused))],
|
||||
),
|
||||
MirrorTimerEdge::Stop { .. } => ("MIRROR_TIMER_STOP", vec![name]),
|
||||
};
|
||||
// One line per edge. The bars live inside the script VM, so from outside it a mirror
|
||||
// timer is otherwise unobservable — and "did the server start a breath timer *here*?" is
|
||||
// the first question of any drowning / fatigue / liquid-hazard probe.
|
||||
// One log line per edge: the bars live inside the VM, so this is their only outside trace.
|
||||
debug!("net: mirror timer {event} {edge:?}");
|
||||
script.fire_event(event, args);
|
||||
}
|
||||
}
|
||||
|
||||
/// The mirror-timer UI seam: the queue + its drain, ordered like the cast bar's — before the VM
|
||||
/// ticks, so an edge and its first OnUpdate land on the same frame.
|
||||
/// The mirror timers' packet handlers (decision 0874; in the net handler table since 2313):
|
||||
/// breath / fatigue / feign-death. Pure queue handlers — every meaning (which bar, what colour,
|
||||
/// what caption, how fast it drains) is resolved at the UI seam in this module, and the
|
||||
/// countdown itself is the FrameXML's own OnUpdate integration.
|
||||
/// The mirror timers' packet handlers, which only queue. The drain runs before the VM ticks, so
|
||||
/// an edge and its first `OnUpdate` land on the same frame.
|
||||
mod net {
|
||||
use benilla_protocol::{SessionEvent, SessionEventKind};
|
||||
use bevy::prelude::*;
|
||||
@@ -201,7 +153,6 @@ mod net {
|
||||
use super::{MirrorTimerEdge, MirrorTimerFeed};
|
||||
use crate::net::NetHandlerApp;
|
||||
|
||||
/// Register the handlers — called from [`super::UiMirrorPlugin`].
|
||||
pub(super) fn register(app: &mut App) {
|
||||
use SessionEventKind as K;
|
||||
app.net_handler(K::MirrorTimerStart, on_edge)
|
||||
@@ -236,9 +187,7 @@ impl Plugin for UiMirrorPlugin {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The client's arg1 names, in the server's `MirrorTimer::Type` order — and the fact that
|
||||
/// type 0 is `EXHAUSTION` on the client but `FATIGUE` on the server. Getting this wrong is
|
||||
/// silent: `MirrorTimerColors[timer]` would be nil and the bar's colour read would error.
|
||||
/// A wrong name leaves `MirrorTimerColors[timer]` nil and the bar's colour read errors.
|
||||
#[test]
|
||||
fn arg1_is_the_clients_name_not_the_servers() {
|
||||
assert_eq!(script_name(MirrorTimerKind::Fatigue), "EXHAUSTION");
|
||||
@@ -246,9 +195,7 @@ mod tests {
|
||||
assert_eq!(script_name(MirrorTimerKind::FeignDeath), "FEIGNDEATH");
|
||||
}
|
||||
|
||||
/// With no owning spell the caption is the 1.12 `GlobalStrings.lua` value the `<NAME>_LABEL`
|
||||
/// lookup resolves to — and the feign-death one is empty because that global does not exist
|
||||
/// (the reference's `GetGlobalString` hands back a static empty string, never nil).
|
||||
/// Feign death has no label global, so its caption is empty.
|
||||
#[test]
|
||||
fn a_spell_less_timer_captions_from_the_global_string() {
|
||||
assert_eq!(caption(MirrorTimerKind::Fatigue, None), "Fatigue");
|
||||
@@ -256,16 +203,14 @@ mod tests {
|
||||
assert_eq!(caption(MirrorTimerKind::FeignDeath, None), "");
|
||||
}
|
||||
|
||||
/// The correction 0874 got wrong (`0x5e7b10`): the client tries the OWNING
|
||||
/// SPELL's localized name first and only falls back to the global string. A water-breathing
|
||||
/// effect owns the breath timer while it is up, so the bar reads with the spell's name.
|
||||
/// `0x5e7b10` tries the owning spell's name before the global string.
|
||||
#[test]
|
||||
fn an_owning_spell_captions_the_bar_with_its_own_name() {
|
||||
assert_eq!(
|
||||
caption(MirrorTimerKind::Breath, Some("Water Breathing")),
|
||||
"Water Breathing"
|
||||
);
|
||||
// An id the catalog can't resolve falls back exactly as a spell-less timer does.
|
||||
// An id the catalog cannot resolve falls back as a spell-less timer does.
|
||||
assert_eq!(caption(MirrorTimerKind::Breath, None), "Breath");
|
||||
assert_eq!(caption(MirrorTimerKind::Breath, Some("")), "Breath");
|
||||
}
|
||||
|
||||
@@ -1,18 +1,8 @@
|
||||
//! The connection-telemetry feed — the app side of `benilla_ui::script::net_stats`' seam.
|
||||
//! The latency behind `GetNetStats()`, the main bar's latency meter's one input (`MainMenuBar.xml`
|
||||
//! polls it every 10 s): the average `SMSG_PONG` round trip the net read thread records.
|
||||
//!
|
||||
//! One number: the latency `GetNetStats()` reports, which is the main bar's performance ("ping")
|
||||
//! meter's whole input (stock `MainMenuBar.xml`'s `MainMenuBarPerformanceBarFrame` polls it every 10 s and
|
||||
//! colors the bar green/yellow/red). The measurement itself belongs to the **net read thread** —
|
||||
//! every `SMSG_PONG` is timed where it lands and filed in the connection clock's RTT history at the
|
||||
//! reference's own depth — and this feed only carries its average across the engine boundary (decision
|
||||
//! 0068 §3: the engine never reaches into ECS or the socket). Reading it takes the connection's
|
||||
//! lock, exactly as the reference's `GetNetStats` takes the stats critical section; it is
|
||||
//! uncontended (one writer every 30 s) and this is three words of copy a frame.
|
||||
//!
|
||||
//! The push is unconditional rather than change-gated, for the reason [`crate::minimap`]'s
|
||||
//! containment feed documents: `UiScript` is created when the UI loads, which is *after* a
|
||||
//! connection can already have settled, so a feed that only spoke on change could miss its only
|
||||
//! edge and leave the meter reading 0 forever. Three words of copy a frame is nothing next to that.
|
||||
//! Pushed every frame, not on change: `UiScript` is created after a connection may already have
|
||||
//! settled, so a change-gated feed could miss its only edge and leave the meter at 0.
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
@@ -31,11 +21,10 @@ impl Plugin for UiNetPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
/// Push the averaged round trip behind `GetNetStats()`.
|
||||
fn feed_net_stats(script: Option<NonSendMut<UiScript>>, ping: Res<PingShared>) {
|
||||
let Some(mut script) = script else { return };
|
||||
// Recovered, not unwrapped: the net threads hold this lock too, and a panic there must end
|
||||
// the connection, not the app (decision 2265 §B1).
|
||||
// Recovered, not unwrapped: a panic on a net thread holding this lock ends the connection,
|
||||
// not the app.
|
||||
let latency = ping.0.lock_recover().avg_latency_ms();
|
||||
script.set_latency_ms(latency);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,27 +1,6 @@
|
||||
//! Group/party session state — the `SMSG_GROUP_LIST` wire mirror and the client-composed party
|
||||
//! system lines (decision 0434 §D2).
|
||||
//!
|
||||
//! The 1.12 server sends **no text** for party events (vmangos, exhaustively grepped): joins and
|
||||
//! leaves appear only as roster diffs on `SMSG_GROUP_LIST`, a kick additionally announces itself
|
||||
//! to the kicked player as the empty-body `SMSG_GROUP_UNINVITE`, and `SMSG_PARTY_COMMAND_RESULT`
|
||||
//! acks only the acting player. The real client composes every "%s joins the party." line
|
||||
//! engine-side from those packets against the GlobalStrings templates (the FrameXML never touches
|
||||
//! the `ERR_*` group strings; the mechanism is the reference's errorId→GlobalStrings display,
|
||||
//! `CGGameUI::DisplayError` 0x496720). benilla does the same here: [`GroupState`] mirrors the
|
||||
//! wire, and its `apply_*` methods return the finished lines for the net drain to push as
|
||||
//! CHAT_MSG_SYSTEM. In the mapping (decision 0440), each opcode prints its own line and the
|
||||
//! GROUP_LIST diff prints the rest — there is NO empty-list state machine.
|
||||
//!
|
||||
//! The line law (0440):
|
||||
//! - GROUP_LIST runs an **ungated** two-way roster diff: new guid → "%s joins the party."
|
||||
//! (`0x5e6c19` — including everyone already there on a first roster), vanished guid →
|
||||
//! "%s leaves the party." (`0x5e6d37` — including the whole roster on our own empty list).
|
||||
//! - "You leave the group." comes ONLY from `SMSG_PARTY_COMMAND_RESULT(LEAVE, ok)` (`0x5e690b`).
|
||||
//! - `SMSG_GROUP_UNINVITE` (kick) → "You have been removed from the group.", unconditional.
|
||||
//! - `SMSG_GROUP_DESTROYED` → "Your group has been disbanded.", gated on being grouped. The
|
||||
//! vmangos 2-man collapse sends no GROUP_DESTROYED (`Disband(hideDestroy=true)`,
|
||||
//! `Group.cpp:533`), so the survivor sees only the leave-diff line — rendered faithfully
|
||||
//! (the 0086 server-divergence precedent).
|
||||
//! Group session state: the `SMSG_GROUP_LIST` mirror and the party system lines. vmangos sends no
|
||||
//! text for party events, so, as the reference does through `DisplayError` (`0x496720`), each
|
||||
//! opcode composes its own line and the `SMSG_GROUP_LIST` roster diff composes joins and leaves.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
@@ -55,83 +34,47 @@ impl Plugin for UiPartyPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
/// The party/raid session mirror — `SMSG_GROUP_LIST` verbatim plus the invite/stats side-state.
|
||||
/// Filled by the net drain's group arms, cleared on disconnect beside the other per-login
|
||||
/// resources (decision 0434 §D2). The party *frames* (phase 2) read this through the merged view:
|
||||
/// a streamed member's live `ObjectStore` wins; the [`Self::stats`] snapshot covers the rest.
|
||||
/// The group session mirror: `SMSG_GROUP_LIST` as sent plus its side state, reset on disconnect.
|
||||
#[derive(Resource, Default)]
|
||||
pub struct GroupState {
|
||||
/// True while we hold a roster (any `SMSG_GROUP_LIST` with `leader != 0`; the all-zero
|
||||
/// "you left" shape flips it back off).
|
||||
/// True from any `SMSG_GROUP_LIST` naming a leader until the all-zero "you left" list.
|
||||
pub in_group: bool,
|
||||
/// `0` party, `1` raid (`GROUPTYPE_*`, vmangos `Group.h:116-120`).
|
||||
/// 0 party, 1 raid (vmangos `GroupType`, `Group/Group.h:116-120`).
|
||||
pub group_type: u8,
|
||||
/// Our own subgroup (bits 0-2) + raid-assistant bit (`0x80`).
|
||||
/// Our subgroup (bits 0-2) and raid-assistant bit (`0x80`).
|
||||
pub own_flags: u8,
|
||||
/// The *other* members, wire order (the recipient never appears in its own list).
|
||||
/// The other members in wire order; the list never contains the recipient.
|
||||
pub members: Vec<GroupMemberEntry>,
|
||||
pub leader: u64,
|
||||
/// The loot tail — present whenever the wire list was non-empty.
|
||||
/// The loot tail, present whenever the list has members.
|
||||
pub loot: Option<GroupLootInfo>,
|
||||
/// The inviter's name while an invite popup should be up (`SMSG_GROUP_INVITE` set it; accept/
|
||||
/// decline clears it — phase 2 wires the StaticPopup on top).
|
||||
/// The inviter's name while the invite popup is up.
|
||||
pub pending_invite: Option<String>,
|
||||
/// Out-of-range member snapshots from `SMSG_PARTY_MEMBER_STATS(_FULL)`, keyed by guid. Delta
|
||||
/// packets merge field-wise; `_FULL` replaces. Entries for guids that leave the roster are
|
||||
/// dropped at the next list.
|
||||
/// Each member's record by guid, seated at 1/1 and patched by `SMSG_PARTY_MEMBER_STATS`.
|
||||
pub stats: HashMap<u64, PartyMemberStatsInfo>,
|
||||
/// The raid-target icon board (`MSG_RAID_TARGET_UPDATE`): slot = icon id 0-7 (star..skull),
|
||||
/// value = marked guid, `0` = unset. Consumed by the icon renders in phase 6.
|
||||
/// The marked guid per raid-target icon 0-7 (star to skull), 0 when unset.
|
||||
pub raid_targets: [u64; 8],
|
||||
/// `/partytest` sandbox mode: the roster is synthetic, so the drain applies group-mutating
|
||||
/// intents to this mirror LOCALLY (mimicking the server echo that a real group would send)
|
||||
/// instead of dispatching CMSGs into a void. Any real `SMSG_GROUP_LIST` switches it off —
|
||||
/// the wire always wins.
|
||||
/// `/partytest` sandbox: the drain applies group intents to this mirror; a real list clears it.
|
||||
pub test: bool,
|
||||
/// Our saved raid lockouts (`SMSG_RAID_INSTANCE_INFO`) — the Raid tab's Raid Info panel
|
||||
/// (decision 1549). The answer replaces the list wholesale, empty included.
|
||||
///
|
||||
/// A lockout is per-CHARACTER, not per-group, so this is the one field here that is not a
|
||||
/// group fact. It lives here anyway for the reason that matters: it is per-SESSION state that
|
||||
/// must die with the socket, and [`Self::clear_session`] is that guarantee — a second
|
||||
/// resource would be a second thing to remember to clear. Leaving the group does NOT clear it
|
||||
/// ([`Self::leave_group`]); nor the four ticket fields below.
|
||||
/// Our raid lockouts, the character's rather than the group's: leaving the group keeps them.
|
||||
pub saved_instances: Vec<benilla_protocol::messages::RaidInstanceEntry>,
|
||||
/// **How many times the server has answered** — one per `SMSG_RAID_INSTANCE_INFO`. A TICKET,
|
||||
/// not a flag (the [`Self::ready_check`] shape), and the difference is load-bearing: the
|
||||
/// reference decides its Raid Info button on the SECOND `UPDATE_INSTANCE_INFO`, because
|
||||
/// `RaidFrame.hasRaidInfo` swallows the first. A feed that fires that event on a *diff* never
|
||||
/// reaches the second for the ordinary player — their lockout list is empty, every answer says
|
||||
/// so, nothing ever changes, and the button they should not be able to press stays live (1561).
|
||||
///
|
||||
/// The real client fires per PACKET, not per change. The handler at
|
||||
/// `0x49e070` takes `jbe 0x49e19d` (`0x49e0d8`) when the entry count is zero — straight past
|
||||
/// the parse loop to `mov ecx, 0x21b` (539 = `UPDATE_INSTANCE_INFO`) and the one
|
||||
/// `call FrameScript_SignalEvent 0x703e50` at `0x49e1a7`, which is the function's only exit
|
||||
/// and the event's only fire site in the binary. The empty answer signals exactly like a full
|
||||
/// one.
|
||||
/// How many `SMSG_RAID_INSTANCE_INFO` answers arrived, empty ones included. The reference fires
|
||||
/// `UPDATE_INSTANCE_INFO` once per packet (`0x49e1a7`, straight from `0x49e0d8` when empty)
|
||||
/// and `RaidFrame.lua:43` ignores the first, so the Raid Info button is decided on the second.
|
||||
pub saved_instances_answers: u32,
|
||||
/// A ready-check TICKET, not a flag: every `MSG_RAID_READY_CHECK` open bumps it, so the feed
|
||||
/// fires `READY_CHECK` on a counter edge and a second check while the first popup is still up
|
||||
/// re-arms it. A boolean could not tell the two apart.
|
||||
/// Bumped by each ready check that pops for us; a count, so a second one still fires.
|
||||
pub ready_check: u32,
|
||||
/// The request GENERATION — bumped by BOTH arms of the open form (decision 1989), unlike the
|
||||
/// ticket above, which only the non-leader arm bumps: the leader never gets the popup, because
|
||||
/// `0x4ba360`'s leader arm never reaches the `READY_CHECK` fire. The feed turns an edge here
|
||||
/// into the engine's `ready_check_request` and resets its answer cursor.
|
||||
/// Bumped by every ready-check open, ours as leader included, where [`Self::ready_check`] skips
|
||||
/// the leader's (`0x4ba360`'s leader arm never reaches the `READY_CHECK` fire at `0x4ba53a`).
|
||||
pub ready_check_requests: u32,
|
||||
/// The answers forwarded to us as leader since the last request — `{guid, ready}` in wire
|
||||
/// order. A log the feed replays into the engine from its own cursor, append-only within one
|
||||
/// check so the feed never has to mutate this state; the next request clears it.
|
||||
/// The answers forwarded to us as leader, append-only until the next request clears them.
|
||||
pub ready_check_answers: Vec<(u64, bool)>,
|
||||
}
|
||||
|
||||
impl GroupState {
|
||||
/// Apply one `SMSG_GROUP_LIST` and compose the system lines its roster diff implies. The
|
||||
/// diff is **ungated** (the 0440 byte law, `0x5e6c19`/`0x5e6d37`): the cache starts empty,
|
||||
/// so a first roster prints a join line for every member already there, and the all-zero
|
||||
/// "you left" shape prints a leave line per stale member — every observable composite
|
||||
/// (kick, voluntary leave, disband) is per-opcode lines stacking over this one diff.
|
||||
/// Apply one `SMSG_GROUP_LIST` and return its roster diff's lines. As in the reference
|
||||
/// (`0x5e6c19`, `0x5e6d37`) the diff starts from an empty cache and runs both ways, the
|
||||
/// all-zero list included; kick, leave (`0x5e690b`) and disband lines come from their opcodes.
|
||||
pub fn apply_list(
|
||||
&mut self,
|
||||
group_type: u8,
|
||||
@@ -141,13 +84,12 @@ impl GroupState {
|
||||
loot: Option<GroupLootInfo>,
|
||||
) -> Vec<UiError> {
|
||||
let mut lines = Vec::new();
|
||||
// The all-zero shape is "you are no longer in a group" (vmangos sends a default packet,
|
||||
// `Group.h:257`); `leader == 0` is its reliable discriminator (a live group always names
|
||||
// one, and a solo-leader group has an empty member list *with* a leader guid).
|
||||
// The all-zero list means "not in a group" (vmangos `Server/Packets/Group.h:257`); only
|
||||
// `leader == 0` tells it apart, since a solo leader's list is empty but names a leader.
|
||||
let leaving = leader == 0;
|
||||
// Raid wording keys off whichever side of the transition was a raid. INTERIM (0440):
|
||||
// the party line's `groupType==0` gate is pinned; the raid twin's exact trigger is not
|
||||
// known.
|
||||
// Raid wording when either side of the transition is a raid. The reference's raid lines
|
||||
// come from its raid roster rebuild `0x4ba5f0` and raid-leave leg `0x4ba550`, whose
|
||||
// triggers this diff does not follow.
|
||||
let (added, removed) = if group_type == 1 || (leaving && self.group_type == 1) {
|
||||
("ERR_RAID_MEMBER_ADDED_S", "ERR_RAID_MEMBER_REMOVED_S")
|
||||
} else {
|
||||
@@ -167,25 +109,17 @@ impl GroupState {
|
||||
self.leave_group();
|
||||
return lines;
|
||||
}
|
||||
// Our own party→raid conversion (or joining straight into a raid). INTERIM like the
|
||||
// wording pick above.
|
||||
// Entering a raid prints `ERR_RAID_YOU_JOINED`, as the reference does when its raid
|
||||
// roster was empty (`0x4ba83a`).
|
||||
if group_type == 1 && self.group_type != 1 {
|
||||
lines.push(UiError::key("ERR_RAID_YOU_JOINED"));
|
||||
}
|
||||
// The OTHER direction clears the raid-target board. `0x4ba550` — sole caller `0x5e6ebb`,
|
||||
// inside `SMSG_GROUP_LIST 0x7d`'s raid-flag-CLEAR leg — `rep stosd`-zeroes all eight slots
|
||||
// and refreshes every formerly-marked unit (decision 1820). Without it a raid→party
|
||||
// conversion leaves stale marks on screen: the icons are drawn from this board, and
|
||||
// nothing else clears it short of a `0x321` for each slot, which the server does not send.
|
||||
//
|
||||
// A full disband needs no arm here — `leaving` above resets every group fact
|
||||
// ([`Self::leave_group`]), and the board is one. 1820 recorded this as "stale icons
|
||||
// after a disband", which was wider than the truth; the conversion is the real gap.
|
||||
// Raid to party clears the raid-target board, as the reference's `0x4ba550` does on the
|
||||
// raid-flag-clear leg; a disband clears it in `leave_group`.
|
||||
if group_type != 1 && self.group_type == 1 {
|
||||
self.raid_targets = [0; 8];
|
||||
}
|
||||
|
||||
// Drop stats snapshots for members no longer on the roster.
|
||||
self.stats
|
||||
.retain(|guid, _| members.iter().any(|m| m.guid == *guid));
|
||||
|
||||
@@ -195,16 +129,13 @@ impl GroupState {
|
||||
self.members = members;
|
||||
self.leader = leader;
|
||||
self.loot = loot;
|
||||
// A list apply is the real wire by default; `synthetic_roster` re-raises the flag after
|
||||
// its own call — so a real group arriving mid-sandbox always wins.
|
||||
// A real list ends the sandbox; `synthetic_roster` re-raises the flag after its own call.
|
||||
self.test = false;
|
||||
lines
|
||||
}
|
||||
|
||||
/// The `party1..party4` slot view (byte law `0x5e6baa`, 0440): members of our OWN subgroup
|
||||
/// only, packet order, at most four. In a plain party every flags byte is 0, so the filter
|
||||
/// is a no-op; in a raid it keeps the compact frames on your own subgroup. Slot↔member
|
||||
/// association is rebuilt per packet — never assume it's stable across resyncs.
|
||||
/// The `party1..party4` slots: our own subgroup in packet order, at most four (`0x5e6baa`
|
||||
/// compares the flags under `0x7f`). A slot's member can change on any resync.
|
||||
pub fn party_slots(&self) -> impl Iterator<Item = &GroupMemberEntry> {
|
||||
let own = self.own_flags & 0x7f;
|
||||
self.members
|
||||
@@ -213,28 +144,24 @@ impl GroupState {
|
||||
.take(4)
|
||||
}
|
||||
|
||||
/// `SMSG_GROUP_INVITE` — someone asked us in. Arms the pending invite (the popup rides it)
|
||||
/// and composes the chat-side notice line (CONFIRMED 0440: the real handler `0x5e6730`
|
||||
/// fires ERR_INVITED_TO_GROUP_S in addition to the popup's event).
|
||||
/// `SMSG_GROUP_INVITE`: the popup plus a chat line, as the reference's `0x5e6730` does.
|
||||
pub fn apply_invited(&mut self, inviter: &str) -> Vec<UiError> {
|
||||
self.pending_invite = Some(inviter.to_string());
|
||||
vec![UiError::s("ERR_INVITED_TO_GROUP_S", inviter)]
|
||||
}
|
||||
|
||||
/// `SMSG_GROUP_DECLINE` — our invitee said no (sent to the inviter only).
|
||||
/// `SMSG_GROUP_DECLINE`: our invitee said no; only the inviter gets it.
|
||||
pub fn apply_declined(&mut self, name: &str) -> Vec<UiError> {
|
||||
vec![UiError::s("ERR_DECLINE_GROUP_S", name)]
|
||||
}
|
||||
|
||||
/// `SMSG_GROUP_UNINVITE` (empty body) — we were kicked. Prints ERR_UNINVITE_YOU
|
||||
/// **unconditionally**, at the opcode (0440: handler `0x5e6850`); the empty roster echo
|
||||
/// that follows adds its own leave-diff lines, exactly like the reference.
|
||||
/// `SMSG_GROUP_UNINVITE`: we were kicked; prints unconditionally (`0x5e6850`).
|
||||
pub fn apply_uninvited(&mut self) -> Vec<UiError> {
|
||||
vec![UiError::key("ERR_UNINVITE_YOU")]
|
||||
}
|
||||
|
||||
/// `SMSG_GROUP_DESTROYED` — the group is gone outright. Gated on believing we're grouped
|
||||
/// (0440: `0x5e6880` tests `0x4e86d0() != 0`); the echo list's own lines still follow.
|
||||
/// `SMSG_GROUP_DESTROYED`: prints only while grouped (`0x5e6880` tests `0x4e86d0() != 0`).
|
||||
/// vmangos's two-member collapse sends none (`Group/Group.cpp:533`).
|
||||
pub fn apply_destroyed(&mut self) -> Vec<UiError> {
|
||||
if self.in_group {
|
||||
vec![UiError::key("ERR_GROUP_DISBANDED")]
|
||||
@@ -243,8 +170,7 @@ impl GroupState {
|
||||
}
|
||||
}
|
||||
|
||||
/// `SMSG_GROUP_SET_LEADER` — the broadcast leader change, by name (the guid is already in
|
||||
/// our roster; vmangos re-sends the full list right after, `Group::ChangeLeader`).
|
||||
/// `SMSG_GROUP_SET_LEADER`; vmangos sends the full list right after (`Group::ChangeLeader`).
|
||||
pub fn apply_leader_changed(&mut self, name: &str, own_name: Option<&str>) -> Vec<UiError> {
|
||||
if own_name == Some(name) {
|
||||
vec![UiError::key("ERR_NEW_LEADER_YOU")]
|
||||
@@ -253,43 +179,10 @@ impl GroupState {
|
||||
}
|
||||
}
|
||||
|
||||
/// `SMSG_PARTY_COMMAND_RESULT` — **the reference's own jump table, transcribed**.
|
||||
///
|
||||
/// The handler `0x5e68b0` dispatches `dec eax; cmp eax,7; ja <silent>; jmp [4*eax+0x5e6a14]`,
|
||||
/// and each arm is one `CGGameUI::DisplayError(msgId)`. Returning the **message** rather than
|
||||
/// a sentence is what gets the text, the surface and the sound right at once, since all three
|
||||
/// are fields of the catalog row the id names (decisions 1770, 1815, 2035).
|
||||
///
|
||||
/// | `result` | msgId | key | `%s` |
|
||||
/// |---|---|---|---|
|
||||
/// | `0` + op `0` + non-empty name | `0x3a` | `ERR_INVITE_PLAYER_S` | name |
|
||||
/// | `0` + op `2` | `0x42` | `ERR_LEFT_GROUP_YOU` | — |
|
||||
/// | 1 | `0x47` | `ERR_BAD_PLAYER_NAME_S` | name |
|
||||
/// | 2 | `0x49` | `ERR_TARGET_NOT_IN_GROUP_S` | name |
|
||||
/// | 3 | `0x4a` | `ERR_GROUP_FULL` | — |
|
||||
/// | 4 | `0x3d` | `ERR_ALREADY_IN_GROUP_S` | name |
|
||||
/// | 5 | `0x48` | `ERR_NOT_IN_GROUP` | — |
|
||||
/// | 6 | `0x4b` | `ERR_NOT_LEADER` | — |
|
||||
/// | 7 | `0xff` | `ERR_PLAYER_WRONG_FACTION` | — |
|
||||
/// | 8 | `0x13d` | `ERR_IGNORING_YOU_S` | name |
|
||||
///
|
||||
/// **`result == 7` is the odd one and we had it wrong:** its row is kind **2**, the red
|
||||
/// `UIErrorsFrame` line, where the other nine are kind 0 chat lines. benilla pushed all ten
|
||||
/// into the chat log.
|
||||
///
|
||||
/// **Three inputs are SILENT**, and the reference means it — the default arm is four
|
||||
/// instructions, `mov eax,1; mov esp,ebp; pop ebp; ret 8`, with no call, no store and no
|
||||
/// message:
|
||||
///
|
||||
/// 1. `result >= 9` — benilla printed an invented `"Party command failed (N)."` here, which is
|
||||
/// worse than mis-wording a real string: the client deliberately says nothing.
|
||||
/// 2. `result == 0` with `operation ∉ {0, 2}`.
|
||||
/// 3. `result == 0`, `operation == 0`, and an **empty** name (`0x5e6923 je`) — benilla emitted
|
||||
/// the invite line unconditionally.
|
||||
///
|
||||
/// `operation` is read **only** on the `result == 0` path; on every other it is not touched.
|
||||
/// Takes `&self`: this arm reads the wire and names a message, and changes no state — the
|
||||
/// roster moves on `SMSG_GROUP_LIST`, not on the ack.
|
||||
/// `SMSG_PARTY_COMMAND_RESULT` through the reference's jump table (`0x5e6a14`): one message
|
||||
/// per result, its catalog row giving text, surface and sound (result 7 is the red error line,
|
||||
/// the rest chat). Silent, as in the reference: results past 8, `OK` for an operation other
|
||||
/// than invite or leave, and an invite ack with no name (`0x5e6923`).
|
||||
pub fn apply_command_result(
|
||||
&self,
|
||||
operation: u32,
|
||||
@@ -299,8 +192,7 @@ impl GroupState {
|
||||
let named = |key: &'static str| UiError::s(key, member);
|
||||
match result {
|
||||
party_result::OK => match operation {
|
||||
// The empty-name guard is the reference's, not defensiveness: an invite ack that
|
||||
// carries no name prints nothing at all.
|
||||
// The reference's guard: an invite ack with no name prints nothing.
|
||||
party_operation::INVITE if !member.is_empty() => Some(named("ERR_INVITE_PLAYER_S")),
|
||||
party_operation::LEAVE => Some(UiError::key("ERR_LEFT_GROUP_YOU")),
|
||||
_ => None,
|
||||
@@ -317,8 +209,7 @@ impl GroupState {
|
||||
}
|
||||
}
|
||||
|
||||
/// `SMSG_PARTY_MEMBER_STATS(_FULL)` — merge (delta) or replace (`full`) one member's
|
||||
/// out-of-range snapshot.
|
||||
/// `SMSG_PARTY_MEMBER_STATS`: a delta merges field by field, `_FULL` replaces the record.
|
||||
pub fn apply_stats(&mut self, guid: u64, full: bool, info: PartyMemberStatsInfo) {
|
||||
if full {
|
||||
self.stats.insert(guid, info);
|
||||
@@ -365,14 +256,14 @@ impl GroupState {
|
||||
}
|
||||
}
|
||||
|
||||
/// `MSG_RAID_TARGET_UPDATE` mode 0 — one icon changed (`guid == 0` clears it).
|
||||
/// `MSG_RAID_TARGET_UPDATE` mode 0: one icon, cleared by guid 0.
|
||||
pub fn apply_raid_target(&mut self, icon: u8, guid: u64) {
|
||||
if let Some(slot) = self.raid_targets.get_mut(icon as usize) {
|
||||
*slot = guid;
|
||||
}
|
||||
}
|
||||
|
||||
/// `MSG_RAID_TARGET_UPDATE` mode 1 — the whole board (absent icons are unset).
|
||||
/// `MSG_RAID_TARGET_UPDATE` mode 1: the whole board; an absent icon is unset.
|
||||
pub fn apply_raid_target_list(&mut self, entries: &[(u8, u64)]) {
|
||||
self.raid_targets = [0; 8];
|
||||
for (icon, guid) in entries {
|
||||
@@ -380,9 +271,7 @@ impl GroupState {
|
||||
}
|
||||
}
|
||||
|
||||
/// The mark on a unit, from the board: `1..=8` (the Lua `GetRaidTargetIndex` scale), `0`
|
||||
/// unmarked. The board stores one guid per icon slot, so this is the reverse lookup the
|
||||
/// per-unit snapshots feed from.
|
||||
/// A unit's mark on the Lua `GetRaidTargetIndex` scale, 1 to 8, or 0 when unmarked.
|
||||
pub fn raid_target_index(&self, guid: u64) -> u8 {
|
||||
if guid == 0 {
|
||||
return 0;
|
||||
@@ -393,22 +282,12 @@ impl GroupState {
|
||||
.map_or(0, |i| i as u8 + 1)
|
||||
}
|
||||
|
||||
/// The all-zero `SMSG_GROUP_LIST` — we are no longer in a group. Resets the **group facts**
|
||||
/// and nothing else.
|
||||
///
|
||||
/// This used to be `*self = GroupState::default()`, which is [`Self::clear_session`]'s job,
|
||||
/// and it took the per-SESSION tickets down with the roster: the feed fires on edges of those
|
||||
/// tickets, so every leave, kick or disband fired a `READY_CHECK` (a popup naming no leader,
|
||||
/// and a 30 s request armed for a check nobody started) and an `UPDATE_INSTANCE_INFO` over an
|
||||
/// emptied lockout list. In the reference `READY_CHECK` has one fire site, the
|
||||
/// `MSG_RAID_READY_CHECK` open handler (decision 1989), and a lockout belongs to the character.
|
||||
///
|
||||
/// **The destructure is exhaustive on purpose** — no `..`: a field added to [`GroupState`]
|
||||
/// does not compile until someone decides here which side of that line it is on. The defaulted
|
||||
/// reset that caused this bug classified every new field silently.
|
||||
/// The all-zero `SMSG_GROUP_LIST`: resets the group facts only, since the feed fires events on
|
||||
/// the ready-check and lockout state's edges. The destructure has no `..`, so a new field does
|
||||
/// not compile until it is sorted onto one side.
|
||||
pub(super) fn leave_group(&mut self) {
|
||||
let GroupState {
|
||||
// Group facts: the roster, its side-state, the sandbox (the wire always wins).
|
||||
// Group facts.
|
||||
in_group,
|
||||
group_type,
|
||||
own_flags,
|
||||
@@ -418,11 +297,10 @@ impl GroupState {
|
||||
stats,
|
||||
raid_targets,
|
||||
test,
|
||||
// An invite is an offer into a group we are not in yet; it is cleared with the group
|
||||
// as it always was here (UNVERIFIED either way — vmangos sends invitees nothing on a
|
||||
// disband, so the case needs a leave to race an invite popup).
|
||||
// Cleared with the group. The reference's side is untraced; vmangos tells an invitee
|
||||
// nothing on a disband (`Group::RemoveAllInvites`).
|
||||
pending_invite,
|
||||
// Session tickets — the feed's edges. Kept; `clear_session` is where they die.
|
||||
// Session state the feed fires edges on: kept until `clear_session`.
|
||||
saved_instances: _,
|
||||
saved_instances_answers: _,
|
||||
ready_check: _,
|
||||
@@ -441,15 +319,12 @@ impl GroupState {
|
||||
*pending_invite = None;
|
||||
}
|
||||
|
||||
/// Session teardown (decision 0065's lifecycle): everything resets with the socket.
|
||||
/// Session teardown: everything resets with the socket.
|
||||
pub fn clear_session(&mut self) {
|
||||
*self = GroupState::default();
|
||||
}
|
||||
|
||||
/// `SMSG_RAID_INSTANCE_INFO` — our saved lockouts, replacing the list wholesale (decision
|
||||
/// 1549). **The empty answer is the ordinary one and still counts as an answer**: it bumps the
|
||||
/// ticket like any other, which is what makes the feed fire `UPDATE_INSTANCE_INFO` for a
|
||||
/// player who has no lockouts at all — see [`GroupState::saved_instances_answers`] (1561).
|
||||
/// `SMSG_RAID_INSTANCE_INFO`: replaces the lockouts and counts the answer, even an empty one.
|
||||
pub fn apply_raid_instance_info(
|
||||
&mut self,
|
||||
entries: Vec<benilla_protocol::messages::RaidInstanceEntry>,
|
||||
@@ -458,12 +333,9 @@ impl GroupState {
|
||||
self.saved_instances_answers = self.saved_instances_answers.wrapping_add(1);
|
||||
}
|
||||
|
||||
/// `MSG_RAID_READY_CHECK` (open form) — the leader started one, and the server echoes the
|
||||
/// request to every member including the leader. The handler `0x4ba360` splits on the leader
|
||||
/// guid (decision 1989): the **leader** takes the response-collection arm and neither prints
|
||||
/// nor pops; everyone **else** prints `ERR_RAID_LEADER_READY_CHECK_START_S` with the leader's
|
||||
/// name and gets the popup — the ticket the feed turns into a `READY_CHECK` event edge. Both
|
||||
/// arms bump the request generation and start a fresh answer log.
|
||||
/// `MSG_RAID_READY_CHECK`, open form, echoed to the whole group: `0x4ba360` splits on the
|
||||
/// leader guid, and only a non-leader prints the leader's line and gets the popup. Both arms
|
||||
/// count the request and start a fresh answer log.
|
||||
pub fn apply_ready_check_request(&mut self, we_lead: bool) -> Vec<UiError> {
|
||||
self.ready_check_requests = self.ready_check_requests.wrapping_add(1);
|
||||
self.ready_check_answers.clear();
|
||||
@@ -471,8 +343,7 @@ impl GroupState {
|
||||
return Vec::new();
|
||||
}
|
||||
self.ready_check = self.ready_check.wrapping_add(1);
|
||||
// The reference resolves the leader's name from its cache and prints whatever it holds;
|
||||
// ours is the roster's name for the leader's guid, which is the same cache's content.
|
||||
// The reference prints its name cache's entry for the leader; the roster holds the same.
|
||||
let leader = self
|
||||
.members
|
||||
.iter()
|
||||
@@ -482,8 +353,7 @@ impl GroupState {
|
||||
vec![UiError::s("ERR_RAID_LEADER_READY_CHECK_START_S", leader)]
|
||||
}
|
||||
|
||||
/// `MSG_RAID_READY_CHECK` (answer form) — one member's answer, which the server forwards to
|
||||
/// the leader alone. Logged for the feed to replay into the engine's flags (decision 1989).
|
||||
/// `MSG_RAID_READY_CHECK`, answer form: one member's answer, forwarded to the leader alone.
|
||||
pub fn apply_ready_check_answer(&mut self, guid: u64, ready: bool) {
|
||||
self.ready_check_answers.push((guid, ready));
|
||||
}
|
||||
@@ -502,18 +372,11 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// **Every answer is an answer, including the one that says nothing changed** (1561). This is
|
||||
/// the invariant the whole Raid Info button hangs off: the reference throws its first
|
||||
/// `UPDATE_INSTANCE_INFO` away and decides the button on the second, so an empty list that
|
||||
/// answers a second time has to be distinguishable from an empty list that answered once. A
|
||||
/// flag cannot; a ticket can. Revert this to a `bool` and a player with no lockouts keeps a
|
||||
/// live button onto an empty panel — which is exactly how it shipped.
|
||||
#[test]
|
||||
fn an_unchanged_lockout_list_still_counts_as_an_answer() {
|
||||
let mut g = GroupState::default();
|
||||
assert_eq!(g.saved_instances_answers, 0, "nobody has asked yet");
|
||||
|
||||
// The ordinary player: no lockouts, and the server says so every time it is asked.
|
||||
g.apply_raid_instance_info(Vec::new());
|
||||
assert_eq!(g.saved_instances_answers, 1);
|
||||
g.apply_raid_instance_info(Vec::new());
|
||||
@@ -522,7 +385,6 @@ mod tests {
|
||||
"the second empty answer is the one the button is decided on"
|
||||
);
|
||||
|
||||
// And a list that DOES change is not counted twice for it.
|
||||
g.apply_raid_instance_info(vec![benilla_protocol::messages::RaidInstanceEntry {
|
||||
map: 409,
|
||||
reset: 86_400,
|
||||
@@ -531,19 +393,11 @@ mod tests {
|
||||
assert_eq!(g.saved_instances_answers, 3);
|
||||
assert_eq!(g.saved_instances.len(), 1, "and the list is the new one");
|
||||
|
||||
// The socket dies, the ticket dies with it — a fresh session has not been answered.
|
||||
g.clear_session();
|
||||
assert_eq!(g.saved_instances_answers, 0);
|
||||
assert!(g.saved_instances.is_empty());
|
||||
}
|
||||
|
||||
/// **Leaving the group is not the socket dying.** The all-zero `SMSG_GROUP_LIST` clears the
|
||||
/// roster — and ONLY the roster. The ready-check and saved-instance fields are per-session
|
||||
/// tickets the feed fires events on edges of: resetting them here made every leave, kick or
|
||||
/// disband look like a new `READY_CHECK` (a popup with no leader, and a stale 30 s request
|
||||
/// armed) and a fresh `UPDATE_INSTANCE_INFO` carrying an emptied lockout list — a blank Raid
|
||||
/// Info panel. In the reference `READY_CHECK` has one fire site, the `MSG_RAID_READY_CHECK`
|
||||
/// open handler (decision 1989); a lockout is the character's, not the group's.
|
||||
#[test]
|
||||
fn leaving_the_group_keeps_the_session_tickets() {
|
||||
let lockout = benilla_protocol::messages::RaidInstanceEntry {
|
||||
@@ -566,12 +420,10 @@ mod tests {
|
||||
|
||||
g.apply_list(0, 0, vec![], 0, None);
|
||||
|
||||
// The group facts are gone …
|
||||
assert!(!g.in_group);
|
||||
assert!(g.members.is_empty());
|
||||
assert_eq!(g.leader, 0);
|
||||
assert_eq!(g.raid_targets, [0; 8]);
|
||||
// … the session's tickets are not: no edge for the feed to fire on.
|
||||
assert_eq!(
|
||||
(g.ready_check, g.ready_check_requests),
|
||||
(1, 1),
|
||||
@@ -584,17 +436,11 @@ mod tests {
|
||||
"the lockouts are the character's"
|
||||
);
|
||||
|
||||
// The socket's end is still the one place they die.
|
||||
g.clear_session();
|
||||
assert_eq!((g.ready_check, g.saved_instances_answers), (0, 0));
|
||||
assert!(g.saved_instances.is_empty());
|
||||
}
|
||||
|
||||
/// The ungated diff (0440 byte law): a FIRST roster prints joins for everyone already
|
||||
/// there; later rosters diff both ways.
|
||||
/// The open form's two arms (decision 1989): the leader's own echo neither prints nor pops;
|
||||
/// a member prints the leader's line and takes the popup ticket. Both bump the request
|
||||
/// generation and restart the answer log.
|
||||
#[test]
|
||||
fn the_open_form_pops_for_a_member_and_stays_quiet_for_the_leader() {
|
||||
let mut g = GroupState {
|
||||
@@ -631,19 +477,16 @@ mod tests {
|
||||
#[test]
|
||||
fn join_lines_come_from_roster_diffs() {
|
||||
let mut g = GroupState::default();
|
||||
// Our first list — we just accepted; Alice was already there → her join line prints
|
||||
// (the real client's cache starts empty, 0x5e6c19).
|
||||
// Our first list prints Alice's join: the reference's cache starts empty (`0x5e6c19`).
|
||||
assert_eq!(
|
||||
g.apply_list(0, 0, vec![member("Alice", 1)], 1, None),
|
||||
vec![UiError::s("ERR_JOINED_GROUP_S", "Alice")]
|
||||
);
|
||||
assert!(g.in_group);
|
||||
// Carol joins.
|
||||
assert_eq!(
|
||||
g.apply_list(0, 0, vec![member("Alice", 1), member("Carol", 3)], 1, None),
|
||||
vec![UiError::s("ERR_JOINED_GROUP_S", "Carol")]
|
||||
);
|
||||
// Carol leaves (3→2).
|
||||
assert_eq!(
|
||||
g.apply_list(0, 0, vec![member("Alice", 1)], 1, None),
|
||||
vec![UiError::s("ERR_LEFT_GROUP_S", "Carol")]
|
||||
@@ -654,10 +497,9 @@ mod tests {
|
||||
.is_empty());
|
||||
}
|
||||
|
||||
/// Per-opcode lines stack over the diff (0440) — no empty-list state machine.
|
||||
#[test]
|
||||
fn leave_kick_disband_lines_stack_per_opcode() {
|
||||
// Voluntary: the LEAVE ack prints; the empty list adds the leave-diff.
|
||||
// Voluntary: the leave ack prints; the empty list adds the leave lines.
|
||||
let mut g = GroupState::default();
|
||||
g.apply_list(0, 0, vec![member("Alice", 1)], 1, None);
|
||||
assert_eq!(
|
||||
@@ -670,7 +512,7 @@ mod tests {
|
||||
);
|
||||
assert!(!g.in_group);
|
||||
|
||||
// Kicked: SMSG_GROUP_UNINVITE prints unconditionally at the opcode.
|
||||
// Kicked: `SMSG_GROUP_UNINVITE` prints unconditionally.
|
||||
let mut g = GroupState::default();
|
||||
g.apply_list(0, 0, vec![member("Alice", 1)], 1, None);
|
||||
assert_eq!(g.apply_uninvited(), vec![UiError::key("ERR_UNINVITE_YOU")]);
|
||||
@@ -679,7 +521,7 @@ mod tests {
|
||||
vec![UiError::s("ERR_LEFT_GROUP_S", "Alice")]
|
||||
);
|
||||
|
||||
// Destroyed: GROUP_DESTROYED prints (grouped), the echo list still runs its diff.
|
||||
// Destroyed while grouped: it prints, and the empty list still runs its diff.
|
||||
let mut g = GroupState::default();
|
||||
g.apply_list(0, 0, vec![member("Alice", 1)], 1, None);
|
||||
assert_eq!(
|
||||
@@ -690,10 +532,10 @@ mod tests {
|
||||
g.apply_list(0, 0, Vec::new(), 0, None),
|
||||
vec![UiError::s("ERR_LEFT_GROUP_S", "Alice")]
|
||||
);
|
||||
// Ungrouped, GROUP_DESTROYED is silent (the 0x4e86d0 gate).
|
||||
// Ungrouped, it is silent (the `0x4e86d0` gate).
|
||||
assert!(g.apply_destroyed().is_empty());
|
||||
|
||||
// The 2-man collapse (vmangos sends no GROUP_DESTROYED): just the leave-diff line.
|
||||
// vmangos's two-member collapse sends no `SMSG_GROUP_DESTROYED`: just the leave line.
|
||||
let mut g = GroupState::default();
|
||||
g.apply_list(0, 0, vec![member("Alice", 1)], 1, None);
|
||||
assert_eq!(
|
||||
@@ -702,17 +544,15 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Raid wording: diffs use the raid strings, and the conversion prints the you-joined line.
|
||||
#[test]
|
||||
fn raid_wording_and_conversion() {
|
||||
let mut g = GroupState::default();
|
||||
g.apply_list(0, 0, vec![member("Alice", 1)], 1, None);
|
||||
// Party → raid conversion: same roster, type flips.
|
||||
// Party to raid: same roster, the type flips.
|
||||
assert_eq!(
|
||||
g.apply_list(1, 0, vec![member("Alice", 1)], 1, None),
|
||||
vec![UiError::key("ERR_RAID_YOU_JOINED")]
|
||||
);
|
||||
// A raid join/leave uses the raid strings.
|
||||
assert_eq!(
|
||||
g.apply_list(1, 0, vec![member("Alice", 1), member("Dave", 4)], 1, None),
|
||||
vec![UiError::s("ERR_RAID_MEMBER_ADDED_S", "Dave")]
|
||||
@@ -722,8 +562,7 @@ mod tests {
|
||||
lines.pop(),
|
||||
Some(UiError::s("ERR_RAID_MEMBER_REMOVED_S", "Dave"))
|
||||
);
|
||||
// Leaving the raid: the ack prints the LEAVE line; the empty list diffs with raid
|
||||
// wording (keyed off the departed group's type).
|
||||
// Leaving the raid: the empty list's diff keeps the departed raid's wording.
|
||||
assert_eq!(
|
||||
g.apply_command_result(party_operation::LEAVE, "Us", party_result::OK),
|
||||
Some(UiError::key("ERR_LEFT_GROUP_YOU"))
|
||||
@@ -734,20 +573,18 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The party1..4 slot view filters to our own subgroup (0440: `(flags^own)&0x7f`).
|
||||
#[test]
|
||||
fn party_slots_filter_to_own_subgroup() {
|
||||
let mut g = GroupState::default();
|
||||
let mut m2 = member("Bob", 2);
|
||||
m2.flags = 0x01; // subgroup 1
|
||||
let mut m3 = member("Carol", 3);
|
||||
m3.flags = 0x80; // subgroup 0, assistant — the 0x80 bit is ignored by the filter
|
||||
m3.flags = 0x80; // subgroup 0, assistant: the filter ignores 0x80
|
||||
g.apply_list(1, 0x00, vec![member("Alice", 1), m2, m3], 1, None);
|
||||
let slots: Vec<&str> = g.party_slots().map(|m| m.name.as_str()).collect();
|
||||
assert_eq!(slots, vec!["Alice", "Carol"]);
|
||||
}
|
||||
|
||||
/// Invite-side lines: the ack, the error table, the decline, the inbound invite.
|
||||
#[test]
|
||||
fn invite_lines() {
|
||||
let mut g = GroupState::default();
|
||||
@@ -778,7 +615,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Leader lines: name match against our own name picks the YOU form.
|
||||
#[test]
|
||||
fn leader_lines() {
|
||||
let mut g = GroupState::default();
|
||||
@@ -792,7 +628,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Stats: deltas merge field-wise, FULL replaces, roster departure drops the snapshot.
|
||||
#[test]
|
||||
fn stats_merge_and_retention() {
|
||||
let mut g = GroupState::default();
|
||||
@@ -830,12 +665,10 @@ mod tests {
|
||||
assert_eq!(s.cur_hp, Some(70));
|
||||
assert_eq!(s.max_hp, None, "FULL replaces the snapshot outright");
|
||||
|
||||
// Alice leaves → her snapshot drops.
|
||||
g.apply_list(0, 0, Vec::new(), 0, None);
|
||||
assert!(g.stats.is_empty());
|
||||
}
|
||||
|
||||
/// The raid-target board: deltas set/clear one slot, the list form resets the board.
|
||||
#[test]
|
||||
fn raid_target_board() {
|
||||
let mut g = GroupState::default();
|
||||
@@ -850,10 +683,6 @@ mod tests {
|
||||
assert_eq!(g.raid_targets[3], 0, "the list form resets absent icons");
|
||||
}
|
||||
|
||||
/// A raid→party conversion CLEARS the board; a disband does too (by replacing the state), and
|
||||
/// an ordinary roster change inside a raid does not. The reference zeroes all eight slots on
|
||||
/// `SMSG_GROUP_LIST`'s raid-flag-clear leg (`0x4ba550`, decision 1820) — without it the marks
|
||||
/// are drawn from a board nothing else empties, so they linger on screen.
|
||||
#[test]
|
||||
fn a_raid_to_party_conversion_clears_the_raid_target_board() {
|
||||
let mut g = GroupState::default();
|
||||
@@ -861,7 +690,6 @@ mod tests {
|
||||
g.apply_raid_target(7, 0x22);
|
||||
assert_eq!(g.raid_targets[7], 0x22, "marked while a raid");
|
||||
|
||||
// Still a raid, roster moved: the board survives.
|
||||
g.apply_list(
|
||||
1,
|
||||
0,
|
||||
@@ -874,7 +702,6 @@ mod tests {
|
||||
"a roster change inside a raid keeps the marks"
|
||||
);
|
||||
|
||||
// Raid flag clears while the group lives on — the leg the reference zeroes on.
|
||||
g.apply_list(0, 0, vec![member("Ally", 0x22)], 0x22, None);
|
||||
assert_eq!(
|
||||
g.raid_targets, [0; 8],
|
||||
@@ -882,14 +709,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// **The refusal table is the reference's, asserted by message id.**
|
||||
///
|
||||
/// Each `result` must name the catalog row whose id the binary's own jump table pushes to
|
||||
/// `DisplayError` (read out of the PE at `0x5e6a14`).
|
||||
/// Asserting the *id* rather than the sentence is the point, and this family is the reason:
|
||||
/// nothing about the displayed English distinguishes a right key from a wrong one here, and a
|
||||
/// plausible-looking `ERR_WRONG_FACTION` — which exists in neither `GlobalStrings.lua` nor the
|
||||
/// catalog — would have shown **nothing at all**, silently.
|
||||
/// Asserted by message id, since the English cannot tell a right key from a wrong one.
|
||||
#[test]
|
||||
fn every_party_result_names_the_message_id_the_reference_pushes() {
|
||||
const TABLE: &[(u32, u16, bool)] = &[
|
||||
@@ -910,8 +730,7 @@ mod tests {
|
||||
let row = benilla_ui::messages::by_key(msg.key)
|
||||
.unwrap_or_else(|| panic!("result {result} named {}, not a catalog row", msg.key));
|
||||
assert_eq!(row.id, id, "result {result} -> {} (id {})", msg.key, row.id);
|
||||
// The `%s` arms are exactly the arms the binary passes `&name` to, which is an
|
||||
// independent cross-check: every one of them is an `_S` key.
|
||||
// The arms the reference passes the name to are exactly the `_S` keys.
|
||||
assert_eq!(
|
||||
msg.arg_s().is_some(),
|
||||
takes_name,
|
||||
@@ -924,7 +743,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// The OK path's two arms, where `operation` is the only thing read.
|
||||
// The two `OK` arms, where the operation decides.
|
||||
let ok = |op| g.apply_command_result(op, "Zed", party_result::OK);
|
||||
assert_eq!(
|
||||
benilla_ui::messages::by_key(ok(party_operation::INVITE).unwrap().key)
|
||||
@@ -940,8 +759,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// **`result == 7` is the red line; the other nine are chat.** The one row in this family whose
|
||||
/// `+0x04` is kind 2, and benilla pushed all ten into the chat log.
|
||||
#[test]
|
||||
fn the_wrong_faction_refusal_is_the_red_line_and_the_rest_are_chat() {
|
||||
use benilla_ui::messages::MsgKind;
|
||||
@@ -967,16 +784,11 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// **Three inputs display NOTHING, and the reference means it.** Its default arm is four
|
||||
/// instructions — `mov eax,1; mov esp,ebp; pop ebp; ret 8` — with no call and no store.
|
||||
///
|
||||
/// benilla printed an invented `"Party command failed (N)."` for the first of these. Inventing
|
||||
/// a sentence for a case the client is deliberately silent on is worse than mis-wording one it
|
||||
/// has a string for: there is no wording that could be right.
|
||||
/// Three inputs show nothing: the reference's default arm (`0x5e6a06`) returns with no call.
|
||||
#[test]
|
||||
fn the_silent_inputs_show_nothing() {
|
||||
let g = GroupState::default();
|
||||
// 1 — every result past the table's end (`dec eax; cmp eax,7; ja`, unsigned).
|
||||
// 1: every result past the table (`dec eax; cmp eax,7; ja`, unsigned).
|
||||
for r in [9u32, 10, 42, u32::MAX] {
|
||||
assert!(
|
||||
g.apply_command_result(party_operation::INVITE, "Zed", r)
|
||||
@@ -984,25 +796,22 @@ mod tests {
|
||||
"result {r} must be silent"
|
||||
);
|
||||
}
|
||||
// 2 — OK with an operation that is neither invite nor leave.
|
||||
// 2: `OK` for an operation other than invite or leave.
|
||||
for op in [1u32, 3, 99] {
|
||||
assert!(g
|
||||
.apply_command_result(op, "Zed", party_result::OK)
|
||||
.is_none());
|
||||
}
|
||||
// 3 — an invite ack carrying no name (`0x5e6923 je`).
|
||||
// 3: an invite ack with no name (`0x5e6923`).
|
||||
assert!(g
|
||||
.apply_command_result(party_operation::INVITE, "", party_result::OK)
|
||||
.is_none());
|
||||
// …but a named one still prints, so the guard is the name and not the operation.
|
||||
// A named one still prints: the guard is the name.
|
||||
assert!(g
|
||||
.apply_command_result(party_operation::INVITE, "Zed", party_result::OK)
|
||||
.is_some());
|
||||
}
|
||||
|
||||
/// Every key this family raises resolves in the shipped 1.12 `GlobalStrings.lua` to the
|
||||
/// sentence the real client shows — the runtime half, since a key that is a valid catalog row
|
||||
/// can still be absent from the player's own chain. Skips without client data.
|
||||
#[test]
|
||||
fn party_result_keys_resolve_to_the_real_1_12_sentences() {
|
||||
let data = benilla_formats::wow_data_or_skip!();
|
||||
@@ -1029,7 +838,6 @@ mod tests {
|
||||
.unwrap();
|
||||
let text: String = s.lua().globals().get(msg.key).expect(msg.key);
|
||||
assert!(!text.is_empty(), "{} resolves empty", msg.key);
|
||||
// An `_S` key must actually carry the hole its fill expects.
|
||||
assert_eq!(
|
||||
text.contains("%s"),
|
||||
msg.arg_s().is_some(),
|
||||
@@ -1039,16 +847,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// **The twelve roster/invite/leader lines, by key** — the family that used to be twelve
|
||||
/// re-typed sentences beside twelve key names in comments, where nothing joined the two
|
||||
/// (decision 2045). The same three checks the command-result family gets: the key resolves in
|
||||
/// the shipped file, it is a catalog row (so its surface and sound are read rather than
|
||||
/// guessed), and an `_S` row's hole matches the fill that reaches it.
|
||||
///
|
||||
/// **What the old chat-log path could not do**, and this one does: `ERR_DECLINE_GROUP_S`'s row
|
||||
/// names the `igPlayerInviteDecline` cue. Pushing the composed sentence straight into the chat
|
||||
/// log threw the row away and with it the sound — silently, since a missing cue looks exactly
|
||||
/// like a cue that has not been wired.
|
||||
#[test]
|
||||
fn the_roster_lines_resolve_and_carry_their_catalog_row() {
|
||||
let data = benilla_formats::wow_data_or_skip!();
|
||||
@@ -1061,9 +859,7 @@ mod tests {
|
||||
|
||||
let mut g = GroupState::default();
|
||||
let mut lines = Vec::new();
|
||||
// Every producer in the file, DRIVEN rather than listed — a party join and leave, then a
|
||||
// raid conversion with its own join and leave, then the per-opcode lines. Listing the keys
|
||||
// would let a new line skip this test; driving the producers cannot.
|
||||
// Driven through every producer, so a new line cannot skip the checks below.
|
||||
lines.extend(g.apply_list(0, 0, vec![member("Alice", 1), member("Bob", 2)], 1, None));
|
||||
lines.extend(g.apply_list(0, 0, vec![member("Alice", 1)], 1, None));
|
||||
lines.extend(g.apply_list(1, 0, vec![member("Alice", 1), member("Dave", 4)], 1, None));
|
||||
|
||||
@@ -1,16 +1,7 @@
|
||||
//! The group/party packet handlers (decision 0434 §D2, superseded by 0440; in the net handler
|
||||
//! table since 2321, moved out of the drain's group arm file). [`GroupState`] mirrors the wire and
|
||||
//! names the **messages** its roster diff implies; these are the shims that put them on the
|
||||
//! shared by-key queue. Two bodies here are not packet handlers but the object layer's hook on a
|
||||
//! roster member's stream-out ([`member_deactivated`], [`roster_deactivated`]); the drain's
|
||||
//! object arms still call them, and they become listeners on the object kinds once those are
|
||||
//! peeled.
|
||||
//!
|
||||
//! **They are message ids, not sentences** (decisions 2045/2054). The reference reaches all twelve
|
||||
//! through one `CGGameUI::DisplayError(msgId)`, and the catalog row that id names answers three
|
||||
//! questions at once — the text, the surface, and the sound. Composing the English here answered
|
||||
//! only the first, and got the third wrong for free: `ERR_DECLINE_GROUP_S` carries the
|
||||
//! `igPlayerInviteDecline` cue, which a straight chat push had no way to play.
|
||||
//! The group packet handlers. [`GroupState`] names the messages each packet implies and these
|
||||
//! queue them by key, as the reference's `DisplayError` (`0x496720`) takes a message id: the
|
||||
//! catalog row gives the text, the surface and the sound. [`member_deactivated`] and
|
||||
//! [`roster_deactivated`] are the object layer's hooks on a roster member's stream-out.
|
||||
|
||||
use benilla_protocol::messages::{
|
||||
member_status, GroupLootInfo, GroupMemberEntry, PartyMemberStatsInfo,
|
||||
@@ -25,8 +16,7 @@ use crate::net::{ClientCommand, GuidIndex, NetCommands, NetHandlerApp, ObjectSto
|
||||
use crate::ui_action::{UiError, UiErrorKeys};
|
||||
use crate::ui_quest::QuestGiver;
|
||||
|
||||
/// Register the group handlers — called from [`super::UiPartyPlugin`]. One per kind, plus the
|
||||
/// session-end listener.
|
||||
/// One handler per group kind, plus the session-end listener.
|
||||
pub(super) fn register(app: &mut App) {
|
||||
use SessionEventKind as K;
|
||||
app.net_handler(K::GroupInvite, on_invite)
|
||||
@@ -190,49 +180,39 @@ fn on_raid_instance_info(In(ev): In<SessionEvent>, mut group: ResMut<GroupState>
|
||||
}
|
||||
}
|
||||
|
||||
/// The group dies with the socket. A listener on the session end
|
||||
/// (a second handler on the kind, after the bridge's own teardown).
|
||||
/// The group state dies with the socket, after the bridge's own teardown.
|
||||
fn on_session_end(In(_): In<SessionEvent>, mut group: ResMut<GroupState>) {
|
||||
group.clear_session();
|
||||
}
|
||||
|
||||
/// Queue the messages a `GroupState::apply_*` named. `ui_action::feed_actions` resolves each key
|
||||
/// against the VM's own `GlobalStrings.lua` and puts the line on the surface its catalog row
|
||||
/// names — `ui_guild`'s `push_lines` (decision 2054), one window over.
|
||||
/// Queue the messages an `apply_*` named; `ui_action::feed_actions` resolves and shows them.
|
||||
fn push_group_lines(errors: &mut UiErrorKeys, lines: Vec<UiError>) {
|
||||
errors.0.extend(lines);
|
||||
}
|
||||
|
||||
/// `MSG_RAID_READY_CHECK`, the open form (decision 1989): our own echo as leader takes the
|
||||
/// response-collection arm and prints nothing; as anyone else we print the leader's line and take
|
||||
/// the popup ticket. The leader test is the reference's guid compare (`0x4ba3a0`).
|
||||
/// We lead when our guid is the leader's, the reference's test at `0x4ba3a0`.
|
||||
fn ready_check_request(group: &mut GroupState, errors: &mut UiErrorKeys, self_guid: &SelfGuid) {
|
||||
let we_lead = self_guid.0 == Some(group.leader);
|
||||
push_group_lines(errors, group.apply_ready_check_request(we_lead));
|
||||
}
|
||||
|
||||
/// `SMSG_GROUP_INVITE` — someone asked us into their group.
|
||||
fn invited(group: &mut GroupState, errors: &mut UiErrorKeys, inviter: &str) {
|
||||
push_group_lines(errors, group.apply_invited(inviter));
|
||||
}
|
||||
|
||||
/// `SMSG_GROUP_DECLINE` — our invitee said no (sent to the inviter only).
|
||||
fn declined(group: &mut GroupState, errors: &mut UiErrorKeys, name: &str) {
|
||||
push_group_lines(errors, group.apply_declined(name));
|
||||
}
|
||||
|
||||
/// `SMSG_GROUP_UNINVITE` — we were kicked.
|
||||
fn uninvited(group: &mut GroupState, errors: &mut UiErrorKeys) {
|
||||
push_group_lines(errors, group.apply_uninvited());
|
||||
}
|
||||
|
||||
/// `SMSG_GROUP_DESTROYED` — the group is gone outright.
|
||||
fn destroyed(group: &mut GroupState, errors: &mut UiErrorKeys) {
|
||||
push_group_lines(errors, group.apply_destroyed());
|
||||
}
|
||||
|
||||
/// `SMSG_GROUP_SET_LEADER` — the line reads differently when the new leader is us, so the composer
|
||||
/// needs our own name. It is cache-seeded at login (`session::connected`), so this never asks.
|
||||
/// Our own name, cached from login (`session::connected`), picks the line; this never queries.
|
||||
fn leader_changed(
|
||||
group: &mut GroupState,
|
||||
errors: &mut UiErrorKeys,
|
||||
@@ -247,17 +227,9 @@ fn leader_changed(
|
||||
push_group_lines(errors, group.apply_leader_changed(name, own.as_deref()));
|
||||
}
|
||||
|
||||
/// `SMSG_GROUP_LIST` — the roster echo (and the join/leave diff's line source). Roster changes move
|
||||
/// shared-quest availability, so the questgiver sweep re-asks from here (0654).
|
||||
///
|
||||
/// **A roster entry is a sighting** (decision 1564): every member guid is warmed into the
|
||||
/// [`NameCache`] here, the same ask-once discipline `net::objects` applies the moment a unit
|
||||
/// streams in. The roster wire carries a member's *name*, so this is not asked for the name — it is
|
||||
/// asked for the `(race, class, gender)` triple that rides the same answer, and which is the ONLY
|
||||
/// source of those three for a member we never see: their descriptor never arrives. Two surfaces
|
||||
/// read them and both were empty for an out-of-area member before this — the raid grid's class
|
||||
/// column (`ui_party::feed::raid_roster`, whose own-row twin of this hole 1549 §7 found live), and
|
||||
/// the party frame's 2D portrait stand-in (`portrait::temporary_portrait`, report B315).
|
||||
/// `SMSG_GROUP_LIST`: apply the roster, seat new members' records and re-ask the questgiver sweep
|
||||
/// (shared-quest availability follows the roster). Every member is name-queried once: the
|
||||
/// answer's race, class and gender are the only source of them for a member we never see streamed.
|
||||
fn list(
|
||||
group: &mut GroupState,
|
||||
errors: &mut UiErrorKeys,
|
||||
@@ -274,8 +246,8 @@ fn list(
|
||||
for m in &members {
|
||||
let _ = names.resolve(m.guid, net_commands);
|
||||
}
|
||||
// The roster BEFORE the apply, so the "new to this roster" test below is the slot writer's
|
||||
// own `srcRec == 0` (see `seat_new_records`), not a re-read of what we just stored.
|
||||
// Taken before `apply_list` consumes the list; `seat_new_records` then treats a member with
|
||||
// no record as new, the reference's `srcRec == 0`.
|
||||
let seats: Vec<(u64, bool)> = members
|
||||
.iter()
|
||||
.map(|m| (m.guid, m.status & member_status::ONLINE != 0))
|
||||
@@ -286,19 +258,10 @@ fn list(
|
||||
quest.bump_reask();
|
||||
}
|
||||
|
||||
/// **The GROUP_LIST slot writer's record leg** — `0x4e82d0`, the second of the two places the
|
||||
/// reference asks for a member's stats.
|
||||
///
|
||||
/// Per member the writer has two legs, chosen on whether the member already had a record
|
||||
/// (`srcRec`): a **known** member's record is copied forward and *nothing is sent* — a resync of a
|
||||
/// group you are already in must not fire four queries — while a member **new to the roster** gets
|
||||
/// a zeroed record with the `1/1` placeholder ([`PartyMemberStatsInfo::placeholder`]), and then, if
|
||||
/// their player object is not in the object manager (`4e8398 74 4a je`), the request
|
||||
/// (`4e83e4 39 7d f8 cmp [ebp-8],edi; 75 5f jne` — the second gate, on `srcRec` again).
|
||||
///
|
||||
/// The consequence worth stating: after this, **every roster member owns a record**, which is what
|
||||
/// makes the merged view's out-of-range leg total. A member you have never seen shows a full `1/1`
|
||||
/// bar rather than an empty `0/0` one until their stats land — the reference's own picture.
|
||||
/// `SMSG_GROUP_LIST`'s record leg (`0x4e82d0`, raid twin `0x4ba5f0`): a known member's record
|
||||
/// carries over and nothing is sent; a new member gets the 1/1 placeholder and, when we hold no
|
||||
/// object for them, a stats request (`0x4e83f1`, raid `0x4bab6e`). Every member therefore owns a
|
||||
/// record, so an unseen one shows full bars, not 0/0, until their stats land.
|
||||
fn seat_new_records(
|
||||
group: &mut GroupState,
|
||||
seats: &[(u64, bool)],
|
||||
@@ -306,8 +269,7 @@ fn seat_new_records(
|
||||
net_commands: &NetCommands,
|
||||
) {
|
||||
for (guid, online) in seats {
|
||||
// `apply_list` has already dropped the records of everyone who left, and kept the rest —
|
||||
// so "no record now" is exactly the writer's `srcRec == 0`.
|
||||
// `apply_list` kept only the staying members' records, so a missing one means new.
|
||||
if group.stats.contains_key(guid) {
|
||||
continue;
|
||||
}
|
||||
@@ -322,22 +284,10 @@ fn seat_new_records(
|
||||
}
|
||||
}
|
||||
|
||||
/// **The despawn edge** — CGPlayer_C vtable slot 1 `0x5e9aa0`, which `0x464920` invokes on both
|
||||
/// `SMSG_DESTROY_OBJECT` and the `SMSG_UPDATE_OBJECT` OUT_OF_RANGE block. A party or raid
|
||||
/// member's object is leaving the object manager, so:
|
||||
/// **snapshot its live descriptor into the roster record** (`0x5f0880`), then **ask the server for
|
||||
/// the member's stats** (`CMSG_REQUEST_PARTY_MEMBER_STATS`, `0x4e8646`), in that order and on that
|
||||
/// same instruction sequence.
|
||||
///
|
||||
/// The pair is why the reference's party frame does not blank when somebody walks over the hill
|
||||
/// (report B334): the snapshot means the bars keep the numbers they were showing at the edge, and
|
||||
/// the request means the server answers `_FULL` rather than leaving us on whatever delta its
|
||||
/// accumulated mask happens to carry next. Neither existed here before decision 1640.
|
||||
///
|
||||
/// **Two gates, and both are the reference's.** This runs on every despawn, so a guid that is not
|
||||
/// on the roster falls straight through; and a guid we hold **no object for** falls through too —
|
||||
/// the hook is a *virtual on the object*, so no object means it never ran, and a server that
|
||||
/// re-announces a stream-out we have already applied must not cost a packet.
|
||||
/// The despawn hook, the reference's deactivate virtual `0x5e9aa0` (object destroyed or out of
|
||||
/// range): for a roster member, snapshot the live descriptor into their record (`0x5f0880`), then
|
||||
/// request their stats (`0x4e8646`). Nothing happens off the roster or without an object, since
|
||||
/// the hook is a virtual on the object.
|
||||
pub(crate) fn member_deactivated(
|
||||
guid: u64,
|
||||
group: &mut GroupState,
|
||||
@@ -360,9 +310,8 @@ pub(crate) fn member_deactivated(
|
||||
.send(ClientCommand::RequestPartyMemberStats { guid });
|
||||
}
|
||||
|
||||
/// [`member_deactivated`] for **every** streamed roster member at once — the bulk teardown a
|
||||
/// cross-map transfer performs, where the reference destroys the same objects one at a time and
|
||||
/// runs the same hook on each.
|
||||
/// [`member_deactivated`] for every streamed roster member: a cross-map transfer's teardown, where
|
||||
/// the reference destroys each object and runs the hook on each.
|
||||
pub(crate) fn roster_deactivated(
|
||||
group: &mut GroupState,
|
||||
index: &GuidIndex,
|
||||
@@ -381,7 +330,6 @@ pub(crate) fn roster_deactivated(
|
||||
}
|
||||
}
|
||||
|
||||
/// `SMSG_PARTY_COMMAND_RESULT` — the verdict on an invite/kick/leave we asked for.
|
||||
fn command_result(
|
||||
group: &mut GroupState,
|
||||
errors: &mut crate::ui_action::UiErrorKeys,
|
||||
@@ -389,10 +337,7 @@ fn command_result(
|
||||
member: &str,
|
||||
result: u32,
|
||||
) {
|
||||
// By KEY, not by sentence, and into the shared queue rather than straight into chat: the
|
||||
// catalog row decides the surface, so `result == 7` reaches the red `UIErrorsFrame` line while
|
||||
// the other nine stay chat lines (`0x496720`; decision 2035's shape). `None` is the
|
||||
// reference's own silence — three inputs display nothing at all.
|
||||
// By key, so the catalog row picks the surface; `None` is the reference's silence.
|
||||
errors
|
||||
.0
|
||||
.extend(group.apply_command_result(operation, member, result));
|
||||
@@ -413,13 +358,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The roster edge is where a member we may never SEE becomes askable. Their descriptor is the
|
||||
/// only other source of race/class/gender, and it never arrives while they are out of the local
|
||||
/// area — so without this ask the raid grid's class column and the party frame's portrait
|
||||
/// stand-in are both permanently blank for exactly the members that need them (B315).
|
||||
///
|
||||
/// Ask-ONCE: a re-sent roster (every join, leave, loot-method change re-sends the whole list)
|
||||
/// must not re-ask, or a busy group would spam a query per member per packet.
|
||||
#[test]
|
||||
fn the_roster_warms_every_member_into_the_name_cache_once() {
|
||||
let (tx, rx) = crossbeam_channel::unbounded();
|
||||
@@ -430,7 +368,7 @@ mod tests {
|
||||
QuestGiver::default(),
|
||||
);
|
||||
let mut names = NameCache::default();
|
||||
// A player guid: `counter | (high << 48)` — the shape `NameCache::resolve` routes on.
|
||||
// A player guid, `counter | (high << 48)`, the shape `NameCache::resolve` routes on.
|
||||
let player_guid = |counter: u64| counter | (u64::from(guid::HIGH_PLAYER) << 48);
|
||||
let (leader, far) = (player_guid(7), player_guid(8));
|
||||
|
||||
@@ -484,13 +422,12 @@ mod tests {
|
||||
assert_eq!(names.player_traits(leader), Some((1, 4, 1)));
|
||||
}
|
||||
|
||||
// ── The out-of-range record (decision 1640, report B334) ────────────────────────────────
|
||||
// ── The out-of-range record ──────────────────────────────────────────────
|
||||
|
||||
/// Descriptor field indices, spelled locally the way the other app-side descriptor tests do
|
||||
/// (`ui_unit`'s vitals block) — the private `FIELD_UNIT_*` values, build 5875.
|
||||
/// Unit descriptor field indices, build 5875.
|
||||
const HEALTH: u16 = 22;
|
||||
const MAXHEALTH: u16 = 28;
|
||||
/// `UNIT_FIELD_POWER2` / `MAXPOWER2` — the RAGE slot (`POWER1 + POWER_RAGE`).
|
||||
/// `UNIT_FIELD_POWER2`/`MAXPOWER2`, the rage slot (`POWER1 + POWER_RAGE`).
|
||||
const POWER2: u16 = 24;
|
||||
const MAXPOWER2: u16 = 30;
|
||||
const LEVEL: u16 = 34;
|
||||
@@ -511,23 +448,15 @@ mod tests {
|
||||
group
|
||||
}
|
||||
|
||||
/// **The despawn edge is where the numbers are kept** (the deactivate virtual `0x5e9aa0`
|
||||
/// snapshots `0x5f0880` and sends `0x27f`, in that order).
|
||||
///
|
||||
/// Falsifier for the whole report: delete the snapshot and this member's record stays whatever
|
||||
/// the wire last said — which, for a member who has never had a stats packet, is nothing, and
|
||||
/// the frame reads `0/0`. Delete the send and the server is never asked for a `_FULL`, so the
|
||||
/// record stays frozen at the snapshot until the member's health happens to change.
|
||||
#[test]
|
||||
fn a_members_despawn_snapshots_their_descriptor_and_asks_for_their_stats() {
|
||||
let (tx, rx) = crossbeam_channel::unbounded();
|
||||
let net = NetCommands(tx);
|
||||
let guid = 0x1234;
|
||||
let mut group = grouped(&[member(guid, "Brisca"), member(0x99, "Aldwyn")]);
|
||||
let _ = asked(&rx); // the roster's own seat-time asks (they are unstreamed here)
|
||||
let _ = asked(&rx); // start from an empty queue
|
||||
|
||||
// A warrior: rage rides the wire ×10, and the record stores it raw exactly as the
|
||||
// descriptor does.
|
||||
// A warrior: rage is stored raw, ten times the shown value, as in the descriptor.
|
||||
let store = ObjectStore(benilla_protocol::messages::ObjectFields::from_pairs(&[
|
||||
(HEALTH, 2400),
|
||||
(MAXHEALTH, 3000),
|
||||
@@ -558,9 +487,6 @@ mod tests {
|
||||
assert_eq!(asked(&rx), vec![guid], "and the server is asked, once");
|
||||
}
|
||||
|
||||
/// The hook runs on **every** despawn — a mob, a totem, a stranger — and must be silent for
|
||||
/// everyone who is not on the roster. Without the guard every stream-out in a busy city would
|
||||
/// put a `CMSG_REQUEST_PARTY_MEMBER_STATS` on the wire.
|
||||
#[test]
|
||||
fn a_despawn_that_is_not_a_party_member_asks_nothing() {
|
||||
let (tx, rx) = crossbeam_channel::unbounded();
|
||||
@@ -575,16 +501,11 @@ mod tests {
|
||||
assert!(asked(&rx).is_empty());
|
||||
assert!(!group.stats.contains_key(&0xdead));
|
||||
|
||||
// …and the object gate on its own: a roster member we hold no object for never
|
||||
// deactivated, so re-announcing their stream-out costs nothing.
|
||||
// The object gate alone: a roster member we hold no object for asks nothing.
|
||||
member_deactivated(0x1234, &mut group, None, &net);
|
||||
assert!(asked(&rx).is_empty());
|
||||
}
|
||||
|
||||
/// **The roster seat** (`0x4e82d0`): a member new to the roster gets the `1/1`
|
||||
/// placeholder, and is asked for only when we hold no object for them. A resync asks nothing —
|
||||
/// the gate the reference puts on `srcRec`, and the reason a busy group does not fire four
|
||||
/// queries per GROUP_LIST.
|
||||
#[test]
|
||||
fn a_roster_new_member_is_seated_at_one_one_and_asked_for_only_when_unseen() {
|
||||
let (tx, rx) = crossbeam_channel::unbounded();
|
||||
@@ -634,8 +555,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// The wire answers for `far`, and then the roster is re-sent (somebody changed the loot
|
||||
// method). The cached record is restored and nothing is asked again.
|
||||
// The wire answers for `far`, then the roster is re-sent: nothing is asked again.
|
||||
group.apply_stats(
|
||||
far,
|
||||
true,
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
//! The pet action bar's **feed** — ten packed words in, ten `PetActionView`s out, once a frame.
|
||||
//!
|
||||
//! **The shape of this system is the stance bar's mirror image, and that is the thing to hold on
|
||||
//! to.** `crate::ui_shapeshift` derives its whole bar locally, from the known-spell set × the
|
||||
//! `Spell.dbc` catalog, and the server never has an opinion about it. Here the server owns the
|
||||
//! bar's CONTENTS — which slots exist and what is in them — and hands them over whole in
|
||||
//! `SMSG_PET_SPELLS`. So the feed does no admission and no ordering: it *renders* the ten words
|
||||
//! the last packet delivered. The slot law it renders them by is [`super`]'s.
|
||||
//! The pet action bar's feed: the ten packed words of the last `SMSG_PET_SPELLS`, which the
|
||||
//! server owns whole, rendered as ten `PetActionView`s each frame.
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
@@ -23,44 +17,24 @@ use crate::ui_action::Spells;
|
||||
use super::drain::UNIT_FLAG_POSSESSED;
|
||||
use super::PetBar;
|
||||
|
||||
/// `GetPetActionsUsable()` — may the bar be used at all (the predicate `0x4bcf70`).
|
||||
///
|
||||
/// benilla's earlier reading — "the enabled-flags byte's `0x8`" — was REFUTED as the
|
||||
/// *whole* answer, but it survives as one of the seven steps: the client tests bit 27 of the state
|
||||
/// dword, which is that same byte's `0x8` (see `PET_STATE_BAR_DISABLED`). The step it was missing
|
||||
/// is the pet's own crowd-control state — a **stunned, confused or feared** pet cannot be ordered,
|
||||
/// and its bar greys until it recovers.
|
||||
///
|
||||
/// The remaining four steps are ownership identity (the player resolves, the pet resolves, the pet
|
||||
/// is ours, we are not ourselves charmed). Holding a bar at all already means the server named us
|
||||
/// this pet's controller, so they are structurally true here; the two that can actually change
|
||||
/// while a bar is on screen are the two we test.
|
||||
///
|
||||
/// This is the same predicate that gates whether a press does anything, so a false answer greys
|
||||
/// the icons *and* is the honest reason a click would be ignored.
|
||||
/// `GetPetActionsUsable()` (`0x4bcf70`): false while the state's bit 27 is set or the pet is
|
||||
/// stunned, confused or fleeing. Its other four steps test ownership, which a held bar implies.
|
||||
pub(super) fn actions_usable(bar: &PetBar, pet_flags: Option<u32>) -> bool {
|
||||
!bar.spells.bar_disabled()
|
||||
&& pet_flags.is_none_or(|f| f & benilla_protocol::messages::PET_UNUSABLE_UNIT_FLAGS == 0)
|
||||
}
|
||||
|
||||
/// What the feed last pushed, so `PET_BAR_UPDATE` fires on a real change rather than every frame
|
||||
/// (the [`crate::ui_shapeshift`] memory pattern).
|
||||
///
|
||||
/// The leading `u32` is [`PetBar::bar_signals`], and it is what keeps the dedup from swallowing a
|
||||
/// press that changes nothing — see that field for why the reference cannot afford to skip one.
|
||||
/// What the feed last pushed, so `PET_BAR_UPDATE` fires on a change; the key's leading `u32` is
|
||||
/// [`PetBar::bar_signals`], so a press that changes nothing still repaints.
|
||||
#[derive(Default)]
|
||||
pub(super) struct PetBarMemory {
|
||||
pushed: Option<(u32, bool, bool, bool, Vec<PetActionView>)>,
|
||||
/// The ten slots' cooldown triples as last pushed — `PET_BAR_UPDATE_COOLDOWN`'s own edge.
|
||||
/// The ten cooldown triples as last pushed: `PET_BAR_UPDATE_COOLDOWN`'s edge.
|
||||
cooldowns: Vec<Option<(i64, u32, bool)>>,
|
||||
}
|
||||
|
||||
/// The command tokens' `(GlobalStrings key, texture-global name)` pair.
|
||||
///
|
||||
/// Both are returned as **the names of globals**, not values — the reference's token convention.
|
||||
/// The name keys ship in `GlobalStrings.lua` (`PET_ACTION_ATTACK` = "Attack" at l.3029-3032); the
|
||||
/// texture globals are declared by `PetActionBar.xml` — our own code, under the names and with the
|
||||
/// art paths the reference's `PetActionBarFrame.lua:6-12` specifies (1260).
|
||||
/// A command token's `(name, texture)` pair, both global names (`PetActionBarFrame.lua:98-104`):
|
||||
/// keys from `GlobalStrings.lua:3029-3032`, textures from `PetActionBarFrame.lua:6-12`.
|
||||
pub(super) fn command_token(action: u32) -> Option<(&'static str, &'static str)> {
|
||||
Some(match action {
|
||||
PET_COMMAND_STAY => ("PET_ACTION_WAIT", "PET_WAIT_TEXTURE"),
|
||||
@@ -71,11 +45,8 @@ pub(super) fn command_token(action: u32) -> Option<(&'static str, &'static str)>
|
||||
})
|
||||
}
|
||||
|
||||
/// The reaction tokens' pair, same convention. `PET_MODE_*` ship in `GlobalStrings.lua`
|
||||
/// (l.3045-3047) — deliberately the `PET_MODE_*` keys, which name the state the pet is IN, not the
|
||||
/// `PET_AGGRESSIVE`/`PET_DEFENSIVE`/`PET_PASSIVE` keys, which are the right-click menu's
|
||||
/// imperatives. Both read identically in enUS ("Aggressive"), so only a localized client can tell
|
||||
/// them apart — which is exactly why the key matters rather than the string.
|
||||
/// A reaction token's pair: the `PET_MODE_*` keys (`GlobalStrings.lua:3045-3047`), naming the
|
||||
/// pet's state, not the menu's imperative `PET_AGGRESSIVE` family, though enUS reads them alike.
|
||||
pub(super) fn reaction_token(action: u32) -> Option<(&'static str, &'static str)> {
|
||||
Some(match action {
|
||||
PET_REACT_PASSIVE => ("PET_MODE_PASSIVE", "PET_PASSIVE_TEXTURE"),
|
||||
@@ -85,15 +56,9 @@ pub(super) fn reaction_token(action: u32) -> Option<(&'static str, &'static str)
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolve one packed slot word into what the bar draws.
|
||||
///
|
||||
/// `cooldown` and `showing_active` are passed in rather than looked up here so the whole function
|
||||
/// stays a pure (state, word) → view mapping, which is what the tests below exercise.
|
||||
/// `showing_active` is [`active_aura_press`]'s predicate — the *same* answer that decides whether
|
||||
/// a click cancels, because in the reference it is literally the same call (`0x4bcea0`, reached
|
||||
/// from `GetPetActionInfo` at `0x4bdd2f` and from `CastPetAction` at `0x4bd24a`). Computing it
|
||||
/// once and handing it to both is what keeps the icon honest: the button that shows the active art
|
||||
/// is exactly the button whose next press takes the aura off.
|
||||
/// One packed slot word as the bar draws it. `showing_active` is [`active_aura_press`]'s answer,
|
||||
/// the one call (`0x4bcea0`) that both `GetPetActionInfo` (`0x4bdd2f`) and `CastPetAction`
|
||||
/// (`0x4bd24a`) make, so the button showing active art is the one whose press cancels.
|
||||
pub(super) fn slot_view(
|
||||
entry: PetActionEntry,
|
||||
bar: &PetSpells,
|
||||
@@ -103,21 +68,15 @@ pub(super) fn slot_view(
|
||||
showing_active: bool,
|
||||
) -> PetActionView {
|
||||
PetActionView {
|
||||
// The raw word rides EVERY slot, including the ones that draw as empty — decision 1010's
|
||||
// drag is word arithmetic and reads it. Zeroing an "empty" slot here would be wrong on the
|
||||
// wire *and* wrong in the drop core: vmangos fills its unused slots with `ACT_DISABLED` +
|
||||
// spell id 0, and that shape (type 1, low 16 zero) is precisely the relocation candidate
|
||||
// the core hunts for.
|
||||
// Every slot carries its raw word, empty ones too: the drag's relocation candidate is
|
||||
// vmangos's unused slot, `ACT_DISABLED` with spell 0 (type 1, low 16 bits zero).
|
||||
packed: entry.packed,
|
||||
// Only a resolved spell can be passive; a token has no record and an unresolvable id has
|
||||
// no answer, and `false` is the honest reading of both.
|
||||
passive: spell.is_some_and(|s| s.passive),
|
||||
..slot_paint(entry, bar, spell, cooldown, pet_attacking, showing_active)
|
||||
}
|
||||
}
|
||||
|
||||
/// [`slot_view`]'s painted half — everything the button draws, with no wire word in it. Split so
|
||||
/// the drag's two raw fields are stamped in exactly one place rather than on each of four returns.
|
||||
/// [`slot_view`]'s painted half: everything but the raw `packed` and `passive`.
|
||||
pub(super) fn slot_paint(
|
||||
entry: PetActionEntry,
|
||||
bar: &PetSpells,
|
||||
@@ -133,22 +92,9 @@ pub(super) fn slot_paint(
|
||||
.then(|| command_token(action))
|
||||
.flatten()
|
||||
{
|
||||
// A command token lights on `(state >> 8) == action` **or** on the attack latch — read at
|
||||
// the bytes at `0x4bdf01`-`0x4bdf22`, and both halves matter:
|
||||
//
|
||||
// - the compare is against the UNMASKED `state >> 8` (`PetSpells::command_state`'s own
|
||||
// note), so a disabled bar puts every command button out;
|
||||
// - ATTACK gets the extra clause — and it is the *only* thing that can light ATTACK,
|
||||
// because the command byte is never written for it (`0x4bc960`).
|
||||
//
|
||||
// So whether Attack ever appears lit is entirely a question about [`PetBar::attacking`],
|
||||
// and the answer for a pet bar is **never**: that latch is the possess bar's, gated on
|
||||
// `0x5ee5a0` ([`possessing`]). This expression stays faithful rather than hard-coding the
|
||||
// `false`, because under Mind Control or Eyes of the Beast the same button does light.
|
||||
//
|
||||
// `attack_active` — `IsPetAttackActive`, the click fork — is the same latch narrowed to
|
||||
// this slot (`0x4be138`-`0x4be153`: type 7, action 2, and the flag). Same input, so the
|
||||
// button that lights is exactly the button whose next press calls the unit off.
|
||||
// Lit when the unmasked `state >> 8` equals the action, so a disabled bar lights none, or
|
||||
// for Attack alone on the attack latch (`0x4bdf01`-`0x4bdf22`). `attack_active` is
|
||||
// `IsPetAttackActive`, that latch on this slot (`0x4be138`-`0x4be153`).
|
||||
let attacking = pet_attacking && action == PET_COMMAND_ATTACK;
|
||||
return PetActionView {
|
||||
name: Some(name.to_string()),
|
||||
@@ -164,9 +110,7 @@ pub(super) fn slot_paint(
|
||||
.then(|| reaction_token(action))
|
||||
.flatten()
|
||||
{
|
||||
// The reaction compare's left side is forced to Passive when the bar is disabled
|
||||
// (`0x4bde3c`): a pet that cannot be ordered reads as Passive rather than
|
||||
// keeping the mode light it had, which is the honest thing for it to say.
|
||||
// A disabled bar reads as Passive (`0x4bde3c`).
|
||||
let showing = if bar.bar_disabled() {
|
||||
benilla_protocol::messages::PET_REACT_PASSIVE
|
||||
} else {
|
||||
@@ -181,27 +125,20 @@ pub(super) fn slot_paint(
|
||||
};
|
||||
}
|
||||
|
||||
// A spell slot. `is_empty` is the zero WORD (the client tests the dword); vmangos's own unused
|
||||
// middle slots are not zero and arrive here instead, where their spell id 0 misses the catalog
|
||||
// and takes the same exit — the client's own route to the same empty button.
|
||||
// A zero word is empty (the client tests the dword); vmangos's unused slots are spell 0, which
|
||||
// has no record and takes the next exit, as in the client.
|
||||
if !entry.is_spell() || entry.is_empty() {
|
||||
return PetActionView::default();
|
||||
}
|
||||
let Some(spell) = spell else {
|
||||
// The catalog failed to load, or the server named a spell 5875's DBC does not have. Draw
|
||||
// the slot as occupied but nameless rather than inventing a name: the button then hides,
|
||||
// which is honest, and the alternative (a "?" with no tooltip) has fooled nobody.
|
||||
// No `Spell.dbc` record, or no catalog: the reference returns nil and the button hides.
|
||||
return PetActionView::default();
|
||||
};
|
||||
PetActionView {
|
||||
name: Some(spell.name.clone()),
|
||||
subtext: spell.rank.clone(),
|
||||
// THE ICON SWAP (decision 1007, `0x4bdd2f`/`0x4bdd38`/`0x4bdd77`): a spell the
|
||||
// pet is currently running draws its record's `ActiveIconID` instead of its `SpellIconID`.
|
||||
// Falling back to `icon` here would be wrong — the reference looks up whichever id the
|
||||
// predicate chose and pushes **nil** if that lookup fails (`0x4bdd50`), so an unresolvable
|
||||
// active icon hides the button rather than showing the inactive art on an active spell.
|
||||
// `active_icon` is `None` on exactly that failure, so `.clone()` already says it.
|
||||
// A running spell draws its `ActiveIconID` (`0x4bdd2f`/`0x4bdd38`/`0x4bdd77`), with no
|
||||
// fallback: the reference pushes nil when that icon does not resolve (`0x4bdd50`).
|
||||
texture: if showing_active {
|
||||
spell.active_icon.clone()
|
||||
} else {
|
||||
@@ -209,39 +146,21 @@ pub(super) fn slot_paint(
|
||||
},
|
||||
is_token: false,
|
||||
spell_id: Some(action),
|
||||
// A spell slot NEVER reports isActive — nil on every path (pushed at `0x4bdd5e`), which
|
||||
// retires 0982's INTERIM. `isActive` is exclusively a token concept and `autoCast*`
|
||||
// exclusively a spell one; the two halves of the signature never overlap (`0x4bdc50`).
|
||||
//
|
||||
// "The pet is running this spell" is expressed by the icon above, not by this flag — which
|
||||
// is why 0988's hole closes without this line changing.
|
||||
// Nil on every spell path (`0x4bdd5e`): `isActive` is a token's, `autoCast*` a spell's
|
||||
// (`0x4bdc50`).
|
||||
active: false,
|
||||
// Autocast is bits 31/30 of the word, not the type byte (`0x4bdd65`/`0x4bdda4`) — and both
|
||||
// are additionally gated on the spell resolving in `Spell.dbc`, which the early return
|
||||
// above has already enforced by the time we get here.
|
||||
// Bits 31/30, not the type (`0x4bdd65`/`0x4bdda4`), gated on the record checked above.
|
||||
autocast_allowed: entry.autocast_allowed(),
|
||||
autocast_enabled: entry.autocast_on(),
|
||||
attack_active: false,
|
||||
cooldown,
|
||||
// `packed`/`passive` are [`slot_view`]'s to stamp — this half paints, it does not encode.
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// The pet spell slot that is **showing active** — the reference's `0x4bcea0`, returning the spell
|
||||
/// id when it holds so the one answer can drive both of its consumers. Decision 1007.
|
||||
///
|
||||
/// It is not a new predicate: `0x4bcea0` is the *pet-side compiled twin* of the player's
|
||||
/// `0x4e55f0`, which we already carry as [`crate::ui_action::toggle::active_action_toggle`] — same
|
||||
/// three tests (nonzero raw `ActiveIconID`, the spell's own id in a live `UNIT_FIELD_AURA` slot,
|
||||
/// that slot's `AURAFLAGS` nibble bit 0), different unit. So this reaches for it rather than
|
||||
/// restating it, and the pet's store goes in where the player's does.
|
||||
///
|
||||
/// **The `ActiveIconID != 0` gate is load-bearing on the send, not just the icon.** Because the
|
||||
/// binary tests it first (`0x4bcefd`) and `CastPetAction` takes its cancel arm on the whole
|
||||
/// predicate, a pet spell whose record carries no active icon can never be clicked off — it
|
||||
/// re-casts instead. That reads like an oversight and isn't ours to fix: the same `0` is what
|
||||
/// tells the bar there is no "active" art to show, so the two halves are consistent.
|
||||
/// The spell id when the slot shows active: `0x4bcea0`, the pet-side twin of `0x4e55f0`
|
||||
/// ([`crate::ui_action::toggle::active_action_toggle`]). It tests `ActiveIconID != 0` first
|
||||
/// (`0x4bcefd`), so a press on a spell without one re-casts rather than cancels.
|
||||
pub(super) fn active_aura_press(
|
||||
entry: PetActionEntry,
|
||||
pet: Option<&ObjectStore>,
|
||||
@@ -254,14 +173,8 @@ pub(super) fn active_aura_press(
|
||||
crate::ui_action::toggle::active_action_toggle(spell_id, spell?, pet?).then_some(spell_id)
|
||||
}
|
||||
|
||||
/// Rebuild the ten slot views each frame and diff-push them, firing `PET_BAR_UPDATE` on a change
|
||||
/// of the bar and `PET_BAR_UPDATE_COOLDOWN` on a change of its cooldowns alone.
|
||||
///
|
||||
/// The reference fires `PET_BAR_UPDATE` from nine sites, each right after a state change, and
|
||||
/// `PET_BAR_UPDATE_COOLDOWN` from the cooldown subsystem when the PET bank mutates (`0x6e2e8e`).
|
||||
/// Diffing the pushed state is the same edge from the other side: the slots' content and usability
|
||||
/// are the bar's, the ten triples are the bank's. `UNIT_PET` is `feed_pet_unit`'s;
|
||||
/// `UNIT_FLAGS`/`UNIT_AURA` for `"pet"` are the unit feed's.
|
||||
/// Push the ten slot views on a change: `PET_BAR_UPDATE`, or `PET_BAR_UPDATE_COOLDOWN` when only
|
||||
/// cooldowns moved, the reference's fire from the pet's cooldown bank (`0x6e2e8e`).
|
||||
pub(super) fn feed_pet_bar(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
bar: Res<PetBar>,
|
||||
@@ -278,19 +191,15 @@ pub(super) fn feed_pet_bar(
|
||||
let now = Instant::now();
|
||||
let (anchor, ui_now) = (clock.anchor, clock.ui_now);
|
||||
let has_bar = bar.has_bar();
|
||||
// The pet's own descriptor. `None` = we hold a bar for a unit whose descriptor has not arrived
|
||||
// (or has left); the usability predicate then rests on bit 27 alone rather than greying a bar
|
||||
// on missing data, and no slot can read as showing-active.
|
||||
// An unstreamed pet leaves usability to bit 27 alone, and no slot shows active.
|
||||
let pet_store = index
|
||||
.0
|
||||
.get(&bar.spells.pet_guid)
|
||||
.and_then(|&e| stores.get(e).ok());
|
||||
let pet_flags = pet_store.map(|s| s.0.unit_flags());
|
||||
let usable = actions_usable(&bar, pet_flags);
|
||||
// `PickupPetAction`'s own gate, and nobody else's (`0x4be1c1`): a POSSESSED unit's bar cannot
|
||||
// be rearranged. Deliberately not folded into `usable` — the reference keeps possession out of
|
||||
// the flags that grey the bar, because a possessed unit is exactly when the buttons must work.
|
||||
// Absent flags read as not-possessed, matching `usable`'s own missing-data posture.
|
||||
// `PickupPetAction`'s gate alone (`0x4be1c1`): a possessed unit's bar cannot be rearranged,
|
||||
// but its buttons work, so possession stays out of `usable`.
|
||||
let pickup_allowed = pet_flags.unwrap_or(0) & UNIT_FLAG_POSSESSED == 0;
|
||||
let pet_attacking = bar.attacking;
|
||||
|
||||
@@ -322,9 +231,8 @@ pub(super) fn feed_pet_bar(
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
// `bar.bar_signals` rides the key so a press the state does not move still repaints — the
|
||||
// `0x4bc940`/`0x4bc960` signal, which the button's own `SetChecked(0)` makes mandatory. The
|
||||
// cooldown triples are keyed apart: their edge is the bank's, not the bar's.
|
||||
// `bar_signals` in the key repaints a press that moved nothing (`0x4bc940`/`0x4bc960`); the
|
||||
// cooldown triples are keyed apart, their edge being the bank's.
|
||||
let cooldowns: Vec<Option<(i64, u32, bool)>> = fresh.iter().map(|s| s.cooldown).collect();
|
||||
let content: Vec<PetActionView> = fresh
|
||||
.iter()
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
//! The bar's click **intents** → the wire, and the state a press latches locally.
|
||||
//!
|
||||
//! **The bar's STATE — the lit command, the lit reaction, the autocast bit — is applied LOCALLY,
|
||||
//! and that is forced rather than chosen.** The server answers none of those three presses:
|
||||
//! `HandlePetAction`'s command and reaction arms send nothing back,
|
||||
//! `HandlePetSpellAutocastOpcode` sets its bits and returns, and `SMSG_PET_MODE` is emitted from
|
||||
//! exactly one place in the whole server (`Pet::SetEnabled` — the enabled flag, nothing else). A
|
||||
//! client that waited for confirmation would show a bar whose reaction buttons never light and
|
||||
//! whose autocast ring never appears. Verified live against vmangos, 2026-08-05: a
|
||||
//! `TogglePetAutocast` and a Follow press drew no reply packet at all. [`drain_pet_actions`]
|
||||
//! carries that half, and the next `SMSG_PET_SPELLS` re-authorities it.
|
||||
//! The bar's click intents onto the wire, and the state a press latches locally: vmangos answers
|
||||
//! no command or reaction press (`PetHandler.cpp:81-99`) and no autocast toggle
|
||||
//! (`PetHandler.cpp:203-300`), and sends `SMSG_PET_MODE` only from `Pet::SetEnabled`
|
||||
//! (`Pet.cpp:2362-2377`), so the client applies them itself until the next `SMSG_PET_SPELLS`.
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
@@ -24,35 +17,14 @@ use crate::ui_action::Spells;
|
||||
use super::bar::active_aura_press;
|
||||
use super::{PetBar, PetUnit};
|
||||
|
||||
/// `UNIT_FIELD_FLAGS` bit 24 — `UNIT_FLAG_POSSESSED` (vmangos `UnitDefines.h:515`), read by the
|
||||
/// reference as the descriptor byte `[[pet+0x110]+0xA3] & 1`. Gates the pet bar's **drag** and
|
||||
/// nothing else (decision 1010): a possessed unit's buttons still work, its layout is just not
|
||||
/// yours to rearrange.
|
||||
/// `UNIT_FLAG_POSSESSED`, `UNIT_FIELD_FLAGS` bit 24 (vmangos `UnitDefines.h:569`), which the
|
||||
/// reference reads as `[[pet+0x110]+0xA3] & 1`.
|
||||
pub(super) const UNIT_FLAG_POSSESSED: u32 = 0x0100_0000;
|
||||
|
||||
/// Drain the bar's three intents onto the wire.
|
||||
///
|
||||
/// Every one carries the pet's guid from [`PetBar`] — never a guid the VM supplied — so an intent
|
||||
/// queued in the frame a pet was dismissed dies here rather than naming a unit that is gone.
|
||||
///
|
||||
/// `CMSG_PET_ACTION` echoes the slot's **own packed word**: the server re-splits it and dispatches
|
||||
/// on the type byte, so command, reaction and cast all leave through this one send. The target is
|
||||
/// our current selection, which is what makes "select a mob, press Attack" work; a slot that wants
|
||||
/// no target simply has the server ignore it (`HandlePetAction`'s `explicitlySelectedTarget` fork
|
||||
/// drops a target the spell does not want).
|
||||
///
|
||||
/// **This drain is also where the bar's STATE moves, and that is not an optimisation — it is
|
||||
/// forced.** The server answers none of these three: `HandlePetAction`'s command and reaction arms
|
||||
/// send nothing back, and `HandlePetSpellAutocastOpcode` sets its bits and returns
|
||||
/// (`PetHandler.cpp:451-478`). `SMSG_PET_MODE` is emitted from exactly ONE place in the whole
|
||||
/// server — `Pet::SetEnabled`, i.e. only when the bar is enabled or disabled
|
||||
/// (`Pet.cpp:2362-2377`). So a client that waited for confirmation would show a pet bar whose
|
||||
/// reaction buttons never light and whose autocast ring never appears; the reference plainly does
|
||||
/// not, so it must apply all three locally, and so do we. Verified live, 2026-08-05: a
|
||||
/// `TogglePetAutocast` + a Follow press against vmangos drew no packet whatsoever in reply.
|
||||
///
|
||||
/// The optimism is bounded by the same packet that owns everything else — the next
|
||||
/// `SMSG_PET_SPELLS` (a re-summon, a learn, a stable swap) replaces state and contents together.
|
||||
/// Send the bar's intents under [`PetBar`]'s pet guid, never one from the VM, so an intent queued
|
||||
/// as the pet leaves dies here. `CMSG_PET_ACTION` echoes the slot's word, which the server
|
||||
/// dispatches on its type, with our selection as the target; `HandlePetAction` drops a target
|
||||
/// the spell does not want.
|
||||
pub(super) fn drain_pet_actions(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
mut bar: ResMut<PetBar>,
|
||||
@@ -85,25 +57,11 @@ pub(super) fn drain_pet_actions(
|
||||
debug!("ui_pet: dropping queued pet intents — the bar is gone");
|
||||
return;
|
||||
}
|
||||
// NOTE, so it is not "fixed" again (decision 1030 reverting 1027): the repaint is
|
||||
// [`latch_press`]'s alone, and it is deliberately NOT unconditional on the click.
|
||||
// `PetActionButton_OnClick`'s `this:SetChecked(0)` runs for every click, but the reference only
|
||||
// signals `PET_BAR_UPDATE` from the writes that changed something — so a **refused**
|
||||
// `TogglePetAutocast` (`0x4bcbf7`: a token is not autocastable, which is every right-click on
|
||||
// Follow, Stay or a reaction) leaves that button's ring off until the next repaint from
|
||||
// anywhere. That is the reference's own behaviour, checked there by the director, and
|
||||
// reproduced.
|
||||
//
|
||||
// The pet's own descriptor — the ATTACK arm's validator reads it, and so does the spell arm's
|
||||
// showing-active test. Neither is about the bar; both are about the pet.
|
||||
let pet_store = pet.store(pet_guid);
|
||||
// `0x4bd420`'s compare, hoisted once: is the bar's unit the one we are *driving*? Only then may
|
||||
// an ATTACK press latch (see [`possessing`]) — which for an ordinary pet is never.
|
||||
let possessing = possessing(pet_store, pet.self_guid.0);
|
||||
|
||||
// A pressed slot and a one-shot order (`PetAttack` & co., 1958) are the same press: the
|
||||
// slot's own packed word, or the word the verb synthesized — both re-enter the reference's
|
||||
// one dispatcher (`0x4bd1d0`), so they share one loop. The slot number only ever labelled logs.
|
||||
// A slot press and a scripted order (`PetAttack` and the like) are one press: both reach the
|
||||
// reference's one dispatcher (`0x4bd1d0`). The slot number only labels logs.
|
||||
let presses: Vec<(u32, PetActionEntry)> = pressed
|
||||
.into_iter()
|
||||
.filter_map(|slot| slot_entry(&bar, slot).map(|e| (slot, e)))
|
||||
@@ -114,11 +72,8 @@ pub(super) fn drain_pet_actions(
|
||||
)
|
||||
.collect();
|
||||
for (slot, entry) in presses {
|
||||
// The spell arm's early exit (`0x4bd240`–`0x4bd2ad`): a press on a spell the
|
||||
// pet is already running takes the aura OFF and **returns** — `CMSG_PET_ACTION` never
|
||||
// leaves, so it is a cancel, not a re-cast. Nothing is latched locally either: the icon
|
||||
// goes back when the pet's `UNIT_FIELD_AURA` says the aura is gone, which is the honest
|
||||
// order (the server can refuse — a dead pet gets `FEEDBACK_PET_DEAD` instead).
|
||||
// A press on a spell the pet is running cancels its aura and sends no `CMSG_PET_ACTION`
|
||||
// (`0x4bd240`-`0x4bd2ad`); nothing latches, the icon follows the pet's aura field.
|
||||
let display = entry
|
||||
.is_spell()
|
||||
.then(|| spells.as_ref().and_then(|s| s.catalog.get(entry.action())))
|
||||
@@ -130,14 +85,8 @@ pub(super) fn drain_pet_actions(
|
||||
.send(ClientCommand::PetCancelAura { pet_guid, spell_id });
|
||||
continue;
|
||||
}
|
||||
// `0x612df0`, the ATTACK order's validator, and its actor is the **pet**. Phase A is the
|
||||
// pet's own eligibility; Phase B is the target, and it can move your selection — see
|
||||
// [`crate::target::attack_order_target`], which is the whole of "press Attack at nothing
|
||||
// and the pet goes for whatever is in front of you".
|
||||
//
|
||||
// Every other arm sends the selection untouched: `CastPetAction` substitutes it for a nil
|
||||
// Lua argument at `0x4bd212` and only the ATTACK arm ever calls the validator, so a Follow
|
||||
// or a spell carries whatever you happened to have.
|
||||
// Only Attack runs `0x612df0`, the attack validator with the pet as actor, whose target
|
||||
// pick can move the selection; every other press sends the selection as is (`0x4bd212`).
|
||||
let mut target_guid = selection.guid.unwrap_or(0);
|
||||
let refused = if is_attack_order(entry) {
|
||||
crate::ui_action::attack_actor_refusal(pet_store, pet.self_guid.0, &mut ui_errors)
|
||||
@@ -171,6 +120,9 @@ pub(super) fn drain_pet_actions(
|
||||
target_guid,
|
||||
});
|
||||
}
|
||||
// The repaint is `latch_press`'s alone, not every click's: a refused `TogglePetAutocast`
|
||||
// (`0x4bcbf7`: any right-click on a token) signals nothing, so after `OnClick`'s
|
||||
// `SetChecked(0)` that button stays unlit until the next repaint, as in the reference.
|
||||
for slot in toggles {
|
||||
let Some(flipped) = toggle_slot_autocast(&mut bar, slot) else {
|
||||
continue;
|
||||
@@ -182,9 +134,7 @@ pub(super) fn drain_pet_actions(
|
||||
);
|
||||
let _ = commands.0.send(ClientCommand::PetSetAction {
|
||||
pet_guid,
|
||||
// The wire's slot positions are 0-based (vmangos bounds them `< 10`); the Lua's are
|
||||
// 1-based. This subtraction is the whole of the conversion and cannot underflow —
|
||||
// `slot_entry` already rejected 0.
|
||||
// Lua slots are 1-based, the wire's 0-based; `slot_entry` already refused 0.
|
||||
entries: vec![(slot - 1, flipped.packed)],
|
||||
});
|
||||
}
|
||||
@@ -193,11 +143,8 @@ pub(super) fn drain_pet_actions(
|
||||
debug!("ui_pet: stop attack");
|
||||
}
|
||||
}
|
||||
// The drag's writes (decision 1010). The engine ran the assign core against its own mirror and
|
||||
// handed back the `(0-based position, word)` pairs; the authoritative ten words live *here*, so
|
||||
// the app's whole job is to mirror each pair and put the batch on the wire **whole** — the
|
||||
// server tells the one-pair form from the two-pair form by body size, so a relocation and its
|
||||
// write must not be split into two sends.
|
||||
// The drag's `(0-based position, word)` pairs: mirrored here and sent as one batch, since the
|
||||
// server tells one pair from two by body size.
|
||||
for entries in writes {
|
||||
for &(position, packed) in &entries {
|
||||
if let Some(e) = bar.spells.bar.get_mut(position as usize) {
|
||||
@@ -211,19 +158,10 @@ pub(super) fn drain_pet_actions(
|
||||
}
|
||||
}
|
||||
|
||||
/// `TogglePetAutocast`'s local half, `0x4bcbb0` — flip the slot's autocast bit and write it back,
|
||||
/// returning the new word for the send, or `None` when the reference no-ops silently (a slot out
|
||||
/// of range, or a word that is not autocast-ALLOWED, bit 31 — `0x4bcbf1`).
|
||||
///
|
||||
/// The client flips bit 30 in the slot word IN PLACE and sends the whole new word — it is not a
|
||||
/// "set autocast to X for spell Y" verb (`0x4bcbff`/`0x4bcc17`). The server reads the
|
||||
/// direction back out of the type byte it arrives in.
|
||||
///
|
||||
/// Then `0x4bcc19` calls `0x4bd190(&bar[slot])` — the propagation into the **spellbook**: for a
|
||||
/// spell-type slot (type 1), scan the raw pet-spell array **backwards** for the entry equal to the
|
||||
/// slot under `& 0x3FFFFFFF` and copy the slot's FULL word into it. The pet book renders from that
|
||||
/// array, so without the copy a bar toggle left the Pet tab's ring stale until the next
|
||||
/// `SMSG_PET_SPELLS`. The book→bar direction is `ui_pet_book::flip_autocast` (decision 1032).
|
||||
/// `TogglePetAutocast`'s local half (`0x4bcbb0`): flip bit 30 in place and return the whole word
|
||||
/// to send (`0x4bcbff`/`0x4bcc17`), or `None` for a slot out of range or without bit 31
|
||||
/// (`0x4bcbf1`). `0x4bcc19` then calls `0x4bd190`, which copies a spell slot's word over the last
|
||||
/// spellbook entry equal to it under `& 0x3FFFFFFF`.
|
||||
pub(super) fn toggle_slot_autocast(bar: &mut PetBar, slot: u32) -> Option<PetActionEntry> {
|
||||
let entry = slot_entry(bar, slot).filter(|e| e.autocast_allowed())?;
|
||||
let flipped = entry.with_autocast(!entry.autocast_on());
|
||||
@@ -243,15 +181,8 @@ pub(super) fn toggle_slot_autocast(bar: &mut PetBar, slot: u32) -> Option<PetAct
|
||||
Some(flipped)
|
||||
}
|
||||
|
||||
/// `PetStopAttack`'s **core**, `0x4bd650` — call the pet off, and the only thing besides a new pet
|
||||
/// that puts the Attack button out.
|
||||
///
|
||||
/// It **no-ops entirely when the latch is down** (`0x4bd65e`): no packet, no repaint. That gate
|
||||
/// lives here rather than in the VM because the latch does. Returns whether it actually fired,
|
||||
/// which is what its two callers log.
|
||||
///
|
||||
/// Split out of the drain because the drain is *not* its only caller: [`pet_stop_on_old_target_clear`]
|
||||
/// is the second, exactly as `0x4bd650` has a second call site of its own.
|
||||
/// `PetStopAttack`'s core (`0x4bd650`): send `CMSG_PET_STOP_ATTACK` and lower the latch, or do
|
||||
/// nothing when it is already down (`0x4bd65e`). Returns whether it sent.
|
||||
pub(super) fn stop_pet_attack(bar: &mut PetBar, commands: &NetCommands) -> bool {
|
||||
let pet_guid = bar.spells.pet_guid;
|
||||
if !bar.attacking || pet_guid == 0 {
|
||||
@@ -262,43 +193,15 @@ pub(super) fn stop_pet_attack(bar: &mut PetBar, commands: &NetCommands) -> bool
|
||||
true
|
||||
}
|
||||
|
||||
/// `0x493910`'s entry gate, as a predicate on the selection transition.
|
||||
///
|
||||
/// The old-target clear returns immediately unless there **is** a current selection
|
||||
/// (`0x493937 or edx,ecx; je epilogue`) and the guid it was handed is either zero or that same
|
||||
/// selection (`0x493949`/`0x493951`) — and its two callers only ever hand it one of those two.
|
||||
/// So it runs on exactly the transitions below: a selection that existed is being replaced or
|
||||
/// dropped. `None → Some` is *not* one of them.
|
||||
/// `0x493910`'s entry gate: the old-target clear runs only when a selection existed (`0x493937`)
|
||||
/// and is being replaced or dropped (`0x493949`/`0x493951`).
|
||||
pub(super) fn old_target_cleared(previous: Option<u64>, now: Option<u64>) -> bool {
|
||||
previous.is_some() && previous != now
|
||||
}
|
||||
|
||||
/// **Clearing the old target calls `PetStopAttack`'s core** — the third and last way the attack
|
||||
/// latch goes down, and live only on a **possess** bar (see [`possessing`]; a hunter's pet never
|
||||
/// raises the latch in the first place, so [`stop_pet_attack`]'s own gate makes this a no-op for
|
||||
/// it, exactly as `0x4bd65e` does in the reference).
|
||||
///
|
||||
/// `0x493910` — the old-target clear, run by *both* selection writers (`0x493540`'s switch calls
|
||||
/// it with the outgoing guid at `ecx = 0`, the explicit clear with `{0,0}` at `ecx = 1`) — does
|
||||
/// three things past its entry gate, of which benilla previously carried only the first:
|
||||
///
|
||||
/// ```text
|
||||
/// 0x493a08 0x5ecac0(player) ; StopAttack -> CMSG_ATTACKSTOP (target/scan.rs `commit`)
|
||||
/// 0x493a0f 0x5ee5a0(player) ; the unit I am POSSESSING, or null
|
||||
/// 0x493a18 0x4bd650() ; PetStopAttack's core, iff there is one <- THIS
|
||||
/// 0x493a1d if (notifyServer) ... ; CMSG_SET_SELECTION
|
||||
/// ```
|
||||
///
|
||||
/// The `0x5ee5a0` at `0x493a0f` is the same possession resolver `0x4bd420` uses to gate the latch,
|
||||
/// which is the tidy proof that the whole latch arm belongs to one bar: the thing that raises it
|
||||
/// and the thing that clears it ask the identical question. The call also sits **above** the
|
||||
/// notify-server branch, so it runs on the silent switch-clear too, not only on the explicit one.
|
||||
///
|
||||
/// Modelled as a transition on [`Selection`] rather than a hook in the selection writers because
|
||||
/// that is what the reference gets for free by routing every writer through one clear: a `Local`
|
||||
/// mirror sees `/target`, a click, TAB, ESC, the death-teardown clear and the acquire alike. The
|
||||
/// dedup is the reference's own (`0x493540` bails when the guid is already current, so no clear
|
||||
/// runs) and falls out of comparing against the mirror.
|
||||
/// The old-target clear's `PetStopAttack` (`0x493910`, at `0x493a18` when `0x5ee5a0` finds a
|
||||
/// possessed unit at `0x493a0f`). It sits before the notify branch, so every selection writer runs
|
||||
/// it; a re-select runs no clear (`0x493540`).
|
||||
pub(super) fn pet_stop_on_old_target_clear(
|
||||
selection: Res<Selection>,
|
||||
mut previous: Local<Option<u64>>,
|
||||
@@ -316,39 +219,11 @@ pub(super) fn pet_stop_on_old_target_clear(
|
||||
}
|
||||
}
|
||||
|
||||
/// `0x5ee5a0` — **"the unit I am directly possessing"**, and the gate that decides whether an
|
||||
/// ATTACK press latches anything at all.
|
||||
///
|
||||
/// This is the fact the pet bar was missing, and it is the answer to "why can the reference's
|
||||
/// Attack button never stay lit". `0x4bd420` calls this on the *player* and compares the result
|
||||
/// with the bar's own unit; only on a match does `0x4bd42e` raise `[0xb714b0]`. And the function
|
||||
/// is not "resolve my pet" despite the shape — read at the bytes it is four tests in a row:
|
||||
///
|
||||
/// ```text
|
||||
/// 0x5ee5bc [player + 0x1c70] & 1 ; "I am driving something that is not me" — else null
|
||||
/// 0x5ee5d5 the passed unit IS the local player ; else guid 0
|
||||
/// 0x5ee5e9 [[player + 0xe68] + 0x830] ; the guid I am driving -> object lookup (type 8)
|
||||
/// 0x5ee626 [fields + 0xa3] & 1 ; UNIT_FLAG_POSSESSED on that unit — else null
|
||||
/// 0x5ee62f charmedBy (else createdBy) == me ; else null
|
||||
/// ```
|
||||
///
|
||||
/// `[player + 0x1c70]`'s bit 0 is set and cleared by the control-transfer handler `0x5ee2xx`
|
||||
/// against the active-mover guid, firing event `0x27a` with a bool — the client's own
|
||||
/// "control lost / control gained". So the whole thing means *possession*: Mind Control, Eye of
|
||||
/// Kilrogg, Eyes of the Beast. vmangos agrees from the other side — `UNIT_FLAG_POSSESSED` is
|
||||
/// written in exactly three places (`Player::SummonPossessedMinion`, `SPELL_AURA_MOD_POSSESS`,
|
||||
/// `Player::ModPossessPet`), each of them paired with `SetMover` + `UpdateControl`, and **never**
|
||||
/// for an ordinary hunter or warlock pet.
|
||||
///
|
||||
/// Which is why `PetActionButton_OnClick`'s `IsPetAttackActive` fork and `PetStopAttack` are dead
|
||||
/// code for a hunter, the way `PetActionButton_StartFlash` is dead code for everyone: the shipped
|
||||
/// `PetActionBarFrame.lua` is written for both bars and a hunter only ever takes one branch.
|
||||
///
|
||||
/// **What benilla checks, and the gap:** the flag and the ownership, which are the two halves that
|
||||
/// live in the descriptor we already stream. The active-mover halves (`+0x1c70`, the driven guid)
|
||||
/// need client-control state benilla does not model yet; they only ever *narrow* this further, and
|
||||
/// the server sets the flag and the mover together, so the flag standing in for both is faithful
|
||||
/// until possession itself is built.
|
||||
/// `0x5ee5a0`: the unit the player possesses, which must be the bar's unit for an Attack press to
|
||||
/// latch (`0x4bd420`, `0x4bd42e`). It needs `UNIT_FLAG_POSSESSED` (`0x5ee626`) and charmed-by,
|
||||
/// else created-by, us (`0x5ee62f`); vmangos sets that flag only with possession (Mind Control,
|
||||
/// Eyes of the Beast), never for an ordinary pet. The reference also tests the active mover
|
||||
/// (`0x5ee5bc`, `0x5ee5e9`), not modelled here: vmangos sets the mover with the flag.
|
||||
pub(super) fn possessing(store: Option<&ObjectStore>, self_guid: Option<u64>) -> bool {
|
||||
store.is_some_and(|s| {
|
||||
s.0.unit_flags() & UNIT_FLAG_POSSESSED != 0
|
||||
@@ -356,35 +231,16 @@ pub(super) fn possessing(store: Option<&ObjectStore>, self_guid: Option<u64>) ->
|
||||
})
|
||||
}
|
||||
|
||||
/// Is this press the ATTACK **order** — the one slot word that runs a validator before it sends?
|
||||
///
|
||||
/// Type 7 action 2 and nothing else. The reference's type-7 arm branches exactly twice
|
||||
/// (`cmp ecx,1; jle` then `cmp ecx,2; jne`), so DISMISS, every `action >= 4`, and both mode
|
||||
/// commands leave down paths that send unconditionally.
|
||||
/// The Attack order, the one press validated before it sends: the type-7 arm branches only on
|
||||
/// `action <= 1` and `action == 2`, so every other command sends as it is.
|
||||
pub(super) fn is_attack_order(entry: PetActionEntry) -> bool {
|
||||
entry.kind() == benilla_protocol::messages::PET_ACT_COMMAND
|
||||
&& entry.action() == PET_COMMAND_ATTACK
|
||||
}
|
||||
|
||||
/// Commit one press against the bar and answer **whether it goes on the wire** — the composition
|
||||
/// of the gate and the latch, in one place because that composition is where the last bug lived.
|
||||
///
|
||||
/// `refused` is the shared attack-start validator's verdict on the **pet** as actor (`0x4bd40d`
|
||||
/// passes `ecx = edi`, the pet object resolved four instructions earlier). A veto costs
|
||||
/// everything: `0x4bd414 je 0x4bd4c6` jumps to the **function epilogue, not the send**, so there
|
||||
/// is no packet, no `[0xb714b0] = 1`, and no `PET_BAR_UPDATE`. The click is as if it never
|
||||
/// happened, except for the red line the validator itself raised.
|
||||
///
|
||||
/// This closes the residual decision 0998 named in its own text — *"a refused attack currently
|
||||
/// lights the button where the real client would not"* — and it is deliberately the one function
|
||||
/// that both decides and records, because 0998's bug was invisible to thirteen tests that checked
|
||||
/// the read side and the write side separately and never their join.
|
||||
///
|
||||
/// `possessing` is [`possessing`]'s answer, threaded rather than re-derived because it is the
|
||||
/// *second* gate on the same press and it belongs beside the first: `0x4bd420` runs immediately
|
||||
/// after `0x4bd40d`'s validator, and unlike it, failing costs only the latch — `0x4bd427 jne
|
||||
/// 0x4bd444` lands on the **send**, so the order still goes out. That asymmetry is the whole shape
|
||||
/// of the Attack button: the pet is always dispatched, the button never lights for it.
|
||||
/// Apply a press and answer whether it is sent. A veto from the validator (`0x4bd40d`, the pet as
|
||||
/// actor) jumps past the send (`0x4bd414 je 0x4bd4c6`): no packet, no latch, no repaint. Failing
|
||||
/// the possession compare after it (`0x4bd420`) costs only the latch (`0x4bd427` to the send).
|
||||
pub(super) fn commit_press(
|
||||
bar: &mut PetBar,
|
||||
entry: PetActionEntry,
|
||||
@@ -398,76 +254,43 @@ pub(super) fn commit_press(
|
||||
true
|
||||
}
|
||||
|
||||
/// Latch a pressed slot's state locally — the half the server never confirms (the module doc's
|
||||
/// "forced rather than chosen"), now with the client's own masks rather than byte assignments.
|
||||
///
|
||||
/// Both writes are exact transcriptions, and the masks are the point:
|
||||
///
|
||||
/// - **reaction** (`0x4bc94c`): `state = state & 0xFFFFFF00 | action` — byte 0 only.
|
||||
/// - **command** (`0x4bc96f`): `state = state & 0x080000FF | action << 8` — bits 8–15, keeping
|
||||
/// byte 0 and **bit 27**. The client deliberately preserves the disabled bit while rewriting the
|
||||
/// command, which is also the proof that bit 27 is server-owned: no client path writes it.
|
||||
///
|
||||
/// **Only STAY and FOLLOW reach the command write.** The binary's type-7 arm gates it on
|
||||
/// `action <= 1` (`0x4bd3b1`): DISMISS (3) falls straight through, and ATTACK (2) leaves down the
|
||||
/// validation chain that ends at `[0xb714b0] = 1` (`0x4bd42e`) — the attack latch, never the
|
||||
/// command byte. The distinction is the difference between a mode and an order: Stay and Follow
|
||||
/// are what the pet is *doing until told otherwise*, and they stay lit because `isActive`'s state
|
||||
/// compare keeps matching the command byte. Attack is a thing you tell it once.
|
||||
///
|
||||
/// Getting this wrong is visible within one click: latching ATTACK into the command byte leaves
|
||||
/// the Attack button lit forever and blanks Follow and Stay.
|
||||
///
|
||||
/// **The ATTACK arm's latch is gated on possession** — [`possessing`], the `0x5ee5a0` compare at
|
||||
/// `0x4bd420`, which no ordinary pet passes. So on a hunter's bar this match arm never fires and
|
||||
/// the Attack button never checks, which is what the reference does and what the director sees.
|
||||
/// The arm is still written out rather than deleted because the possess bar is the same bar: Mind
|
||||
/// Control and Eyes of the Beast reach it, and everything downstream (`IsPetAttackActive`,
|
||||
/// `PetStopAttack`, the old-target clear) is live for them.
|
||||
///
|
||||
/// The other gate on this press, `0x612df0`, ran back in [`drain_pet_actions`] and is *not*
|
||||
/// symmetric with it: a validator veto costs the whole press (`0x4bd414` jumps to the epilogue,
|
||||
/// no packet), while failing the possession compare costs only the latch (`0x4bd427` jumps to the
|
||||
/// send). [`commit_press`] holds that asymmetry.
|
||||
/// Latch a press's state, the half the server never confirms, with the client's masks. A
|
||||
/// reaction writes byte 0 (`0x4bc94c`). Stay and Follow, the only commands to reach the write
|
||||
/// (`action <= 1` at `0x4bd3b1`), set bits 8-15 and keep byte 0 and bit 27, which only the server
|
||||
/// writes (`0x4bc96f`). Attack raises a possessed unit's attack latch instead (`0x4bd42e`).
|
||||
pub(super) fn latch_press(bar: &mut PetBar, entry: PetActionEntry, possessing: bool) {
|
||||
let action = entry.action();
|
||||
match entry.kind() {
|
||||
benilla_protocol::messages::PET_ACT_COMMAND
|
||||
if action == PET_COMMAND_STAY || action == PET_COMMAND_FOLLOW =>
|
||||
{
|
||||
// `0x4bc960` — the write, then `ecx = 0x161; jmp SignalEvent`. Both halves, and the
|
||||
// signal is unconditional: writing the SAME command still repaints the bar, which is
|
||||
// the only thing that puts back the light `OnClick`'s `SetChecked(0)` just took off.
|
||||
// `0x4bc960` signals `PET_BAR_UPDATE` even when the command is unchanged.
|
||||
bar.spells.state = (bar.spells.state & 0x0800_00FF) | (action << 8);
|
||||
bar.bar_signals = bar.bar_signals.wrapping_add(1);
|
||||
}
|
||||
benilla_protocol::messages::PET_ACT_COMMAND
|
||||
if action == PET_COMMAND_ATTACK && possessing =>
|
||||
{
|
||||
// `0x4bd42e` signals too (`mov ecx,0x161` immediately before the write), and it is the
|
||||
// one signal a pet bar never gets: the possession gate above it is what skips both.
|
||||
// `0x4bd42e` signals too; an ordinary pet never gets here.
|
||||
bar.attacking = true;
|
||||
bar.bar_signals = bar.bar_signals.wrapping_add(1);
|
||||
}
|
||||
benilla_protocol::messages::PET_ACT_REACTION => {
|
||||
// `0x4bc940`, the same shape one byte-mask over.
|
||||
// `0x4bc940`: the same, on byte 0.
|
||||
bar.spells.state = (bar.spells.state & 0xFFFF_FF00) | action;
|
||||
bar.bar_signals = bar.bar_signals.wrapping_add(1);
|
||||
}
|
||||
// DISMISS, spells and every unknown type reach `0x4bd444` without signalling. A spell's
|
||||
// button can be checked (its active-aura ring), and it comes back the honest way: the
|
||||
// pet's aura field moves and the feed sees a real change.
|
||||
// Dismiss, spells and unknown types reach the send (`0x4bd444`) without a signal.
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// The 1-based Lua slot index → the packed word we still hold for it.
|
||||
/// The word held for a 1-based Lua slot.
|
||||
pub(super) fn slot_entry(bar: &PetBar, slot: u32) -> Option<PetActionEntry> {
|
||||
let index = usize::try_from(slot.checked_sub(1)?).ok()?;
|
||||
bar.spells.bar.get(index).copied()
|
||||
}
|
||||
|
||||
/// [`slot_entry`]'s mutable twin, for the autocast flip's write-back.
|
||||
pub(super) fn slot_entry_mut(bar: &mut PetBar, slot: u32) -> Option<&mut PetActionEntry> {
|
||||
let index = usize::try_from(slot.checked_sub(1)?).ok()?;
|
||||
bar.spells.bar.get_mut(index)
|
||||
|
||||
@@ -1,34 +1,8 @@
|
||||
//! The pet **right-click menu** — its two predicates and its three verbs (decision 1066).
|
||||
//!
|
||||
//! `PetFrame_OnClick`'s else arm opens `PetFrameDropDown` on `UnitPopupMenus["PET"]`, whose four
|
||||
//! rows are decided by `PetCanBeAbandoned()` alone (`UnitPopup.lua:402-416`): paperdoll and abandon
|
||||
//! show when it is true, dismiss when it is false, rename when it and `PetCanBeRenamed()` both are.
|
||||
//! So one predicate makes the same menu read *Abandon* on a hunter's pet and *Dismiss* on a
|
||||
//! warlock's demon — and the row a player sees is the whole of the difference the client draws
|
||||
//! between "give this pet up forever" and "send this summon away".
|
||||
//!
|
||||
//! **The predicates** `0x4be500` and `0x4be580` are byte-identical but for three dwords:
|
||||
//! resolve `[0xb714a0]`, check the pet's `UNIT_FIELD_SUMMONEDBY` against the local player, then test
|
||||
//! **one byte of `UNIT_FIELD_FLAGS`** — `[pet+0x110]+0xa0`, dword 46 — with mask `0x20` for abandon
|
||||
//! (`0x4be544`) and `0x10` for rename (`0x4be5c4`). Each returns exactly one value on every path: a
|
||||
//! double `1.0`, or nil.
|
||||
//!
|
||||
//! **The `UNIT_FIELD_BYTES_2` byte-2 layout everybody remembers is REFUTED here** — that is the
|
||||
//! TBC+ home of these bits, its record is dword 164, and neither predicate touches it. This is the
|
||||
//! shape decision 0988 warns about, and it was nearly built wrong: benilla's own
|
||||
//! queue had recorded the TBC guess, off a field that is always zero on this server.
|
||||
//!
|
||||
//! **The two give-up verbs are NOT one opcode** (the seam's two counts, kept apart on principle,
|
||||
//! are what made the fold a two-line change):
|
||||
//!
|
||||
//! - `PetAbandon 0x4be4c0` → `0x4bd740` sends **`CMSG_PET_ABANDON` 0x176** `{u64 petGuid}`.
|
||||
//! - `PetDismiss 0x4be4d0` → `0x4bd6e0` **opens no packet at all**. It stages the packed slot word
|
||||
//! `0x07000003` — command type 7, action 3, `PET_COMMAND_DISMISS` — and hands it to `0x4bd1d0`,
|
||||
//! the *same dispatcher every pet-bar click uses*, which sends **`CMSG_PET_ACTION` 0x175**
|
||||
//! `{u64 petGuid, u32 0x07000003, u64 0}`. Dismiss is a bar press with no button.
|
||||
//!
|
||||
//! Both would have worked against vmangos — `HandlePetAbandon` unsummons a non-hunter pet, and so
|
||||
//! does `COMMAND_DISMISS` — which is exactly why the wrong one would never have been noticed.
|
||||
//! The pet frame's right-click menu. `UnitPopup.lua:402-416` shows the paper doll and Abandon when
|
||||
//! `PetCanBeAbandoned()`, Rename when `PetCanBeRenamed()` too, and Dismiss otherwise. `PetAbandon`
|
||||
//! (`0x4be4c0`, `0x4bd740`) sends `CMSG_PET_ABANDON`; `PetDismiss` (`0x4be4d0`) sends no packet
|
||||
//! itself but hands the Dismiss command word to the bar's dispatcher (`0x4bd1d0`), so it leaves as
|
||||
//! `CMSG_PET_ACTION`. vmangos unsummons a summon on either, so sending the wrong one goes unseen.
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
@@ -40,32 +14,18 @@ use crate::ui_action::{UiError, UiErrorKeys};
|
||||
|
||||
use super::{PetBar, PetUnit};
|
||||
|
||||
/// `UNIT_FIELD_FLAGS` bit 4 — `UNIT_FLAG_PET_RENAME`, `0x4be5c4`'s mask. Set on a tamed hunter pet
|
||||
/// that has never been renamed.
|
||||
///
|
||||
/// **One-shot, and server-side only.** Of the field-view reads of `[+0x110]+0xa0`, exactly three
|
||||
/// test these two masks — the two predicates and the rename sender's own re-check (`0x4bd8b5`) —
|
||||
/// and **no writer exists anywhere in `.text`**. Nothing client-side clears this; the row
|
||||
/// disappears because the server re-streams the field and `UnitPopup` re-evaluates when the menu is
|
||||
/// next built. There is no event to listen for and none is needed.
|
||||
/// `UNIT_FLAG_PET_RENAME`, `0x4be5c4`'s mask: set on a tamed pet not yet renamed. No client code
|
||||
/// writes it; the server clears it after a rename (the sender re-checks it at `0x4bd8b5`).
|
||||
const UNIT_FLAG_PET_RENAME: u32 = 0x0000_0010;
|
||||
/// `UNIT_FIELD_FLAGS` bit 5 — `UNIT_FLAG_PET_ABANDON`, `0x4be544`'s mask. Set on a hunter pet and
|
||||
/// clear on a summon, which is what forks the whole menu.
|
||||
/// `UNIT_FLAG_PET_ABANDON`, `0x4be544`'s mask: set on a hunter pet, clear on a summon.
|
||||
const UNIT_FLAG_PET_ABANDON: u32 = 0x0000_0020;
|
||||
|
||||
/// The client's own silent cap on a pet name — `0x64a7f0(dst, 0x50, "%s", name)` in the rename
|
||||
/// sender `0x4bd840`, i.e. 79 characters plus the terminator.
|
||||
///
|
||||
/// It is **not** the 12 everyone knows: that is `RENAME_PET`'s `maxLetters` in FrameXML, a
|
||||
/// different layer with a different job. This one is the last thing between the Lua and the wire,
|
||||
/// it truncates rather than refusing, and it exists because the binding is reachable from any
|
||||
/// script, not only from the dialog.
|
||||
/// The rename sender's silent truncation, `0x64a7f0(dst, 0x50, "%s", name)` at `0x4bd840`: 79
|
||||
/// plus the NUL. The popup's 12-letter cap is FrameXML's, a layer above.
|
||||
const PET_NAME_MAX: usize = 79;
|
||||
|
||||
/// `(PetCanBeAbandoned, PetCanBeRenamed)` from a pet's `UNIT_FIELD_FLAGS` — the whole fork, in one
|
||||
/// place, so the one mistake that matters here has somewhere to be tested: swapping the two masks
|
||||
/// would offer a hunter *Dismiss* and a warlock *Abandon*, which is quest-breaking in one direction
|
||||
/// and pet-losing in the other.
|
||||
/// `(PetCanBeAbandoned, PetCanBeRenamed)` (`0x4be500`, `0x4be580`), read off `UNIT_FIELD_FLAGS`
|
||||
/// (dword 46), not the TBC's `UNIT_FIELD_BYTES_2`, which this server leaves zero.
|
||||
pub(super) fn menu_predicates(pet_flags: u32) -> (bool, bool) {
|
||||
(
|
||||
pet_flags & UNIT_FLAG_PET_ABANDON != 0,
|
||||
@@ -73,19 +33,9 @@ pub(super) fn menu_predicates(pet_flags: u32) -> (bool, bool) {
|
||||
)
|
||||
}
|
||||
|
||||
/// Push the menu's two predicates off the pet's descriptor.
|
||||
///
|
||||
/// **The ownership test is the predicates' own**, and it is `UNIT_FIELD_SUMMONEDBY` **alone** —
|
||||
/// `0x4be500` compares `[pet+0x110]+0x18/0x1c` against the local player before either bit is read,
|
||||
/// with no `CHARMEDBY` leg. That distinction is the third one in this folder (`0x5ff580` falls back
|
||||
/// from charmed to summoned, `0x5ee5a0` from charmed to created, this reads summoned flat), and it
|
||||
/// is exactly the case it decides: a **charmed** unit has the bar and the cached guid, and is not
|
||||
/// somebody's summon — so its menu is empty, which is right, because it is not yours to abandon.
|
||||
///
|
||||
/// No pet, an unstreamed pet, or a pet that is not our summon pushes `(false, false)` — every row
|
||||
/// off. That is the honest answer and also the useful one: `UnitPopup_ShowMenu` counts the shown
|
||||
/// rows and **refuses to open a menu with nothing but CANCEL in it**, so an unresolved pet takes no
|
||||
/// menu rather than an empty one.
|
||||
/// Push the menu's predicates. Their owner test is `UNIT_FIELD_SUMMONEDBY` alone, with no
|
||||
/// `CHARMEDBY` leg (`0x4be500`), so a charmed unit gets every row off, and `UnitPopup_ShowMenu`
|
||||
/// opens no menu holding only Cancel.
|
||||
pub(super) fn feed_pet_menu(script: Option<NonSendMut<UiScript>>, bar: Res<PetBar>, pet: PetUnit) {
|
||||
let Some(mut script) = script else {
|
||||
return;
|
||||
@@ -99,13 +49,8 @@ pub(super) fn feed_pet_menu(script: Option<NonSendMut<UiScript>>, bar: Res<PetBa
|
||||
script.set_pet_menu(can_be_abandoned, can_be_renamed);
|
||||
}
|
||||
|
||||
/// Drain the menu's three verbs onto the wire.
|
||||
///
|
||||
/// Each carries [`PetBar`]'s cached pet guid rather than anything the VM supplied — the drain's own
|
||||
/// rule, and here it also means a verb queued in the frame the pet left dies rather than naming a
|
||||
/// unit that is gone. Nothing is applied locally: unlike the bar's presses, all three ask the
|
||||
/// *server* for something it may refuse, and it answers by removing the pet or by bumping its name
|
||||
/// timestamp.
|
||||
/// Send the menu's verbs under [`PetBar`]'s pet guid. Nothing is applied locally: the server
|
||||
/// answers by removing the pet or moving its name timestamp.
|
||||
pub(super) fn drain_pet_menu(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
bar: Res<PetBar>,
|
||||
@@ -126,15 +71,12 @@ pub(super) fn drain_pet_menu(
|
||||
return;
|
||||
}
|
||||
|
||||
// Counted rather than deduped: a double click on the confirm is two presses, and the reference
|
||||
// sends two.
|
||||
// Counted, not deduped: the reference sends one per press.
|
||||
for _ in 0..abandons {
|
||||
debug!("ui_pet: abandoning pet {pet_guid:#x}");
|
||||
let _ = commands.0.send(ClientCommand::PetAbandon { pet_guid });
|
||||
}
|
||||
// Dismiss is a bar press with no button (the header): the word `0x07000003` down the ordinary
|
||||
// `CMSG_PET_ACTION` path, target 0. Built from the constants rather than written as the literal
|
||||
// so it stays the same word `PET_COMMAND_DISMISS` means everywhere else in this folder.
|
||||
// `0x4bd6e0` stages `0x07000003` at target 0.
|
||||
let dismiss_word = PET_COMMAND_DISMISS | (u32::from(PET_ACT_COMMAND) << 24);
|
||||
for _ in 0..dismisses {
|
||||
debug!("ui_pet: dismissing pet {pet_guid:#x} ({dismiss_word:#010x})");
|
||||
@@ -145,9 +87,7 @@ pub(super) fn drain_pet_menu(
|
||||
});
|
||||
}
|
||||
for name in renames {
|
||||
// The sender's own two steps, in its order (`0x4bd840`): an empty name is refused to the
|
||||
// error line and sends nothing, then the rest is truncated silently. Neither is the
|
||||
// dialog's 12-letter cap — this is the layer under it.
|
||||
// `0x4bd840`: an empty name raises an error and sends nothing; the rest is truncated.
|
||||
if name.is_empty() {
|
||||
ui_errors.0.push(UiError::key("ERR_NULL_PETNAME"));
|
||||
continue;
|
||||
@@ -158,12 +98,8 @@ pub(super) fn drain_pet_menu(
|
||||
}
|
||||
}
|
||||
|
||||
/// [`PET_NAME_MAX`]'s truncation, on **character** boundaries.
|
||||
///
|
||||
/// The reference counts bytes into a fixed buffer; we cannot, because a mid-character cut would not
|
||||
/// be a shorter name but an invalid string. Every name a 1.12 client can produce is well inside the
|
||||
/// cap either way — the clamp is here so a scripted `PetRename` cannot hand the wire something the
|
||||
/// server will read differently than we meant.
|
||||
/// [`PET_NAME_MAX`]'s truncation. Deviation: it counts characters where the reference counts
|
||||
/// bytes, because a byte cut can split a UTF-8 character.
|
||||
fn clamp_pet_name(name: &str) -> String {
|
||||
name.chars().take(PET_NAME_MAX).collect()
|
||||
}
|
||||
|
||||
@@ -1,41 +1,7 @@
|
||||
//! The pet system — the app side of `benilla_ui::script::pet`'s seam (decision 0982), and the
|
||||
//! home of [`PetBar`], the server-authoritative state `net/apply/pet.rs` writes.
|
||||
//!
|
||||
//! **One concern per file** (the split owed by decision 1003, restated by 1005, done in 1066). What
|
||||
//! decides the cut is *which clock a thing moves on*, which is also why these never wanted to be
|
||||
//! one file:
|
||||
//!
|
||||
//! | module | what it owns | moves on |
|
||||
//! |---|---|---|
|
||||
//! | [`bar`] | the action bar's **feed** — ten packed words rendered as ten `PetActionView`s | `SMSG_PET_SPELLS` |
|
||||
//! | [`unit`] | the **`"pet"` unit token** and the pet frame's events | the pet's descriptor |
|
||||
//! | [`drain`] | the bar's click **intents** → the wire, and the state they latch locally | a click |
|
||||
//! | [`menu`] | the **right-click menu**: two predicates, three verbs | a menu pick |
|
||||
//!
|
||||
//! [`PetBar`], [`PetUnit`] and [`UiPetPlugin`] stay here, because every module reads them.
|
||||
//!
|
||||
//! **The slot law** (decode and constants in `benilla_protocol::messages::pet`, VERIFIED against
|
||||
//! the client itself in decision 0988): each slot is one packed word whose type — bits 24–29,
|
||||
//! **masked** — decides its whole reading:
|
||||
//!
|
||||
//! - `PET_ACT_COMMAND 7` — `action` is a `CommandStates`: Stay 0 / Follow 1 / Attack 2 / Dismiss 3.
|
||||
//! A token: `GetPetActionInfo` returns the NAMES of two globals rather than values (the
|
||||
//! reference's own `PetActionBarFrame.lua:98-104` fork), which `PetActionBar.xml` resolves —
|
||||
//! `PET_ACTION_*` out of the shipped `GlobalStrings.lua`, `PET_*_TEXTURE` out of the constants
|
||||
//! that file declares and ours quotes.
|
||||
//! - `PET_ACT_REACTION 6` — `action` is a `ReactStates`: Passive 0 / Defensive 1 / Aggressive 2.
|
||||
//! Also a token.
|
||||
//! - types **1–5** — `action` is a spell id, and the autocast pair rides in bits 31/30 rather than
|
||||
//! in the type. That split is why the server's `ACT_PASSIVE`/`ACT_DISABLED`/`ACT_ENABLED` are
|
||||
//! one branch here and not three.
|
||||
//!
|
||||
//! The two halves of `GetPetActionInfo`'s signature never overlap: `isActive` is a **token**
|
||||
//! concept and `autoCast*` a **spell** one, each nil on the other's branch.
|
||||
//!
|
||||
//! The **3 commands / 4 spells / 3 reactions** arrangement everyone recognises is therefore
|
||||
//! *server data*, not a layout: it is what `CharmInfo::InitPetActionBar` happens to write, and a
|
||||
//! possessed minion or a charmed creature arrives with a different mix through the same ten words.
|
||||
//! Nothing in this folder or in `PetActionBar.xml` assumes it.
|
||||
//! The pet system: [`PetBar`], the pet action bar's state, and the app side of
|
||||
//! `benilla_ui::script::pet`. The usual three commands, four spells and three reactions are server
|
||||
//! data (vmangos `CharmInfo::InitPetActionBar`), not a layout: a possessed or charmed unit fills
|
||||
//! the same ten words differently, so nothing here assumes them.
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
@@ -60,79 +26,28 @@ use unit::feed_pet_unit;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
/// The pet action bar's mirror of the server's state — written only by `net/apply/pet.rs`, read
|
||||
/// only by [`feed_pet_bar`] and [`drain_pet_actions`].
|
||||
///
|
||||
/// `spells.pet_guid == 0` is "there is no pet bar", and it is the single test: the teardown packet
|
||||
/// carries exactly that and nothing else.
|
||||
///
|
||||
/// The session end resets it the same way (`net::on_session_end`): a dropped socket sends no teardown.
|
||||
/// The pet action bar's state. A zero `spells.pet_guid` means no bar: the teardown packet carries
|
||||
/// only that guid.
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct PetBar {
|
||||
/// The last `SMSG_PET_SPELLS` in full, with `SMSG_PET_MODE`'s state edits folded in.
|
||||
/// The last `SMSG_PET_SPELLS`, with `SMSG_PET_MODE` and local presses folded in.
|
||||
pub(crate) spells: PetSpells,
|
||||
/// The **pet's own** cooldown store — a second [`Cooldowns`] instance, not the player's. The
|
||||
/// two must not share: a hunter and their pet both know Growl-shaped spells on independent
|
||||
/// timers, and the reference keeps a `SPELLHISTORY` list per unit for exactly this reason.
|
||||
/// Seeded from the `SMSG_PET_SPELLS` tail and topped up by `SMSG_SPELL_COOLDOWN` addressed to
|
||||
/// the pet's guid.
|
||||
/// The pet's own cooldowns: the reference keeps a `SPELLHISTORY` per unit. Seeded from
|
||||
/// `SMSG_PET_SPELLS`, topped up by `SMSG_SPELL_COOLDOWN` for the pet's guid.
|
||||
pub(crate) cooldowns: Cooldowns,
|
||||
/// **"The possessed unit is attacking"** — the client's own `[0xb714b0]`, a purely local latch
|
||||
/// with no field behind it anywhere, and **the possess bar's, not the pet bar's**.
|
||||
///
|
||||
/// It is `IsPetAttackActive` entire, and `GetPetActionInfo`'s COMMAND branch ORs it into
|
||||
/// `isActive` for action 2 (`0x4bdf16`–`0x4bdf22`), so it is also the only thing that can ever
|
||||
/// light an Attack button. What gates the one write that raises it is the whole story
|
||||
/// ([`possessing`]): `0x4bd420` demands `0x5ee5a0(player) == the bar's unit`, and `0x5ee5a0`
|
||||
/// answers the **possessed** unit or nothing. A hunter's pet is not possessed, so on an
|
||||
/// ordinary pet bar this latch **can never rise** — which is exactly the director's report:
|
||||
/// Attack is a button you press, never a mode that stays lit.
|
||||
///
|
||||
/// The three ways down are transcribed anyway, because under possession they are live: the lit
|
||||
/// button's own second press (Lua `PetStopAttack` → `0x4bd650`), a new pet (`0x4bc8ce`), and
|
||||
/// the **old-target clear `0x493910` at `0x493a18`** ([`pet_stop_on_old_target_clear`]), which
|
||||
/// every selection writer runs.
|
||||
///
|
||||
/// benilla once read this off the pet's streamed `UNIT_FIELD_TARGET`. That is a
|
||||
/// different question with a different answer: a defensive pet that retaliates on its own has
|
||||
/// a target the player never ordered, and the reference does not light the Attack button for
|
||||
/// it either.
|
||||
///
|
||||
/// **It is not the source of `PET_ATTACK_START`/`PET_ATTACK_STOP`**, however much it reads like
|
||||
/// one — decision 0990 made exactly that inference and it was wrong. Those ride the pet's
|
||||
/// server-owned `UNIT_FIELD_FLAGS` bit `0x800` instead ([`feed_pet_unit`]). This latch answers
|
||||
/// "did the player order the unit they are *driving* to attack"; the flag answers "is the pet
|
||||
/// fighting", and it is the flag that moves for a hunter.
|
||||
/// The client's `[0xb714b0]`, a local latch: all of `IsPetAttackActive`, and the only thing
|
||||
/// that lights Attack (`0x4bdf16`-`0x4bdf22`). Raised only for a possessed unit (`0x4bd420`),
|
||||
/// so never on a hunter's bar; lowered by `PetStopAttack` (`0x4bd650`), a new pet (`0x4bc8ce`)
|
||||
/// and the old-target clear (`0x493a18`). `PET_ATTACK_*` read the pet's flag `0x800` instead.
|
||||
pub(crate) attacking: bool,
|
||||
/// **`SignalEvent(0x161 PET_BAR_UPDATE)`, counted** — the half of a press that is not the state
|
||||
/// write and matters just as much.
|
||||
///
|
||||
/// Both state writers end on an **unconditional** signal, with no compare of the old value
|
||||
/// against the new one:
|
||||
///
|
||||
/// ```text
|
||||
/// 0x4bc940 [0xb71468] = state & 0xFFFFFF00 | action ; ecx = 0x161 ; jmp SignalEvent (reaction)
|
||||
/// 0x4bc960 [0xb71468] = state & 0x080000FF | a << 8 ; ecx = 0x161 ; jmp SignalEvent (command)
|
||||
/// ```
|
||||
///
|
||||
/// That is not redundancy, it is load-bearing, and missing it is visible on the first click:
|
||||
/// the CheckButton widget flips itself before `OnClick` runs and the reference's first line is
|
||||
/// `this:SetChecked(0)`, so **every** press starts by taking the light off. What puts it back
|
||||
/// is `PetActionBar_Update` running on this signal and re-deriving `isActive`. Press the mode
|
||||
/// the pet is *already* in and nothing about the state changes — but the signal still fires and
|
||||
/// the light still comes back.
|
||||
///
|
||||
/// benilla's feed dedups on what it last pushed ([`PetBarMemory`]), which is right for a
|
||||
/// per-frame feed and wrong here: a no-change press pushed nothing, fired nothing, and left the
|
||||
/// button visually un-toggled with no way back until something else moved. So the counter is
|
||||
/// folded into the dedup key — a bump is a forced repaint, which is exactly what the signal
|
||||
/// buys the reference. Wrapping, because only its *changes* are ever read.
|
||||
/// `PET_BAR_UPDATE` signals, counted. Both state writes signal unconditionally (`0x4bc940`,
|
||||
/// `0x4bc960`), and `OnClick`'s `SetChecked(0)` needs that repaint to relight a press on the
|
||||
/// current mode, so the count is in the feed's dedup key. Wrapping: only a change is read.
|
||||
pub(crate) bar_signals: u32,
|
||||
}
|
||||
|
||||
impl PetBar {
|
||||
/// `PetHasActionBar()` — is there a bar at all. The client's own gate is exactly this, a
|
||||
/// nonzero cached pet guid: no alive check, no control check (`0x4bdc20`).
|
||||
/// `PetHasActionBar()`: a nonzero cached guid, with no alive or control check (`0x4bdc20`).
|
||||
pub(crate) fn has_bar(&self) -> bool {
|
||||
self.spells.pet_guid != 0
|
||||
}
|
||||
@@ -146,17 +61,14 @@ impl Plugin for UiPetPlugin {
|
||||
app.init_resource::<PetBar>().add_systems(
|
||||
Update,
|
||||
(
|
||||
// Every feed rides with the unit feed (before the VM ticks, like the stance bar's);
|
||||
// each drain runs after the input pass so a click goes out the same frame.
|
||||
// The old-target clear runs BEFORE the feed so the Attack button goes out in the
|
||||
// same frame the selection moved, not one behind it.
|
||||
// Feeds ride the unit feed, before the VM ticks; drains run after the input pass so
|
||||
// a click is sent that frame. The old-target clear precedes the bar feed so Attack
|
||||
// goes dark the frame the selection moves.
|
||||
pet_stop_on_old_target_clear
|
||||
.in_set(UnitFeed)
|
||||
.before(feed_pet_bar),
|
||||
// …and both event-firing feeds run AFTER the pet snapshot (decision 1073). Their
|
||||
// events reach Lua synchronously, and the handlers read `HasPetUI()` — which is
|
||||
// `crate::ui_pet_stats`'s push, not ours. Unordered, a cold summon fired both
|
||||
// edges against last frame's answer and the Pet tab never came up.
|
||||
// After the pet snapshot: these feeds' events reach Lua at once, and their
|
||||
// handlers read `HasPetUI()`, which `crate::ui_pet_stats` pushes.
|
||||
feed_pet_bar
|
||||
.in_set(UnitFeed)
|
||||
.after(crate::ui_pet_stats::PetSnapshot),
|
||||
@@ -171,35 +83,25 @@ impl Plugin for UiPetPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
/// **The unit behind [`PetBar`]'s cached guid, plus our own identity** — the one lookup both pet
|
||||
/// systems need and neither owns.
|
||||
///
|
||||
/// It exists because the client's two newest pet answers are both *about the pet's own
|
||||
/// descriptor* rather than about the bar: the `PET_ATTACK_*` edge reads the pet's
|
||||
/// `UNIT_FIELD_FLAGS`, and the ATTACK order's validator reads the pet's whole eligibility. Both
|
||||
/// also need the active player's guid, because both test **ownership** before they trust what they
|
||||
/// read (`0x5ff780` and `0x612e33`).
|
||||
/// The unit behind [`PetBar`]'s cached guid, plus our own guid for the ownership tests the
|
||||
/// reference makes before trusting the pet's fields (`0x5ff780`, `0x612e33`).
|
||||
#[derive(bevy::ecs::system::SystemParam)]
|
||||
pub(crate) struct PetUnit<'w, 's> {
|
||||
index: Res<'w, GuidIndex>,
|
||||
stores: Query<'w, 's, &'static ObjectStore>,
|
||||
self_guid: Res<'w, crate::net::SelfGuid>,
|
||||
/// The per-field edges (decision 2297), for `fire_transitions`' watch-bridge arms.
|
||||
/// The per-field edges, for `fire_transitions`' watch-bridge arms.
|
||||
pub(super) edges: MessageReader<'w, 's, crate::net::FieldChanged>,
|
||||
}
|
||||
|
||||
impl PetUnit<'_, '_> {
|
||||
/// The pet's streamed descriptor, or `None` while the bar names a guid whose object has not
|
||||
/// arrived (or has left) — the case the reference treats as "no pet", never as "a bad pet".
|
||||
/// The pet's descriptor, `None` while its object is not streamed: the reference's "no pet".
|
||||
pub(crate) fn store(&self, pet_guid: u64) -> Option<&ObjectStore> {
|
||||
let e = *self.index.0.get(&pet_guid)?;
|
||||
self.stores.get(e).ok()
|
||||
}
|
||||
|
||||
/// The pet's ECS **entity**, under [`Self::store`]'s exact contract — `None` while the named
|
||||
/// guid's object has not arrived (or has left). The pet paper doll's body booth needs the
|
||||
/// entity itself, not its fields (decision 1057, `crate::ui_pet_doll`); gating it on the same
|
||||
/// store presence keeps "there is a pet" one answer rather than two that can disagree.
|
||||
/// The pet's entity under [`Self::store`]'s contract, for the pet paper doll's model.
|
||||
pub(crate) fn entity(&self, pet_guid: u64) -> Option<Entity> {
|
||||
let e = *self.index.0.get(&pet_guid)?;
|
||||
self.stores.contains(e).then_some(e)
|
||||
|
||||
@@ -1,22 +1,7 @@
|
||||
//! The pet's packet handlers (decisions 0982, 0988, 2039; in the net handler table since 2321,
|
||||
//! moved out of the drain's pet arm file) — `SMSG_PET_SPELLS`, `SMSG_PET_MODE`,
|
||||
//! `SMSG_PET_ACTION_FEEDBACK`, `SMSG_PET_CAST_FAILED` folded into [`PetBar`], the three
|
||||
//! pet-feedback packets and the pet's voice.
|
||||
//!
|
||||
//! The whole file is short because `SMSG_PET_SPELLS` is not a delta — it **is** the bar, so
|
||||
//! applying it is a replace. These four arms own the bar's CONTENTS; its lit state is
|
||||
//! [`super`]'s, latched on the press because the server never answers one (that file's
|
||||
//! module doc has the why). The only state this file writes is what actually arrives on the wire.
|
||||
//!
|
||||
//! The two refusal arms share the player's red-line **queues** — one drain, one sink, one
|
||||
//! GlobalStrings lookup — but they do **not** share his message table, and believing they did is
|
||||
//! what decision 2033 corrects. The reference gives each of these packets its own handler with its
|
||||
//! own reason -> errorId map (`0x4bdb70` for the feedback byte, `0x6e8eb0` for the cast refusal),
|
||||
//! and both maps exist precisely to say "your **pet** is dead / rooted / out of range" where the
|
||||
//! player's says "you are". So the caster rides the queue as far as [`cast_fail`], which picks the
|
||||
//! table; everything downstream of that stays pet-unaware, as it should be.
|
||||
//!
|
||||
//! [`cast_fail`]: crate::ui_action
|
||||
//! The pet's packet handlers: `SMSG_PET_SPELLS` and `SMSG_PET_MODE` write [`PetBar`]; the
|
||||
//! refusals, the tame failure and the pet's sounds go to their queues. The pet's refusals share
|
||||
//! the player's red line but not its message tables: the reference maps them in their own handlers
|
||||
//! (`0x4bdb70`, `0x6e8eb0`) to say "your pet" where the player's say "you".
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
@@ -31,7 +16,6 @@ use super::PetBar;
|
||||
use crate::net::{GuidIndex, NetHandlerApp, PetDismissSoundMessage, PetTalkMessage};
|
||||
use crate::ui_action::{CastErrors, PetTameFailures, Spells, UiError, UiErrorKeys};
|
||||
|
||||
/// Register the pet's handlers — called from [`super::UiPetPlugin`].
|
||||
pub(super) fn register(app: &mut App) {
|
||||
use SessionEventKind as K;
|
||||
app.net_handler(K::PetSpells, on_spells)
|
||||
@@ -46,17 +30,11 @@ pub(super) fn register(app: &mut App) {
|
||||
.net_handler(K::Disconnected, on_session_end);
|
||||
}
|
||||
|
||||
/// The pet bar dies with the socket — a listener on the session end (a second handler on the kind,
|
||||
/// after the bridge's own teardown). The zero-guid `SMSG_PET_SPELLS` is the only in-session
|
||||
/// teardown, and a dropped socket never sends one: without this the next session kept the old
|
||||
/// pet's bar, pressed `CMSG_PET_ACTION` at a guid that no longer exists, and the stable read a live
|
||||
/// pet. The same reset as the zero guid's, cooldown store included.
|
||||
/// A lost socket sends no zero-guid `SMSG_PET_SPELLS`, so the session end resets the bar itself.
|
||||
fn on_session_end(In(_): In<SessionEvent>, mut bar: ResMut<PetBar>) {
|
||||
*bar = PetBar::default();
|
||||
}
|
||||
|
||||
/// The pet action bar (decision 0982) — server-authoritative, so PET_SPELLS is a wholesale
|
||||
/// replace and its zero-guid form is the teardown.
|
||||
fn on_spells(In(ev): In<SessionEvent>, catalog: Option<Res<Spells>>, mut bar: ResMut<PetBar>) {
|
||||
if let SessionEvent::PetSpells(spells) = ev {
|
||||
pet_spells(*spells, catalog.as_deref(), &mut bar);
|
||||
@@ -111,13 +89,8 @@ fn on_dismiss_sound(In(ev): In<SessionEvent>, mut sounds: MessageWriter<PetDismi
|
||||
}
|
||||
}
|
||||
|
||||
/// `SMSG_PET_SPELLS` — replace the whole bar, and reseed the pet's own cooldown store from the
|
||||
/// packet's tail.
|
||||
///
|
||||
/// A **zero guid** is the teardown (`Player::RemovePetActionBar`): the bar goes away and the
|
||||
/// cooldown store goes with it, because the next pet is a different unit with different timers.
|
||||
/// Everything else is a wholesale replace — including a re-send from the same pet, which is how a
|
||||
/// learned spell, a mode change or an autocast toggle actually reaches the bar.
|
||||
/// `SMSG_PET_SPELLS`: replace the whole bar and reseed the pet's cooldowns from its tail. A zero
|
||||
/// guid is the teardown (`Player::RemovePetActionBar`), cooldowns included.
|
||||
fn pet_spells(spells: PetSpells, catalog: Option<&Spells>, bar: &mut PetBar) {
|
||||
if spells.pet_guid == 0 {
|
||||
if bar.spells.pet_guid != 0 {
|
||||
@@ -126,10 +99,7 @@ fn pet_spells(spells: PetSpells, catalog: Option<&Spells>, bar: &mut PetBar) {
|
||||
*bar = PetBar::default();
|
||||
return;
|
||||
}
|
||||
// A pet-GUID CHANGE clears the attack latch (`0x4bc8ce`, the client's own single writer of the
|
||||
// pet guid does it unconditionally) — a freshly summoned pet is not attacking, whatever the
|
||||
// last one was doing. A re-send from the SAME pet leaves it alone: a learned spell must not
|
||||
// silently call the pet off.
|
||||
// A new pet guid clears the attack latch (`0x4bc8ce`); a re-send from the same pet keeps it.
|
||||
if bar.spells.pet_guid != spells.pet_guid {
|
||||
bar.attacking = false;
|
||||
}
|
||||
@@ -146,10 +116,6 @@ fn pet_spells(spells: PetSpells, catalog: Option<&Spells>, bar: &mut PetBar) {
|
||||
spells.spells.len(),
|
||||
spells.cooldowns.len(),
|
||||
);
|
||||
// The ten words, spelled out. Worth its own line rather than a raw hex dump: every question
|
||||
// this packet raises ("why is that slot empty", "why is nothing lit", "is that spell in the
|
||||
// catalog") is answered by seeing the type/action pair beside the name we resolved for it, and
|
||||
// reading that off `0xc1000bc2` by hand is exactly the step nobody does.
|
||||
debug!(
|
||||
"net: pet bar slots — {}",
|
||||
spells
|
||||
@@ -171,12 +137,7 @@ fn pet_spells(spells: PetSpells, catalog: Option<&Spells>, bar: &mut PetBar) {
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
);
|
||||
// The spell LIST, spelled out the same way and for the same reason (decision 1032). It is a
|
||||
// different question from the bar's — "why is the pet book shorter than the packet said" — and
|
||||
// the answer is always one of three things this line names: the id resolves to no `Spell.dbc`
|
||||
// record, it carries `DO_NOT_DISPLAY` (the book's whole add-gate, `0x4b2f90`), or it is in.
|
||||
// vmangos sends the pet's runtime passives here alongside its real spells, so the gap between
|
||||
// the two counts is usually large and entirely correct.
|
||||
// vmangos sends the pet's passives here too; the book skips `DO_NOT_DISPLAY` (`0x4b2f90`).
|
||||
debug!(
|
||||
"net: pet spellbook — {}",
|
||||
spells
|
||||
@@ -201,22 +162,17 @@ fn pet_spells(spells: PetSpells, catalog: Option<&Spells>, bar: &mut PetBar) {
|
||||
bar.spells = spells;
|
||||
}
|
||||
|
||||
/// `SMSG_PET_MODE` — the react/command state alone. Applied only when it names the pet whose bar
|
||||
/// we actually hold: a mode packet for a unit we have no bar for has nothing to write into, and
|
||||
/// taking its state anyway would light a reaction button on the wrong pet's bar.
|
||||
/// `SMSG_PET_MODE`: the state word alone, applied only to the bar of the pet it names.
|
||||
fn pet_mode(mode: PetMode, bar: &mut PetBar) {
|
||||
if bar.spells.pet_guid == 0 || bar.spells.pet_guid != mode.pet_guid {
|
||||
return;
|
||||
}
|
||||
debug!("net: pet mode — state {:#010x}", mode.state);
|
||||
// The whole dword, verbatim — the client stores this packet and `SMSG_PET_SPELLS`' state field
|
||||
// through the same writer (`0x4bc930`), and bit 27 can only ever arrive this way.
|
||||
// The whole dword, as the client stores both packets' state (`0x4bc930`).
|
||||
bar.spells.state = mode.state;
|
||||
}
|
||||
|
||||
/// `SMSG_PET_ACTION_FEEDBACK` — one reason byte for a refused order, queued onto the red line by
|
||||
/// GlobalStrings key ([`UiErrorKeys`], the `DisplayError` route). An unrecognised code queues
|
||||
/// nothing, exactly as an absent key shows nothing.
|
||||
/// `SMSG_PET_ACTION_FEEDBACK`: a refused order's reason byte, onto the red line.
|
||||
fn pet_action_feedback(reason: u8, errors: &mut UiErrorKeys) {
|
||||
debug!("net: pet action feedback {reason}");
|
||||
if let Some(key) = pet_feedback_key(reason) {
|
||||
@@ -224,31 +180,10 @@ fn pet_action_feedback(reason: u8, errors: &mut UiErrorKeys) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The `SMSG_PET_ACTION_FEEDBACK` reason → the message-catalog row the reference raises for it.
|
||||
///
|
||||
/// **The whole map, read off the handler** (`0x4bdb70`, decision 2033). The byte is decremented
|
||||
/// and bounded — `dec eax; cmp eax,0x3; ja` — then indexes the four-entry jump table at
|
||||
/// `0x4bdbe8`, so `0` and anything from `5` up fall past every arm and display nothing. Each arm
|
||||
/// is a bare `push <errorId>; call CGGameUI::DisplayError 0x496720`, with no argument and no
|
||||
/// state write: this opcode is a message and nothing else.
|
||||
///
|
||||
/// | byte | vmangos `PetFeedback` | errorId | catalog row |
|
||||
/// |---|---|---|---|
|
||||
/// | `1` | `FEEDBACK_PET_DEAD` | `0x150` | `ERR_PET_SPELL_DEAD` |
|
||||
/// | `2` | `FEEDBACK_NOTHING_TO_ATT` | `0x0a0` | `ERR_NO_ATTACK_TARGET` |
|
||||
/// | `3` | `FEEDBACK_CANT_ATT_TARGET` | `0x0a1` | `ERR_INVALID_ATTACK_TARGET` |
|
||||
/// | `4` | `FEEDBACK_NO_PATH_TO` | `0x151` | `ERR_PET_SPELL_NOPATH` |
|
||||
///
|
||||
/// The four line up one-for-one with `Pet.h`'s enum and with its four `SendPetActionFeedback`
|
||||
/// call sites. **That is what the two-entry map this replaces got wrong**: it read the sends as
|
||||
/// "exactly two" and paired them with `1`/`2`, so a dead pet's refusal said "No path available
|
||||
/// for your pet", a targetless attack said "Out of range", and `3`/`4` said nothing at all.
|
||||
///
|
||||
/// **`ERR_PET_SPELL_NOPATH` has no `GlobalStrings.lua` string in 5875**, so a no-path order is
|
||||
/// silent in the reference too — its own data-suppression face, not a gap here. The key
|
||||
/// `PET_SPELL_NOPATH` ("No path available for your pet") *does* exist, which is what made the old
|
||||
/// map look right on screen; it is not this row's key, and nothing in the shipped client raises
|
||||
/// it.
|
||||
/// The reference's map (`0x4bdb70`, jump table `0x4bdbe8`): vmangos `FEEDBACK_PET_DEAD`,
|
||||
/// `FEEDBACK_NOTHING_TO_ATT`, `FEEDBACK_CANT_ATT_TARGET` and `FEEDBACK_NO_PATH_TO` (1-4) raise
|
||||
/// errorIds `0x150`, `0xa0`, `0xa1` and `0x151` through `0x496720`; other bytes show nothing.
|
||||
/// `ERR_PET_SPELL_NOPATH` has no 1.12 string, so a no-path order is silent in the reference too.
|
||||
fn pet_feedback_key(reason: u8) -> Option<&'static str> {
|
||||
Some(match reason {
|
||||
1 => "ERR_PET_SPELL_DEAD",
|
||||
@@ -259,18 +194,8 @@ fn pet_feedback_key(reason: u8) -> Option<&'static str> {
|
||||
})
|
||||
}
|
||||
|
||||
/// `SMSG_PET_TAME_FAILURE` — the reason byte, straight onto [`PetTameFailures`] for the drain to
|
||||
/// resolve (decision 2039).
|
||||
///
|
||||
/// **Not only taming, despite the name**: vmangos raises it for Call Pet with no pet available
|
||||
/// (`SpellEffects.cpp:3167`), Revive Pet on a live pet (`Spell.cpp:6136`), and any summon while a
|
||||
/// pet is already out (`Spell.cpp:5463`) — which is why the reason vocabulary carries
|
||||
/// `PETTAME_NOPETAVAILABLE`, `PETTAME_DEAD` and `PETTAME_NOTDEAD`, three values the taming spell
|
||||
/// itself can never produce.
|
||||
///
|
||||
/// No local state moves: the refusal is the server's last word on a spell that never took, and
|
||||
/// the client's own cast bookkeeping was already unwound by the `SMSG_CAST_RESULT` that came with
|
||||
/// it.
|
||||
/// `SMSG_PET_TAME_FAILURE`: the reason byte, queued. vmangos also sends it for a summon while a
|
||||
/// pet or a charmed unit is out (`Spell.cpp:5467`).
|
||||
fn pet_tame_failure(reason: u8, failures: &mut PetTameFailures) {
|
||||
debug!(
|
||||
"net: pet tame failure {reason} ({})",
|
||||
@@ -279,42 +204,22 @@ fn pet_tame_failure(reason: u8, failures: &mut PetTameFailures) {
|
||||
failures.0.push(reason);
|
||||
}
|
||||
|
||||
/// `SMSG_PET_NAME_INVALID` — the server refused the rename; raise `ERR_INVALID_PETNAME`.
|
||||
///
|
||||
/// The packet carries nothing at all, so the opcode IS the message — which is exactly what the
|
||||
/// reference's arm does (`0x5e3e33`: `push 0xf7; call 0x496720`, no body read). Decision 1066
|
||||
/// recorded the opposite ("a refused rename silently does nothing"), attributing
|
||||
/// `SMSG_PET_BROKEN`'s handler to this opcode; the two are different functions.
|
||||
///
|
||||
/// Nothing else moves: the rename was optimistic-free by design (1066), so there is no local name
|
||||
/// to roll back, and the popup has already closed.
|
||||
/// `SMSG_PET_NAME_INVALID`, an empty body: the reference raises `ERR_INVALID_PETNAME` and reads
|
||||
/// nothing (`0x5e3e33`). No local name was applied, so nothing rolls back.
|
||||
fn pet_name_invalid(errors: &mut UiErrorKeys) {
|
||||
debug!("net: pet name refused");
|
||||
errors.0.push(UiError::key("ERR_INVALID_PETNAME"));
|
||||
}
|
||||
|
||||
/// `SMSG_PET_BROKEN` — the pet's loyalty hit zero and it left; raise `ERR_PET_BROKEN`
|
||||
/// ("Your pet has run away").
|
||||
///
|
||||
/// Empty body, and the reference's handler `0x4bdc00` is five instructions that read none of it:
|
||||
/// the *bar* teardown is not this packet's job. vmangos sends it immediately before
|
||||
/// `Unsummon(PET_SAVE_AS_DELETED)` (`Pet.cpp:822`), so the zero-guid `SMSG_PET_SPELLS` that
|
||||
/// actually clears the bar arrives on its own heels — which is why touching the bar here would be
|
||||
/// a second, racing teardown rather than a fix.
|
||||
/// `SMSG_PET_BROKEN`, an empty body: loyalty hit zero; raise `ERR_PET_BROKEN` (`0x4bdc00`). The
|
||||
/// zero-guid `SMSG_PET_SPELLS` of the unsummon right after it clears the bar (`Pet.cpp:822`).
|
||||
fn pet_broken(errors: &mut UiErrorKeys) {
|
||||
debug!("net: pet ran away");
|
||||
errors.0.push(UiError::key("ERR_PET_BROKEN"));
|
||||
}
|
||||
|
||||
/// `SMSG_PET_ACTION_SOUND` — the pet's voice: resolve the guid, hand the selector to the sound
|
||||
/// layer (decision 2039).
|
||||
///
|
||||
/// The reference resolves the packet's guid through the object manager and **drops the packet on
|
||||
/// a miss** (`0x604101 je`), which is what the index lookup below is: a bark for a unit that has
|
||||
/// not streamed in has nothing to play from and no position to play at.
|
||||
///
|
||||
/// The selector is not validated here — [`crate::sound::creature`]'s reader is where the
|
||||
/// two-armed `cmp` lives, because that is where the reference has it too (`0x604106`/`0x60411c`).
|
||||
/// `SMSG_PET_ACTION_SOUND`: dropped when the pet is not streamed (`0x604101`); the sound layer
|
||||
/// checks the selector ([`crate::sound::creature`], `0x604106`/`0x60411c`).
|
||||
fn pet_action_sound(
|
||||
pet_guid: u64,
|
||||
talk: u32,
|
||||
@@ -327,12 +232,8 @@ fn pet_action_sound(
|
||||
}
|
||||
}
|
||||
|
||||
/// `SMSG_PET_DISMISS_SOUND` — the parting sound's model id and point, converted into Bevy space
|
||||
/// for the sound layer (decision 2039).
|
||||
///
|
||||
/// The `+1.0` on `z` is the reference's own (`0x6041d0 fadd [0x7ff9d8]`), applied **before** the
|
||||
/// basis change because it is a WoW-space offset: the point on the wire is where the pet stood,
|
||||
/// and the kit sounds a yard above it.
|
||||
/// `SMSG_PET_DISMISS_SOUND`: sounded a yard above the wire point (`0x6041d0 fadd [0x7ff9d8]`),
|
||||
/// in WoW space, so before the change to Bevy's.
|
||||
fn pet_dismiss_sound(
|
||||
model_id: u32,
|
||||
position: [f32; 3],
|
||||
@@ -345,26 +246,9 @@ fn pet_dismiss_sound(
|
||||
});
|
||||
}
|
||||
|
||||
/// `SMSG_PET_CAST_FAILED` — the pet's cast refusal, queued onto the SAME red line as our own
|
||||
/// through [`CastErrors`], but **marked as the pet's** so the display picks the reference's pet
|
||||
/// message table rather than the player's (`push_pet`, decision 2033).
|
||||
///
|
||||
/// The reference handles this opcode in its own function, `Spell_C::HandlePetCastFailed`
|
||||
/// (`0x6e8eb0`), which is not the player's `0x6e1a00` with a flag — it is a separate switch over
|
||||
/// a separate 142-byte index table. Ten reasons resolve differently there, six of them to the
|
||||
/// `ERR_PET_SPELL_*` rows that exist for no other purpose ("Your pet is in combat." where the
|
||||
/// player reads "You are in combat"). The queue is shared; the table is not.
|
||||
///
|
||||
/// Two things it deliberately does NOT do, both because the caster is the pet:
|
||||
///
|
||||
/// - **Touch our cast state.** There is no pending cast of ours to revert, no GCD of ours to
|
||||
/// clear and no button of ours to unflash — the three things `cast_result`'s own failure path
|
||||
/// does. Reusing that path would have made a pet's refused Growl cancel the player's cast bar.
|
||||
/// - **Write a combat-log line.** `0x6e1a00` calls the log formatter `0x62c360` beside its
|
||||
/// `DisplayError`; `0x6e8eb0` calls neither it nor the error-sound `0x458a50` — its whole call
|
||||
/// set is the two packet readers, `0x496720`, and the string plumbing behind it. So a pet's
|
||||
/// refusal never prints "You fail to cast Growl: ..." in the log, and the drain's combat twin
|
||||
/// skips it.
|
||||
/// `SMSG_PET_CAST_FAILED`: queued as the pet's, so the display uses the reference's pet table
|
||||
/// (`0x6e8eb0`, not the player's `0x6e1a00`). Our cast state is untouched, and unlike the
|
||||
/// player's it writes no combat-log line and plays no error sound (`0x62c360`, `0x458a50`).
|
||||
fn pet_cast_failed(spell_id: u32, reason: Option<u8>, errors: &mut CastErrors) {
|
||||
debug!("net: pet cast failed — spell {spell_id} reason {reason:?}");
|
||||
if let Some(reason) = reason {
|
||||
@@ -387,8 +271,6 @@ mod tests {
|
||||
s
|
||||
}
|
||||
|
||||
/// The teardown clears the bar AND its cooldown store — the next pet is a different unit, so
|
||||
/// carrying its predecessor's timers would sweep the new pet's buttons for no reason.
|
||||
#[test]
|
||||
fn a_zero_guid_tears_the_whole_bar_down() {
|
||||
let mut bar = PetBar::default();
|
||||
@@ -400,8 +282,6 @@ mod tests {
|
||||
assert!(!bar.has_bar());
|
||||
}
|
||||
|
||||
/// A mode packet for a DIFFERENT unit is dropped — it would otherwise light a reaction button
|
||||
/// on the bar we are holding for somebody else's pet.
|
||||
#[test]
|
||||
fn pet_mode_only_writes_its_own_bar() {
|
||||
let mut bar = PetBar::default();
|
||||
@@ -425,13 +305,10 @@ mod tests {
|
||||
);
|
||||
assert_eq!(bar.spells.react_state(), 2);
|
||||
assert_eq!(bar.spells.command_state(), 2);
|
||||
// The bar itself is untouched — PET_MODE carries no slots.
|
||||
assert_eq!(bar.spells.bar[0].action(), PET_COMMAND_ATTACK);
|
||||
}
|
||||
|
||||
/// The attack latch is cleared by a pet CHANGE and survives a re-send from the same pet — the
|
||||
/// client's own single pet-guid writer does exactly this (`0x4bc8ce`). A learned spell must
|
||||
/// not silently call the pet off; a new pet must not inherit the last one's attack.
|
||||
/// The client's pet-guid writer clears the latch only on a change (`0x4bc8ce`).
|
||||
#[test]
|
||||
fn the_attack_latch_survives_a_resend_and_dies_on_a_new_pet() {
|
||||
let mut bar = PetBar::default();
|
||||
@@ -445,13 +322,6 @@ mod tests {
|
||||
assert!(!bar.attacking, "a different pet is not attacking");
|
||||
}
|
||||
|
||||
/// The whole feedback map, in the reference's own order (`0x4bdbe8`) — and the bound that
|
||||
/// makes `0` and everything from `5` up draw nothing rather than putting a bare number on the
|
||||
/// red line.
|
||||
///
|
||||
/// Written as the four keys in sequence rather than four asserts because the defect this
|
||||
/// replaces was an *ordering* one: a two-entry map that paired the right vocabulary with the
|
||||
/// wrong bytes still looked plausible read one row at a time.
|
||||
#[test]
|
||||
fn the_feedback_map_is_the_jump_table_at_0x4bdbe8() {
|
||||
let mut errors = UiErrorKeys::default();
|
||||
@@ -469,12 +339,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Every arm names a real `DisplayError` row — which is the check the old map could not have
|
||||
/// passed, and was never asked to: neither `PET_SPELL_NOPATH` nor `SPELL_FAILED_OUT_OF_RANGE`
|
||||
/// is a catalog key, and `message_keys`' source walk only reads `ERR_*` literals, so both
|
||||
/// slipped past it. Naming the rows is also what gives these lines their surface and their
|
||||
/// error-speech: `ERR_NO_ATTACK_TARGET` and `ERR_INVALID_ATTACK_TARGET` carry type tags
|
||||
/// `0x26`/`0x0b`, so the client speaks them.
|
||||
#[test]
|
||||
fn every_feedback_arm_is_a_catalog_row() {
|
||||
for reason in 1..=4u8 {
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
//! The pet system's unit tests — one module for all three concerns, because they share one set of
|
||||
//! fixtures (a packed word, a slot view, a `PetSpells` state) and splitting them per module would
|
||||
//! fork those three ways.
|
||||
//! The pet system's tests, in one module because they share their fixtures.
|
||||
|
||||
use benilla_protocol::messages::{
|
||||
PetActionEntry, PetSpells, PET_ACT_COMMAND, PET_ACT_DISABLED, PET_ACT_ENABLED, PET_ACT_PASSIVE,
|
||||
@@ -22,9 +20,7 @@ fn packed(action: u32, kind: u8) -> PetActionEntry {
|
||||
PetActionEntry::from(action | (u32::from(kind) << 24))
|
||||
}
|
||||
|
||||
/// [`slot_view`] for a slot that is **not** showing active — the ordinary case, and the only
|
||||
/// one the token/spell/cooldown tests care about. The showing-active leg has its own tests,
|
||||
/// which call `slot_view` directly so the flag is visible at the call.
|
||||
/// [`slot_view`] for a slot not showing active.
|
||||
fn view(
|
||||
entry: PetActionEntry,
|
||||
bar: &PetSpells,
|
||||
@@ -53,8 +49,6 @@ fn state(command: u32, react: u32) -> PetSpells {
|
||||
}
|
||||
}
|
||||
|
||||
/// A command token returns GLOBAL NAMES for both name and texture, and is lit exactly when it
|
||||
/// IS the pet's current command — the whole reason three command buttons can share one state.
|
||||
#[test]
|
||||
fn command_tokens_light_on_the_current_command() {
|
||||
let bar = state(PET_COMMAND_FOLLOW, PET_REACT_DEFENSIVE);
|
||||
@@ -81,9 +75,6 @@ fn command_tokens_light_on_the_current_command() {
|
||||
assert!(!stay.active, "only the CURRENT command is lit");
|
||||
}
|
||||
|
||||
/// The Attack button's second clause: the attack latch lights it and arms the call-off fork
|
||||
/// **independently of the command state**, which is how a pet told to Follow can still show
|
||||
/// Attack lit while it is on something.
|
||||
#[test]
|
||||
fn the_attack_latch_lights_attack_whatever_the_command_state_says() {
|
||||
let following = state(PET_COMMAND_FOLLOW, PET_REACT_DEFENSIVE);
|
||||
@@ -96,7 +87,6 @@ fn the_attack_latch_lights_attack_whatever_the_command_state_says() {
|
||||
assert!(on.active, "the latch lights it even on a FOLLOW command");
|
||||
assert!(on.attack_active, "and the next press calls the pet off");
|
||||
|
||||
// The latch never reaches another command's button, however busy the pet is.
|
||||
let follow = view(
|
||||
packed(PET_COMMAND_FOLLOW, PET_ACT_COMMAND),
|
||||
&following,
|
||||
@@ -108,8 +98,6 @@ fn the_attack_latch_lights_attack_whatever_the_command_state_says() {
|
||||
assert!(follow.active, "…but Follow is still the standing command");
|
||||
}
|
||||
|
||||
/// Reaction tokens use the `PET_MODE_*` keys — the state words — not the right-click menu's
|
||||
/// `PET_AGGRESSIVE` imperatives. Identical in enUS, distinguishable only by key.
|
||||
#[test]
|
||||
fn reaction_tokens_use_the_mode_keys_and_light_on_the_current_react() {
|
||||
let bar = state(PET_COMMAND_FOLLOW, PET_REACT_DEFENSIVE);
|
||||
@@ -135,9 +123,6 @@ fn reaction_tokens_use_the_mode_keys_and_light_on_the_current_react() {
|
||||
);
|
||||
}
|
||||
|
||||
/// A DISABLED bar (bit 27) changes what both token classes report, and neither is a special
|
||||
/// case we wrote: the reaction compare's left side is forced to Passive, and the command
|
||||
/// compare — unmasked `state >> 8` — is put out of range of every command at once.
|
||||
#[test]
|
||||
fn a_disabled_bar_reads_passive_and_lights_no_command() {
|
||||
let mut bar = state(PET_COMMAND_FOLLOW, PET_REACT_DEFENSIVE);
|
||||
@@ -175,8 +160,6 @@ fn a_disabled_bar_reads_passive_and_lights_no_command() {
|
||||
);
|
||||
}
|
||||
|
||||
/// `GetPetActionsUsable` — bit 27 OR the pet's own crowd-control flags. The second half is the
|
||||
/// leg benilla was missing: a feared/stunned/confused pet's bar greys.
|
||||
#[test]
|
||||
fn usability_is_the_disabled_bit_and_the_pets_crowd_control() {
|
||||
let mut bar = PetBar {
|
||||
@@ -192,15 +175,13 @@ fn usability_is_the_disabled_bit_and_the_pets_crowd_control() {
|
||||
for flag in [0x0004_0000, 0x0040_0000, 0x0080_0000] {
|
||||
assert!(!actions_usable(&bar, Some(flag)), "flag {flag:#x} disables");
|
||||
}
|
||||
// POSSESSED is deliberately not one of them — that IS the pet-bar case.
|
||||
// Those three are stunned, confused, fleeing; POSSESSED is not among them.
|
||||
assert!(actions_usable(&bar, Some(0x0100_0000)));
|
||||
|
||||
bar.spells.state |= PET_STATE_BAR_DISABLED;
|
||||
assert!(!actions_usable(&bar, Some(0)));
|
||||
}
|
||||
|
||||
/// A spell slot: real name, rank subtext, icon PATH — and the autocast pair read off **bits
|
||||
/// 31/30**, not the type byte. `isActive` is nil on every spell path.
|
||||
#[test]
|
||||
fn spell_slots_read_their_autocast_off_bits_31_and_30() {
|
||||
let bar = state(PET_COMMAND_FOLLOW, PET_REACT_DEFENSIVE);
|
||||
@@ -229,7 +210,6 @@ fn spell_slots_read_their_autocast_off_bits_31_and_30() {
|
||||
);
|
||||
assert!(off.autocast_allowed && !off.autocast_enabled);
|
||||
|
||||
// A passive pet spell shows, but can never autocast — no ring, no sparkle.
|
||||
let passive = view(
|
||||
packed(3010, PET_ACT_PASSIVE),
|
||||
&bar,
|
||||
@@ -240,9 +220,7 @@ fn spell_slots_read_their_autocast_off_bits_31_and_30() {
|
||||
assert!(!passive.autocast_allowed && !passive.autocast_enabled);
|
||||
}
|
||||
|
||||
/// The two routes to an empty-looking button: the client's zero WORD, and vmangos's own
|
||||
/// `(0, ACT_DISABLED)` filler, which takes the spell branch and misses the catalog. Both must
|
||||
/// draw nothing — reading either one wrong puts four "?" buttons mid-bar.
|
||||
/// The client's zero word, and vmangos's `(0, ACT_DISABLED)` filler, which misses the catalog.
|
||||
#[test]
|
||||
fn both_kinds_of_empty_slot_draw_nothing() {
|
||||
let bar = state(PET_COMMAND_FOLLOW, PET_REACT_DEFENSIVE);
|
||||
@@ -259,9 +237,7 @@ fn both_kinds_of_empty_slot_draw_nothing() {
|
||||
assert_eq!(
|
||||
view(filler, &bar, None, None, false),
|
||||
PetActionView {
|
||||
// Draws nothing and still CARRIES its word (decision 1010) — this is the exact
|
||||
// slot the drop core hunts for as a relocation candidate (type 1, low 16 zero),
|
||||
// so zeroing it here would both send the wrong word and lose the candidate.
|
||||
// The word stays: it is the drag's relocation candidate.
|
||||
packed: filler.packed,
|
||||
..Default::default()
|
||||
},
|
||||
@@ -269,8 +245,6 @@ fn both_kinds_of_empty_slot_draw_nothing() {
|
||||
);
|
||||
}
|
||||
|
||||
/// A spell the catalog cannot name draws nothing rather than a nameless icon — and with it
|
||||
/// goes the autocast pair, which the client also gates on the record resolving.
|
||||
#[test]
|
||||
fn an_unresolvable_spell_draws_nothing() {
|
||||
let bar = state(PET_COMMAND_FOLLOW, PET_REACT_DEFENSIVE);
|
||||
@@ -286,9 +260,7 @@ fn an_unresolvable_spell_draws_nothing() {
|
||||
assert!(!v.autocast_allowed && !v.autocast_enabled);
|
||||
}
|
||||
|
||||
/// A type outside 1–7 is inert. The client's own default arm under-pushes here (`0x4bde6f`);
|
||||
/// we answer the empty slot instead, and in particular never reach the spell catalog with an
|
||||
/// arbitrary number.
|
||||
/// A type outside 1-7 draws empty; the reference's default arm under-pushes there (`0x4bde6f`).
|
||||
#[test]
|
||||
fn an_unknown_type_byte_is_inert() {
|
||||
let bar = state(PET_COMMAND_FOLLOW, PET_REACT_DEFENSIVE);
|
||||
@@ -303,8 +275,6 @@ fn an_unknown_type_byte_is_inert() {
|
||||
);
|
||||
}
|
||||
|
||||
/// The slot index map is 1-based and bounded — a stale index from the VM cannot read past the
|
||||
/// ten words or wrap into slot 10.
|
||||
#[test]
|
||||
fn slot_lookup_is_one_based_and_bounded() {
|
||||
let mut bar = PetBar {
|
||||
@@ -322,10 +292,6 @@ fn slot_lookup_is_one_based_and_bounded() {
|
||||
assert!(slot_entry_mut(&mut bar, 11).is_none());
|
||||
}
|
||||
|
||||
/// The local latch, which exists because the server confirms none of it (module doc), written
|
||||
/// with the client's own masks: a command press moves bits 8–15 and keeps byte 0 **and bit
|
||||
/// 27**, a reaction press moves byte 0 only, a spell press moves neither — and DISMISS moves
|
||||
/// nothing, because it ends the pet rather than becoming its standing order.
|
||||
#[test]
|
||||
fn a_press_latches_the_state_the_server_never_echoes() {
|
||||
let mut bar = PetBar {
|
||||
@@ -367,16 +333,8 @@ fn a_press_latches_the_state_the_server_never_echoes() {
|
||||
);
|
||||
}
|
||||
|
||||
/// **The director's report: clicking a lit Follow or Passive un-toggles it.**
|
||||
///
|
||||
/// The CheckButton flips itself before `OnClick` runs and the reference's first line is
|
||||
/// `this:SetChecked(0)`, so every press starts by taking the light off. `0x4bc940`/`0x4bc960`
|
||||
/// put it back by signalling `PET_BAR_UPDATE` **unconditionally** — no old-vs-new compare — so
|
||||
/// pressing the mode the pet is already in still repaints. Our feed dedups on what it pushed,
|
||||
/// so without a counter in that key the light had no way back.
|
||||
///
|
||||
/// A press that the reference does NOT signal must not bump: DISMISS and a spell reach
|
||||
/// `0x4bd444` straight, and a hunter's ATTACK skips `0x4bd429` with the latch it never raises.
|
||||
/// `OnClick`'s `SetChecked(0)` unlights every press; `0x4bc940`/`0x4bc960` relight it by
|
||||
/// signalling unconditionally, and a hunter's Attack skips the signal at `0x4bd429`.
|
||||
#[test]
|
||||
fn every_press_the_reference_signals_forces_a_repaint() {
|
||||
let mut bar = PetBar {
|
||||
@@ -385,8 +343,7 @@ fn every_press_the_reference_signals_forces_a_repaint() {
|
||||
};
|
||||
let signals = |b: &PetBar| b.bar_signals;
|
||||
|
||||
// Pressing the mode the pet is ALREADY in: nothing about the state moves, and the signal
|
||||
// still fires. This is the whole bug.
|
||||
// The mode the pet is already in: the state does not move, the signal still fires.
|
||||
let before = signals(&bar);
|
||||
latch_press(&mut bar, packed(PET_COMMAND_FOLLOW, PET_ACT_COMMAND), false);
|
||||
assert_eq!(bar.spells.command_state(), PET_COMMAND_FOLLOW, "unmoved");
|
||||
@@ -416,14 +373,12 @@ fn every_press_the_reference_signals_forces_a_repaint() {
|
||||
"Dismiss, a spell and a pet's Attack all reach 0x4bd444 without signalling"
|
||||
);
|
||||
|
||||
// A POSSESSED unit's Attack does signal — `0x4bd429`, one instruction before the latch.
|
||||
// A possessed unit's Attack signals (`0x4bd429`).
|
||||
latch_press(&mut bar, packed(PET_COMMAND_ATTACK, PET_ACT_COMMAND), true);
|
||||
assert_ne!(signals(&bar), before);
|
||||
}
|
||||
|
||||
/// ATTACK is the one command press that can raise the attack latch — **and only while we are
|
||||
/// possessing the unit** (`0x4bd420`). On an ordinary pet bar the arm is unreachable, which is
|
||||
/// the whole reason its button never checks.
|
||||
/// The latch's possession gate is `0x4bd420`.
|
||||
#[test]
|
||||
fn only_a_possessed_units_attack_press_raises_the_latch() {
|
||||
let mut bar = PetBar {
|
||||
@@ -433,7 +388,6 @@ fn only_a_possessed_units_attack_press_raises_the_latch() {
|
||||
let attack = packed(PET_COMMAND_ATTACK, PET_ACT_COMMAND);
|
||||
assert!(!bar.attacking);
|
||||
|
||||
// A hunter's pet: the press goes out (the caller sends regardless) and latches nothing.
|
||||
latch_press(&mut bar, attack, false);
|
||||
assert!(
|
||||
!bar.attacking,
|
||||
@@ -449,11 +403,7 @@ fn only_a_possessed_units_attack_press_raises_the_latch() {
|
||||
assert!(!bar.attacking, "only ATTACK raises it");
|
||||
}
|
||||
|
||||
/// `0x5ee5a0` itself — the compare that decides all of the above.
|
||||
///
|
||||
/// This is the fact the pet bar was missing: the reference is not asking "is this my pet", it
|
||||
/// is asking "is this the unit I am *driving*". A hunter pet answers no on the flag, so no
|
||||
/// hunter press ever reaches the latch.
|
||||
/// `0x5ee5a0` asks for the unit we possess, not the one we own.
|
||||
#[test]
|
||||
fn the_latch_gate_is_possession_not_ownership() {
|
||||
const FLAGS: u16 = 46;
|
||||
@@ -463,7 +413,7 @@ fn the_latch_gate_is_possession_not_ownership() {
|
||||
|pairs: &[(u16, u32)]| ObjectStore(benilla_protocol::ObjectFields::from_pairs(pairs));
|
||||
let me = Some(0x77u64);
|
||||
|
||||
// An ordinary hunter pet: ours, in combat, not possessed. The answer is no.
|
||||
// An ordinary hunter pet: ours, in combat, not possessed.
|
||||
let hunter_pet = unit(&[
|
||||
(CREATEDBY, 0x77),
|
||||
(CREATEDBY + 1, 0),
|
||||
@@ -471,7 +421,7 @@ fn the_latch_gate_is_possession_not_ownership() {
|
||||
]);
|
||||
assert!(!possessing(Some(&hunter_pet), me));
|
||||
|
||||
// The same pet under Eyes of the Beast — possessed, and now the latch arm is live.
|
||||
// The same pet under Eyes of the Beast.
|
||||
let driven = unit(&[
|
||||
(CREATEDBY, 0x77),
|
||||
(CREATEDBY + 1, 0),
|
||||
@@ -479,11 +429,11 @@ fn the_latch_gate_is_possession_not_ownership() {
|
||||
]);
|
||||
assert!(possessing(Some(&driven), me));
|
||||
|
||||
// A mind-controlled mob: CHARMEDBY is the primary leg, so it needs no CREATEDBY at all.
|
||||
// A mind-controlled mob: CHARMEDBY comes first, so it needs no CREATEDBY.
|
||||
let charmed = unit(&[(CHARMEDBY, 0x77), (CHARMEDBY + 1, 0), (FLAGS, 0x0100_0000)]);
|
||||
assert!(possessing(Some(&charmed), me));
|
||||
|
||||
// Somebody ELSE's possessed unit is not ours to latch for.
|
||||
// Another player's possessed unit.
|
||||
let theirs = unit(&[
|
||||
(CHARMEDBY, 0x99),
|
||||
(CHARMEDBY + 1, 0),
|
||||
@@ -491,19 +441,9 @@ fn the_latch_gate_is_possession_not_ownership() {
|
||||
]);
|
||||
assert!(!possessing(Some(&theirs), me));
|
||||
|
||||
// No descriptor is no possession — the honest reading of missing data, and the same
|
||||
// posture the rest of the bar takes.
|
||||
assert!(!possessing(None, me));
|
||||
}
|
||||
|
||||
/// **The director's report, twice over: the Attack button is never a toggle.**
|
||||
///
|
||||
/// Reported by eye against the reference client — *"there is no way in the ref client for the
|
||||
/// attack button to stay lit up/toggled like the follow or stay… it's simply a button to send
|
||||
/// the pet"* — and the first fix was wrong because it took the latch's *lifetime* to be the
|
||||
/// question. It is not: the latch never rises at all on a pet bar, because `0x4bd420` gates it
|
||||
/// on possession. This drives a whole pet's worth of ATTACK presses and asserts the button
|
||||
/// stays dark through every one.
|
||||
#[test]
|
||||
fn a_pets_attack_button_never_lights() {
|
||||
let mut bar = PetBar {
|
||||
@@ -535,14 +475,10 @@ fn a_pets_attack_button_never_lights() {
|
||||
"and Follow keeps the light that is actually a mode's"
|
||||
);
|
||||
|
||||
// Stay is still reachable as a mode — the command byte was never hijacked.
|
||||
latch_press(&mut bar, packed(PET_COMMAND_STAY, PET_ACT_COMMAND), false);
|
||||
assert_eq!(bar.spells.command_state(), PET_COMMAND_STAY);
|
||||
}
|
||||
|
||||
/// The **possess** bar is the other half of the same button, and it does light — which is why
|
||||
/// the `isActive` clause, `IsPetAttackActive` and `PetStopAttack` are all still modelled rather
|
||||
/// than deleted as dead weight.
|
||||
#[test]
|
||||
fn a_possessed_units_attack_button_does_light() {
|
||||
let mut bar = PetBar {
|
||||
@@ -560,20 +496,14 @@ fn a_possessed_units_attack_button_does_light() {
|
||||
"the latch is not the command byte — Follow keeps its own light"
|
||||
);
|
||||
|
||||
// The call-off: `PetStopAttack` clears the latch, and the latch was the ONLY thing lighting
|
||||
// the button — so it goes out.
|
||||
// `PetStopAttack` lowers the latch, the only thing lighting Attack.
|
||||
bar.attacking = false;
|
||||
let called_off = view(attack, &bar.spells, None, None, bar.attacking);
|
||||
assert!(!called_off.active);
|
||||
assert!(!called_off.attack_active);
|
||||
}
|
||||
|
||||
/// **A refused ATTACK costs everything** — no packet, no latch, no light. The reference's veto
|
||||
/// jumps to the function epilogue rather than the shared send (`0x4bd414 je 0x4bd4c6`), so a
|
||||
/// pet that is dead, stunned, feared, confused, pacified or charmed away simply does not take
|
||||
/// the order, and the button must not report that it did.
|
||||
///
|
||||
/// This is the join decision 0998 could not test: the gate and the latch, composed.
|
||||
/// The veto jumps past the send (`0x4bd414 je 0x4bd4c6`).
|
||||
#[test]
|
||||
fn a_refused_attack_neither_sends_nor_lights_the_button() {
|
||||
let mut bar = PetBar {
|
||||
@@ -597,20 +527,13 @@ fn a_refused_attack_neither_sends_nor_lights_the_button() {
|
||||
"a refusal cannot move the standing command either"
|
||||
);
|
||||
|
||||
// The same press with the gate clear is the ordinary attack, unchanged. The two gates are
|
||||
// NOT symmetric: this one costs the packet, possession costs only the latch.
|
||||
// With the gate clear, the same press sends and latches.
|
||||
assert!(commit_press(&mut bar, attack, false, true));
|
||||
assert!(bar.attacking);
|
||||
assert!(view(attack, &bar.spells, None, None, bar.attacking).active);
|
||||
}
|
||||
|
||||
/// **Touching your target calls the pet off** — `0x493910`'s `0x493a18`, the second call site
|
||||
/// of `PetStopAttack`'s core and the one benilla was missing.
|
||||
///
|
||||
/// This is the mechanism behind the director's report that Attack is not a toggle. Without it
|
||||
/// the latch had only two ways down (the lit button's own second press and a new pet), so an
|
||||
/// order lit the button for the rest of the pet's life and read exactly like the Stay/Follow
|
||||
/// modes it is supposed to contrast with.
|
||||
/// The old-target clear's `PetStopAttack` (`0x493910`, at `0x493a18`).
|
||||
#[test]
|
||||
fn touching_your_target_calls_the_pet_off() {
|
||||
let (tx, rx) = crossbeam_channel::unbounded();
|
||||
@@ -624,13 +547,12 @@ fn touching_your_target_calls_the_pet_off() {
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// Selecting when nothing was selected is NOT a clear — `0x493937` returns before anything.
|
||||
// Selecting from nothing is not a clear (`0x493937`).
|
||||
assert!(!old_target_cleared(None, Some(7)));
|
||||
// Replacing one target with another IS, and so is dropping it: the guard is on the OLD
|
||||
// selection existing, never on the new one being empty.
|
||||
// Replacing a selection is, and so is dropping it.
|
||||
assert!(old_target_cleared(Some(7), Some(9)));
|
||||
assert!(old_target_cleared(Some(7), None));
|
||||
// A no-op re-select never reaches the clear at all (`0x493540`'s own dedup).
|
||||
// A re-select never reaches the clear (`0x493540`).
|
||||
assert!(!old_target_cleared(Some(7), Some(7)));
|
||||
|
||||
assert!(stop_pet_attack(&mut bar, &commands));
|
||||
@@ -640,20 +562,15 @@ fn touching_your_target_calls_the_pet_off() {
|
||||
"and the server is told, with the BAR's guid"
|
||||
);
|
||||
|
||||
// The button is dark now, because the latch was the only thing lighting it.
|
||||
let attack = packed(PET_COMMAND_ATTACK, PET_ACT_COMMAND);
|
||||
assert!(!view(attack, &bar.spells, None, None, bar.attacking).active);
|
||||
|
||||
// Re-clearing is a no-op: `0x4bd65e` returns before the send when the latch is already
|
||||
// down, so a target change per second does not spray packets.
|
||||
// With the latch down it sends nothing (`0x4bd65e`).
|
||||
assert!(!stop_pet_attack(&mut bar, &commands));
|
||||
assert!(rx.try_recv().is_err());
|
||||
}
|
||||
|
||||
/// `PET_ATTACK_START`/`STOP` read the pet's **server-owned** in-combat flag, and only for a
|
||||
/// unit we own — the correction to decision 0990, which derived them from the local click
|
||||
/// latch. The owner test uses SUMMONEDBY as its fallback, which is the callback's own choice
|
||||
/// and not the one `0x5ee5a0` makes for the same-shaped read.
|
||||
/// The owner test falls back to SUMMONEDBY, not `0x5ee5a0`'s CREATEDBY.
|
||||
#[test]
|
||||
fn the_attack_events_read_the_pets_combat_flag_not_the_click_latch() {
|
||||
const FLAGS: u16 = 46;
|
||||
@@ -663,17 +580,17 @@ fn the_attack_events_read_the_pets_combat_flag_not_the_click_latch() {
|
||||
|pairs: &[(u16, u32)]| ObjectStore(benilla_protocol::ObjectFields::from_pairs(pairs));
|
||||
let me = Some(0x77u64);
|
||||
|
||||
// Ours by SUMMONEDBY, fighting / not fighting.
|
||||
// Ours by SUMMONEDBY, fighting or not.
|
||||
let mine = |flags: u32| unit(&[(SUMMONEDBY, 0x77), (SUMMONEDBY + 1, 0), (FLAGS, flags)]);
|
||||
assert_eq!(
|
||||
pet_combat_flag(&mine(UNIT_FLAG_PET_IN_COMBAT), me),
|
||||
Some(true)
|
||||
);
|
||||
assert_eq!(pet_combat_flag(&mine(0), me), Some(false));
|
||||
// An unrelated flag bit is not combat — the callback tests one bit.
|
||||
// The callback tests one bit.
|
||||
assert_eq!(pet_combat_flag(&mine(0x1000), me), Some(false));
|
||||
|
||||
// Somebody else's minion never fires, however hard it is fighting.
|
||||
// Another player's minion never fires.
|
||||
let theirs = unit(&[
|
||||
(SUMMONEDBY, 0x99),
|
||||
(SUMMONEDBY + 1, 0),
|
||||
@@ -681,8 +598,7 @@ fn the_attack_events_read_the_pets_combat_flag_not_the_click_latch() {
|
||||
]);
|
||||
assert_eq!(pet_combat_flag(&theirs, me), None);
|
||||
|
||||
// CHARMEDBY wins over SUMMONEDBY: a mob WE mind-controlled is ours even though it was
|
||||
// summoned by nobody, and a minion charmed AWAY from us stops being ours.
|
||||
// CHARMEDBY wins over SUMMONEDBY, both ways.
|
||||
let charmed_by_me = unit(&[
|
||||
(CHARMEDBY, 0x77),
|
||||
(CHARMEDBY + 1, 0),
|
||||
@@ -699,10 +615,6 @@ fn the_attack_events_read_the_pets_combat_flag_not_the_click_latch() {
|
||||
assert_eq!(pet_combat_flag(&stolen, me), None);
|
||||
}
|
||||
|
||||
/// Only the ATTACK order consults the validator. The reference's type-7 arm branches exactly
|
||||
/// twice, and every other action — the two modes, DISMISS, and anything `>= 4` — leaves down a
|
||||
/// path that sends unconditionally. Gating them too would make a stunned pet impossible to
|
||||
/// dismiss or to put back on Follow, which is not what the binary does.
|
||||
#[test]
|
||||
fn only_the_attack_order_is_gated() {
|
||||
assert!(is_attack_order(packed(PET_COMMAND_ATTACK, PET_ACT_COMMAND)));
|
||||
@@ -718,17 +630,15 @@ fn only_the_attack_order_is_gated() {
|
||||
"command {action} sends unconditionally"
|
||||
);
|
||||
}
|
||||
// A REACTION slot whose action happens to equal ATTACK's 2 (that is Aggressive) is a
|
||||
// different word entirely — the type byte decides, never the action alone.
|
||||
// Aggressive shares Attack's action 2; the type decides.
|
||||
assert!(!is_attack_order(packed(
|
||||
PET_REACT_AGGRESSIVE,
|
||||
PET_ACT_REACTION
|
||||
)));
|
||||
}
|
||||
|
||||
/// A pet with one aura in slot 0. `AURAFLAGS` is nibble-packed 8 slots to the u32, so slot 0's
|
||||
/// nibble is the low four bits: `0x2` is an effect-index bit (what makes the slot *live*) and
|
||||
/// `0x1` is `AFLAG_CANCELABLE` — the bit `0x4bcea0` actually tests.
|
||||
/// A pet with one aura in slot 0. `AURAFLAGS` packs a nibble per slot: `0x2` is an effect-index
|
||||
/// bit (the slot is live), `0x1` is `AFLAG_CANCELABLE`, the bit `0x4bcea0` tests.
|
||||
fn pet_running(spell_id: u32, nibble: u32) -> ObjectStore {
|
||||
const AURA: u16 = 47;
|
||||
const AURAFLAGS: u16 = 95;
|
||||
@@ -738,7 +648,7 @@ fn pet_running(spell_id: u32, nibble: u32) -> ObjectStore {
|
||||
]))
|
||||
}
|
||||
|
||||
/// A spell with an active icon — the shape the predicate needs before it can ever fire.
|
||||
/// A spell with an active icon, which the predicate requires.
|
||||
fn toggle_spell() -> benilla_formats::SpellDisplay {
|
||||
benilla_formats::SpellDisplay {
|
||||
active_icon_id: 122,
|
||||
@@ -747,9 +657,6 @@ fn toggle_spell() -> benilla_formats::SpellDisplay {
|
||||
}
|
||||
}
|
||||
|
||||
/// The showing-active predicate, all four of its ways to say no. The pet-side twin of
|
||||
/// `0x4e55f0` is the *same function* we already had, so what these assert is the wiring: the
|
||||
/// pet's store goes in, and the three tests are the reference's three.
|
||||
#[test]
|
||||
fn a_pet_spell_shows_active_only_while_it_is_a_live_cancelable_aura_on_the_pet() {
|
||||
let slot = packed(2645, benilla_protocol::messages::PET_TYPE_SPELL_FIRST);
|
||||
@@ -761,11 +668,10 @@ fn a_pet_spell_shows_active_only_while_it_is_a_live_cancelable_aura_on_the_pet()
|
||||
Some(2645)
|
||||
);
|
||||
|
||||
// No ActiveIconID: never a toggle — and that gate is on the SEND too, so this spell
|
||||
// re-casts rather than cancelling however live its aura is (the reference's own quirk).
|
||||
// No ActiveIconID: never active, so a press re-casts.
|
||||
let plain = spell("Growl", None);
|
||||
assert_eq!(active_aura_press(slot, Some(&running), Some(&plain)), None);
|
||||
// Live but NOT cancelable (effect-index bit only).
|
||||
// Live but not cancelable.
|
||||
let uncancelable = pet_running(2645, 0x2);
|
||||
assert_eq!(active_aura_press(slot, Some(&uncancelable), Some(&d)), None);
|
||||
// A different spell's aura, and no pet descriptor at all.
|
||||
@@ -774,16 +680,13 @@ fn a_pet_spell_shows_active_only_while_it_is_a_live_cancelable_aura_on_the_pet()
|
||||
None
|
||||
);
|
||||
assert_eq!(active_aura_press(slot, None, Some(&d)), None);
|
||||
// A COMMAND slot whose action equals the spell id is a different word entirely — the type
|
||||
// byte decides here exactly as it does for the ATTACK gate.
|
||||
// A command slot with the same action: the type decides.
|
||||
assert_eq!(
|
||||
active_aura_press(packed(2645, PET_ACT_COMMAND), Some(&running), Some(&d)),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
/// The icon swap itself: `ActiveIconID`'s texture replaces `SpellIconID`'s while the spell is
|
||||
/// running, and the button keeps everything else it had.
|
||||
#[test]
|
||||
fn an_active_pet_spell_draws_its_active_icon() {
|
||||
let slot = packed(2645, benilla_protocol::messages::PET_TYPE_SPELL_FIRST);
|
||||
@@ -804,10 +707,7 @@ fn an_active_pet_spell_draws_its_active_icon() {
|
||||
);
|
||||
}
|
||||
|
||||
/// An active spell whose `ActiveIconID` does not resolve in `SpellIcon.dbc` pushes **nil**,
|
||||
/// not the inactive art. The reference looks up whichever id the predicate chose and gives up
|
||||
/// if that lookup fails (`0x4bdd50`) — falling back would draw "not running" on a running
|
||||
/// spell, which is worse than drawing nothing.
|
||||
/// The reference pushes nil when the chosen icon is not in `SpellIcon.dbc` (`0x4bdd50`).
|
||||
#[test]
|
||||
fn an_unresolvable_active_icon_hides_rather_than_falling_back() {
|
||||
let slot = packed(2645, benilla_protocol::messages::PET_TYPE_SPELL_FIRST);
|
||||
@@ -822,70 +722,46 @@ fn an_unresolvable_active_icon_hides_rather_than_falling_back() {
|
||||
.is_none());
|
||||
}
|
||||
|
||||
/// **The menu's fork, and the direction of each mask** (decision 1066).
|
||||
///
|
||||
/// This is the test that exists because the two bits are one nibble apart and the failure is
|
||||
/// silent both ways: a hunter offered *Dismiss* loses the taming chain's only exit, and a warlock
|
||||
/// offered *Abandon* is offered a row the reference never shows them.
|
||||
#[test]
|
||||
fn the_menu_fork_reads_abandon_and_rename_off_the_right_bits() {
|
||||
// A freshly tamed hunter pet carries both: abandon/rename/paperdoll show, dismiss hides.
|
||||
// A freshly tamed hunter pet carries both.
|
||||
assert_eq!(menu_predicates(0x30), (true, true));
|
||||
// …and after its first rename the server clears only the rename bit.
|
||||
// After its first rename the server clears the rename bit.
|
||||
assert_eq!(menu_predicates(0x20), (true, false));
|
||||
// A warlock's demon carries neither — the whole menu flips to Dismiss.
|
||||
// A warlock's demon carries neither.
|
||||
assert_eq!(menu_predicates(0), (false, false));
|
||||
// The masks are not interchangeable, which is the point of the test.
|
||||
assert_eq!(menu_predicates(0x10), (false, true));
|
||||
// Neighbouring flags are not these two: 0x8 is PLAYER_CONTROLLED (on every pet) and 0x40 is
|
||||
// PLUS_MOB. Reading the pair as a byte rather than as two bits would swallow both.
|
||||
// 0x8 is PLAYER_CONTROLLED, on every pet; 0x40 is PLUS_MOB.
|
||||
assert_eq!(menu_predicates(0x8 | 0x40), (false, false));
|
||||
}
|
||||
|
||||
/// The rename signal: a pet's cached name is dropped when *that pet's* timestamp moves, and on no
|
||||
/// other transition. The guid half of the pair is what keeps the NEXT pet from reading as a
|
||||
/// rename of the last one.
|
||||
#[test]
|
||||
fn only_the_same_pets_moving_timestamp_reads_as_a_rename() {
|
||||
// Nothing seen before — a login, or the pet's object just streamed.
|
||||
// Nothing seen before: a login, or the pet just streamed.
|
||||
assert!(!was_renamed(None, (0xF14, Some(100))));
|
||||
// The same pet, the same stamp: the ordinary frame, which is nearly all of them.
|
||||
assert!(!was_renamed(Some((0xF14, Some(100))), (0xF14, Some(100))));
|
||||
// The same pet, a moved stamp: the rename.
|
||||
assert!(was_renamed(Some((0xF14, Some(100))), (0xF14, Some(200))));
|
||||
// A field that had never been sent starting to arrive is also a move — a pet named for the
|
||||
// first time goes from absent to a stamp, and its cached name is just as stale.
|
||||
// A stamp first arriving is a move: a pet named for the first time.
|
||||
assert!(was_renamed(Some((0xF14, None)), (0xF14, Some(200))));
|
||||
// A DIFFERENT pet is never a rename, whatever the stamps do — dismiss one pet, summon
|
||||
// another, and the second must not have its name dropped on the first's account.
|
||||
// A different pet is never a rename, whatever the stamps do.
|
||||
assert!(!was_renamed(Some((0xF14, Some(100))), (0xABC, Some(200))));
|
||||
assert!(!was_renamed(Some((0xF14, Some(100))), (0xABC, Some(100))));
|
||||
}
|
||||
|
||||
/// **Dismiss is a bar press with no button.** `PetDismiss 0x4be4d0` opens no packet: it stages
|
||||
/// the packed word `0x07000003` and hands it to the same dispatcher every pet-bar click uses, so it
|
||||
/// leaves as `CMSG_PET_ACTION`.
|
||||
///
|
||||
/// The literal is pinned here against the word the constants build, because the two ways of saying
|
||||
/// it are the two halves of the finding, and the first draft of this feature sent
|
||||
/// `CMSG_PET_ABANDON` for both rows — which vmangos would have honoured, silently.
|
||||
/// `PetDismiss` (`0x4be4d0`) stages `0x07000003` for the bar's dispatcher.
|
||||
#[test]
|
||||
fn the_dismiss_word_is_the_carved_literal() {
|
||||
assert_eq!(
|
||||
PET_COMMAND_DISMISS | (u32::from(PET_ACT_COMMAND) << 24),
|
||||
0x0700_0003,
|
||||
);
|
||||
// …and it decodes back as the command token it is, through the client's own masked read.
|
||||
let entry = PetActionEntry::from(0x0700_0003u32);
|
||||
assert_eq!(entry.kind(), PET_ACT_COMMAND);
|
||||
assert_eq!(entry.action(), PET_COMMAND_DISMISS);
|
||||
assert!(!entry.is_spell(), "dismiss is a command, never a cast");
|
||||
}
|
||||
|
||||
/// The bar's two events are two edges (1953): a change of the slots fires `PET_BAR_UPDATE`; a
|
||||
/// change of the cooldown triples alone fires `PET_BAR_UPDATE_COOLDOWN` — the reference's
|
||||
/// cooldown-subsystem fire for the pet bank (`0x6e2e8e`) — and the
|
||||
/// pushed state carries the new triple either way.
|
||||
/// The reference fires `PET_BAR_UPDATE_COOLDOWN` from the pet's cooldown bank (`0x6e2e8e`).
|
||||
#[test]
|
||||
fn a_cooldown_alone_fires_the_cooldown_event_and_not_the_bar_update() {
|
||||
use bevy::prelude::*;
|
||||
@@ -960,12 +836,7 @@ fn a_cooldown_alone_fires_the_cooldown_event_and_not_the_bar_update() {
|
||||
assert_eq!(enable, 1);
|
||||
}
|
||||
|
||||
/// **A bar toggle reaches the spellbook**: after the
|
||||
/// local write-back, `0x4bcc19` calls `0x4bd190(&bar[slot])`, which scans the raw pet-spell
|
||||
/// array backwards for the entry equal to the slot under `& 0x3FFFFFFF` and copies the slot's
|
||||
/// FULL word into it. The book renders from that array, so without the copy the Pet tab kept
|
||||
/// the old autocast ring until the next `SMSG_PET_SPELLS`. The book→bar direction is
|
||||
/// `ui_pet_book::flip_autocast` (decision 1032); this is its twin.
|
||||
/// `0x4bcc19` calls `0x4bd190`, which copies the toggled word into the pet spellbook.
|
||||
#[test]
|
||||
fn a_bar_autocast_toggle_reaches_the_pet_spellbook() {
|
||||
const CLAW: u32 = 16827;
|
||||
@@ -996,20 +867,16 @@ fn a_bar_autocast_toggle_reaches_the_pet_spellbook() {
|
||||
"a different spell's book entry is untouched"
|
||||
);
|
||||
|
||||
// And back on: the copy is the full word, both directions.
|
||||
toggle_slot_autocast(&mut bar, 4);
|
||||
assert!(bar.spells.spells[1].autocast_on());
|
||||
|
||||
// `0x4bcbf1`: a word without bit 31 aborts before anything is written, book included.
|
||||
// `0x4bcbf1`: a word without bit 31 aborts before any write, book included.
|
||||
bar.spells.bar[5] = packed(3025, PET_ACT_PASSIVE);
|
||||
bar.spells.spells.push(packed(3025, PET_ACT_PASSIVE));
|
||||
assert!(toggle_slot_autocast(&mut bar, 6).is_none());
|
||||
assert_eq!(bar.spells.spells[2], packed(3025, PET_ACT_PASSIVE));
|
||||
}
|
||||
|
||||
/// **The pet bar dies with the session.** Only the zero-guid `SMSG_PET_SPELLS` cleared it, and a
|
||||
/// dropped socket never sends one — so the next session kept the old pet's bar: presses went out
|
||||
/// as `CMSG_PET_ACTION` to a guid that no longer exists, and the stable read `has_live_pet`.
|
||||
#[test]
|
||||
fn the_session_end_tears_the_pet_bar_down() {
|
||||
let mut app = bevy::prelude::App::new();
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
//! The **`"pet"` unit token** and the pet frame's events ([`feed_pet_unit`], decision 0990).
|
||||
//!
|
||||
//! This lives with the pet bar rather than beside the `"player"`/`"target"` feed for one reason:
|
||||
//! the token's identity is [`PetBar`]'s cached pet guid — the client's `[0xb714a0]`, which is also
|
||||
//! what `UNIT_PET` fires off (`0x4bc84f`) — so the token and its repaint wire read the same word,
|
||||
//! from the module that owns it.
|
||||
//! The `"pet"` unit token and the pet frame's events, keyed on [`PetBar`]'s pet guid: the client's
|
||||
//! `[0xb714a0]`, whose writer fires `UNIT_PET` (`0x4bc84f`).
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
@@ -16,48 +12,29 @@ use crate::ui_unit::{fire_transitions, snapshot};
|
||||
|
||||
use super::{PetBar, PetUnit};
|
||||
|
||||
/// What the `"pet"` token feed last pushed — the three edges it fires on (decision 0990).
|
||||
/// What the `"pet"` feed last pushed, for its edges.
|
||||
#[derive(Default)]
|
||||
pub(super) struct PetUnitMemory {
|
||||
/// The last snapshot pushed under `"pet"`, for [`fire_transitions`]' per-field diff.
|
||||
pushed: Option<UnitState>,
|
||||
/// The last pet guid — `UNIT_PET`'s trigger. `None` until the first feed, so a login with a
|
||||
/// pet already out still announces it once.
|
||||
/// `UNIT_PET`'s trigger; `None` until the first feed, so a pet out at login announces once.
|
||||
guid: Option<u64>,
|
||||
/// The pet's last `UNIT_FIELD_FLAGS & 0x800` — the `PET_ATTACK_START`/`PET_ATTACK_STOP` pair's
|
||||
/// trigger. `None` until the first resolved pet, so a login with a pet already fighting
|
||||
/// announces it once instead of reading as a transition from "calm".
|
||||
/// The pet's last in-combat flag, `PET_ATTACK_*`'s trigger; `None` until a pet resolves, so a
|
||||
/// pet already fighting at login announces once.
|
||||
in_combat: Option<bool>,
|
||||
/// The last `(pet guid, UNIT_FIELD_PET_NAME_TIMESTAMP)` seen — the rename's only signal
|
||||
/// (decision 1066). The guid rides along because the timestamp alone would read a *different*
|
||||
/// pet's stamp as a rename of this one.
|
||||
/// The last `(pet guid, UNIT_FIELD_PET_NAME_TIMESTAMP)`: the rename signal.
|
||||
name_stamp: Option<(u64, Option<u32>)>,
|
||||
/// The gate's counter memory (1439): the name cache by its landed counter — the token's own
|
||||
/// `resolve` miss lands frames later, and `is_changed` cannot flag it.
|
||||
/// The name cache's counter, which catches a `resolve` answer landing frames later.
|
||||
names_generation: gate::Watch,
|
||||
}
|
||||
|
||||
/// `UNIT_FIELD_FLAGS` bit `0x800` — the **pet-in-combat** flag, and the whole trigger for
|
||||
/// `PET_ATTACK_START`/`PET_ATTACK_STOP` (`0x5ff75e test ah,8`).
|
||||
///
|
||||
/// Server-owned and server-written: nothing client-side sets it, which is exactly why it — and not
|
||||
/// the local click latch — is what the reference watches.
|
||||
/// `UNIT_FIELD_FLAGS` `0x800`, the server-written in-combat flag: the whole trigger for
|
||||
/// `PET_ATTACK_START`/`PET_ATTACK_STOP` (`0x5ff75e`).
|
||||
pub(super) const UNIT_FLAG_PET_IN_COMBAT: u32 = 0x0000_0800;
|
||||
|
||||
/// Feed the **`"pet"` unit token** and the pet frame's three events (decision 0990).
|
||||
///
|
||||
/// **The token resolves off the bar's cached pet guid, not off our own `UNIT_FIELD_SUMMON`**, and
|
||||
/// that is the client's own choice rather than a convenience. `UNIT_PET` fires
|
||||
/// from inside `SetPet 0x4bc7e0` (`0x4bc84f`: `SignalEvent(2, "%s", "player")`) — the single writer
|
||||
/// of `[0xb714a0]`, the same cached guid the whole pet bar reads — and **only when that guid
|
||||
/// actually changed**. Since `UNIT_PET` is the pet frame's only repaint wire, a token sourced from
|
||||
/// anywhere else would repaint on the wrong edges. Reading both off one guid is what keeps them in
|
||||
/// step, and it is also what makes a **possessed or charmed** unit — which has a bar but is nobody's
|
||||
/// `UNIT_FIELD_SUMMON` — carry a frame at all.
|
||||
///
|
||||
/// The reaction argument is `0`: the pet frame reads no reaction (only `"target"` resolves one —
|
||||
/// [`crate::ui_unit::feed_units`]' own note), and the party feed passes the same for the same
|
||||
/// reason.
|
||||
/// Feed the `"pet"` token and the pet frame's events. The token follows the bar's guid, as
|
||||
/// `UNIT_PET` does (`SetPet`, `0x4bc7e0`), not our `UNIT_FIELD_SUMMON`, so a charmed unit has a
|
||||
/// frame. The reaction is 0: the pet frame reads none.
|
||||
pub(super) fn feed_pet_unit(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
bar: Res<PetBar>,
|
||||
@@ -73,9 +50,6 @@ pub(super) fn feed_pet_unit(
|
||||
};
|
||||
let (memory, vm_reset) = memory.get_reset(&script);
|
||||
let edges = crate::net::FieldEdges::collect(&mut pet.edges);
|
||||
// The gate (1439): the bar (the token's identity), any descriptor change or DESPAWN (the
|
||||
// snapshot, the combat flag, and the rename timestamp all live on the pet's store), and the
|
||||
// name cache by its landed counter.
|
||||
let names_moved = memory.names_generation.moved(names.generation());
|
||||
let bar_changed = bar.is_changed();
|
||||
let stores_changed = !changed_stores.is_empty();
|
||||
@@ -98,9 +72,7 @@ pub(super) fn feed_pet_unit(
|
||||
}
|
||||
let pet_guid = bar.spells.pet_guid;
|
||||
watch_name_timestamp(pet_guid, &mut script, &pet, &mut names, memory);
|
||||
// A bar whose unit has not streamed yet (or has left) pushes nothing: `UnitExists("pet")` then
|
||||
// reads false and the frame hides, which is honest — we have the guid but none of the fields
|
||||
// the frame draws.
|
||||
// An unstreamed pet pushes nothing: `UnitExists("pet")` reads false and the frame hides.
|
||||
let fresh = (pet_guid != 0)
|
||||
.then(|| pet.store(pet_guid))
|
||||
.flatten()
|
||||
@@ -108,15 +80,12 @@ pub(super) fn feed_pet_unit(
|
||||
let name = names
|
||||
.resolve_unit(pet_guid, Some(store), &commands)
|
||||
.map(str::to_string);
|
||||
// No `ChrClasses.dbc` here, and none needed: a pet is a UNIT, so the reference's
|
||||
// own TYPEMASK_PLAYER gate refuses it before the class byte is ever read. Passing
|
||||
// the table could not change the answer.
|
||||
// No `ChrClasses.dbc`: the reference reads a class only for TYPEMASK_PLAYER.
|
||||
let mut s = snapshot(store, name, 0, None);
|
||||
s.guid = pet_guid;
|
||||
s
|
||||
});
|
||||
|
||||
// Pushed on diff (1439): an identical snapshot re-pushed is invisible to the VM.
|
||||
let dirty = match (&fresh, &memory.pushed) {
|
||||
(Some(cur), Some(prev)) => cur != prev,
|
||||
(None, None) => false,
|
||||
@@ -129,11 +98,6 @@ pub(super) fn feed_pet_unit(
|
||||
match &fresh {
|
||||
Some(cur) => {
|
||||
if memory.pushed.as_ref() != Some(cur) {
|
||||
// The one line worth having here, and it is about the failure that is otherwise
|
||||
// SILENT: holding a bar for a guid whose descriptor never streamed leaves
|
||||
// `UnitExists("pet")` false, so the frame simply never appears and nothing says
|
||||
// why. Naming the resolved unit on each change makes "the bar is up but the frame
|
||||
// is not" a one-grep question.
|
||||
if memory.pushed.is_none() {
|
||||
debug!(
|
||||
"ui_pet: \"pet\" resolved — {} ({}/{} hp)",
|
||||
@@ -146,15 +110,11 @@ pub(super) fn feed_pet_unit(
|
||||
memory.pushed = Some(cur.clone());
|
||||
}
|
||||
}
|
||||
// Clearing a token is not a UNIT_* event — the frame reacts to UNIT_PET below, exactly as
|
||||
// the target frame reacts to PLAYER_TARGET_CHANGED rather than to a cleared snapshot.
|
||||
// Clearing the token fires no UNIT_* event; the frame repaints on UNIT_PET.
|
||||
None => memory.pushed = None,
|
||||
}
|
||||
|
||||
// UNIT_PET(arg1 = "player"), fired only when the pet guid changes (`0x4bc84f`).
|
||||
// Summon, stable swap and dismiss are the three edges; a re-sent `SMSG_PET_SPELLS` for
|
||||
// the same pet (a learned spell, a mode change) is not one, which is why this diffs the guid
|
||||
// rather than riding `feed_pet_bar`'s whole-state diff.
|
||||
// UNIT_PET("player") only on a pet guid change (`0x4bc84f`), not on a same-pet re-send.
|
||||
if memory.guid != Some(pet_guid) {
|
||||
gate.audit("feed_pet_unit", "the UNIT_PET edge");
|
||||
memory.guid = Some(pet_guid);
|
||||
@@ -162,26 +122,9 @@ pub(super) fn feed_pet_unit(
|
||||
script.fire_event("UNIT_PET", vec![ScriptValue::Str("player".into())]);
|
||||
}
|
||||
|
||||
// PET_ATTACK_START (334) / PET_ATTACK_STOP (335) — the pet frame's flashing
|
||||
// `UI-Player-AttackStatus` overlay.
|
||||
//
|
||||
// **CORRECTED.** Decision 0990 derived these from the attack latch `[0xb714b0]`'s edges and
|
||||
// said so honestly; the derivation was wrong. The real fire site is `0x5ff793`/`0x5ff79a`
|
||||
// inside `0x5ff580`, a per-field change callback registered *by field byte offset*
|
||||
// (`0x6042e2 mov edx,0xa0`), which is why walking the call graph out of the pet TU never
|
||||
// reaches it. The trigger is the unit's own server-supplied `UNIT_FIELD_FLAGS & 0x800`
|
||||
// **transition**, gated on the unit's owner guid being ours.
|
||||
//
|
||||
// The two are not the same question and they visibly diverge: the latch is a local click
|
||||
// record with exactly three writers, so a pet that disengages on its own — its target dies, it
|
||||
// runs out of range, it is feared off — clears the flag with no client-side call at all, and
|
||||
// the latch-driven version would hold the frame's combat glow lit until the player pressed
|
||||
// something. Conversely a defensive pet that retaliates unbidden raises the flag without any
|
||||
// press, and the latch never moves.
|
||||
//
|
||||
// (`PetActionBarFrame`'s Attack *button* is the other mechanism and keeps the latch — it is
|
||||
// driven by `PET_BAR_UPDATE` + `IsPetAttackActive`. Two frames, two sources, genuinely
|
||||
// independent.)
|
||||
// PET_ATTACK_START/STOP (334/335), the pet frame's attack glow, fire on the pet's
|
||||
// `UNIT_FIELD_FLAGS & 0x800` edge: a field callback (`0x5ff580`, at `0x5ff793`/`0x5ff79a`,
|
||||
// registered by offset at `0x6042e2`), not the Attack button's latch.
|
||||
let in_combat = fresh
|
||||
.as_ref()
|
||||
.and_then(|_| pet_combat_flag(pet.store(pet_guid)?, pet.self_guid.0));
|
||||
@@ -200,38 +143,15 @@ pub(super) fn feed_pet_unit(
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// No pet, or not ours: forget the edge rather than firing a STOP the reference never
|
||||
// sends. `0x5ff580` is a *change* callback — a unit going away does not call it.
|
||||
// No pet, or not ours: forget the edge; `0x5ff580` is not called when a unit leaves.
|
||||
memory.in_combat = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// **Watch the pet's name timestamp, drop the cached name when it moves, and announce it**
|
||||
/// (decision 1066).
|
||||
///
|
||||
/// A pet's name is the one name in the client that can change under us. It does not ride the
|
||||
/// descriptor — it is answered once by `CMSG_PET_NAME_QUERY` into `petnamecache.wdb` and keyed by
|
||||
/// pet NUMBER — so after a rename `UnitName("pet")` would keep reporting the old name until relog,
|
||||
/// which is exactly what a taming session does three times an hour.
|
||||
///
|
||||
/// **This is the reference's own mechanism, not a workaround for one.** `0x604400` registers a
|
||||
/// field-change callback *by byte offset* — `mov edx,0x218`, which is
|
||||
/// `UNIT_FIELD_PET_NAME_TIMESTAMP` — and `0x604aa0` evicts the cache row, re-queries, and fires
|
||||
/// `LOCALPLAYER_PET_RENAMED` when the unit is ours. (Registration by offset is why no call-graph
|
||||
/// walk out of the pet TU ever finds it — the same shape as `PET_ATTACK_*`'s `0x5ff580` above, and
|
||||
/// the second time in this file that has hidden a mechanism.)
|
||||
///
|
||||
/// The **event has no handler in stock 1.12** — `UIParent.lua:95` registers it and defines nothing
|
||||
/// — so the refresh is the eviction, not the event. It is fired anyway because an addon may listen,
|
||||
/// and because a client that evicts silently is one where "did that fire?" has no answer.
|
||||
///
|
||||
/// **A changed pet guid is not a rename.** Comparing timestamps alone would read the next pet's
|
||||
/// stamp as this one's having moved, so the memory holds the pair and a new guid only records.
|
||||
///
|
||||
/// Scope, stated because it is narrower than the reference's: this watches **our** pet, the one the
|
||||
/// `"pet"` token resolves; the reference's callback is on the field itself and so covers every
|
||||
/// streamed unit. Another player's pet renamed while we watch it keeps its old name in our cache
|
||||
/// until it despawns. Widening it means watching the field at the descriptor apply, not here.
|
||||
/// Drop our pet's cached name when its name timestamp moves, as `0x604aa0` does from the field
|
||||
/// callback on offset `0x218` (`0x604400`), and fire `LOCALPLAYER_PET_RENAMED`, which stock 1.12
|
||||
/// registers (`UIParent.lua:95`) but never handles. The reference watches every unit; this
|
||||
/// watches only our pet, so another player's renamed pet keeps its old name.
|
||||
fn watch_name_timestamp(
|
||||
pet_guid: u64,
|
||||
script: &mut UiScript,
|
||||
@@ -252,8 +172,7 @@ fn watch_name_timestamp(
|
||||
debug!("ui_pet: pet {pet_guid:#x} was renamed (stamp → {now:?}) — re-asking its name");
|
||||
names.forget_pet(pet_number);
|
||||
}
|
||||
// `0x604aa0`'s own gate on the event, and only on the event: the eviction above is
|
||||
// unconditional, the announcement is for a unit charmed or summoned by us.
|
||||
// `0x604aa0` evicts for any owner but fires the event only for our charm or summon.
|
||||
if store
|
||||
.0
|
||||
.unit_owner(benilla_protocol::OwnerFallback::SummonedBy)
|
||||
@@ -263,22 +182,13 @@ fn watch_name_timestamp(
|
||||
}
|
||||
}
|
||||
|
||||
/// [`watch_name_timestamp`]'s decision alone: did the SAME pet's name timestamp move?
|
||||
///
|
||||
/// Four cases, and only one of them is a rename: nothing seen before (a login, or the pet just
|
||||
/// streamed) is not; a different guid is not, however the stamps compare; the same guid with the
|
||||
/// same stamp is not; the same guid with a different stamp is.
|
||||
/// Whether the same pet's name timestamp moved; a new guid is never a rename.
|
||||
pub(super) fn was_renamed(previous: Option<(u64, Option<u32>)>, now: (u64, Option<u32>)) -> bool {
|
||||
previous.is_some_and(|(guid, stamp)| guid == now.0 && stamp != now.1)
|
||||
}
|
||||
|
||||
/// What the `PET_ATTACK_*` field-change callback would see for this unit: `Some(fighting)` when
|
||||
/// the unit is **ours**, `None` when it is nobody's business of ours and no event may fire.
|
||||
///
|
||||
/// Both halves are `0x5ff580`'s, in its order: the owner test at `0x5ff780` first — CHARMEDBY when
|
||||
/// set, else SUMMONEDBY, which is *not* the fallback `0x5ee5a0` uses for the same-shaped read — and
|
||||
/// only then the flag at `0x5ff78d`. Reading the flag without the owner test would let any unit in
|
||||
/// the world flash our pet frame.
|
||||
/// The in-combat flag as `0x5ff580` sees it: `None` unless the unit is ours by CHARMEDBY, else
|
||||
/// SUMMONEDBY (`0x5ff780`, not `0x5ee5a0`'s CREATEDBY fallback); then the flag (`0x5ff78d`).
|
||||
pub(super) fn pet_combat_flag(store: &ObjectStore, self_guid: Option<u64>) -> Option<bool> {
|
||||
(store
|
||||
.0
|
||||
|
||||
@@ -1,50 +1,17 @@
|
||||
//! The **pet spellbook** — the spellbook window's second tab (decision 1032).
|
||||
//! The pet spellbook, the spellbook window's second tab, built from the packed words
|
||||
//! `SMSG_PET_SPELLS` carries after the ten bar slots
|
||||
//! ([`benilla_protocol::messages::PetSpells::spells`]).
|
||||
//!
|
||||
//! Its source has been on the wire and parsed since 0988 and nothing read it:
|
||||
//! [`benilla_protocol::messages::PetSpells::spells`], the packed-word list `SMSG_PET_SPELLS`
|
||||
//! carries after the ten bar slots. This module is the join that turns it into a book.
|
||||
//! Unlike the player book, its add-gate (`0x4b2f90`) refuses only `Attributes & 0x80`
|
||||
//! (`DO_NOT_DISPLAY`); it has no tabs, so a button's 1..12 id is the book id; and each slot
|
||||
//! carries the raw word's autocast bits (31, 30) and the word itself, which `PickupSpell` puts on
|
||||
//! the cursor. The order is shared: `0x4b2fd0` sorts with `0x4b30c0`, the player book's comparator.
|
||||
//!
|
||||
//! **It is not the player book with a flag on it**, and the three differences are the reason this
|
||||
//! is its own module rather than an arm of [`crate::ui_spellbook`]:
|
||||
//!
|
||||
//! - **A different add-gate.** `0x4b2f90` keeps a spell out only on `Attributes & 0x80`
|
||||
//! (`DO_NOT_DISPLAY`) — `SpellDisplay::in_pet_book`. The player book's `IS_TRADESKILL` and
|
||||
//! `castUI` legs are simply absent.
|
||||
//! - **No tabs.** `GetNumSpellTabs`/`GetSpellTabInfo` never see this book;
|
||||
//! `SpellBook_GetSpellID`'s pet arm is a bare `return id`, so a button's own 1..12 id *is* the
|
||||
//! book id.
|
||||
//! - **Two extra per-slot answers the player book has no analogue for** — the autocast pair, read
|
||||
//! off the pet's **raw** words (bits 31/30) rather than the filtered book, and the packed word
|
||||
//! itself, which is what `PickupSpell(id, "pet")` puts on the cursor.
|
||||
//!
|
||||
//! The **order** is shared, though: `0x4b2fd0(ecx = 0, edx = 1)` sorts the pet array with
|
||||
//! `0x4b30c0`, the player book's own comparator (name, then the parsed rank number), so
|
||||
//! [`crate::ui_spellbook::spell_sort_key`] serves both.
|
||||
//!
|
||||
//! ## Where each answer comes from
|
||||
//!
|
||||
//! | Lua | source |
|
||||
//! |---|---|
|
||||
//! | `GetSpellName` / `GetSpellTexture` / `IsSpellPassive` | `Spell.dbc` via [`Spells`] |
|
||||
//! | `GetSpellCooldown(id, "pet")` | **bank 1** — [`PetBar::cooldowns`], the pet's own store. The reference reaches it with the same `0x6e2ea0(edx = isPet)` `GetPetActionCooldown` uses (`0x4b40dd`), so a spell on the bar and the same spell in the book read one timer, and they do here too. |
|
||||
//! | `GetSpellAutocast` | the raw word's bits 31/30 |
|
||||
//! | `IsCurrentCast` | the pet's own auras — `0x4b36f0`'s pet arm scans `[pet.fields + 0xa4]`, which is exactly the predicate the bar's [`crate::ui_pet`] already applies per slot |
|
||||
//! | `HasPetSpells`'s token | `ChrClasses.dbc` field 4, via [`crate::chr_classes`] |
|
||||
//!
|
||||
//! ## The two verbs
|
||||
//!
|
||||
//! Both are pet verbs, and neither is the player book's:
|
||||
//!
|
||||
//! - **`CastSpell(id, "pet")` → `CMSG_PET_ACTION`** with a **synthesized** type-1 word,
|
||||
//! `0x0100_0000 | (spellId & 0xFFFF)` (`0x4b350a`-`0x4b3516`), aimed at the passed target or,
|
||||
//! absent one, the current selection (`0x4b34af`). So the book can cast a pet spell that is not
|
||||
//! on the bar at all — the word is built for the send, never looked up.
|
||||
//! - **`ToggleSpellAutocast(id, "pet")` → `CMSG_PET_SPELL_AUTOCAST 0x2F3`**, naming the **spell**.
|
||||
//! Its sender `0x4bccb0` does three things before the packet leaves, and all three are
|
||||
//! reproduced in [`drain_pet_book`]: it flips bit 30 in the raw word in place, it **mirrors that
|
||||
//! bit onto every bar slot carrying the same action** (`0x4bcd20`-`0x4bcd5a`, compared under
|
||||
//! `& 0x3FFFFFFF`), and it fires `PET_BAR_UPDATE`. That mirror is why toggling autocast in the
|
||||
//! book lights the sparkle on the bar button in the same frame.
|
||||
//! A book cooldown is the pet's own store, the one `GetPetActionCooldown` reads (`0x6e2ea0` with
|
||||
//! `isPet`, `0x4b40dd`), so bar and book share one timer. `CastSpell(id, "pet")` sends
|
||||
//! `CMSG_PET_ACTION` with a word built for the send, so the book casts spells not on the bar.
|
||||
//! `ToggleSpellAutocast(id, "pet")` sends `CMSG_PET_SPELL_AUTOCAST` (0x2F3) after `0x4bccb0` flips
|
||||
//! bit 30 locally, mirrors it onto the bar and fires `PET_BAR_UPDATE`.
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
@@ -68,21 +35,19 @@ impl Plugin for UiPetBookPlugin {
|
||||
app.add_systems(
|
||||
Update,
|
||||
(
|
||||
// Rides the unit feed beside the player book's, and before the input pass so a
|
||||
// tab flipped this frame already reads a populated book.
|
||||
// Before the input pass, so a tab flipped this frame reads a populated book.
|
||||
feed_pet_book
|
||||
.in_set(UnitFeed)
|
||||
.before(crate::ui_action::CooldownEvents),
|
||||
// After the input pass, and — like `ui_pet`'s own drain — writing back into
|
||||
// `PetBar`, whose next feed carries the mirrored autocast bit.
|
||||
// After the input pass; it writes back into `PetBar`, whose next feed carries the
|
||||
// mirrored autocast bit.
|
||||
drain_pet_book.after(UiInput),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// What the feed last pushed, so `SPELLS_CHANGED` fires on a real change rather than every frame
|
||||
/// (the [`crate::ui_spellbook`] memory pattern).
|
||||
/// What the feed last pushed, so `SPELLS_CHANGED` fires only on a real change.
|
||||
#[derive(Default)]
|
||||
struct FeedMemory {
|
||||
pushed: PetBookState,
|
||||
@@ -103,8 +68,7 @@ fn feed_pet_book(
|
||||
return;
|
||||
};
|
||||
let memory = memory.get(&script);
|
||||
// Nothing to resolve a name/icon/passive from yet — try again once Spell.dbc lands. Unlike the
|
||||
// bar (which can still draw its command tokens), a book with no catalog has no slots at all.
|
||||
// Without `Spell.dbc` the book has no slots at all; try again once it lands.
|
||||
let Some(spells) = spells.as_deref() else {
|
||||
return;
|
||||
};
|
||||
@@ -116,8 +80,8 @@ fn feed_pet_book(
|
||||
.and_then(|&e| stores.get(e).ok());
|
||||
|
||||
let mut fresh = PetBookState {
|
||||
// The token is the PLAYER's class, not the pet's — `0x4b4463` reads `player.fields + 0x79`.
|
||||
// A warlock reading "Demon" while a hunter reads "Pet" is the whole of it.
|
||||
// `HasPetSpells`'s token is the player's class, not the pet's (`0x4b4463` reads
|
||||
// `player.fields + 0x79`), named by `ChrClasses.dbc` field 4.
|
||||
token: self_q.single().ok().and_then(|s| {
|
||||
let class = u32::from(s.0.unit_class()?);
|
||||
classes
|
||||
@@ -151,19 +115,17 @@ fn feed_pet_book(
|
||||
let changed = book_changed(&fresh, &memory.pushed);
|
||||
script.set_pet_book(fresh.clone());
|
||||
memory.pushed = fresh;
|
||||
// The reference fires ONE `SPELLS_CHANGED` for both books off the shared re-sort
|
||||
// (`0x4b2fd0` tail-jumps `SignalEvent(0x104)`), which is also what re-reads the spell
|
||||
// buttons. The cooldown/autocast half rides `PET_BAR_UPDATE` instead — `SpellButton_OnLoad`
|
||||
// registers it precisely so the pet page can repaint without a book change
|
||||
// (`SpellBookFrame.lua:214`, `l.227-231`) — and `ui_pet`'s own feed already fires that.
|
||||
// One `SPELLS_CHANGED` for both books off the shared re-sort (`0x4b2fd0` tail-jumps
|
||||
// `SignalEvent(0x104)`). Cooldown and autocast repaint the pet page through
|
||||
// `PET_BAR_UPDATE` (`SpellBookFrame.lua:214`, `227-231`), which `ui_pet`'s feed fires.
|
||||
if changed {
|
||||
script.fire_event("SPELLS_CHANGED", vec![]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Did the book itself move, as opposed to a per-slot cooldown/autocast/ring bit? Only the former
|
||||
/// is `SPELLS_CHANGED`; the latter reaches the buttons through `PET_BAR_UPDATE`.
|
||||
/// Whether the book itself changed; a slot's cooldown, autocast or ring reaches the buttons
|
||||
/// through `PET_BAR_UPDATE` instead.
|
||||
fn book_changed(fresh: &PetBookState, old: &PetBookState) -> bool {
|
||||
fresh.token != old.token
|
||||
|| fresh.slots.len() != old.slots.len()
|
||||
@@ -190,25 +152,22 @@ fn slot_view(
|
||||
rank: d.rank.clone(),
|
||||
texture: d.icon.clone(),
|
||||
passive: d.passive,
|
||||
// `IsCurrentCast`'s pet arm (`0x4b36f0`, and the delegate `0x4b3600` it shares a shape
|
||||
// with): the ring lights while the spell's aura is on the PET. Same predicate the bar's
|
||||
// ActiveIcon swap uses, so a lit book slot and a lit bar slot always agree.
|
||||
// `IsCurrentCast`'s pet arm (`0x4b36f0`, and its delegate `0x4b3600`): lit while the
|
||||
// spell's aura is on the pet (`[pet.fields + 0xa4]`), the bar's own predicate.
|
||||
current: pet_store
|
||||
.is_some_and(|s| crate::ui_action::toggle::active_action_toggle(spell_id, d, s)),
|
||||
// **Bank 1** — the pet's own store, the same one `GetPetActionCooldown` reads.
|
||||
// The pet's own cooldown store, the one `GetPetActionCooldown` reads.
|
||||
cooldown: bar
|
||||
.cooldowns
|
||||
.info(spell_id, 0, Some(d), now)
|
||||
.ui_triple(anchor, ui_now),
|
||||
// The autocast pair off the RAW word. `autocast_allowed` is additionally gated on the
|
||||
// spell resolving in `Spell.dbc` (`0x4bdd65`'s own second test) — which it has, or we
|
||||
// would not be here.
|
||||
// Off the raw word; `0x4bdd65` also requires a `Spell.dbc` record, which every slot has.
|
||||
autocast: Some((entry.autocast_allowed(), entry.autocast_on())),
|
||||
packed: entry.packed,
|
||||
}
|
||||
}
|
||||
|
||||
/// Drain the pet book's two intents.
|
||||
/// Drains the pet book's two intents.
|
||||
fn drain_pet_book(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
mut bar: ResMut<PetBar>,
|
||||
@@ -234,11 +193,8 @@ fn drain_pet_book(
|
||||
let pet_store = pet.store(pet_guid);
|
||||
|
||||
for spell_id in casts {
|
||||
// **The press-again-to-cancel arm comes first** (`0x4b33af`-`0x4b3461`), exactly as it does
|
||||
// on the bar: a click on a pet spell whose aura is already running on the pet sends
|
||||
// `CMSG_PET_CANCEL_AURA` (0x26B) **and returns** — the ordinary order never leaves. Same
|
||||
// predicate, same order of tests, one implementation
|
||||
// (`crate::ui_action::toggle::active_action_toggle`).
|
||||
// Cancel first, as on the bar (`0x4b33af`-`0x4b3461`): a spell whose aura is on the pet
|
||||
// sends `CMSG_PET_CANCEL_AURA` (0x26B) instead of the order.
|
||||
let display = spells.as_ref().and_then(|s| s.catalog.get(spell_id));
|
||||
if let (Some(d), Some(store)) = (display, pet_store) {
|
||||
if crate::ui_action::toggle::active_action_toggle(spell_id, d, store) {
|
||||
@@ -249,9 +205,8 @@ fn drain_pet_book(
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// The synthesized word, `0x4b350a`-`0x4b3516`: `(spellId & 0xFFFF) | 0x01000000`. Type 1
|
||||
// is the client's spell branch and the autocast bits are left clear — this word describes
|
||||
// the ORDER, not the slot, so nothing is read back out of it.
|
||||
// The word built for the send (`0x4b350a`-`0x4b3516`): type 1, the spell branch, autocast
|
||||
// bits clear; with no target passed it aims at the selection (`0x4b34af`).
|
||||
let packed = 0x0100_0000 | (spell_id & 0xFFFF);
|
||||
debug!("ui_pet_book: cast {spell_id} (target {target_guid:#x})");
|
||||
let _ = commands.0.send(ClientCommand::PetAction {
|
||||
@@ -265,9 +220,8 @@ fn drain_pet_book(
|
||||
let Some(on) = flip_autocast(&mut bar.spells, spell_id) else {
|
||||
continue;
|
||||
};
|
||||
// `0x4bcdc6`'s `SignalEvent(0x161)` — the bar repainted before the packet left, which is
|
||||
// the same "write locally, never wait for a reply" law the rest of this bar runs on
|
||||
// (`ui_pet`'s module doc). Bumping the signal count is how that repaint reaches the feed.
|
||||
// `0x4bcdc6`'s `SignalEvent(0x161)`: the bar repaints before the packet leaves, and the
|
||||
// signal count carries that to the feed.
|
||||
bar.bar_signals = bar.bar_signals.wrapping_add(1);
|
||||
debug!("ui_pet_book: autocast {spell_id} -> {on}");
|
||||
let _ = commands.0.send(ClientCommand::PetSpellAutocast {
|
||||
@@ -278,17 +232,10 @@ fn drain_pet_book(
|
||||
}
|
||||
}
|
||||
|
||||
/// `0x4bccb0`'s local half, in its own order: find the spell's **raw** word, flip bit 30 in place,
|
||||
/// and mirror the new bit onto every **bar** slot carrying the same action. Returns the new state,
|
||||
/// or `None` when the reference would have aborted before sending anything.
|
||||
///
|
||||
/// The mirror is the load-bearing half and the easy one to miss (`0x4bcd20`-`0x4bcd5a`): the loop
|
||||
/// walks all ten bar words, compares `(bar[i] ^ word) & 0x3FFFFFFF` — type and action, autocast
|
||||
/// bits excluded — and copies bit 30 across on a match. Without it, flipping autocast in the book
|
||||
/// leaves the bar button's sparkle stale until the next `SMSG_PET_SPELLS`.
|
||||
///
|
||||
/// The gate is `0x4bccf5`: **the word must be autocast-ALLOWED** (bit 31). A passive's is not, and
|
||||
/// neither is a command token's — so neither can be toggled from anywhere.
|
||||
/// `0x4bccb0`'s local half: flip bit 30 of the spell's raw word, then copy it onto every bar slot
|
||||
/// whose type and action match under `& 0x3FFFFFFF` (`0x4bcd20`-`0x4bcd5a`), or the bar's sparkle
|
||||
/// stays stale until the next `SMSG_PET_SPELLS`. `None` where the reference aborts unsent: the
|
||||
/// word is not autocast-allowed (`0x4bccf5`), as a passive's and a command token's are not.
|
||||
fn flip_autocast(
|
||||
spells: &mut benilla_protocol::messages::PetSpells,
|
||||
spell_id: u32,
|
||||
@@ -319,9 +266,6 @@ mod tests {
|
||||
PetActionEntry::from((u32::from(kind) << 24) | spell_id)
|
||||
}
|
||||
|
||||
/// **The pet book's add-gate is not the player book's.** `0x4b2f90` tests exactly one bit —
|
||||
/// `Attributes & 0x80` (DO_NOT_DISPLAY) — where the player book also refuses `IS_TRADESKILL`
|
||||
/// (`0x20`) and `castUI != 0`. A spell with either of those two SHOWS in a pet book.
|
||||
#[test]
|
||||
fn the_pet_books_gate_is_do_not_display_alone() {
|
||||
let shown = |attributes: u32, cast_ui: u32| SpellDisplay {
|
||||
@@ -331,7 +275,7 @@ mod tests {
|
||||
};
|
||||
assert!(shown(0, 0).in_pet_book());
|
||||
assert!(!shown(0x80, 0).in_pet_book(), "DO_NOT_DISPLAY hides it");
|
||||
// The two the PLAYER book additionally refuses — kept here, and this is the whole point.
|
||||
// The two the player book also refuses.
|
||||
assert!(
|
||||
shown(0x20, 0).in_pet_book(),
|
||||
"IS_TRADESKILL is not a pet-book gate"
|
||||
@@ -339,26 +283,22 @@ mod tests {
|
||||
assert!(!shown(0x20, 0).in_spellbook());
|
||||
assert!(shown(0, 1).in_pet_book(), "castUI is not a pet-book gate");
|
||||
assert!(!shown(0, 1).in_spellbook());
|
||||
// A passive is shown in both — being passive was never a book gate.
|
||||
// A passive shows in both.
|
||||
assert!(shown(0x40, 0).in_pet_book() && shown(0x40, 0).in_spellbook());
|
||||
}
|
||||
|
||||
/// The autocast toggle's local half (`0x4bccb0`): the flip lands on the raw **book** word AND
|
||||
/// on every **bar** slot carrying the same action, compared under `& 0x3FFFFFFF`. Missing the
|
||||
/// mirror leaves the bar button's sparkle stale until the next `SMSG_PET_SPELLS`.
|
||||
#[test]
|
||||
fn the_book_toggle_mirrors_onto_the_bar() {
|
||||
const CLAW: u32 = 16827;
|
||||
let mut spells = PetSpells {
|
||||
pet_guid: 0x2A,
|
||||
spells: vec![
|
||||
word(PET_ACT_DISABLED, CLAW), // autocastable, currently OFF
|
||||
word(PET_ACT_DISABLED, CLAW), // autocastable, off
|
||||
word(PET_ACT_PASSIVE, 3025), // a passive: not autocastable
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
// Claw sits in bar slot 4; slot 5 holds a DIFFERENT spell with the same low bits pattern
|
||||
// is impossible, so use a plain second spell to prove the compare is by action.
|
||||
// Claw in bar slot 4 and another spell in slot 5: the compare is by action.
|
||||
spells.bar[4] = word(PET_ACT_DISABLED, CLAW);
|
||||
spells.bar[5] = word(PET_ACT_DISABLED, 2649);
|
||||
|
||||
@@ -370,12 +310,12 @@ mod tests {
|
||||
"a different action is untouched"
|
||||
);
|
||||
|
||||
// Flipping back does the same in reverse.
|
||||
// Flipping back reverses both.
|
||||
assert_eq!(flip_autocast(&mut spells, CLAW), Some(false));
|
||||
assert!(!spells.spells[0].autocast_on());
|
||||
assert!(!spells.bar[4].autocast_on());
|
||||
|
||||
// `0x4bccf5`: a word that is not autocast-ALLOWED aborts before anything is written.
|
||||
// `0x4bccf5`: a word not autocast-allowed aborts before anything is written.
|
||||
assert_eq!(
|
||||
flip_autocast(&mut spells, 3025),
|
||||
None,
|
||||
@@ -388,9 +328,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A word already ON flips OFF and an `ACT_ENABLED` word starts ON — the two server states our
|
||||
/// decode collapses onto one type (`0988`), which is exactly why the flip reads bit 30 and not
|
||||
/// the type byte.
|
||||
/// `ACT_ENABLED` and `ACT_DISABLED` decode to one type, so only bit 30 tells them apart.
|
||||
#[test]
|
||||
fn the_flip_reads_bit_thirty_not_the_type_byte() {
|
||||
const GROWL: u32 = 2649;
|
||||
|
||||
@@ -1,32 +1,11 @@
|
||||
//! The app-side **pet paper-doll feed** (decision 1057) — `PetPaperDollFrame`'s data, the
|
||||
//! [`crate::ui_char`] pattern pointed at the pet.
|
||||
//! The pet paper-doll feed: `PetPaperDollFrame`'s combat stats and its model booth.
|
||||
//!
|
||||
//! Two jobs, each frame, in the feed phase before the VM ticks ([`crate::ui_script::UiFeed`]):
|
||||
//!
|
||||
//! - **The pet's [`UnitCombatStats`]**, built through [`crate::ui_char::unit_combat_stats`] — the
|
||||
//! descriptor-only core, which is all a creature has. It is the *same* snapshot type and the
|
||||
//! *same* bindings the character sheet reads, because that is the reference's own arrangement:
|
||||
//! `PetPaperDollFrame_Update` calls `PaperDollFrame_SetDamage/_SetAttackPower/_SetArmor/…` with
|
||||
//! `unit = "pet"` (ref `PetPaperDollFrame.lua:73-81`) and its own two setters read
|
||||
//! `UnitStat("pet", i)` / `UnitResistance("pet", id)`. Nothing here is a parallel API.
|
||||
//! - **Pointing the body booth**: [`PetDollBooth`]'s `unit` gets the resolved pet entity, and its
|
||||
//! `yaw` the facing the stock pane's rotate buttons wrote onto `PetModelFrame` itself
|
||||
//! (`UiScript::model_pane_facing` — decision 1751).
|
||||
//!
|
||||
//! **Why a module of its own rather than more of [`crate::ui_pet_stats`]**, which already resolves
|
||||
//! the same pet: these are the *shared* paper-doll surface (every value passes through a binding
|
||||
//! the character sheet uses too, and every event is one `PaperDollFrame` fires for the player),
|
||||
//! while that file is the hunter-only block behind the `0x6116e0` class gate. One diff over both
|
||||
//! would fire the shared repaint events off happiness drift, and the hunter gate would sit in the
|
||||
//! path of numbers a warlock's imp has too.
|
||||
//!
|
||||
//! Events: [`crate::ui_char::fire_stat_transitions`] with `arg1 = "pet"` — the eight groups the
|
||||
//! ref's page registers (`PetPaperDollFrame.lua:12-20`) that also have a source. `UNIT_LEVEL` is
|
||||
//! already fired for `"pet"` by [`crate::ui_pet`]'s token feed, and `UNIT_PET` /
|
||||
//! `UNIT_PET_EXPERIENCE` / `UNIT_PET_TRAINING_POINTS` by that file and
|
||||
//! [`crate::ui_pet_stats`] — none is re-fired here. `PET_UI_UPDATE`/`PET_UI_CLOSE` have no source
|
||||
//! anywhere yet (a named deferral in 1057), and `UNIT_DEFENSE` has none either while
|
||||
//! `UnitDefense("pet")` is the INTERIM `(0, 0)`.
|
||||
//! The pet's [`UnitCombatStats`] come from the character sheet's descriptor-only core and
|
||||
//! bindings, as in the reference, whose page calls the `PaperDollFrame_Set*` setters with `"pet"`
|
||||
//! (`PetPaperDollFrame.lua:73-81`). The stat events fire with `arg1 = "pet"` for the page's
|
||||
//! registrations (`PetPaperDollFrame.lua:12-20`) except `UNIT_DEFENSE`, which nothing fires.
|
||||
//! `UNIT_LEVEL` and the `UNIT_PET*` events come from [`crate::ui_pet`] and
|
||||
//! [`crate::ui_pet_stats`]; nothing fires `PET_UI_UPDATE` or `PET_UI_CLOSE`.
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
@@ -41,8 +20,7 @@ pub(crate) struct UiPetDollPlugin;
|
||||
|
||||
impl Plugin for UiPetDollPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
// Rides the unit feed beside the pet bar's and the stat block's, and before the VM ticks —
|
||||
// the whole page repaints out of the one pass that pushes the pet's health.
|
||||
// In the unit feed, so the page repaints in the pass that pushes the pet's health.
|
||||
app.add_systems(Update, feed_pet_doll.in_set(UnitFeed));
|
||||
}
|
||||
}
|
||||
@@ -58,27 +36,22 @@ fn feed_pet_doll(
|
||||
return;
|
||||
};
|
||||
let last = last.get(&script);
|
||||
// The pane's rotate buttons own the yaw; the booth mirrors it (the inspect pane's arrangement,
|
||||
// decision 0631 §4). Written every frame, pet or no pet — a stale yaw would snap the model the
|
||||
// moment one is summoned.
|
||||
// The pane's rotate buttons own the yaw. Written every frame, pet or not, so a new pet does
|
||||
// not snap to a stale one.
|
||||
booth.yaw = script.model_pane_facing("PetModelFrame");
|
||||
|
||||
let pet_guid = bar.spells.pet_guid;
|
||||
let store = (pet_guid != 0).then(|| pet.store(pet_guid)).flatten();
|
||||
// `None` empties the booth — the same "no pet" the rest of the page shows, and the same test:
|
||||
// a bar naming a guid whose object never streamed is not a pet we can draw.
|
||||
// A guid whose object never streamed is no pet: `None` empties the booth.
|
||||
booth.unit = (pet_guid != 0).then(|| pet.entity(pet_guid)).flatten();
|
||||
|
||||
let fresh = store.map(unit_combat_stats);
|
||||
// Push only on change (the pet-bar feed's discipline): the page repaints off the UNIT_* events
|
||||
// below, so a per-frame push would be pure churn — but the DIFF is what makes it cheap, so a
|
||||
// real change still lands the frame it arrives.
|
||||
// Push only on change: the page repaints off the `UNIT_*` events below.
|
||||
if *last == fresh {
|
||||
return;
|
||||
}
|
||||
// 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, then fire: the handlers run synchronously, and fired first they would paint the old
|
||||
// values for good.
|
||||
let prev = last.take();
|
||||
script.set_pet_combat_stats(fresh.clone());
|
||||
if let Some(stats) = &fresh {
|
||||
@@ -90,8 +63,7 @@ fn feed_pet_doll(
|
||||
}
|
||||
fire_stat_transitions(&mut script, "pet", prev.as_ref(), stats);
|
||||
}
|
||||
// A pet going away fires nothing: the page hides on `UNIT_PET` (which `crate::ui_pet` fires on
|
||||
// the guid edge), exactly as the character sheet's stat lines are not cleared by an event.
|
||||
// A pet going away fires nothing here: the page hides on `crate::ui_pet`'s `UNIT_PET`.
|
||||
*last = fresh;
|
||||
}
|
||||
|
||||
@@ -102,8 +74,7 @@ mod tests {
|
||||
|
||||
use crate::net::ObjectStore;
|
||||
|
||||
// The UNIT-block wire indices the core reads (`benilla_protocol`'s private
|
||||
// `fields::FIELD_UNIT_*` table; the `ui_pet_stats` fixtures' own convention).
|
||||
// The UNIT-block field indices the core reads (`benilla_protocol`'s `fields::FIELD_UNIT_*`).
|
||||
const BASEATTACKTIME: u16 = 126;
|
||||
const MINDAMAGE: u16 = 134;
|
||||
const MAXDAMAGE: u16 = 135;
|
||||
@@ -112,15 +83,8 @@ mod tests {
|
||||
const ATTACK_POWER: u16 = 165;
|
||||
const ATTACK_POWER_MODS: u16 = 166;
|
||||
|
||||
/// A boar's descriptor: the UNIT half a creature really streams, and nothing else — no PLAYER
|
||||
/// block at all, which is the whole point of the fixture.
|
||||
///
|
||||
/// **CREATED, and that is the load-bearing half** (decision 1081). A live pet arrives as a
|
||||
/// create block, and a create is a *complete* snapshot — absent means 0, not unknown. Built
|
||||
/// bare (the fixture default), this boar answered `None` for every PLAYER field and the
|
||||
/// defaults below looked right while the live client's read `0`; the pet sheet's damage
|
||||
/// tooltip came out `inf - inf` / `nan` under a green test. Take `.into_created` off and the
|
||||
/// `damage_percent` assertion is the one that fails.
|
||||
/// A boar's descriptor: the UNIT block alone, built as a create block, because a live pet
|
||||
/// arrives as one and a create reads an absent field as 0, not unknown.
|
||||
fn boar() -> ObjectStore {
|
||||
ObjectStore(
|
||||
ObjectFields::from_pairs(&[
|
||||
@@ -142,9 +106,7 @@ mod tests {
|
||||
)
|
||||
}
|
||||
|
||||
/// The descriptor-only core over a real creature's field set: the UNIT values come through,
|
||||
/// and **every PLAYER-block-sourced value keeps its default** — which is what makes the ref's
|
||||
/// pet sheet plain white numbers rather than a buff decomposition.
|
||||
/// PLAYER-block values keep their defaults: the reference's pet sheet shows no buff split.
|
||||
#[test]
|
||||
fn the_core_reads_a_creatures_unit_block_and_defaults_the_rest() {
|
||||
let s = unit_combat_stats(&boar());
|
||||
@@ -157,15 +119,13 @@ mod tests {
|
||||
(s.attack_power, s.attack_power_pos, s.attack_power_neg),
|
||||
(178, 12, -4)
|
||||
);
|
||||
// No PLAYER block ⇒ no buff splits, no damage-done mods…
|
||||
assert_eq!(s.stat_pos, [0; 5]);
|
||||
assert_eq!(s.stat_neg, [0; 5]);
|
||||
assert_eq!(s.resistance_pos, [0; 7]);
|
||||
assert_eq!(s.resistance_neg, [0; 7]);
|
||||
assert_eq!((s.physical_bonus_pos, s.physical_bonus_neg), (0, 0));
|
||||
// …and `damage_percent` MUST stay 1.0: the ref Lua divides the damage range by it.
|
||||
// `damage_percent` stays 1.0: the stock Lua divides the damage range by it.
|
||||
assert_eq!(s.damage_percent, 1.0);
|
||||
// The equipment/skill half is the player feed's; a pet has neither.
|
||||
assert!(!s.has_offhand && !s.has_wand);
|
||||
assert_eq!(s.main_weapon_skill, (0, 0));
|
||||
assert_eq!(s.ranged_weapon_skill, (0, 0));
|
||||
@@ -176,8 +136,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// An empty store (a guid whose fields have not streamed) is the absent shape, not a panic and
|
||||
/// not a division-by-zero waiting to happen in the Lua.
|
||||
#[test]
|
||||
fn an_unstreamed_pet_reads_the_absent_shape() {
|
||||
let s = unit_combat_stats(&ObjectStore(ObjectFields::from_pairs(&[])));
|
||||
@@ -188,23 +146,18 @@ mod tests {
|
||||
assert_eq!(s.main_attack_time_ms, 2000);
|
||||
}
|
||||
|
||||
/// **The seam the gates cannot see**: the exact composition [`feed_pet_doll`] performs —
|
||||
/// descriptor → core → `set_pet_combat_stats` → the ref's own bindings — really answers the
|
||||
/// boar's numbers under `"pet"`. A feed wired to the wrong setter, or a core that dropped a
|
||||
/// field on the way, compiles and shows an all-zero pet sheet; only reading it back through
|
||||
/// the VM catches that.
|
||||
#[test]
|
||||
fn the_feeds_composition_answers_the_pet_bindings() {
|
||||
let mut s = benilla_ui::script::UiScript::new().unwrap();
|
||||
s.set_pet_combat_stats(Some(unit_combat_stats(&boar())));
|
||||
|
||||
// `PetPaperDollFrame_SetStats`'s read (ref l.149) — stamina is the 3rd, 1-based.
|
||||
// `PetPaperDollFrame.lua:149`'s read; stamina is the third, 1-based.
|
||||
assert_eq!(
|
||||
s.eval::<(i64, i64, i64, i64)>(r#"return UnitStat("pet", 3)"#)
|
||||
.unwrap(),
|
||||
(68, 68, 0, 0)
|
||||
);
|
||||
// `PetPaperDollFrame_SetResistances`'s (ref l.112) — fire is school 2.
|
||||
// `PetPaperDollFrame.lua:112`'s read; fire is school 2.
|
||||
assert_eq!(
|
||||
s.eval::<(i64, i64, i64, i64)>(r#"return UnitResistance("pet", 2)"#)
|
||||
.unwrap(),
|
||||
@@ -229,8 +182,7 @@ mod tests {
|
||||
(178, 12, -4)
|
||||
);
|
||||
|
||||
// Dismissing the pet is the feed's `None` push — every line falls back to the absent
|
||||
// shape rather than freezing on the last pet's numbers.
|
||||
// Dismissing the pet pushes `None`: every line falls back to the absent shape.
|
||||
s.set_pet_combat_stats(None);
|
||||
assert_eq!(
|
||||
s.eval::<(i64, i64, i64, i64, i64)>(r#"return UnitArmor("pet")"#)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user