GM tickets are in, and a login failure shows the string 1.12 shows

- GM tickets, end to end: five request/answer pairs, the seven Help-window bindings, three events and `GMTicketCategory.dbc` — the ten categories `HelpFrame.lua` keys 1..10 live in a DBC nothing here had ever opened (the four `TICKET_TYPE` strings feed a dropdown 1.12 never gave a frame); the category is one byte on the wire (a u32 would shift the map id and position and file every ticket at a garbage spot), and `UPDATETEXT` carries it too, which vmangos stores and cmangos swallows as the first character of the text — faithful to the client, the divergence recorded
- the engine re-asks for the ticket after a create or update succeeds and on a status push (the ask opcode has three callers in the reference, two of them engine legs with no idempotence guard) — which is why the shipped FrameXML registers no handler for the three response opcodes and still repaints; without that leg the form stays a form for ten minutes; `UPDATE_GM_STATUS` is a signed passthrough (`0xFFFFFFFF` → −1), the five ticket verbs drain through one ordered queue because call order is wire order, and the Help micro button — drawn, hovering and a no-op since the micro menu landed — is live
- a typed wrong password reads the reference's long "check the spelling of the account name and password" (0x04 and 0x05 share one jump-table arm — the client cannot say "wrong password", and the lockout cmangos's header describes does not exist in the binary), unknown codes read "Disconnected from server" rather than "Authentication failed", and every realmd row of the old map was in the wrong one: the two login-status enums have two key tables and two dialog paths, and a server source can only ever verify the codes, never which string the client draws for them

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-29 17:20:24 +07:00
co-authored by Claude Fable 5
parent 78e958ef4f
commit 1c4cc147f5
32 changed files with 5215 additions and 172 deletions
File diff suppressed because it is too large Load Diff
+24 -9
View File
@@ -29,12 +29,13 @@
bar plate doesn't paint over them. Same shape the bag bar next door already uses.
SCOPE / real-client divergences (noted, not bugs):
- ONE button has no panel to open yet: Help (no HelpFrame). It is drawn, hovers, and
highlights like the rest, but its click is a no-op — the same "art only, wired the day the
panel lands" call the page up/down arrows on this same bar already make. Deliberately NOT
greyed with the ref's `-Disabled` art: a dead grey button mid-row reads as a broken bar,
and the point of this slice is that the bar reads whole. It is one OnClick line from live.
(Socials went live with decision 0668, Main Menu with 0674.)
- EVERY button now opens something. Help was the last "art only, wired the day the panel
lands" one, and HelpFrame.xml is that panel — its click is `ToggleHelpFrame()` and its
pushed arm is in UpdateMicroButtons below, exactly as the ref writes both. (Socials went
live with decision 0668, Main Menu with 0674, Help with the HelpFrame transcription.)
The bar was deliberately never greyed with the ref's `-Disabled` art while the gap
lasted: a dead grey button mid-row reads as a broken bar, and it really was one OnClick
line from live.
- The "(key)" suffix is LIVE since 0997: `button.binding` carries the binding ACTION
name (TOGGLECHARACTER0, TOGGLESPELLBOOK, ...) and the tooltip looks the key up through
GetBindingKey at hover time — the ref MicroButtonTooltipText's own contract, restored
@@ -173,15 +174,23 @@
-- ref UpdateMicroButtons (l.20-84): each button reflects whether its panel is up. The
-- MainMenu arm is the ref's own six-way test (l.47-58) minus the five frames that don't
-- exist here — GameMenuFrame is the one that does (decision 0674), and it is the one the
-- reference lists first. Its keyring arm has no frame here; the still-unwired Help button
-- simply never pushes.
-- reference lists first. Its keyring arm (l.79-83) still has no frame here.
--
-- The Help arm is the ref's l.72-76, live since HelpFrame.xml landed. It calls BACK into
-- this function from its own OnShow and OnHide, which is the ref's wiring too and is why
-- the reference's HelpFrame is the one panel that does not need an event to stay in sync.
function UpdateMicroButtons()
BenillaMicroButton_SetPushed(CharacterMicroButton, BenillaMicro_PanelShown("CharacterFrame"))
BenillaMicroButton_SetPushed(SpellbookMicroButton, BenillaMicro_PanelShown("SpellBookFrame"))
BenillaMicroButton_SetPushed(TalentMicroButton, BenillaMicro_PanelShown("BenillaTalentFrame"))
BenillaMicroButton_SetPushed(QuestLogMicroButton, BenillaMicro_PanelShown("QuestLogFrame"))
-- ref l.60-64. Missing since the Socials button went live with decision 0668: the
-- window opened but its button never read pushed. Spotted while wiring Help, which is
-- the same arm one line down; fixed here rather than left for a bug report.
BenillaMicroButton_SetPushed(SocialsMicroButton, BenillaMicro_PanelShown("FriendsFrame"))
BenillaMicroButton_SetPushed(WorldMapMicroButton, BenillaMicro_PanelShown("WorldMapFrame"))
BenillaMicroButton_SetPushed(MainMenuMicroButton, BenillaMicro_PanelShown("GameMenuFrame"))
BenillaMicroButton_SetPushed(HelpMicroButton, BenillaMicro_PanelShown("HelpFrame"))
end
-- ref CharacterMicroButton_SetPushed/_SetNormal (l.109-117): the portrait shifts its window
@@ -358,13 +367,19 @@
</Scripts>
</Button>
<!-- No HelpFrame yet (SCOPE note). -->
<!-- The GM help window (HelpFrame.xml). `ToggleHelpFrame()` is the ref's own OnClick
(ref-MainMenuBarMicroButtons.xml l.217-219) and it is the whole wiring: the toggle takes
care of the panel manager, and the panel's own OnShow/OnHide call UpdateMicroButtons back
so this button's pushed state follows it however the window was opened or closed.
It takes no binding — 1.12 has no TOGGLEHELP action — so the tooltip shows the bare
label, which is the ref's behaviour for this one too. -->
<Button name="HelpMicroButton" inherits="BenillaMicroButtonTemplate">
<Anchors>
<Anchor point="BOTTOMLEFT" relativeTo="MainMenuMicroButton" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="-3" y="0"/></Offset></Anchor>
</Anchors>
<Scripts>
<OnLoad>BenillaMicroButton_OnLoad(self, "Help", HELP_BUTTON, nil, NEWBIE_TOOLTIP_HELP)</OnLoad>
<OnClick>ToggleHelpFrame()</OnClick>
</Scripts>
</Button>
</Ui>
+15 -3
View File
@@ -26,9 +26,10 @@
<Script><![CDATA[
-- UIParent.lua l.14-42: UIPanelWindows["FrameName"] = { area, pushable, whileDead }. Only
-- the rows benilla ships today; the ref rows with no benilla window yet (Tabard, Petition,
-- GuildRegistrar, Battlefield, Help, Cinematic, WorldStateScore, Minigame) join as their
-- windows land (audited row-by-row against the bytes, decision 1507). Two have since left
-- that list: the doublewide AuctionFrame with decision 1511, PetStableFrame with 1676.
-- GuildRegistrar, Battlefield, Cinematic, WorldStateScore, Minigame) join as their windows
-- land (audited row-by-row against the bytes, decision 1507). Three have since left that
-- list: the doublewide AuctionFrame with decision 1511, PetStableFrame with 1676, and
-- HelpFrame with 1673 — all three are rows below.
UIPanelWindows = {}
UIPanelWindows["GossipFrame"] = { area = "left", pushable = 0 }
UIPanelWindows["MerchantFrame"] = { area = "left", pushable = 0 }
@@ -155,6 +156,17 @@
-- the client. (The ref's l.17-18 register SoundOptionsFrame/UIOptionsFrame the same way;
-- those panels are this one window's category rows now, so the rows have no frames.)
UIPanelWindows["OptionsFrame"] = { area = "center", pushable = 0, whileDead = 1 }
-- The GM help window (HelpFrame.xml) — the ref's own registration verbatim
-- (`UIPanelWindows["HelpFrame"] = { area = "center", pushable = 0, whileDead = 1 }`,
-- UIParent.lua l.35): the THIRD native-center row, and the first one that is not part of
-- the settings shell. Same MODE as the two above — it keeps its own screen-CENTER anchor,
-- closes every other window on the way in, and while it holds the slot ShowUIPanel refuses
-- every non-center panel (`CanOpenPanels` below). That is deliberate here rather than
-- incidental: petitioning a GM is a full stop, not something you do beside your bags.
-- whileDead, because being dead somewhere you cannot leave is one of the things you page a
-- GM about — and it is what lets the ticket toast's HELP_TICKET dialog reach the window
-- from a corpse. (The ref's l.40 WorldStateScoreFrame row is center too; no frame here.)
UIPanelWindows["HelpFrame"] = { area = "center", pushable = 0, whileDead = 1 }
-- (0997's KeyBindingFrame row is gone — decision 1008 folded key bindings into the
-- options window above, the 1.15 layout's own placement; KeyBindingsPage.xml.)
-- The world map (decision 0203 phase 2) — the ref's own line verbatim (UIParent.lua l.30)
+16
View File
@@ -429,6 +429,22 @@ MacroFrame.xml
# UiPanels (ShowUIPanel/HideUIPanel + the UIPanelWindows row that seats it and gets it closed
# by ESC). Before GameMenuFrame, which is deliberately last.
ScriptLogFrame.xml
# The GM help window + the ticket-status toast (the HelpFrame.xml transcription): the third
# **center**-area UIPanelWindows row, so it opens like the game menu and the options window and
# shuts every other panel on the way in. It heads the SHELL TAIL for that reason — Help, then the
# options pair, then the game menu last — and it goes ahead of KeyBindingsPage.xml rather than
# between it and OptionsFrame.xml, because those two are a hard must-load-before pair and nothing
# should be wedged into it. Every dependency is far above: UiPanels (ShowUIPanel/HideUIPanel, the
# UIPanelWindows row, and the StaticPopup engine its three ticket dialogs register into),
# UIPanelTemplates (UIPanelCloseButton, UIPanelButtonTemplate2, GameMenuButtonTemplate and the
# real UIPanelScrollFrameTemplate the ticket editor is built on), ScrollTemplates (the faux kit
# behind the GM category list, its shared trough, and the ScrollingEdit pair), GameTooltip (the
# toast wears the tooltip backdrop colours at OnLoad) and MicroMenu (whose Help button calls this
# file's ToggleHelpFrame, and whose UpdateMicroButtons this file calls back on show/hide — both
# resolved at call time, so that pair could sit either way round). The one seat that is a REAL
# requirement rather than legibility is **after BuffFrame.xml**: the toast re-anchors
# TemporaryEnchantFrame out of its own way, and that frame is declared there.
HelpFrame.xml
# The Keybindings page MODULE (decision 1008, superseding 0997's standalone window):
# the templates + script of the Options window's Keybindings category, over the
# engine's binding table (benilla_ui::script::keybind + crate::bindings — the 1.12
+2
View File
@@ -90,6 +90,7 @@ mod probe_charcreate;
mod probe_chest;
mod probe_clam;
mod probe_crossing;
mod probe_gm_ticket;
mod probe_guard_poi;
mod probe_mail;
mod probe_melee;
@@ -113,6 +114,7 @@ pub(crate) use probe_charcreate::ProbeCharCreatePlugin;
pub(crate) use probe_chest::ProbeChestPlugin;
pub(crate) use probe_clam::ProbeClamPlugin;
pub(crate) use probe_crossing::ProbeCrossingPlugin;
pub(crate) use probe_gm_ticket::ProbeGmTicketPlugin;
pub(crate) use probe_guard_poi::ProbeGuardPoiPlugin;
pub(crate) use probe_mail::ProbeMailPlugin;
pub(crate) use probe_melee::ProbeMeleePlugin;
@@ -0,0 +1,779 @@
//! The GM trouble-ticket live probe (`WOW_PROBE_GMTICKET=1`) — decision 1673's end-to-end
//! instrument: log in, and drive the whole five-opcode ticket wire through the **live Lua VM's own
//! bindings** (`GetGMStatus`, `DeleteGMTicket`, `GetGMTicket`, `NewGMTicket`, `UpdateGMTicket`),
//! observing every answer where the Help window observes it — as an `UPDATE_TICKET` /
//! `UPDATE_GM_STATUS` event fired at a real frame. One
//! `PROBE_GMTICKET: <step> PASS/FAIL/SKIP <detail>` line per step, then a final
//! `PROBE_GMTICKET: DONE pass=<n> fail=<m>`. Modeled on [`super::probe_binder`] and
//! [`super::probe_bank`] — same phase machine, same trace style, same self-terminating exit
//! ([`super::probes::ProbeExitPlugin`]'s pattern), same live-VM observation idiom (a small Lua hook
//! appending to a probe table, read back with `script.eval`).
//!
//! ## Why unit tests cannot close this feature
//!
//! 1673 ships 23 unit tests, byte-exact wire goldens and a DBC oracle, and **not one of them can
//! tell whether vmangos accepts the packet**. The server answers several refusals with *silence*:
//! `HandleGMTicketCreateOpcode` returns with no packet at all when the queue is off, when the
//! player is under `GMTickets.MinLevel`, or when the category is `>= GMTICKET_MAX` (11), and
//! `HandleGMTicketDeleteTicketOpcode` likewise when there is no ticket (vmangos
//! `Handlers/GMTicketHandler.cpp`, read this session). A create the server drops on the floor is
//! byte-identical, from the client's side, to one that worked. Only a live round trip settles it.
//!
//! ## What each step can and cannot conclude
//!
//! 1. **queue** — `GetGMStatus()`, then `UPDATE_GM_STATUS` carrying **1**. `GMTickets.Enable`
//! defaults to `true` (`World.cpp:684`), so 1 is the expected answer; no event at all means the
//! opcode round trip is dead and is a FAIL. A `0` is not a client defect — it is the queue
//! genuinely switched off, and every later step would then be answered with silence, so the
//! probe SKIPs the rest rather than manufacturing failures out of a server setting.
//! 2. **clean** — `DeleteGMTicket()`, then `GetGMTicket()`, expecting `arg1 == 0`. Clears a
//! leftover ticket from an earlier run *and* proves the "no ticket" answer decodes
//! (`GMTICKET_STATUS_DEFAULT`, a 4-byte body). **The trap:** delete-with-no-ticket is answered
//! with nothing at all, so this waits on the `GetGMTicket` answer that follows, never on a
//! delete response.
//! 3. **create** — `NewGMTicket(4, <unique text>)` (4 = Item, `GMTicketCategory.dbc` id 4 =
//! vmangos `GMTICKET_ITEM`), then `GetGMTicket()`, expecting `arg1 == 4` and `arg2` **equal** to
//! the text sent. The load-bearing step: it proves the `u8` category, the map/position block,
//! the text and the trailing `"Reserved for future use"` cstring are laid out the way vmangos
//! reads them. A category widened to `u32` would shift the map id and the position three bytes
//! down; the server would file the ticket at a garbage spot or reject the packet outright.
//! 4. **db** — the row itself. **A two-part check, and the probe only does the first half**: the
//! client never reads map/position back, so the echo in step 3 cannot prove they landed. The
//! probe *prints* what it sent (`PROBE_GMTICKET: db-expect …`) and the operator runs the SQL
//! after the run and compares. This step therefore always reports SKIP — it is not a pass the
//! probe is entitled to claim. The line carries a `drift=` figure: how far the body could have
//! moved between the position sample and the frame the packet was stamped (see [`REST_EPS`]).
//! `drift=0.000` means the position in the line is exact.
//! 5. **edit** — `UpdateGMTicket(4, <second unique text>)`, then `GetGMTicket()`, expecting `arg2`
//! **equal** to the new text. This is the step that proves the **category byte** on
//! `CMSG_GMTICKET_UPDATETEXT` — the emulator fork 1673 records, where cmangos-classic reads a
//! bare cstring and would swallow our category byte as the first character of the text. The
//! comparison is equality, never `contains`, because a stray leading control byte is exactly
//! the failure this step exists to catch.
//! 6. **abandon** — `DeleteGMTicket()`, then `GetGMTicket()`, expecting `arg1 == 0` again.
//!
//! ## Two facts about waiting that this probe is built around
//!
//! **The wait is for the SERVER, never for the drain.** Lua's calls reach the wire in call order:
//! [`crate::ui_gm_ticket`]'s `drain_gm_ticket` walks ONE ordered intent queue
//! (`take_gm_ticket_intents`), so `DeleteGMTicket(); GetGMTicket()` in a single chunk arrives
//! delete-then-get. It did not always — the drain used to walk a counter per verb and put every
//! ask ahead of every write and delete, which inverted exactly that pair and made the get answer
//! with the *pre*-delete state. This probe is what surfaced that (driving the bindings directly is
//! what a third-party addon does and what no shipped-window test exercises); decision 1673's "One
//! ordered queue, not a counter per verb" records the fix. What still has to be waited for is the
//! **round trip**: each step separates its write from the read-back by [`WRITE_GAP_SECS`] and then
//! waits up to [`ANSWER_TIMEOUT_SECS`] for an answer that matches. The gap is no longer a
//! correctness crutch; it keeps one exchange per trace line, which is what makes a failing step
//! readable.
//!
//! **Answers are not correlated to asks, so nothing here waits for "the next event".** Three
//! sources put an `UPDATE_TICKET` on the stream that this probe never explicitly asked for: a GM's
//! `.ticket viewid`/`escalate`/`complete`; vmangos's own delete handler (`SendTicket(this,
//! nullptr)`, right after the delete response); and the **engine's own re-ask**, which fires on
//! every create-ok (2) and update-ok (4) response (`GmTicketState::note_write_landed`, the
//! reference's `0x5e4479`). So after step 3's `NewGMTicket` an answer arrives *before* the probe's
//! own `GetGMTicket()` — expected, not an anomaly. Each step latches the observed event count as a
//! baseline and then waits for **any** answer after it that matches the expectation, failing only
//! at the timeout and printing every answer it did see. Matching on a *unique* text for the two
//! load-bearing steps is what keeps that from being a weaker assertion than "the next one".
//!
//! ## What the DB row looks like afterwards
//!
//! vmangos's abandon is `CloseTicket`, which sets `closed_by` and `SaveToDB()`s — it does **not**
//! delete the row (`GMTicketMgr.cpp:401-410`) — so step 6 leaves the row in place for the operator
//! to read. `HandleGMTicketUpdateTextOpcode` calls `SetTicketType(packet.type)` as well as
//! `SetMessage`, so after step 5 the row's `message` is the **edited** text while `map`/
//! `position_*` still carry the create's values (the row is REPLACEd in place). Both `db-expect`
//! lines say which is which.
//!
//! ## The run recipe
//!
//! ```text
//! WOW_NOSOUND=1 WOW_USER=probe3 WOW_PASS=pprobe3 WOW_CHAR=Probethree \
//! WOW_PROBE_GMTICKET=1 cargo run -q -p benilla
//! ```
//! (the slot-keyed probe identity — this worktree is `pool-3` → `probe3`/`pprobe3`/`Probethree`;
//! method.md "The local vmangos server"). Non-combat; the probe never drives the body and sends no
//! GM command, so GM mode and position are left exactly as found — but note that the body is not
//! necessarily *still* when a run starts (see [`REST_EPS`]). An outer `timeout` + grep on
//! `PROBE_GMTICKET:` is the whole harness; the probe self-exits once DONE. Nothing here is a
//! timing measurement, so an occluded window costs wall clock and no correctness.
//!
//! Every step SKIPs with a note rather than FAILing for an environmental problem (no UI VM in this
//! build, the queue switched off server-side, a binding that would not run). A genuine wrong value
//! is a FAIL.
use bevy::prelude::*;
use benilla_assets::coords::bevy_to_wow;
use benilla_ui::script::UiScript;
use super::probes::ProbeClock;
use crate::net::SelfPlayer;
use crate::player::Player;
/// `GMTicketCategory.dbc` id 4 = "Item" (decision 1673's table), which is vmangos's
/// `GMTICKET_ITEM = 4` (`SharedDefines.h:1779`) — the same number on both sides, which is the
/// point: the id is the wire value, not a list index.
const CATEGORY_ITEM: u32 = 4;
/// `SMSG_GMTICKETSYSTEMSTATUS`'s "the queue is taking tickets" (`GMTICKET_QUEUE_STATUS_ENABLED`).
///
/// The field is **signed** (`GmTicketState::queue_status`, byte-verified: the reference loads it
/// with `fild dword`), so `-1` — the window's "queue down, and say so" arm — is a value this probe
/// can legitimately read back. vmangos only ever sends 0 or 1.
const QUEUE_ENABLED: f64 = 1.0;
/// The `UPDATE_TICKET` `arg1` that means "you have no ticket" — the window's whole else-branch
/// hangs on `arg1 and arg1 ~= 0`.
const NO_TICKET: f64 = 0.0;
/// Settle before the first ask: lets the body land, the world stream, and any login-time
/// `GetGMTicket()` the shipped Help window fires drain and answer before a baseline is latched.
const SETTLE_SECS: f64 = 3.0;
/// The gap between a write/delete and the `GetGMTicket()` that reads it back.
///
/// Not a correctness requirement — the drain preserves Lua's call order (module doc) — but a
/// readability one: it keeps one exchange per trace line, so a step that fails names the exchange
/// that failed rather than a batch of them.
const WRITE_GAP_SECS: f64 = 1.0;
/// How long a step waits for a matching answer before calling it. Generous on purpose: a starved
/// (occluded) window polls at ~1 fps and a timeout tight enough to trip on that would report a
/// FAIL about the wire, which is the one thing an instrument must never do.
const ANSWER_TIMEOUT_SECS: f64 = 20.0;
/// **The body must be at rest before the ticket is filed.** A ticket records *where you are*, and
/// the first live run of this probe filed one while the body was still falling: it entered the
/// world at z ≈ 94, the preflight banner read 82, and it was through 60 a second later — 21 yd of
/// travel across the create. The row happened to match anyway (the drain sampled the same frame),
/// but "happened to" is not a property an instrument may rest step 4 on, so the create now waits
/// for the body to stop moving. Movement under [`REST_EPS`] yd for [`REST_HOLD_SECS`] counts as
/// stopped.
const REST_EPS: f32 = 0.02;
const REST_HOLD_SECS: f64 = 0.5;
/// The cap on that wait. Timing out is not a failure — the probe files anyway and says the
/// position may be off, because a body that genuinely cannot come to rest (a lift, a swim, a
/// treadmill of terrain streaming) still has a working ticket wire to test.
const REST_TIMEOUT_SECS: f64 = 20.0;
/// How far the body may have travelled between the position sample and the frame the drain stamps
/// the packet, before the db-expect line stops being trustworthy (yd, WoW space).
const STAMP_DRIFT_EPS: f32 = 0.05;
/// The operator's half of step 4, printed verbatim so it can be pasted.
const DB_QUERY: &str = "cd /Users/sam/dev/vmangos-deploy && docker compose exec -T database \
mariadb -umangos -pmangos characters -e \"SELECT ticket_id, name, \
ticket_type, map, position_x, position_y, position_z, closed_by, message \
FROM gm_tickets ORDER BY ticket_id DESC LIMIT 1;\"";
pub(crate) struct ProbeGmTicketPlugin;
impl Plugin for ProbeGmTicketPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<GmTicketProbe>()
.add_systems(Update, gm_ticket_probe);
}
}
/// The probe's phase machine plus what it discovered along the way (the bank/binder probes' shape:
/// a `Copy` phase snapshotted out of the resource each tick, so an arm can mutate `probe` freely).
#[derive(Resource, Default)]
struct GmTicketProbe {
phase: Phase,
/// The text step 3 filed — unique per run, so no stale answer can satisfy step 3 by accident.
create_text: String,
/// The text step 5 edited it to.
edit_text: String,
/// Map and WoW-space position sampled at the moment `NewGMTicket` was called — what the drain
/// stamps into `CMSG_GMTICKET_CREATE` in that frame or the next, and what the DB row must
/// carry.
create_map: u32,
create_pos: [f32; 3],
/// How far the body had travelled by the frame after the create call — the honest bound on
/// how wrong the db-expect position could be (`None` until that frame has been seen).
stamp_drift: Option<f32>,
passes: u32,
fails: u32,
/// Latched once [`Phase::Done`] has fired its exit (never re-fire on a later frame).
exited: bool,
}
#[derive(Default, Clone, Copy, PartialEq)]
enum Phase {
#[default]
Wait,
/// Hook installed; settling before the first ask (module doc).
Settling {
since: f64,
},
/// `GetGMStatus()` called; waiting for `UPDATE_GM_STATUS` (step 1).
Queue {
since: f64,
baseline: usize,
},
/// `DeleteGMTicket()` called; letting the drain flush it before the read-back (step 2a).
CleanGap {
since: f64,
},
/// `GetGMTicket()` called; waiting for `arg1 == 0` (step 2b).
CleanGet {
since: f64,
baseline: usize,
},
/// Waiting for the body to stop moving before the ticket is filed ([`REST_EPS`]) — `last` is
/// the previous sample and `still_since` when it was last seen to move.
Rest {
since: f64,
last: [f32; 3],
still_since: f64,
},
/// `NewGMTicket(4, …)` called; letting the drain flush it (step 3a).
CreateGap {
since: f64,
},
/// `GetGMTicket()` called; waiting for the filed ticket to come back (step 3b).
CreateGet {
since: f64,
baseline: usize,
},
/// `UpdateGMTicket(4, …)` called; letting the drain flush it (step 5a).
EditGap {
since: f64,
},
/// `GetGMTicket()` called; waiting for the *new* text (step 5b).
EditGet {
since: f64,
baseline: usize,
},
/// `DeleteGMTicket()` called; letting the drain flush it (step 6a).
AbandonGap {
since: f64,
},
/// `GetGMTicket()` called; waiting for `arg1 == 0` again (step 6b).
AbandonGet {
since: f64,
baseline: usize,
},
Done,
}
/// The `UPDATE_TICKET` answers the live VM has seen, newest last, as `(arg1, arg2)` — the category
/// (0 for "no ticket") and the description. `arg2` is recorded as `""` whenever `arg1` is 0, since
/// the no-ticket fire carries a single argument and reading a second one would be reading whatever
/// the global happened to hold.
fn ticket_answers(script: &UiScript) -> Vec<(f64, String)> {
let cats = script
.eval::<Vec<f64>>("return ProbeGmTicketCat or {}")
.unwrap_or_default();
let texts = script
.eval::<Vec<String>>("return ProbeGmTicketText or {}")
.unwrap_or_default();
cats.into_iter().zip(texts).collect()
}
/// The `UPDATE_GM_STATUS` values the live VM has seen, newest last.
fn status_answers(script: &UiScript) -> Vec<f64> {
script
.eval::<Vec<f64>>("return ProbeGmStatus or {}")
.unwrap_or_default()
}
/// The first answer after `baseline` that satisfies `want` — the module doc's match-or-timeout
/// rule. Answers are not correlated to asks, so "the next event" is not a safe read; "any answer
/// after the baseline that matches" is, and the timeout is what turns a never-matching stream into
/// a FAIL that prints everything it saw.
fn matching<T>(answers: &[T], baseline: usize, want: impl Fn(&T) -> bool) -> Option<usize> {
answers
.iter()
.enumerate()
.skip(baseline)
.find(|(_, a)| want(a))
.map(|(i, _)| i)
}
/// A unique-per-run ticket text, so no stale or unsolicited answer can satisfy steps 3/5 by
/// accident. Seconds resolution is plenty — two runs cannot start in the same second and reach the
/// same step, and the suffix distinguishes the two texts within a run.
fn unique_text(suffix: &str) -> String {
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
format!("benilla probe {secs}{suffix}")
}
fn gm_ticket_probe(
time: ProbeClock,
mut probe: ResMut<GmTicketProbe>,
script: Option<NonSendMut<UiScript>>,
self_player: Query<(), With<SelfPlayer>>,
player: Res<Player>,
map: Option<Res<benilla_world::world_map::CurrentMap>>,
mut exit: MessageWriter<AppExit>,
) {
if self_player.is_empty() {
return; // not in-world yet
}
let Some(script) = script else {
return; // no UI VM this build (headless net-only) — nothing this probe can drive
};
let now = time.elapsed_secs_f64();
let phase = probe.phase;
match phase {
Phase::Wait => {
// The observation channel, installed before anything is asked so it is live for every
// answer including the unsolicited ones (the binder probe's exact pattern).
if let Err(e) = script.run(
r#"
if not ProbeGmTicketHooked then
ProbeGmTicketHooked = true
ProbeGmTicketCat = {}
ProbeGmTicketText = {}
ProbeGmStatus = {}
local f = CreateFrame("Frame")
f:RegisterEvent("UPDATE_TICKET")
f:RegisterEvent("UPDATE_GM_STATUS")
f:SetScript("OnEvent", function()
if event == "UPDATE_TICKET" then
local c = arg1 or 0
table.insert(ProbeGmTicketCat, c)
if c == 0 then
table.insert(ProbeGmTicketText, "")
else
table.insert(ProbeGmTicketText, arg2 or "")
end
else
table.insert(ProbeGmStatus, arg1 or 0)
end
end)
end
"#,
) {
// Without the hook nothing can be observed, and every step would then FAIL for a
// reason that has nothing to do with the wire. That is the one failure mode an
// instrument must never produce, so it stops here instead.
warn!(
"PROBE_GMTICKET: SKIP (0 hook) — the UPDATE_TICKET/UPDATE_GM_STATUS hook would \
not install in the live VM: {e}. Nothing can be observed, so no step below \
could be trusted (environmental, not a wire failure)."
);
probe.phase = Phase::Done;
return;
}
info!("PROBE_GMTICKET: hook installed; settling {SETTLE_SECS}s before the first ask");
probe.phase = Phase::Settling { since: now };
}
Phase::Settling { since } => {
if now - since < SETTLE_SECS {
return;
}
let baseline = status_answers(&script).len();
if !run_or_skip(&mut probe, &script, "1 queue", "GetGMStatus()") {
return;
}
probe.phase = Phase::Queue {
since: now,
baseline,
};
}
// Step 1 — the queue status. No event at all means the round trip is dead.
Phase::Queue { since, baseline } => {
let seen = status_answers(&script);
let Some(i) = matching(&seen, baseline, |_| true) else {
if now - since > ANSWER_TIMEOUT_SECS {
error!(
"PROBE_GMTICKET: FAIL (1 queue) — no UPDATE_GM_STATUS within \
{ANSWER_TIMEOUT_SECS}s of GetGMStatus(). The CMSG_GMTICKET_SYSTEMSTATUS \
→ SMSG_GMTICKETSYSTEMSTATUS round trip is dead: either the send never \
reached the wire or the answer never decoded. (values seen this run: \
{seen:?})"
);
probe.fails += 1;
probe.phase = Phase::Done;
}
return;
};
let status = seen[i];
if status != QUEUE_ENABLED {
warn!(
"PROBE_GMTICKET: SKIP (1 queue) — the round trip WORKS (UPDATE_GM_STATUS \
fired carrying {status}), but the server's ticket queue is not enabled. \
vmangos then answers create with silence (GMTicketHandler.cpp: \
`GetStatus() == GMTICKET_QUEUE_STATUS_DISABLED` → return), so every later \
step would fail for a server setting rather than a client defect. Set \
GMTickets.Enable = 1 and re-run."
);
probe.phase = Phase::Done;
return;
}
info!(
"PROBE_GMTICKET: PASS (1 queue) — UPDATE_GM_STATUS fired carrying \
{QUEUE_ENABLED} (the petition queue is up; the window's PETITION_QUEUE_ACTIVE \
gate opens)"
);
probe.passes += 1;
if !run_or_skip(&mut probe, &script, "2 clean", "DeleteGMTicket()") {
return;
}
probe.phase = Phase::CleanGap { since: now };
}
// Step 2 — the clean slate. The delete is deliberately NOT waited on: with no ticket
// vmangos answers it with nothing at all.
Phase::CleanGap { since } => {
if now - since < WRITE_GAP_SECS {
return;
}
let baseline = ticket_answers(&script).len();
if !run_or_skip(&mut probe, &script, "2 clean", "GetGMTicket()") {
return;
}
probe.phase = Phase::CleanGet {
since: now,
baseline,
};
}
Phase::CleanGet { since, baseline } => {
let seen = ticket_answers(&script);
if matching(&seen, baseline, |(c, _)| *c == NO_TICKET).is_some() {
info!(
"PROBE_GMTICKET: PASS (2 clean) — UPDATE_TICKET fired with arg1 == 0: no open \
ticket, and the 4-byte GMTICKET_STATUS_DEFAULT answer decodes"
);
probe.passes += 1;
probe.phase = Phase::Rest {
since: now,
last: bevy_to_wow(player.pos),
still_since: now,
};
} else if now - since > ANSWER_TIMEOUT_SECS {
error!(
"PROBE_GMTICKET: FAIL (2 clean) — no UPDATE_TICKET with arg1 == 0 within \
{ANSWER_TIMEOUT_SECS}s of DeleteGMTicket() + GetGMTicket(). Answers seen \
after the baseline: {:?}. A leftover ticket that refuses to clear, or a \
GETTICKET round trip that never answers.",
&seen[baseline.min(seen.len())..]
);
probe.fails += 1;
probe.phase = Phase::Done;
}
}
// The rest gate: a ticket records where you are, so file it from a body that has stopped
// moving (module doc — the first live run filed one mid-fall).
Phase::Rest {
since,
last,
still_since,
} => {
let here = bevy_to_wow(player.pos);
if distance(here, last) > REST_EPS {
probe.phase = Phase::Rest {
since,
last: here,
still_since: now,
};
return;
}
let timed_out = now - since > REST_TIMEOUT_SECS;
if !timed_out && now - still_since < REST_HOLD_SECS {
return;
}
if timed_out {
warn!(
"PROBE_GMTICKET: (3 create) the body never came to rest within \
{REST_TIMEOUT_SECS}s (still at {here:?}); filing anyway — the wire is still \
testable, but treat the db-expect position as approximate."
);
}
let text = unique_text("");
probe.create_pos = here;
probe.create_map = map.map(|m| m.0).unwrap_or(0);
if !run_or_skip(
&mut probe,
&script,
"3 create",
&format!("NewGMTicket({CATEGORY_ITEM}, \"{text}\")"),
) {
return;
}
probe.create_text = text;
probe.phase = Phase::CreateGap { since: now };
}
// Step 3 — the load-bearing one: the create body's layout, proven by the echo.
Phase::CreateGap { since } => {
// The first tick after the create call is the last frame in which the drain can still
// stamp the packet, so this is the whole window in which the body could move between
// the sample and the wire. Measuring it here — rather than a second later, at the
// db-expect print — is the difference between an honest bound and crying wolf at a
// body that simply kept falling after the packet had already gone.
if probe.stamp_drift.is_none() {
let drift = distance(bevy_to_wow(player.pos), probe.create_pos);
probe.stamp_drift = Some(drift);
if drift > STAMP_DRIFT_EPS {
warn!(
"PROBE_GMTICKET: (3 create) the body moved {drift:.3} yd in the frame the \
packet was stamped; the db-expect position below is good only to that."
);
}
}
if now - since < WRITE_GAP_SECS {
return;
}
let baseline = ticket_answers(&script).len();
if !run_or_skip(&mut probe, &script, "3 create", "GetGMTicket()") {
return;
}
probe.phase = Phase::CreateGet {
since: now,
baseline,
};
}
Phase::CreateGet { since, baseline } => {
let seen = ticket_answers(&script);
let want = probe.create_text.clone();
if let Some(i) = matching(&seen, baseline, |(_, t)| *t == want) {
let (cat, text) = &seen[i];
if *cat != f64::from(CATEGORY_ITEM) {
error!(
"PROBE_GMTICKET: FAIL (3 create) — the ticket came back with the right \
text but category {cat}, wanted {CATEGORY_ITEM}. The category byte is \
the head of CMSG_GMTICKET_CREATE; a wrong value there shifts the map id \
and the whole position."
);
probe.fails += 1;
probe.phase = Phase::Done;
return;
}
info!(
"PROBE_GMTICKET: PASS (3 create) — the server filed and echoed the ticket: \
arg1 == {cat} (Item) and arg2 == {text:?} byte for byte. The u8 category, \
the map/position block, the text and the trailing \"Reserved for future \
use\" cstring are all laid out the way vmangos reads them."
);
probe.passes += 1;
report_db_expectation(&probe, "after create", &want);
warn!(
"PROBE_GMTICKET: SKIP (4 db) — operator step, by design: the client never \
reads map/position back, so only the server's own row proves they landed. \
After the run: {DB_QUERY} — and compare against the db-expect line(s)."
);
let text = unique_text(" edited");
if !run_or_skip(
&mut probe,
&script,
"5 edit",
&format!("UpdateGMTicket({CATEGORY_ITEM}, \"{text}\")"),
) {
return;
}
probe.edit_text = text;
probe.phase = Phase::EditGap { since: now };
} else if now - since > ANSWER_TIMEOUT_SECS {
error!(
"PROBE_GMTICKET: FAIL (3 create) — the ticket never came back within \
{ANSWER_TIMEOUT_SECS}s. Wanted arg2 == {want:?}; answers seen after the \
baseline: {:?}. vmangos answers a REFUSED create with silence — queue off, \
under GMTickets.MinLevel, or category >= 11 — and a malformed body reads as \
exactly this. Step 1 already proved the queue is up, so a wrong create body \
is the live suspect.",
&seen[baseline.min(seen.len())..]
);
probe.fails += 1;
probe.phase = Phase::Done;
}
}
// Step 5 — the edit, and with it the category byte on CMSG_GMTICKET_UPDATETEXT.
Phase::EditGap { since } => {
if now - since < WRITE_GAP_SECS {
return;
}
let baseline = ticket_answers(&script).len();
if !run_or_skip(&mut probe, &script, "5 edit", "GetGMTicket()") {
return;
}
probe.phase = Phase::EditGet {
since: now,
baseline,
};
}
Phase::EditGet { since, baseline } => {
let seen = ticket_answers(&script);
let want = probe.edit_text.clone();
// Equality, never `contains`: a leading control byte in the stored text is precisely
// the cmangos-style failure this step exists to catch (decision 1673's divergence).
if let Some(i) = matching(&seen, baseline, |(_, t)| *t == want) {
let (cat, text) = &seen[i];
info!(
"PROBE_GMTICKET: PASS (5 edit) — the edit landed and echoed EXACTLY: arg2 == \
{text:?} (arg1 == {cat}). No stray leading byte, so the server read our \
category as a category and the text as text — CMSG_GMTICKET_UPDATETEXT's \
`u8 type; cstring text` is right for this server."
);
probe.passes += 1;
report_db_expectation(&probe, "after edit", &want);
if !run_or_skip(&mut probe, &script, "6 abandon", "DeleteGMTicket()") {
return;
}
probe.phase = Phase::AbandonGap { since: now };
} else if now - since > ANSWER_TIMEOUT_SECS {
let stray = seen
.iter()
.skip(baseline)
.any(|(_, t)| t.len() > want.len() && t.ends_with(&want));
error!(
"PROBE_GMTICKET: FAIL (5 edit) — the edited text never came back within \
{ANSWER_TIMEOUT_SECS}s. Wanted arg2 == {want:?}; answers seen after the \
baseline: {:?}. leading-byte-swallowed={stray} — if that is true, the server \
read our category byte as the first character of the text, which is the \
cmangos-classic behaviour decision 1673 records.",
&seen[baseline.min(seen.len())..]
);
probe.fails += 1;
probe.phase = Phase::Done;
}
}
// Step 6 — abandon. vmangos keeps the row (CloseTicket → closed_by + SaveToDB), so the
// operator's SQL still finds it afterwards.
Phase::AbandonGap { since } => {
if now - since < WRITE_GAP_SECS {
return;
}
let baseline = ticket_answers(&script).len();
if !run_or_skip(&mut probe, &script, "6 abandon", "GetGMTicket()") {
return;
}
probe.phase = Phase::AbandonGet {
since: now,
baseline,
};
}
Phase::AbandonGet { since, baseline } => {
let seen = ticket_answers(&script);
if matching(&seen, baseline, |(c, _)| *c == NO_TICKET).is_some() {
info!(
"PROBE_GMTICKET: PASS (6 abandon) — UPDATE_TICKET fired with arg1 == 0 again: \
the ticket is gone from the player's view (vmangos marks it closed_by and \
keeps the row, so the db-expect rows above still resolve)"
);
probe.passes += 1;
} else if now - since > ANSWER_TIMEOUT_SECS {
error!(
"PROBE_GMTICKET: FAIL (6 abandon) — still holding a ticket \
{ANSWER_TIMEOUT_SECS}s after DeleteGMTicket() + GetGMTicket(). Answers seen \
after the baseline: {:?}",
&seen[baseline.min(seen.len())..]
);
probe.fails += 1;
} else {
return;
}
probe.phase = Phase::Done;
}
Phase::Done => {
if probe.exited {
return;
}
probe.exited = true;
info!(
"PROBE_GMTICKET: DONE pass={} fail={}",
probe.passes, probe.fails
);
// The probe self-exit pattern (`ProbeExitPlugin::fire_probe_exit`): a polite AppExit
// plus a hard backstop thread, so a net/winit teardown hang can't leave a zombie
// client holding the probe account.
exit.write(AppExit::Success);
std::thread::spawn(|| {
std::thread::sleep(std::time::Duration::from_secs(5));
warn!("PROBE_GMTICKET: still alive 5s after AppExit — hard exit");
std::process::exit(0);
});
}
}
}
/// Run one binding in the live VM, or SKIP the whole probe with the reason.
///
/// A binding that will not run is environmental (no VM, a broken chunk) — never a wire verdict —
/// so it must not print a FAIL. Returns `false` once the probe has been parked in [`Phase::Done`].
fn run_or_skip(probe: &mut GmTicketProbe, script: &UiScript, step: &str, chunk: &str) -> bool {
match script.run(chunk) {
Ok(()) => {
info!("PROBE_GMTICKET: ({step}) {chunk} run in the live VM");
true
}
Err(e) => {
warn!(
"PROBE_GMTICKET: SKIP ({step}) — `{chunk}` would not run in the live VM: {e} \
(environmental, not a wire failure)"
);
probe.phase = Phase::Done;
false
}
}
}
/// Print step 4's half of the check: exactly what the DB row must carry.
///
/// The position is the one sampled at the `NewGMTicket` call — what the drain stamps into the
/// packet, in that frame or the next. How much the body could have moved inside that window is
/// measured once, at the frame after the call, and reported here as `drift=`; a value at or under
/// [`STAMP_DRIFT_EPS`] means the line is exact. The map id and `ticket_type` are exact regardless.
/// **Nothing here is a PASS** — the row is the operator's to read.
fn report_db_expectation(probe: &GmTicketProbe, when: &str, message: &str) {
let [x, y, z] = probe.create_pos;
info!(
"PROBE_GMTICKET: db-expect ({when}) map={} pos={x},{y},{z} ticket_type={CATEGORY_ITEM} \
message={message:?} drift={:.3}",
probe.create_map,
probe.stamp_drift.unwrap_or(0.0)
);
}
/// Straight-line distance between two WoW-space points, in yards.
fn distance(a: [f32; 3], b: [f32; 3]) -> f32 {
((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt()
}
#[cfg(test)]
mod tests {
use super::*;
/// The match-or-timeout rule: a step accepts **any** answer after its baseline that matches,
/// because unsolicited answers (a GM's `.ticket viewid`, vmangos's own post-delete push) share
/// the stream with the solicited ones. It must never accept one from *before* the baseline.
#[test]
fn a_step_matches_after_its_baseline_and_never_before_it() {
let answers = vec![
(0.0, String::new()),
(4.0, "mine".to_string()),
(0.0, String::new()),
(4.0, "mine".to_string()),
];
assert_eq!(matching(&answers, 0, |(_, t)| t == "mine"), Some(1));
// Baseline past the first match: the later identical answer is the one taken.
assert_eq!(matching(&answers, 2, |(_, t)| t == "mine"), Some(3));
// Nothing after the baseline matches — the caller's timeout is what turns this into a
// FAIL, and it must not be papered over by an earlier answer.
assert_eq!(matching(&answers, 4, |(_, t)| t == "mine"), None);
assert_eq!(matching(&answers, 2, |(c, _)| *c == NO_TICKET), Some(2));
}
/// The two texts within a run are distinct — step 5's assertion is "the text CHANGED to this",
/// and it would be vacuous if the edit reused the create's string.
#[test]
fn the_create_and_edit_texts_are_distinct() {
assert_ne!(unique_text(""), unique_text(" edited"));
assert!(unique_text(" edited").ends_with(" edited"));
}
}
+8
View File
@@ -323,6 +323,14 @@ impl Plugin for DevProbesPlugin {
if std::env::var("WOW_PROBE_BINDER").is_ok() {
app.add_plugins(crate::capture::ProbeBinderPlugin);
}
// The GM trouble-ticket live probe: `WOW_PROBE_GMTICKET=1` drives the whole five-opcode
// ticket wire through the live VM's own bindings — queue status, clean slate, file, edit,
// abandon — and prints the row the server must have stored so the operator can check the
// map/position the client never reads back (decision 1673's end-to-end instrument; see
// `capture::ProbeGmTicketPlugin`).
if std::env::var("WOW_PROBE_GMTICKET").is_ok() {
app.add_plugins(crate::capture::ProbeGmTicketPlugin);
}
// The world-book live probe: `WOW_PROBE_BOOK=1` teleports to the Old Town plaque and
// measures what having the item-text reader open costs per frame, closed vs open —
// B240's instrument (see `capture::ProbeBookPlugin`).
+7
View File
@@ -116,6 +116,7 @@ mod ui_dressup;
mod ui_duel;
mod ui_follow;
mod ui_gamma;
mod ui_gm_ticket;
mod ui_gossip;
mod ui_guild;
mod ui_hide;
@@ -193,6 +194,7 @@ use ui_chat::UiChatPlugin;
use ui_craft::UiCraftPlugin;
use ui_duel::UiDuelPlugin;
use ui_follow::UiFollowPlugin;
use ui_gm_ticket::UiGmTicketPlugin;
use ui_gossip::UiGossipPlugin;
use ui_guild::UiGuildPlugin;
use ui_item_text::UiItemTextPlugin;
@@ -599,6 +601,11 @@ pub fn run(build: BuildId) -> AppExit {
// CONFIRM_BINDER dialog it raises, and the CMSG_BINDER_ACTIVATE its Accept sends — the only
// packet in the flow that actually binds anything.
.add_plugins(UiBinderPlugin)
// The GM trouble-ticket flow (decision 1673): the Help window's five sends, the UPDATE_TICKET
// answer ticket behind its 10-minute poll, and the GMTicketCategory.dbc list its "page a GM"
// rows are built from. Beside the binder because it is the same feed/drain shape, and after it
// because both want UiInput ordering and this reads better grouped.
.add_plugins(UiGmTicketPlugin)
// Auto-follow's UI seam: the popup's Follow row + `FollowUnit`/`FollowByName` inbound, and
// the AUTOFOLLOW_BEGIN/END pair that drives the centre-screen status line outbound.
.add_plugins(UiFollowPlugin)
+142 -27
View File
@@ -247,6 +247,30 @@ fn world_refusal_text(strings: &GlueStrings, code: u8) -> &str {
m::AUTH_SERVER_SHUTTING_DOWN => ("AUTH_SERVER_SHUTTING_DOWN", "Server Shutting Down"),
m::AUTH_ALREADY_LOGGING_IN => ("AUTH_ALREADY_LOGGING_IN", "Already Logging In"),
m::AUTH_LOGIN_SERVER_NOT_FOUND => ("AUTH_LOGIN_SERVER_NOT_FOUND", "Invalid Login Server"),
// The tail past the queue. These five are exactly the rows of the client's `OKAY_WITH_URL`
// table (`0x803740`), which is why the URL dialog is reachable only from THIS enum: the
// lookup is a linear search over a ClientServices code and realmd's results never enter it.
// benilla shows the text without the URL button — `LaunchURL` is a shell-out we do not do,
// and its whitelist (`0x85cc34`) is ten worldofwarcraft.com/blizzard.com domains that have
// not served these pages in fifteen years.
m::AUTH_BANNED => (
"AUTH_BANNED",
"This account has been banned for violating the Terms of Use Agreement",
),
m::AUTH_ALREADY_ONLINE => ("AUTH_ALREADY_ONLINE", "This character is still logged on"),
m::AUTH_NO_TIME => (
"AUTH_NO_TIME",
"Your World of Warcraft subscription has expired",
),
m::AUTH_DB_BUSY => ("AUTH_DB_BUSY", "This session has timed out"),
m::AUTH_SUSPENDED => (
"AUTH_SUSPENDED",
"This account has been temporarily suspended for violating the Terms of Use Agreement",
),
m::AUTH_PARENTAL_CONTROL => (
"AUTH_PARENTAL_CONTROL",
"Access to this account has been blocked by parental controls.",
),
// `AUTH_OK` never reaches here (it is the success path) and `AUTH_WAIT_QUEUE` is not a
// refusal at all; anything else is a code this client does not know.
_ => ("AUTH_FAILED", "Authentication failed"),
@@ -254,27 +278,90 @@ fn world_refusal_text(strings: &GlueStrings, code: u8) -> &str {
strings.text(key, fallback)
}
/// The **realmd** logon-proof refusal, borrowed straight out of the table.
/// The **realmd** logon-proof refusal, in the client's own words — the LONG `LOGIN_*` family.
///
/// **This table was wrong in every row until decision 1679**, and wrong in an instructive way: the
/// codes were right and the *string family* was not. Decision 0539 §6 built it by reading vmangos'
/// `AuthCodes.h`, which names the wire bytes but says nothing about what the client displays, and
/// `GlueStrings.lua` happens to define two plausible families whose keys read alike. So a bad
/// password showed the terse `AUTH_UNKNOWN_ACCOUNT` ("Unknown account") — a real reference string,
/// in the wrong slot.
///
/// VERIFIED (wow-re `system/glue/scratch/login-failure-dialogs.md`, §5 cross-checked): the client
/// keeps **two** login-status enums with **two** key tables, and they are conflated precisely
/// because both raise the dialog through `OPEN_STATUS_DIALOG`. realmd's results resolve against
/// table `0x836b78` — the long `LOGIN_*` family; the world server's resolve against `0x85cae8` —
/// the short `AUTH_*` family ([`world_refusal_text`]). The chain is grunt opcode table `0x85e278`
/// → `Logon::OnAuthResult 0x5b2c90` (byte-index table `0x5b2ea4` + jump table `0x5b2e78`) →
/// `CGlueMgr::OnLoginState 0x46b0f0` → `CGlueMgr::UpdateLoginDialog 0x46b140`.
///
/// Two consequences worth stating outright:
///
/// **0x04 and 0x05 share one jump-table arm** — byte-identical, both `LOGIN_UNKNOWN_ACCOUNT`. The
/// reference client structurally *cannot* say "wrong password" on a logon refusal, and
/// `LOGIN_INCORRECT_PASSWORD` is dead surface it never reaches. That also retires the emulator lore
/// that the client locks out after 0x05 (vmangos sends 0x04 for both to avoid it): there is no
/// lockout — no disabled button, no delay, nothing persisted. Its one counter is gated on a
/// `securityFlags` byte that is zero on vmangos and cmangos, and only escalates the same string
/// toward its `_PIN`/`_CALL` variants.
///
/// **Anything past 0x0F is clamped**, not passed through: the byte-index table saturates at 0xFF,
/// which lands on the arm that shows `DISCONNECTED`. So 0x10/0x11/0x12 and every unknown future
/// code read as a disconnect rather than as an authentication message.
fn logon_refusal_text(strings: &GlueStrings, code: Option<u8>) -> &str {
let (key, fallback): (&str, &str) = match code {
None => ("LOGIN_FAILED", "Unable to connect"),
Some(0x03) => ("AUTH_BANNED", "This account has been banned"),
// vmangos sends 0x04 for unknown account AND wrong password (its AuthCodes.h comment:
// the client locks out after an 0x05).
Some(0x04) => ("AUTH_UNKNOWN_ACCOUNT", "Unknown account"),
Some(0x05) => ("AUTH_INCORRECT_PASSWORD", "Incorrect Password"),
Some(0x06) => ("AUTH_ALREADY_ONLINE", "This account is already logged in"),
Some(0x07) => ("AUTH_NO_TIME", "Your subscription has expired"),
Some(0x08) => ("AUTH_DB_BUSY", "This session has timed out"),
Some(0x09) => ("AUTH_VERSION_MISMATCH", "Wrong client version"),
Some(0x0B) => ("LOGIN_FAILED", "Unable to connect"),
Some(0x0C) => (
"AUTH_SUSPENDED",
"This account has been temporarily suspended",
// A transport failure never had a code; the client's own answer for the codes that mean
// "no usable connection" is the same string, so they share this row.
// 0x01/0x02 (unknown0/1), 0x0B (invalid server), 0x0D (no access).
None | Some(0x01) | Some(0x02) | Some(0x0B) | Some(0x0D) => {
("LOGIN_FAILED", "Unable to connect")
}
Some(0x03) => (
"LOGIN_BANNED",
"This World of Warcraft account has been closed and is no longer available for use. \
Please go to http://www.worldofwarcraft.com/misc/banned.html for further information. ",
),
Some(0x0D) => ("AUTH_REJECT", "Login unavailable"),
Some(0x0F) => ("AUTH_PARENTAL_CONTROL", "Blocked by parental controls"),
Some(_) => ("AUTH_FAILED", "Authentication failed"),
// BOTH the unknown account and the wrong password — see the doc above.
Some(0x04) | Some(0x05) => (
"LOGIN_UNKNOWN_ACCOUNT",
"The information you have entered is not valid. Please check the spelling of the \
account name and password. If you need help in retrieving a lost or stolen password \
and account, see www.worldofwarcraft.com for more information.",
),
Some(0x06) => (
"LOGIN_ALREADYONLINE",
"This account is already logged into World of Warcraft. Please check the spelling and \
try again.",
),
Some(0x07) => (
"LOGIN_NOTIME",
"You have used up your prepaid time for this account. Please purchase more to continue \
playing",
),
Some(0x08) => (
"LOGIN_DBBUSY",
"Could not log in to World of Warcraft at this time. Please try again later.",
),
Some(0x09) => (
"LOGIN_BADVERSION",
"Unable to validate game version. This may be caused by file corruption or the \
interference of another program. Please visit www.blizzard.com/support/wow/ for more \
information and possible solutions to this issue.",
),
Some(0x0C) => (
"LOGIN_SUSPENDED",
"This World of Warcraft account has been temporarily suspended. Please go to \
http://www.worldofwarcraft.com/misc/banned.html for further information.",
),
Some(0x0F) => (
"LOGIN_PARENTALCONTROL",
"Access to this account has been blocked by parental controls. Your settings may be \
changed in your account preferences at http://www.worldofwarcraft.com.",
),
// 0x0A (version update) is IGNORED on the proof (`0x5bada2`) and never reaches a dialog;
// it only means anything on the challenge, where it is the patch-download state. Every
// remaining code clamps to the disconnect arm.
Some(_) => ("DISCONNECTED", "Disconnected from server"),
};
strings.text(key, fallback)
}
@@ -1340,15 +1427,39 @@ mod tests {
}
/// The code→string map quotes the client's own strings for the vmangos-verified rows.
/// The realmd map, against the byte-verified table (wow-re `login-failure-dialogs.md`).
///
/// Every row of this changed in decision 1679: the codes were always right and the string
/// FAMILY was always wrong, so each of these used to answer with the terse `AUTH_*` twin of
/// the string it now gives.
#[test]
fn fail_text_maps_the_verified_codes() {
let strings = GlueStrings::default(); // empty table → the fallback literals
let logon = |b| fail_text(&strings, Some(LoginRefusal::Logon(b)), None);
assert_eq!(logon(0x04), "Unknown account");
assert_eq!(logon(0x05), "Incorrect Password");
// 0x04 and 0x05 share one jump-table arm — the client cannot say "wrong password", and
// this is the string a player actually gets for a typo.
assert!(logon(0x04).starts_with("The information you have entered is not valid."));
assert_eq!(logon(0x05), logon(0x04), "one arm, byte-identical");
assert!(logon(0x04).contains("account name and password"));
// The four "no usable connection" codes share `LOGIN_FAILED` with a bare transport failure.
assert_eq!(fail_text(&strings, None, None), "Unable to connect");
assert_eq!(logon(0x09), "Wrong client version");
assert_eq!(logon(0xEE), "Authentication failed");
for code in [0x01, 0x02, 0x0B, 0x0D] {
assert_eq!(logon(code), "Unable to connect", "code {code:#04x}");
}
assert!(logon(0x03).contains("has been closed"));
assert!(logon(0x06).contains("already logged into"));
assert!(logon(0x09).contains("Unable to validate game version"));
assert!(logon(0x0C).contains("temporarily suspended"));
assert!(logon(0x0F).contains("parental controls"));
// Past 0x0F the byte-index table saturates onto the disconnect arm — so an unknown code
// reads as a disconnect, not as an authentication message.
for code in [0x10, 0x11, 0x12, 0xEE] {
assert_eq!(logon(code), "Disconnected from server", "code {code:#04x}");
}
}
/// **The two result enums are different enums**, and the same byte must not mean the same
@@ -1362,7 +1473,9 @@ mod tests {
let logon = |b| fail_text(&strings, Some(LoginRefusal::Logon(b)), None);
let world = |b| fail_text(&strings, Some(LoginRefusal::World(b)), None);
assert_eq!(logon(0x0C), "This account has been temporarily suspended");
// 0x0C: "suspended" to realmd, and to the world server it is AUTH_OK — success, which
// never reaches a refusal string at all.
assert!(logon(0x0C).contains("temporarily suspended"));
assert_eq!(world(m::AUTH_BILLING_ERROR), "Billing system error");
assert_ne!(logon(0x0C), world(0x0C));
@@ -1375,6 +1488,10 @@ mod tests {
world(m::AUTH_UNAVAILABLE),
"System unavailable - Please try again later"
);
// The five codes the reference's URL dialog keys on — reachable only from this enum.
assert!(world(m::AUTH_BANNED).contains("banned"));
assert!(world(m::AUTH_SUSPENDED).contains("temporarily suspended"));
assert!(world(m::AUTH_PARENTAL_CONTROL).contains("parental controls"));
// A code this client does not know still lands on the authored catch-all.
assert_eq!(world(0xEE), "Authentication failed");
}
@@ -1404,10 +1521,8 @@ mod tests {
"the name resolved to nothing — this one IS the address",
);
// A refusal still wins its own authored string; the dial verdict only speaks for the dial.
assert_eq!(
fail_text(&strings, Some(LoginRefusal::Logon(0x05)), None),
"Incorrect Password",
);
assert!(fail_text(&strings, Some(LoginRefusal::Logon(0x05)), None)
.starts_with("The information you have entered is not valid."));
}
/// Save → load → clear round-trips through the dot-file (the ref's Get/SetSavedAccountName).
File diff suppressed because it is too large Load Diff
+36
View File
@@ -237,6 +237,11 @@ pub(super) fn apply_net_updates(
// The minimap ping (decision 1596) — a group member's `MSG_MINIMAP_PING` seats the
// world point here and the minimap renderer derives everything else from it.
ResMut<crate::minimap::MinimapPing>,
// The GM ticket (decision 1673) — `SMSG_GMTICKET_GETTICKET` replaces the held
// ticket here and BUMPS AN ANSWER COUNTER, which is what `crate::ui_gm_ticket`
// diffs on: the Help window re-polls every 10 minutes and an unchanged answer
// still has to re-fire `UPDATE_TICKET`.
ResMut<crate::ui_gm_ticket::GmTicketState>,
),
),
),
@@ -412,6 +417,7 @@ pub(super) fn apply_net_updates(
mut auction_open,
guild_notify,
mut ping,
mut gm_ticket,
),
),
) = caches;
@@ -525,6 +531,7 @@ pub(super) fn apply_net_updates(
&mut duel,
&mut social,
&mut guild,
&mut gm_ticket,
&mut aura.6,
&mut disconnects,
);
@@ -749,6 +756,35 @@ pub(super) fn apply_net_updates(
});
}
SessionEvent::BinderConfirm { binder: npc } => binder.ask(npc),
// The GM ticket answers (decision 1673). The GETTICKET arm takes EVERY answer,
// 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.
SessionEvent::GmTicket { ticket } => gm_ticket.answer(ticket),
SessionEvent::GmTicketSystemStatus { status } => gm_ticket.answer_queue(status),
// 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 arm is dead; cmangos
// makes it the whole notification model, which is why it is parsed rather than dropped.
SessionEvent::GmTicketStatusUpdate { status } => {
crate::ui_gm_ticket::apply::status_update(status, &mut gm_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 (see `ui_gm_ticket::apply`).
// 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.
SessionEvent::GmTicketCreated { response } => {
crate::ui_gm_ticket::apply::write_response("create", response, 2, &mut gm_ticket)
}
SessionEvent::GmTicketUpdated { response } => {
crate::ui_gm_ticket::apply::write_response("update", response, 4, &mut gm_ticket)
}
SessionEvent::GmTicketDeleted { response } => {
crate::ui_gm_ticket::apply::response("delete", response)
}
// A zero trainer guid is vmangos's "you have no talents to reset" refusal, not a
// question — there is nothing to ask about, so nothing goes on screen (decision 1580;
// `crate::ui_talent_wipe`'s header carries why the reference instead re-sends here).
@@ -164,6 +164,7 @@ pub(super) fn disconnected(
duel: &mut crate::ui_duel::DuelState,
social: &mut crate::ui_social::SocialState,
guild: &mut crate::ui_guild::GuildState,
gm_ticket: &mut crate::ui_gm_ticket::GmTicketState,
pending_transfer: &mut PendingTransfer,
disconnects: &mut MessageWriter<DisconnectedMessage>,
) {
@@ -247,6 +248,11 @@ pub(super) fn disconnected(
// lazily, and keeping a cache alive across a socket only to save one query is not worth the
// one wrong name a renamed guild would show.
*guild = crate::ui_guild::GuildState::default();
// The GM ticket is login-scoped too (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.
gm_ticket.clear_session();
// The death stores are session-scoped too: a reclaim expiry, resurrect offer, or corpse
// marker must not survive the socket (the reconnect re-sends the reclaim delay when dead).
*death_net = crate::death::DeathNet::default();
+12
View File
@@ -803,6 +803,18 @@ fn writer_loop(
ClientCommand::RepairItem { vendor, item_guid } => {
w.repair_item(vendor, item_guid)
}
ClientCommand::GmTicketCreate {
category,
map,
pos,
text,
} => w.gm_ticket_create(category, map, pos, &text),
ClientCommand::GmTicketUpdate { category, text } => {
w.gm_ticket_updatetext(category, &text)
}
ClientCommand::GmTicketGet => w.gm_ticket_get(),
ClientCommand::GmTicketDelete => w.gm_ticket_delete(),
ClientCommand::GmTicketSystemStatus => w.gm_ticket_system_status(),
ClientCommand::BinderActivate { binder } => w.binder_activate(binder),
ClientCommand::TalentWipeConfirm { trainer } => w.talent_wipe_confirm(trainer),
ClientCommand::BankerActivate { guid } => w.banker_activate(guid),
+611
View File
@@ -0,0 +1,611 @@
//! The GM trouble-ticket flow — the Help window's wire, its events, and its answers
//! (decision 1673).
//!
//! **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.
//!
//! ## 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 `/Users/sam/wre/vmangos-src` (`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.
use benilla_ui::script::{GmTicketIntent, GmTicketWrite, ScriptValue, UiScript};
use bevy::prelude::*;
use benilla_assets::coords::bevy_to_wow;
use crate::net::{ClientCommand, NetCommands};
use crate::player::Player;
use crate::ui_script::{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.
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.
#[derive(Resource, Default)]
pub(crate) struct GmTicketState {
/// The open ticket, or `None` for "you have no ticket" — the ordinary answer.
ticket: Option<Box<benilla_protocol::messages::GmTicket>>,
/// One per `SMSG_GMTICKET_GETTICKET`, wrapping. The answer ticket.
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.
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-verified rather than a preference** (wow-re §5, this
/// session): 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.
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.
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.
///
/// Byte-verified in wow-re (§5, this session): `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.
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.
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.
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.
#[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,
queue_answers: u32,
categories_pushed: bool,
}
/// Fire `UPDATE_TICKET`/`UPDATE_GM_STATUS` for answers the UI has not heard yet, and push the
/// category catalog once.
fn feed_gm_ticket(
script: Option<NonSendMut<UiScript>>,
state: Res<GmTicketState>,
categories: Option<Res<GmTicketCategories>>,
mut feed: ResMut<GmTicketFeedState>,
) {
let Some(mut script) = script else {
return;
};
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());
fed.categories_pushed = true;
}
}
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.
script.fire_event(
"UPDATE_GM_STATUS",
vec![ScriptValue::Int(i64::from(state.queue_status))],
);
}
if fed.answers != state.answers {
fed.answers = state.answers;
script.fire_event("UPDATE_TICKET", update_ticket_args(state.ticket.as_deref()));
}
}
/// `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`.
fn update_ticket_args(ticket: Option<&benilla_protocol::messages::GmTicket>) -> Vec<ScriptValue> {
let Some(t) = ticket else {
return vec![ScriptValue::Int(0)];
};
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.
ScriptValue::Number(f64::from(t.ticket_age)),
ScriptValue::Number(f64::from(t.oldest_ticket_age)),
ScriptValue::Number(f64::from(t.update_time)),
ScriptValue::Int(i64::from(t.assigned_to_gm)),
ScriptValue::Int(i64::from(t.opened_by_gm)),
]
}
/// Turn the window's clicks into packets.
fn drain_gm_ticket(
script: Option<NonSendMut<UiScript>>,
mut reask: ResMut<GmTicketState>,
commands: Res<NetCommands>,
player: Option<Res<Player>>,
map: Option<Res<benilla_world::world_map::CurrentMap>>,
) {
let Some(mut script) = script else {
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.
for _ in 0..reask.take_reasks() {
let _ = commands.0.send(ClientCommand::GmTicketGet);
}
for _ in 0..script.take_stuck_casts() {
let _ = commands.0.send(ClientCommand::CastSpell {
spell_id: SPELL_STUCK,
target: None,
});
}
let intents = script.take_gm_ticket_intents();
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.
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.
for intent in intents {
let _ = commands.0.send(match intent {
GmTicketIntent::Ask => ClientCommand::GmTicketGet,
GmTicketIntent::AskStatus => ClientCommand::GmTicketSystemStatus,
GmTicketIntent::Delete => ClientCommand::GmTicketDelete,
GmTicketIntent::Write(write) => {
let category = write.category;
match client_command_for(write, map, pos) {
Some(cmd) => cmd,
None => {
warn!(
"gm ticket: refusing to file under category {category} — not a \
GMTicketCategory.dbc id (1..={GM_TICKET_CATEGORY_MAX})"
);
continue;
}
}
}
});
}
}
/// One drained write → its packet, or `None` for a category that cannot be filed.
///
/// 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.
///
/// **The range check is not defensive tidiness — it is the only thing between a bad argument and a
/// silently misfiled ticket.** The category rides the wire as one byte, and vmangos's 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`).
/// So 11 and up are dropped by the server — but **0 is not**: it passes that check and is stored as
/// `ticket_type = 0`, a heading `GetTicketCategoryName` cannot name. Truncating a nonsense argument
/// into a `u8` lands exactly there, and a category of 256 truncates to 0 as well. No shipped UI
/// path can produce one (the buttons carry `GMTicketCategory.dbc` ids, 1..10), so this guards a
/// third-party addon's argument — and it refuses loudly rather than filing a ticket no GM can
/// categorise.
fn client_command_for(write: GmTicketWrite, map: u32, pos: [f32; 3]) -> Option<ClientCommand> {
let category = u8::try_from(write.category)
.ok()
.filter(|c| (1..=GM_TICKET_CATEGORY_MAX).contains(c))?;
Some(if write.is_new {
ClientCommand::GmTicketCreate {
category,
map,
pos,
text: write.text,
}
} else {
ClientCommand::GmTicketUpdate {
category,
text: write.text,
}
})
}
/// 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.
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.
#[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.
fn load_gm_ticket_dbc(
mut commands: Commands,
world_assets: Option<Res<benilla_assets::WorldAssets>>,
) {
use benilla_assets::LockRecover;
let Some(world_assets) = world_assets else {
return;
};
let mut chain = world_assets.chain.lock_recover();
match benilla_formats::load_gm_ticket_categories(&mut chain) {
Ok(cat) => {
info!(
"gm ticket: GMTicketCategory.dbc loaded ({} categories)",
cat.len()
);
commands.insert_resource(GmTicketCategories(
cat.categories()
.iter()
.map(|c| (c.id, c.name.clone()))
.collect(),
));
}
Err(e) => warn!("gm ticket: GMTicketCategory.dbc failed to load: {e:#}"),
}
}
/// The net drain's arms, beside the state they drive.
pub(crate) mod apply {
use bevy::prelude::*;
/// `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.
pub(crate) fn write_response(
what: &str,
response: u32,
success: u32,
ticket: &mut super::GmTicketState,
) {
if response == success {
debug!("gm ticket: {what} landed ({response}) — re-asking for the ticket");
ticket.note_write_landed();
} else {
debug!("gm ticket: {what} answered {response}");
}
}
/// `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.
pub(crate) fn status_update(status: u32, ticket: &mut super::GmTicketState) {
match status {
1 => {
debug!("gm ticket: a GM updated the ticket — re-asking");
ticket.note_write_landed();
}
2 => debug!("gm ticket: a GM closed the ticket"),
3 => {
debug!("gm ticket: a GM survey was offered — the survey window is deferred (1673)")
}
other => debug!("gm ticket: unknown status update {other}"),
}
}
/// `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.
pub(crate) fn response(what: &str, response: u32) {
debug!("gm ticket: {what} answered {response}");
}
}
/// The GM trouble-ticket flow: the answer feed and the window's sends.
pub(crate) struct UiGmTicketPlugin;
impl Plugin for UiGmTicketPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<GmTicketState>()
.init_resource::<GmTicketFeedState>()
.add_systems(
Startup,
load_gm_ticket_dbc.after(benilla_assets::AssetSet::Open),
)
.add_systems(
Update,
(
feed_gm_ticket.before(UiInput),
drain_gm_ticket.after(UiInput),
),
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use benilla_protocol::messages::GmTicket;
fn ticket() -> Box<GmTicket> {
Box::new(GmTicket {
text: "Stuck in a rock.".into(),
category: 1,
ticket_age: 0.25,
oldest_ticket_age: 2.5,
update_time: 0.01,
assigned_to_gm: 2,
opened_by_gm: 1,
})
}
/// 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.
#[test]
fn the_event_args_lead_with_the_category_not_the_text() {
let t = ticket();
let args = update_ticket_args(Some(&t));
assert_eq!(args[0], ScriptValue::Int(1), "arg1 is the category");
assert_eq!(
args[1],
ScriptValue::Str("Stuck in a rock.".into()),
"arg2 is the description"
);
assert_eq!(args.len(), 7, "arg1..arg7, the shipped window's whole read");
assert_eq!(args[5], ScriptValue::Int(2), "arg6 assignedToGM");
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.
#[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`).
#[test]
fn an_unknown_wait_time_reaches_lua_as_a_negative_not_a_zero() {
let mut t = ticket();
t.oldest_ticket_age = -1.0;
t.update_time = -1.0;
let args = update_ticket_args(Some(&t));
assert_eq!(args[3], ScriptValue::Number(-1.0));
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.
#[test]
fn a_repeated_identical_answer_still_counts_as_an_answer() {
let mut state = GmTicketState::default();
state.answer(Some(ticket()));
let first = state.answers;
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.
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.
#[test]
fn a_landed_write_makes_the_engine_reask_and_a_refused_one_does_not() {
let mut state = GmTicketState::default();
apply::write_response("create", 2, 2, &mut state);
apply::write_response("update", 4, 4, &mut state);
assert_eq!(state.take_reasks(), 2, "one re-ask per landed write");
assert_eq!(state.take_reasks(), 0, "drained means drained");
apply::write_response("create", 3, 2, &mut state); // CREATE_ERROR
apply::write_response("update", 5, 4, &mut state); // UPDATE_ERROR
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.
#[test]
fn a_minus_one_queue_status_survives_to_lua_as_minus_one() {
let mut state = GmTicketState::default();
state.answer_queue(-1);
assert_eq!(state.queue_status, -1);
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();
state.answer(Some(ticket()));
state.answer_queue(1);
state.clear_session();
assert!(state.ticket.is_none());
assert_eq!(state.answers, 0);
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 {
category,
text: "gone".into(),
is_new,
};
assert!(matches!(
client_command_for(write(4, true), 1, [1.0, 2.0, 3.0]),
Some(ClientCommand::GmTicketCreate {
category: 4,
map: 1,
..
})
));
assert!(matches!(
client_command_for(write(4, false), 1, [1.0, 2.0, 3.0]),
Some(ClientCommand::GmTicketUpdate { category: 4, .. })
));
}
/// A category the server cannot file is refused here rather than sent.
///
/// **0 is the one that matters**, and it is the one a naive truncation produces: vmangos only
/// rejects `>= 11`, so a zero passes its check and is stored as an unnameable
/// `ticket_type = 0` — a ticket filed under nothing, with no error anywhere. 256 truncates to
/// 0 in a `u8` and would land in the same place.
#[test]
fn a_category_the_server_cannot_file_is_refused_rather_than_sent() {
let write = |category| GmTicketWrite {
category,
text: "x".into(),
is_new: true,
};
for bad in [0, 11, 256, 99_999] {
assert!(
client_command_for(write(bad), 1, [0.0; 3]).is_none(),
"category {bad} must not reach the wire"
);
}
for good in 1..=10 {
assert!(client_command_for(write(good), 1, [0.0; 3]).is_some());
}
}
}
@@ -0,0 +1,400 @@
//! The GM help window (decision 1673, HelpFrame.xml): the category list the DBC feeds it, the two
//! faces of `UPDATE_TICKET`, the queue gate, the ticket toast, and the three dialogs.
//!
//! Written as the **falsification** pass over the transcription rather than a demonstration of it:
//! every test is named after one claim the window makes, and each was checked to fail when the
//! claim is broken. The load-bearing one is
//! [`clicking_a_category_files_a_ticket_under_that_categorys_dbc_id`] — the id travels from
//! `GMTicketCategory.dbc` through a button, a page, and the editor onto the wire, and a break
//! anywhere in that chain files every ticket under the wrong heading with nothing on screen to
//! show for it.
use benilla_ui::script::{GmTicketIntent, GmTicketWrite, ScriptValue, UiScript};
/// Load one shipped `assets/ui/<file>` into `s`, panicking on any loader error (the binder tests'
/// loader, duplicated so this file is self-contained).
fn load_xml(s: &UiScript, file: &str) -> usize {
let text = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("assets/ui")
.join(file),
)
.unwrap();
let doc = benilla_ui::framexml::parse(&text).unwrap();
let report = benilla_ui::loader::load(s, &doc, &|_| None);
assert!(
report.errors.is_empty(),
"{file}: loader errors: {:?}",
report.errors
);
report.frames
}
/// The window, its dependencies, and the catalog the app pushes — the real ten `GMTicketCategory`
/// rows, so a test that walks the list is walking the shipped data.
fn setup() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
for file in [
"Fonts.xml",
"BasicControls.xml",
"UIPanelTemplates.xml",
"ScrollTemplates.xml",
"GameTooltip.xml",
// Before UiPanels.xml: the shared StaticPopup carries a `SmallMoneyFrameTemplate` coin
// row, whose OnLoad calls `SmallMoneyFrame_OnLoad` — the TOC's own order (1580's
// talent-wipe fixture hit this first).
"MoneyFrame.xml",
"UiPanels.xml",
"HelpFrame.xml",
] {
load_xml(&s, file);
}
s.set_gm_ticket_categories(vec![
(1, "Stuck".into()),
(2, "Behavior/Harassment".into()),
(3, "Guild".into()),
(4, "Item".into()),
(5, "Environmental".into()),
(6, "Non-Quest/Creep".into()),
(7, "Quest/Quest NPC".into()),
(8, "Technical".into()),
(9, "Account/Billing".into()),
(10, "Character".into()),
]);
s
}
/// The `UPDATE_TICKET` argument list the app's feed builds for an open ticket — category first,
/// text second, exactly as `ui_gm_ticket::update_ticket_args` orders it. Kept in sync by being
/// written the same way in both places; if they ever disagree, this file's tests are what notices.
fn open_ticket_args(
category: i64,
text: &str,
age: f64,
oldest: f64,
update: f64,
) -> Vec<ScriptValue> {
vec![
ScriptValue::Int(category),
ScriptValue::Str(text.into()),
ScriptValue::Number(age),
ScriptValue::Number(oldest),
ScriptValue::Number(update),
ScriptValue::Int(0),
ScriptValue::Int(0),
]
}
/// **The whole point of the feature, end to end.** The category id travels
/// `GMTicketCategory.dbc` → `GetGMTicketCategories()` → a `HelpFrameButton*` → the page route →
/// `HelpFrameOpenTicket.ticketType` → `NewGMTicket`'s wire value. Break any link and every ticket
/// files under the wrong heading, silently — nothing on screen looks wrong.
#[test]
fn clicking_a_category_files_a_ticket_under_that_categorys_dbc_id() {
let mut s = setup();
s.run("ShowUIPanel(HelpFrame) HelpFrame_ShowFrame(\"GMHome\")")
.unwrap();
// Opening the window is itself traffic: its OnShow calls `GetGMStatus()`. Drained here so the
// assertion below is about the CLICK and nothing else — the intents are one ordered queue, so
// setup traffic and the thing under test share it (decision 1673).
let _ = s.take_gm_ticket_intents();
// Row 4 is Item, DBC id 4 — the row's LABEL and its stored id must be the same pair.
assert_eq!(
s.eval::<String>("return HelpFrameButton4Text:GetText()")
.unwrap(),
"Item"
);
assert_eq!(
s.eval::<i64>("return HelpFrameButton4.ticketType").unwrap(),
4,
"the button carries the DBC id, not its row number"
);
// Click it, take the page's action button through to the editor, and submit.
s.run("HelpFrameButton4:Click()").unwrap();
s.run("HelpFrameGeneralButton_OnClick(HelpFrameGeneralButton)")
.unwrap();
s.run("HelpFrameOpenTicketText:SetText(\"My sword vanished.\")")
.unwrap();
s.run("HelpFrameOpenTicketSubmit_OnClick()").unwrap();
assert_eq!(
s.take_gm_ticket_intents(),
vec![GmTicketIntent::Write(GmTicketWrite {
category: 4,
text: "My sword vanished.".into(),
is_new: true,
})],
"the clicked category's DBC id is what goes on the wire"
);
}
/// The ten shipped rows paint in DBC order, and row 2 routes to its OWN page rather than the
/// shared one — the single category in the table with a bespoke frame.
#[test]
fn the_ten_categories_paint_in_dbc_order_and_harassment_has_its_own_page() {
let s = setup();
s.run("ShowUIPanel(HelpFrame) HelpFrame_ShowFrame(\"GMHome\")")
.unwrap();
assert_eq!(
s.eval::<String>("return HelpFrameButton1Text:GetText()")
.unwrap(),
"Stuck"
);
assert_eq!(
s.eval::<String>("return HelpFrameButton10Text:GetText()")
.unwrap(),
"Character",
"all ten rows fit without scrolling"
);
s.run("HelpFrameButton2:Click()").unwrap();
assert!(
s.eval::<bool>("return HelpFrameHarassment:IsVisible()")
.unwrap(),
"category 2 routes to HelpFrameHarassment"
);
s.run("HelpFrameButton3:Click()").unwrap();
assert!(
s.eval::<bool>("return HelpFrameGeneral:IsVisible()")
.unwrap(),
"category 3 routes to the shared general page"
);
assert!(
!s.eval::<bool>("return HelpFrameHarassment:IsVisible()")
.unwrap(),
"and the previous page is hidden — one page at a time"
);
}
/// **`UPDATE_TICKET`'s two faces.** With a ticket the editor becomes an editor (Save Changes /
/// Exit); with the bare `arg1 = 0` it goes back to being a form (Submit / Cancel). The zero leg is
/// the one that would silently rot: it is the ordinary answer, so a window stuck in edit mode
/// looks fine until you try to file a second ticket.
#[test]
fn an_open_ticket_turns_the_form_into_an_editor_and_a_zero_turns_it_back() {
let mut s = setup();
s.run("ShowUIPanel(HelpFrame) HelpFrame_ShowFrame(\"OpenTicket\")")
.unwrap();
s.fire_event(
"UPDATE_TICKET",
open_ticket_args(7, "Where is this NPC?", 0.25, 2.5, 0.01),
);
assert_eq!(
s.eval::<String>("return HelpFrameOpenTicketText:GetText()")
.unwrap(),
"Where is this NPC?",
"arg2 is the description"
);
assert_eq!(
s.eval::<i64>("return HelpFrameOpenTicket.ticketType")
.unwrap(),
7,
"arg1 is the category"
);
assert_eq!(
s.eval::<i64>("return HelpFrameOpenTicket.hasTicket")
.unwrap(),
1
);
// And now the ordinary answer.
s.fire_event("UPDATE_TICKET", vec![ScriptValue::Int(0)]);
assert_eq!(
s.eval::<String>("return HelpFrameOpenTicketText:GetText()")
.unwrap(),
"",
"the editor empties"
);
assert!(
s.eval::<bool>("return HelpFrameOpenTicket.hasTicket == nil")
.unwrap(),
"and stops believing it has a ticket"
);
}
/// The Submit button picks its verb from the window's own `hasTicket`, which is what the app must
/// not second-guess: an edit after an answer is an UPDATE, a submit before one is a CREATE.
#[test]
fn submit_sends_create_before_an_answer_and_update_after_one() {
let mut s = setup();
s.run("ShowUIPanel(HelpFrame) HelpFrame_ShowFrame(\"OpenTicket\")")
.unwrap();
let _ = s.take_gm_ticket_intents(); // the OnShow GetGMStatus — see the category test
s.run("HelpFrameOpenTicket.ticketType = 3 HelpFrameOpenTicketText:SetText(\"a\")")
.unwrap();
s.run("HelpFrameOpenTicketSubmit_OnClick()").unwrap();
assert!(
matches!(
s.take_gm_ticket_intents().as_slice(),
[GmTicketIntent::Write(w)] if w.is_new
),
"no ticket known yet — this is a create"
);
s.fire_event("UPDATE_TICKET", open_ticket_args(3, "a", 0.1, 0.2, 0.01));
s.run("HelpFrameOpenTicketText:SetText(\"a, still\")")
.unwrap();
s.run("HelpFrameOpenTicketSubmit_OnClick()").unwrap();
let intents = s.take_gm_ticket_intents();
let [GmTicketIntent::Write(write)] = intents.as_slice() else {
panic!("expected exactly one write, got {intents:?}");
};
assert!(!write.is_new, "a ticket is known — this is an update");
assert_eq!(write.text, "a, still");
}
/// **The queue gate.** `UPDATE_GM_STATUS(0)` takes the petition queue down, and asking for the
/// editor then closes the window and says why instead of showing a form that cannot submit.
/// The `1` leg must put it back — a one-way gate would lock the player out for the session.
#[test]
fn a_downed_queue_refuses_the_editor_and_says_so_and_comes_back_up() {
let mut s = setup();
s.fire_event("UPDATE_GM_STATUS", vec![ScriptValue::Int(0)]);
s.run("ShowUIPanel(HelpFrame) HelpFrame_ShowFrame(\"OpenTicket\")")
.unwrap();
assert!(
!s.eval::<bool>("return HelpFrame:IsVisible()").unwrap(),
"the window closes"
);
assert!(
s.eval::<bool>("return StaticPopup_Visible(\"HELP_TICKET_QUEUE_DISABLED\") ~= nil")
.unwrap(),
"and the dialog says why"
);
s.fire_event("UPDATE_GM_STATUS", vec![ScriptValue::Int(1)]);
s.run("ShowUIPanel(HelpFrame) HelpFrame_ShowFrame(\"OpenTicket\")")
.unwrap();
assert!(
s.eval::<bool>("return HelpFrameOpenTicket:IsVisible()")
.unwrap(),
"queue back up, editor opens"
);
}
/// The toast follows the ticket: up while one is open, gone when it is not. It is the only thing
/// on screen that says a ticket exists at all once the window is closed.
#[test]
fn the_ticket_toast_follows_the_ticket() {
let mut s = setup();
s.fire_event(
"UPDATE_TICKET",
open_ticket_args(1, "Stuck.", 0.1, 0.2, 0.01),
);
assert!(
s.eval::<bool>("return TicketStatusFrame:IsVisible()")
.unwrap(),
"a ticket raises the toast"
);
s.fire_event("UPDATE_TICKET", vec![ScriptValue::Int(0)]);
assert!(
!s.eval::<bool>("return TicketStatusFrame:IsVisible()")
.unwrap(),
"and abandoning it takes the toast away"
);
}
/// The toast's own poll is what keeps a long wait honest: `TicketStatus_OnUpdate` re-asks the
/// server every `GMTICKET_CHECK_INTERVAL`, and not before. This is the reason the app counts
/// answers instead of diffing them, so it is worth a test on this side too.
#[test]
fn the_toast_repolls_the_server_only_after_the_full_interval() {
let mut s = setup();
s.fire_event(
"UPDATE_TICKET",
open_ticket_args(1, "Stuck.", 0.1, 0.2, 0.01),
);
let _ = s.take_gm_ticket_intents();
s.run("TicketStatus_OnUpdate(599)").unwrap();
assert!(s.take_gm_ticket_intents().is_empty(), "not yet");
s.run("TicketStatus_OnUpdate(2)").unwrap();
assert_eq!(
s.take_gm_ticket_intents(),
vec![GmTicketIntent::Ask],
"600s elapsed — re-ask"
);
s.run("TicketStatus_OnUpdate(1)").unwrap();
assert!(
s.take_gm_ticket_intents().is_empty(),
"and the clock restarts"
);
}
/// Abandoning goes through a confirm, and only its Yes sends the delete. A dialog whose Yes did
/// nothing would look identical to one that worked, right up until the ticket reappeared.
#[test]
fn abandoning_a_ticket_confirms_first_and_only_yes_sends_it() {
let mut s = setup();
s.run("StaticPopup_Show(\"HELP_TICKET_ABANDON_CONFIRM\")")
.unwrap();
s.run("StaticPopup_OnClick(StaticPopup1, 2)").unwrap();
assert!(s.take_gm_ticket_intents().is_empty(), "No sends nothing");
s.run("StaticPopup_Show(\"HELP_TICKET_ABANDON_CONFIRM\")")
.unwrap();
s.run("StaticPopup_OnClick(StaticPopup1, 1)").unwrap();
assert_eq!(s.take_gm_ticket_intents(), vec![GmTicketIntent::Delete]);
}
/// `ToggleHelpFrame` is the micro button's whole wiring, and opening the window asks the server
/// for the queue status — without that ask the gate above would run on a stale assumption for the
/// life of the session.
#[test]
fn toggling_the_window_opens_it_and_asks_for_the_queue_status() {
let mut s = setup();
s.run("ToggleHelpFrame()").unwrap();
assert!(s.eval::<bool>("return HelpFrame:IsVisible()").unwrap());
assert_eq!(
s.take_gm_ticket_intents(),
vec![GmTicketIntent::AskStatus],
"OnShow calls GetGMStatus — the gate must not run on an assumption"
);
// This is the one test that asserts the OnShow traffic itself; the others drain it away first.
assert!(
s.eval::<bool>("return HelpFrameHome:IsVisible()").unwrap(),
"and it opens on Home"
);
s.run("ToggleHelpFrame()").unwrap();
assert!(!s.eval::<bool>("return HelpFrame:IsVisible()").unwrap());
}
/// The Auto-Unstuck button casts the Stuck spell and closes the window. It is the one control in
/// this window that does something other than talk about tickets.
#[test]
fn auto_unstuck_casts_and_closes() {
let mut s = setup();
s.run("ShowUIPanel(HelpFrame) HelpFrame_ShowFrame(1)")
.unwrap();
s.run("HelpFrameUnstick_OnClick()").unwrap();
assert_eq!(s.take_stuck_casts(), 1);
assert!(!s.eval::<bool>("return HelpFrame:IsVisible()").unwrap());
}
/// **The geometry oracle** (decision 0675): every element the transcription shares with the
/// reference file carries the reference's own `<AbsDimension>` numbers.
///
/// **Verified to fail**, as 0675 requires: nudging `TicketStatusFrame`'s width 208 → 209 makes it
/// report `TicketStatusFrame: ours [(209.0, 52.0), …] != ref [(208.0, 52.0), …]` and fail. It is a
/// guard, not a comfort.
#[test]
fn the_windows_geometry_matches_the_reference_file() {
let Some(reference) = super::framexml_diff::reference("HelpFrame.xml") else {
return; // no install — this test is a no-op rather than a failure
};
/// Deliberate deviations, by REFERENCE name. Each earns its reason here; a tolerance would
/// let a real difference hide, so this is a list and stays a list.
const EXPECTED: &[&str] = &[
// The GM category list is the house FAUX scroll kit, not the reference's ScrollFrame, and
// the kit's shared trough replaces the three loose bar textures the reference hangs beside
// it (HelpFrame.xml's header, divergence 1). Same for the ticket editor's own trough.
"HelpFrameGMScrollFrame",
"HelpFrameOpenTicketScrollFrame",
];
super::framexml_diff::assert_geometry_matches("HelpFrame.xml", &reference, EXPECTED, 60);
}
+6
View File
@@ -917,6 +917,12 @@ mod friends_tests;
#[cfg(test)]
mod guild_tests;
/// The GM help window and its ticket toast (decision 1673). Its own module because every test in
/// it pushes a `GMTicketCategory.dbc` catalog and drives the ticket wire's own event vocabulary,
/// which none of the neighbouring windows share.
#[cfg(test)]
mod help_frame_tests;
/// The social window's fourth tab — the raid pane and its grid (decision 1549). Its own module for
/// `guild_tests`' reason: every test in it pushes a RAID roster first, which a file about the
/// friends list must not be in the business of.
@@ -0,0 +1,143 @@
//! GMTicketCategory.dbc — the ten trouble-ticket categories the Help window's "page a GM" list is
//! built from, and the id each one submits as (decision 1673).
//!
//! This table is the answer to a question the shipped FrameXML makes look unanswerable. Its
//! `HelpFrameGM_UpdateCategories(GetGMTicketCategories())` consumes the binding's varargs as
//! `(key, text)` PAIRS, and `HELPFRAME_FRAMES`/`GENERAL_HELPFRAME` are keyed `1..10` with ten
//! distinct titles — yet `GlobalStrings.lua` ships only `TICKET_TYPE1..4` ("Game Play",
//! "Harassment", "Stuck", "Bug"). Those four are not this list: they feed the OpenTicket
//! *dropdown*, which has no frame in 1.12 and whose `OnShow` call is commented out. The category
//! list is DBC data, and it lines up with `GENERAL_HELPFRAME`'s keys row for row:
//!
//! | id | name | the frame `HELPFRAME_FRAMES[id]` sends it to |
//! |---|---|---|
//! | 1 | Stuck | `HelpFrameGeneral`, configured by `GENERAL_HELPFRAME[1]` (`STUCK_OPTION`) |
//! | 2 | Behavior/Harassment | `HelpFrameHarassment` |
//! | 3 | Guild | `HelpFrameGeneral` (`HELPFRAME_GUILD_TITLE`) |
//! | 4 | Item | `HelpFrameGeneral` (`HELPFRAME_ITEM_TITLE`) |
//! | 5 | Environmental | `HelpFrameGeneral` (`HELPFRAME_ENVIRONMENTAL_TITLE`) |
//! | 6 | Non-Quest/Creep | `HelpFrameGeneral` (`HELPFRAME_NONQUEST_TITLE`) |
//! | 7 | Quest/Quest NPC | `HelpFrameGeneral` (`HELPFRAME_QUEST_TITLE`) |
//! | 8 | Technical | `HelpFrameGeneral` (`HELPFRAME_TECHNICAL_TITLE`) |
//! | 9 | Account/Billing | `HelpFrameGeneral` (`HELPFRAME_ACCOUNT_TITLE`) |
//! | 10 | Character | `HelpFrameGeneral` (`HELPFRAME_CHARACTER_TITLE`) |
//!
//! **The id is the wire value**, not just a list index: the clicked button stores it as
//! `HelpFrameOpenTicket.ticketType`, and that is what `NewGMTicket(category, text)` puts in
//! `CMSG_GMTICKET_CREATE`'s category field. So a catalog that renumbered on load would file every
//! ticket under the wrong heading, silently — which is why this is an ordered id-keyed read of the
//! file rather than a `Vec` indexed from zero.
//!
//! Record layout (10 rows in the shipped 5875 file, verified by loading it): `ID@0`,
//! `Name_Lang@1..8`, `NameFlags@9` — the same 8-locale + mask shape as [`crate::itembagfamily`].
//! Ids are 1..10 contiguous here, but nothing downstream may assume that: the ordered pairs are
//! what the binding pushes, and the id is what the wire carries.
use anyhow::{Context, Result};
use benilla_dbc::{FieldType, Schema, SchemaField};
use crate::dbc::{parse, str_at, u32_at};
use crate::Chain;
const GM_TICKET_CATEGORY: &str = "DBFilesClient\\GMTicketCategory.dbc";
/// GMTicketCategory.dbc, in file order — the order `GetGMTicketCategories()` pushes them in, which
/// is the order the ten `HelpFrameButton*` rows are painted in.
pub struct GmTicketCategoryCatalog {
categories: Vec<GmTicketCategory>,
}
/// One row: the wire category id and its localized display name.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GmTicketCategory {
/// The id `CMSG_GMTICKET_CREATE` carries — see the module doc's warning about renumbering.
pub id: u32,
/// The button label, from the active locale's `Name_Lang` slot.
pub name: String,
}
impl GmTicketCategoryCatalog {
/// The rows, in file order.
pub fn categories(&self) -> &[GmTicketCategory] {
&self.categories
}
/// Row count, for the load log.
pub fn len(&self) -> usize {
self.categories.len()
}
pub fn is_empty(&self) -> bool {
self.categories.is_empty()
}
}
fn gm_ticket_category_schema() -> Schema {
let mut s = Schema::new("GMTicketCategory");
s.add_field(SchemaField::new("ID", FieldType::UInt32));
for i in 0..8 {
s.add_field(SchemaField::new(format!("Name{i}"), FieldType::String));
}
s.add_field(SchemaField::new("NameFlags", FieldType::UInt32));
s
}
/// Load GMTicketCategory.dbc from the patch chain.
///
/// A row whose name is empty is skipped rather than pushed blank: the Help window paints one
/// button per returned pair and an unnamed button is a dead click, not a category. The shipped
/// 5875 file has no such row — this is a guard on a locale slot, not a modelled branch.
pub fn load_gm_ticket_categories(chain: &mut Chain) -> Result<GmTicketCategoryCatalog> {
let bytes = chain
.read_file(GM_TICKET_CATEGORY)
.with_context(|| format!("reading {GM_TICKET_CATEGORY}"))?;
let rs = parse(&bytes, gm_ticket_category_schema(), "GMTicketCategory")?;
let mut categories = Vec::with_capacity(rs.records().len());
for r in rs.records() {
let (Some(id), Some(name)) = (u32_at(r, 0), str_at(&rs, r, 1).filter(|n| !n.is_empty()))
else {
continue;
};
categories.push(GmTicketCategory { id, name });
}
Ok(GmTicketCategoryCatalog { categories })
}
#[cfg(test)]
mod tests {
use super::*;
/// The shipped 5875 table, read as data rather than assumed. Pins **both** halves that matter:
/// the ids (which go on the wire) and the order (which is the painted order), plus the row-for-
/// row correspondence with `GENERAL_HELPFRAME`'s ten keys that the module doc tabulates.
/// Skips without client data.
#[test]
fn the_shipped_categories_are_the_ten_help_window_rows_in_order() {
let data = crate::wow_data_or_skip!();
let mut chain = crate::open_chain(&data).expect("open chain");
let cat = load_gm_ticket_categories(&mut chain).expect("GMTicketCategory.dbc");
let rows: Vec<(u32, &str)> = cat
.categories()
.iter()
.map(|c| (c.id, c.name.as_str()))
.collect();
assert_eq!(
rows,
vec![
(1, "Stuck"),
(2, "Behavior/Harassment"),
(3, "Guild"),
(4, "Item"),
(5, "Environmental"),
(6, "Non-Quest/Creep"),
(7, "Quest/Quest NPC"),
(8, "Technical"),
(9, "Account/Billing"),
(10, "Character"),
],
"the wire ids and the painted order both matter — see the module doc"
);
assert_eq!(cat.len(), 10, "the whole shipped table");
}
}
+4
View File
@@ -46,6 +46,10 @@ mod macro_icons;
pub use unit_blood::{load_blood_catalog, BloodCatalog};
mod itemsets;
pub use itemsets::{load_item_sets, ItemSetCatalog, ItemSetInfo};
mod gm_ticket_category;
pub use gm_ticket_category::{
load_gm_ticket_categories, GmTicketCategory, GmTicketCategoryCatalog,
};
mod itembagfamily;
pub use itembagfamily::{load_item_bag_families, ItemBagFamilyCatalog};
mod itemclass;
+20 -1
View File
@@ -12,7 +12,7 @@
use crate::messages::{
ActionButton, AttackerState, AuctionBidderNotification, AuctionCommandTail, AuctionListEntry,
AuctionOwnerNotification, ChannelNoticeTail, Character, CreateSpline, DamageShield,
EnvironmentalDamageLog, ExplorationXp, FriendEntry, FriendStatusUpdate, GossipOption,
EnvironmentalDamageLog, ExplorationXp, FriendEntry, FriendStatusUpdate, GmTicket, GossipOption,
GroupLootInfo, GroupMemberEntry, GuildCommandResult, GuildEventNotice, GuildInfo,
GuildQueryResponse, GuildRoster, InspectHonorStats, ItemInfo, ItemPushResult, JumpInfo,
LevelUpInfo, LootAllPassed, LootItem, LootRoll, LootRollWon, LootStartRoll, MailListEntry,
@@ -372,6 +372,25 @@ pub enum SessionEvent {
/// The player's hearthstone bind point (`SMSG_BINDPOINTUPDATE`): the AreaTable id the
/// `$z` token names ("Returns you to <area>.").
BindPoint { area: u32 },
/// The player's open GM ticket, or `None` for the ordinary "you have no ticket" answer
/// (`SMSG_GMTICKET_GETTICKET`; decision 1673). **Every one of these is an answer, including a
/// `None`** — the Help window re-polls every 10 minutes and must re-fire `UPDATE_TICKET` each
/// time even when nothing changed, so the consumer counts these rather than diffing them.
GmTicket { ticket: Option<Box<GmTicket>> },
/// The answer to filing a ticket (`SMSG_GMTICKET_CREATE`): 2 = created, 3 = refused,
/// 1 = one already exists (vmangos never sends 1 and sometimes sends nothing at all).
GmTicketCreated { response: u32 },
/// The answer to editing a ticket (`SMSG_GMTICKET_UPDATETEXT`): 4 = saved, 5 = refused.
GmTicketUpdated { response: u32 },
/// The answer to abandoning a ticket (`SMSG_GMTICKET_DELETETICKET`): 9 = deleted. Also arrives
/// unsolicited when a GM runs `.ticket delete`.
GmTicketDeleted { response: u32 },
/// Whether the petition queue is taking tickets (`SMSG_GMTICKETSYSTEMSTATUS`): 1 = yes.
/// Drives `UPDATE_GM_STATUS`, and through it the Help window's "page a GM" gate.
GmTicketSystemStatus { status: i32 },
/// A GM touched the ticket (`SMSG_GM_TICKET_STATUS_UPDATE`): 1 = updated, 2 = closed,
/// 3 = a survey is offered. vmangos never sends it; cmangos makes it the notification model.
GmTicketStatusUpdate { status: u32 },
/// An innkeeper is asking whether to make this your home (`SMSG_BINDER_CONFIRM`) — the
/// question the `CONFIRM_BINDER` dialog puts on screen. `binder` is the innkeeper's guid, and
/// it must be echoed in `CMSG_BINDER_ACTIVATE` for the bind to happen at all (decision 1331).
@@ -666,6 +666,22 @@ pub fn decode(packet: ServerPacket) -> Vec<SessionEvent> {
vec![SessionEvent::ServerUnixTime { unix_time }]
}
ServerPacket::BindPoint { area, .. } => vec![SessionEvent::BindPoint { area }],
ServerPacket::GmTicketAnswer { ticket } => vec![SessionEvent::GmTicket { ticket }],
ServerPacket::GmTicketCreated { response } => {
vec![SessionEvent::GmTicketCreated { response }]
}
ServerPacket::GmTicketUpdated { response } => {
vec![SessionEvent::GmTicketUpdated { response }]
}
ServerPacket::GmTicketDeleted { response } => {
vec![SessionEvent::GmTicketDeleted { response }]
}
ServerPacket::GmTicketSystemStatus { status } => {
vec![SessionEvent::GmTicketSystemStatus { status }]
}
ServerPacket::GmTicketStatusUpdate { status } => {
vec![SessionEvent::GmTicketStatusUpdate { status }]
}
ServerPacket::BinderConfirm { binder } => vec![SessionEvent::BinderConfirm { binder }],
ServerPacket::TalentWipeConfirm { trainer, cost } => {
vec![SessionEvent::TalentWipeConfirm { trainer, cost }]
@@ -0,0 +1,282 @@
//! The GM trouble-ticket family — the five request/answer pairs behind the Help window
//! (opcodes 0x205-0x21B; decision 1673).
//!
//! | opcode | direction | body |
//! |---|---|---|
//! | `CMSG_GMTICKET_CREATE` 0x205 | out | `u8` category, `u32` map, `f32` x/y/z, cstring text, cstring reserved |
//! | `SMSG_GMTICKET_CREATE` 0x206 | in | `u32` response |
//! | `CMSG_GMTICKET_UPDATETEXT` 0x207 | out | `u8` category, cstring text |
//! | `SMSG_GMTICKET_UPDATETEXT` 0x208 | in | `u32` response |
//! | `CMSG_GMTICKET_GETTICKET` 0x211 | out | *empty* |
//! | `SMSG_GMTICKET_GETTICKET` 0x212 | in | `u32` status, then the ticket iff status == 6 |
//! | `CMSG_GMTICKET_DELETETICKET` 0x217 | out | *empty* |
//! | `SMSG_GMTICKET_DELETETICKET` 0x218 | in | `u32` response |
//! | `CMSG_GMTICKET_SYSTEMSTATUS` 0x21A | out | *empty* |
//! | `SMSG_GMTICKET_SYSTEMSTATUS` 0x21B | in | `u32` queue status |
//!
//! ## The two outbound bodies are byte-verified against the real client, not just the server
//!
//! Both are places where reading only the emulator would have got it wrong, so both were taken
//! from the 5875 binary's own senders:
//!
//! - **`CMSG_GMTICKET_CREATE`'s category is a `u8`, not a `u32`** (sender `0x5ef740`, reached from
//! the Lua glue `NewGMTicket` `0x48c9d0`): `PutInt8` at `0x5ef851`, then `PutInt32(mapId)`, three
//! `PutFloat`s, `PutCString(text)`, and a second cstring — the literal
//! [`RESERVED_FOR_FUTURE_USE`] at VA `0x860708`. vmangos reads exactly that head
//! (`Server/Packets/GmTicket.cpp:5-17`).
//! - **`CMSG_GMTICKET_UPDATETEXT` carries the category byte too** (sender `0x5efac0`: `PutInt8` at
//! `0x5efb2d`, `PutCString` at `0x5efb3c`). This one is a real fork in the emulator world —
//! vmangos reads `u8 type; cstring text` (`GmTicket.cpp:19-23`) and matches the client, while
//! cmangos-classic reads a bare cstring (`GMTicketHandler.cpp:160-161`) and therefore swallows
//! our category byte as the first character of the ticket. We are faithful to the client; a
//! cmangos server would store a stray control byte at the head of the text. Recorded rather than
//! worked around: the client is the authority, and bending the wire to one emulator's bug would
//! break the other.
//!
//! **The `category == 2` chat-log tail is deliberately not sent.** The real client appends a
//! zlib-compressed transcript of the last (up to 60) chat lines to a Behavior/Harassment ticket
//! (`0x5ef936` onward). Neither emulator uses it: vmangos does not read it at all (and logs a
//! parse-length warning for the leftover bytes), cmangos reads two `u32`s under the wrong names
//! and discards the rest. Sending it would buy nothing and cost a log line per ticket, so
//! [`gm_ticket_create`] stops after the reserved string — see [`crate::messages::gm_ticket`]'s
//! test for the byte-exact shape we do send.
//!
//! ## The inbound ticket reads TEXT BEFORE CATEGORY
//!
//! [`GmTicket`]'s field order is the *wire* order, and it is not the order the Lua `UPDATE_TICKET`
//! event uses (`HelpFrameOpenTicket_OnEvent` reads arg1 = category, arg2 = description). vmangos
//! and cmangos agree byte-for-byte on the wire order — `cstring text` then `u8 category`
//! (vmangos `Server/Packets/GmTicket.cpp:66-79` filled at `GMTicketMgr.cpp:120-151`; cmangos
//! `GMTickets/GMTicketHandler.cpp:35-51`) — so the reorder happens inside the client's own handler
//! and belongs to the app's event feed, not here. Decoding it any other way would desynchronise
//! the whole struct after the first string.
use std::io;
use crate::wire::{read_cstring, read_f32_le, read_u32_le, read_u8};
/// The second cstring of `CMSG_GMTICKET_CREATE`, verbatim from the client's own constant at VA
/// `0x860708`. It is exactly what it says: a slot nothing has ever read. We send the real string
/// rather than an empty one because that is what the client puts on the wire, and a server that
/// ever grows a use for it should see what retail sent.
pub const RESERVED_FOR_FUTURE_USE: &str = "Reserved for future use";
/// `SMSG_GMTICKET_GETTICKET`'s status when a ticket follows (`GMTICKET_STATUS_HASTEXT`).
pub const GMTICKET_STATUS_HASTEXT: u32 = 0x06;
/// `SMSG_GMTICKET_GETTICKET`'s status when there is no ticket (`GMTICKET_STATUS_DEFAULT`) — a
/// 4-byte body and nothing after it. **This is the ordinary answer, not an error**: it is what
/// retail answered the client's own post-login ask with in the 1.12.1 sniff, and it is what the
/// Help window's "you have no open ticket" state is drawn from.
pub const GMTICKET_STATUS_DEFAULT: u32 = 0x0A;
/// The queue is accepting tickets (`GMTICKET_QUEUE_STATUS_ENABLED`).
pub const GMTICKET_QUEUE_ENABLED: i32 = 1;
/// The player's open ticket, as `SMSG_GMTICKET_GETTICKET` describes it.
///
/// The three `f32`s are **days**, not seconds, and two of them can legitimately be negative:
/// cmangos sends `-1.0` for `oldest_ticket_age`/`update_time` when it has never computed them
/// (`GMTicketMgr.h:318`, `GMTicketMgr.cpp:1340-1343`), and the shipped FrameXML tests exactly that
/// (`arg4 < 0 or arg5 < 0` → "Wait time currently unavailable"). So this is a plain `f32` with no
/// clamping: the negative *is* the signal.
#[derive(Debug, Clone, PartialEq)]
pub struct GmTicket {
/// What the player typed. On vmangos a completed ticket has the GM's answer **appended into
/// this same string** (`GMTicketMgr.cpp:124-136`) — there is no response channel in 1.12, so
/// the reply arrives as part of the description.
pub text: String,
/// The `GMTicketCategory.dbc` id the ticket was filed under (1..10).
pub category: u8,
/// Days since the ticket was last modified — the FrameXML's `arg3`.
pub ticket_age: f32,
/// Days since the oldest open ticket on the realm was last modified; `0.0` when there is none,
/// negative when unknown. The FrameXML subtracts `ticket_age` from this to estimate the wait.
pub oldest_ticket_age: f32,
/// How stale the oldest-ticket figure is, in days; negative or `> 0.042` (≈ 1 hour) means the
/// data is no good and the window says so instead of guessing.
pub update_time: f32,
/// 0 = unassigned, 1 = assigned to a GM, 2 = in the escalation queue. vmangos clamps its
/// internal 3 ("escalated and assigned") down to 2 before sending (`GMTicketMgr.cpp:147`), so
/// nothing above 2 can arrive.
pub assigned_to_gm: u8,
/// 1 once a GM has actually opened the ticket, 0 before that.
pub opened_by_gm: u8,
}
/// Body of `CMSG_GMTICKET_CREATE` — the Submit that files a new ticket.
///
/// `map`/`x`/`y`/`z` are where the player is standing when they file it; vmangos stores them so a
/// GM can `.ticket go` to the spot. The text is sent as typed: the client's own 1999-char cap
/// (`SStrCopy` bound `0x7d0` at `0x5ef7fd`) sits far above the Help window's `letters="500"`
/// EditBox, so nothing here can truncate a ticket the UI allowed.
pub fn gm_ticket_create(category: u8, map: u32, pos: [f32; 3], text: &str) -> Vec<u8> {
let mut out =
Vec::with_capacity(1 + 4 + 12 + text.len() + 1 + RESERVED_FOR_FUTURE_USE.len() + 1);
out.push(category);
out.extend_from_slice(&map.to_le_bytes());
for c in pos {
out.extend_from_slice(&c.to_le_bytes());
}
out.extend_from_slice(text.as_bytes());
out.push(0);
out.extend_from_slice(RESERVED_FOR_FUTURE_USE.as_bytes());
out.push(0);
out
}
/// Body of `CMSG_GMTICKET_UPDATETEXT` — the "Save Changes" that edits an open ticket.
///
/// Carries the category byte as well as the text (module doc: byte-verified at `0x5efb2d`), and
/// vmangos **stores** it: `HandleGMTicketUpdateTextOpcode` does `SetMessage(packet.ticketText)`
/// then `SetTicketType(TicketType(packet.type))` before saving (`GMTicketHandler.cpp:59-61`). So an
/// edit re-files the ticket under whatever category the byte carries, and getting this field wrong
/// silently moves a GM's ticket to a different queue rather than merely being ignored.
///
/// The shipped Help window always passes the ticket's existing `HelpFrameOpenTicket.ticketType`
/// here, so in practice the value is unchanged by an edit — but that is the window's discipline,
/// not the server's.
pub fn gm_ticket_updatetext(category: u8, text: &str) -> Vec<u8> {
let mut out = Vec::with_capacity(1 + text.len() + 1);
out.push(category);
out.extend_from_slice(text.as_bytes());
out.push(0);
out
}
/// Read the shared `u32` answer of `SMSG_GMTICKET_CREATE` / `UPDATETEXT` / `DELETETICKET`.
///
/// One reader for three opcodes because all three bodies are the same 4 bytes — the *meaning* of
/// the number is the opcode's, and that split lives in [`crate::events`]. The value set (vmangos
/// `GMTicketMgr.h:49-54`): 1 already-exists, 2 create-ok, 3 create-error, 4 update-ok,
/// 5 update-error, 9 deleted.
pub(super) fn read_gm_ticket_response(r: &mut &[u8]) -> io::Result<u32> {
read_u32_le(r)
}
/// Read `SMSG_GMTICKETSYSTEMSTATUS`: 1 = the queue is taking tickets, 0 = it is not.
///
/// **Signed, and that is byte-verified rather than a preference.** The reference copies the field
/// off the wire verbatim (`0x418e95`, no extension), pushes it unmodified (`0x5e467b`) and hands it
/// to Lua through `0x704fa6 fild dword` — a signed load, with no `cmp`, `test`, clamp or mapping
/// anywhere in between (wow-re §5). `HelpFrame`'s `arg1 == -1` arm — "the queue is down, say so" —
/// is therefore reachable exactly when a server sends `0xFFFFFFFF`, and reading this unsigned would
/// turn that into 4294967295 and silently cost the dialog. vmangos only ever sends 0 or 1, so
/// nothing here would have caught the difference in a live run.
pub(super) fn read_gm_ticket_system_status(r: &mut &[u8]) -> io::Result<i32> {
Ok(read_u32_le(r)? as i32)
}
/// Read `SMSG_GMTICKET_GETTICKET`: a status, and the ticket itself only when that status is
/// [`GMTICKET_STATUS_HASTEXT`].
///
/// Returns `Ok(None)` for every other status — which in practice is the 4-byte
/// [`GMTICKET_STATUS_DEFAULT`] body meaning "you have no open ticket". Deliberately **not** an
/// error: that answer is the common case, it is what the client's own post-login ask gets, and
/// treating it as a parse failure would put a warn line in the log on every login.
pub(super) fn read_gm_ticket(r: &mut &[u8]) -> io::Result<Option<GmTicket>> {
if read_u32_le(r)? != GMTICKET_STATUS_HASTEXT {
return Ok(None);
}
Ok(Some(GmTicket {
text: read_cstring(r)?,
category: read_u8(r)?,
ticket_age: read_f32_le(r)?,
oldest_ticket_age: read_f32_le(r)?,
update_time: read_f32_le(r)?,
assigned_to_gm: read_u8(r)?,
opened_by_gm: read_u8(r)?,
}))
}
#[cfg(test)]
mod tests {
use super::*;
/// The create body, byte for byte — the client's own field order and widths (`0x5ef740`), with
/// the category as ONE byte. Getting this wrong by widening the category to `u32` would shift
/// the map id and the whole position by three bytes and file every ticket at a garbage spot.
#[test]
fn the_create_body_is_a_category_byte_then_map_position_text_and_the_reserved_string() {
let body = gm_ticket_create(4, 1, [-8949.95, -132.493, 83.5312], "My sword vanished.");
let mut want = vec![0x04];
want.extend_from_slice(&1u32.to_le_bytes());
want.extend_from_slice(&(-8949.95f32).to_le_bytes());
want.extend_from_slice(&(-132.493f32).to_le_bytes());
want.extend_from_slice(&(83.5312f32).to_le_bytes());
want.extend_from_slice(b"My sword vanished.\0");
want.extend_from_slice(b"Reserved for future use\0");
assert_eq!(body, want);
// The tail the client sends for a Behavior/Harassment ticket is deliberately absent: the
// body ends at the reserved string for every category, including 2.
assert_eq!(
gm_ticket_create(2, 0, [0.0; 3], "x").len(),
1 + 4 + 12 + 2 + 24,
"no chat-log tail, even for category 2"
);
}
/// The updatetext body leads with the category byte — the divergence the module doc records.
/// A bare cstring here is what cmangos expects and what the client never sends.
#[test]
fn the_updatetext_body_leads_with_the_category_byte() {
assert_eq!(
gm_ticket_updatetext(7, "Still stuck."),
b"\x07Still stuck.\0"
);
}
/// The ticket round-trips in WIRE order — text first, category second. Pinning it here is the
/// point: the Lua event's order is the reverse, and a "fix" that swapped these two to match
/// the event would desynchronise every field after the string.
#[test]
fn a_held_ticket_decodes_in_wire_order_text_before_category() {
let mut bytes = GMTICKET_STATUS_HASTEXT.to_le_bytes().to_vec();
bytes.extend_from_slice(b"Stuck in a rock.\0");
bytes.push(1);
bytes.extend_from_slice(&0.25f32.to_le_bytes());
bytes.extend_from_slice(&2.5f32.to_le_bytes());
bytes.extend_from_slice(&0.01f32.to_le_bytes());
bytes.push(2);
bytes.push(1);
assert_eq!(
read_gm_ticket(&mut &bytes[..]).unwrap(),
Some(GmTicket {
text: "Stuck in a rock.".to_string(),
category: 1,
ticket_age: 0.25,
oldest_ticket_age: 2.5,
update_time: 0.01,
assigned_to_gm: 2,
opened_by_gm: 1,
})
);
}
/// The queue status is read SIGNED: `0xFFFFFFFF` must reach Lua as `-1`, which is the value the
/// shipped window branches on to raise "GM Help Tickets are currently unavailable." Read it
/// unsigned and that arm can never fire.
#[test]
fn the_queue_status_is_signed_so_all_ones_reads_as_minus_one() {
assert_eq!(
read_gm_ticket_system_status(&mut &0xFFFF_FFFFu32.to_le_bytes()[..]).unwrap(),
-1
);
assert_eq!(
read_gm_ticket_system_status(&mut &1u32.to_le_bytes()[..]).unwrap(),
GMTICKET_QUEUE_ENABLED
);
}
/// "You have no ticket" is a 4-byte body and an `Ok(None)`, never an error — it is the answer
/// retail gave the client's own post-login ask, so an error here would warn on every login.
#[test]
fn the_no_ticket_answer_is_four_bytes_and_is_not_an_error() {
let bytes = GMTICKET_STATUS_DEFAULT.to_le_bytes();
assert_eq!(read_gm_ticket(&mut &bytes[..]).unwrap(), None);
// Any other status is likewise "nothing to show" rather than a parse failure.
let bytes = 0u32.to_le_bytes();
assert_eq!(read_gm_ticket(&mut &bytes[..]).unwrap(), None);
}
}
@@ -29,6 +29,7 @@ mod combat_log;
mod death;
mod duel;
mod gameobject;
mod gm_ticket;
mod gossip;
mod group;
mod guild;
@@ -110,6 +111,10 @@ pub use duel::{
read_duel_winner, DuelRequested, DuelWinner,
};
pub use gameobject::{gameobj_use, gameobject_query, GameObjectQueryInfo};
pub use gm_ticket::{
gm_ticket_create, gm_ticket_updatetext, GmTicket, GMTICKET_QUEUE_ENABLED,
GMTICKET_STATUS_DEFAULT, GMTICKET_STATUS_HASTEXT, RESERVED_FOR_FUTURE_USE,
};
pub use gossip::{
gossip_hello, gossip_select_option, npc_text_query, select_greeting, GossipOption, GossipPoi,
NpcTextBlock, QuestOption, NPC_TEXT_BLOCKS,
@@ -256,6 +261,15 @@ pub const AUTH_ALREADY_LOGGING_IN: u8 = 0x19;
pub const AUTH_LOGIN_SERVER_NOT_FOUND: u8 = 0x1A;
/// The realm is full and we are **queued**, not refused — the one code here that is not an ending.
pub const AUTH_WAIT_QUEUE: u8 = 0x1B;
// The tail past the queue. These five are exactly the codes the client's `OKAY_WITH_URL` table
// (`0x803740`, 5 records, stride 0x24) keys on — banned/no-time/db-busy/suspended/parental — which
// is why the URL dialog is reachable ONLY from this enum and never from realmd.
pub const AUTH_BANNED: u8 = 0x1C;
pub const AUTH_ALREADY_ONLINE: u8 = 0x1D;
pub const AUTH_NO_TIME: u8 = 0x1E;
pub const AUTH_DB_BUSY: u8 = 0x1F;
pub const AUTH_SUSPENDED: u8 = 0x20;
pub const AUTH_PARENTAL_CONTROL: u8 = 0x21;
/// `LogoutResult::Success` (`SMSG_LOGOUT_RESPONSE`).
pub const LOGOUT_SUCCESS: u32 = 0x0;
/// Chat `Language` wire ids (VERIFIED vmangos `SharedDefines.h:256-261`): the faction tongues.
@@ -728,6 +728,47 @@ pub const CMSG_BINDER_ACTIVATE: u16 = 0x01B5; // 437
pub const SMSG_PLAYERBOUND: u16 = 0x0158; // 344
pub const SMSG_BINDER_CONFIRM: u16 = 0x02EB; // 747
// The GM trouble-ticket set (VERIFIED vmangos `Opcodes_1_12_1.h` + `Opcodes.cpp:608-630`; decision
// 1673). Five request/answer pairs behind the Help window, and the client asks for all of them —
// there is no ticket state pushed at login, so `CMSG_GMTICKET_GETTICKET` right after world entry is
// the client's own (seen in the 1.12.1 retail sniff, answered `TicketStatus: 10 (NoText)`).
//
// **Two of these answers can also arrive UNSOLICITED on vmangos**, which is why they are decoded
// rather than correlated to a pending ask: `.ticket viewid`/`viewname`/`escalate`/`complete` push a
// fresh `SMSG_GMTICKET_GETTICKET` at the ticket's author (`GMTicketMgr.cpp:153-159`,
// `TicketCommands.cpp:265,443-452`), and `.ticket delete <id>` pushes a
// `SMSG_GMTICKET_DELETETICKET` = `TICKET_DELETED` (`TicketCommands.cpp:100-103`).
//
// **And two requests can be answered with SILENCE**, which is why nothing in the client blocks on a
// reply: `HandleGMTicketCreateOpcode` simply returns — 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`), and delete-with-no-ticket likewise returns silently (`:73-86`).
// Bodies in [`super::gm_ticket`].
pub const CMSG_GMTICKET_CREATE: u16 = 0x0205; // 517
pub const SMSG_GMTICKET_CREATE: u16 = 0x0206; // 518
pub const CMSG_GMTICKET_UPDATETEXT: u16 = 0x0207; // 519
pub const SMSG_GMTICKET_UPDATETEXT: u16 = 0x0208; // 520
pub const CMSG_GMTICKET_GETTICKET: u16 = 0x0211; // 529
pub const SMSG_GMTICKET_GETTICKET: u16 = 0x0212; // 530
pub const CMSG_GMTICKET_DELETETICKET: u16 = 0x0217; // 535
pub const SMSG_GMTICKET_DELETETICKET: u16 = 0x0218; // 536
pub const CMSG_GMTICKET_SYSTEMSTATUS: u16 = 0x021A; // 538
pub const SMSG_GMTICKET_SYSTEMSTATUS: u16 = 0x021B; // 539
/// The GM's own ticket-state push (VERIFIED vmangos `Opcodes_1_12_1.h`: 808). Body is a bare `u32`:
/// 1 = updated, 2 = closed, 3 = a survey is offered.
///
/// **vmangos never constructs this packet on any path** — it is registered in its opcode table and
/// nothing in its source sends it — so on the server benilla talks to this arm is dead. cmangos-
/// classic makes it the core of its notification model (escalation, first GM read, re-sort, text
/// update, assignment, queue toggle, close), which is why it is parsed rather than dropped.
///
/// It is decoded for one reason beyond completeness: the reference engine answers value 1 by
/// re-asking for the ticket (`0x5e7932`, wow-re §5), the same leg the create/update success codes
/// take. Values 2 and 3 are recorded but not acted on — 3 is the GM-survey trigger, and the survey
/// window is deferred (decision 1673).
pub const SMSG_GM_TICKET_STATUS_UPDATE: u16 = 0x0328; // 808
// The bank set (VERIFIED vmangos `Opcodes_1_12_1.h`: 439,440-441,642-643).
// `CMSG_BANKER_ACTIVATE` right-clicks a pure banker;
// `SMSG_SHOW_BANK` also arrives unprompted from the `GOSSIP_OPTION_BANKER` gossip option. Slot
+40 -1
View File
@@ -8,7 +8,7 @@ use super::{
ActionButton, AttackerState, AuctionBidderNotification, AuctionCommandTail, AuctionListEntry,
AuctionOwnerNotification, CastOutcome, ChannelNotify, Character, ChatMessage, CorpseLocation,
DamageShield, EnvironmentalDamageLog, ExplorationXp, FriendEntry, FriendStatusUpdate,
GameObjectQueryInfo, GossipOption, GossipPoi, GroupLootInfo, GroupMemberEntry,
GameObjectQueryInfo, GmTicket, GossipOption, GossipPoi, GroupLootInfo, GroupMemberEntry,
GuildCommandResult, GuildEventNotice, GuildInfo, GuildQueryResponse, GuildRoster,
InitWorldStates, InspectHonorStats, ItemInfo, ItemPushResult, JumpInfo, LevelUpInfo,
LootAllPassed, LootItem, LootRoll, LootRollWon, LootStartRoll, MailListEntry, MirrorTimerStart,
@@ -217,6 +217,39 @@ pub enum ServerPacket {
map: u32,
area: u32,
},
/// `SMSG_GMTICKET_GETTICKET` — the player's open GM ticket, or `None` for the ordinary
/// "you have no ticket" answer (decision 1673). **Not always solicited**: vmangos pushes a
/// fresh one at the ticket's author when a GM views, escalates or completes it
/// (`GMTicketMgr.cpp:153-159`), which is how a reply reaches the player at all in 1.12.
GmTicketAnswer {
ticket: Option<Box<GmTicket>>,
},
/// `SMSG_GMTICKET_CREATE` — the answer to filing a ticket: 2 = created, 3 = refused,
/// 1 = you already have one (decision 1673). vmangos never sends 1 (an existing ticket just
/// yields 3), and answers several refusals with **silence** rather than a code at all.
GmTicketCreated {
response: u32,
},
/// `SMSG_GMTICKET_UPDATETEXT` — the answer to editing a ticket: 4 = saved, 5 = refused.
GmTicketUpdated {
response: u32,
},
/// `SMSG_GMTICKET_DELETETICKET` — the answer to abandoning a ticket: 9 = deleted.
/// Also arrives **unsolicited** when a GM runs `.ticket delete` (`TicketCommands.cpp:100-103`).
GmTicketDeleted {
response: u32,
},
/// `SMSG_GMTICKETSYSTEMSTATUS` — 1 = the petition queue is taking tickets, 0 = it is not.
/// Answers `CMSG_GMTICKET_SYSTEMSTATUS`; cmangos also broadcasts it to every session when a
/// GM toggles the queue, vmangos never does.
GmTicketSystemStatus {
status: i32,
},
/// `SMSG_GM_TICKET_STATUS_UPDATE` — a GM touched the ticket: 1 = updated, 2 = closed,
/// 3 = a survey is offered. Never sent by vmangos; the core of cmangos's notification model.
GmTicketStatusUpdate {
status: u32,
},
/// `SMSG_BINDER_CONFIRM` — an innkeeper is *asking* whether to make this your home
/// (decision 1331). The body is the innkeeper's guid, which must come back in
/// `CMSG_BINDER_ACTIVATE` for anything to happen: this packet binds nothing on its own.
@@ -1310,6 +1343,12 @@ impl ServerPacket {
ServerPacket::TimeSpeed { .. } => "SMSG_LOGIN_SETTIMESPEED".into(),
ServerPacket::QueryTimeResponse { .. } => "SMSG_QUERY_TIME_RESPONSE".into(),
ServerPacket::BindPoint { .. } => "SMSG_BINDPOINTUPDATE".into(),
ServerPacket::GmTicketAnswer { .. } => "SMSG_GMTICKET_GETTICKET".into(),
ServerPacket::GmTicketCreated { .. } => "SMSG_GMTICKET_CREATE".into(),
ServerPacket::GmTicketUpdated { .. } => "SMSG_GMTICKET_UPDATETEXT".into(),
ServerPacket::GmTicketDeleted { .. } => "SMSG_GMTICKET_DELETETICKET".into(),
ServerPacket::GmTicketSystemStatus { .. } => "SMSG_GMTICKETSYSTEMSTATUS".into(),
ServerPacket::GmTicketStatusUpdate { .. } => "SMSG_GM_TICKET_STATUS_UPDATE".into(),
ServerPacket::BinderConfirm { .. } => "SMSG_BINDER_CONFIRM".into(),
ServerPacket::PlayerBound { .. } => "SMSG_PLAYERBOUND".into(),
ServerPacket::TalentWipeConfirm { .. } => "MSG_TALENT_WIPE_CONFIRM".into(),
+24 -4
View File
@@ -12,10 +12,10 @@ use crate::wire::{
use super::{
action_bar, area_trigger, attack, auction, bank, binder, channel, chat, combat_log, death,
duel, gameobject, gossip, group, guild, items, loot, mail, mirror_timer, monster_move,
movement, opcode, page_text, pet, progression, pvp, quest, social, spellbook, spells, stable,
taxi, trade, trainer, update_object, vendor, world_state, Character, CreatureQueryInfo,
MoveMode, ServerPacket, SpeedKind,
duel, gameobject, gm_ticket, gossip, group, guild, items, loot, mail, mirror_timer,
monster_move, movement, opcode, page_text, pet, progression, pvp, quest, social, spellbook,
spells, stable, taxi, trade, trainer, update_object, vendor, world_state, Character,
CreatureQueryInfo, MoveMode, ServerPacket, SpeedKind,
};
/// Read one `SMSG_FORCE_*_SPEED_CHANGE` body — `[packed mover guid][u32 counter][f32 speed]`,
@@ -287,6 +287,26 @@ pub fn parse_server(opcode: u16, body: &[u8]) -> io::Result<ServerPacket> {
area,
}
}
// The GM trouble-ticket answers (decision 1673). The three response opcodes share one
// 4-byte body and therefore one reader; only the opcode says which verb was answered.
opcode::SMSG_GMTICKET_GETTICKET => ServerPacket::GmTicketAnswer {
ticket: gm_ticket::read_gm_ticket(&mut r)?.map(Box::new),
},
opcode::SMSG_GMTICKET_CREATE => ServerPacket::GmTicketCreated {
response: gm_ticket::read_gm_ticket_response(&mut r)?,
},
opcode::SMSG_GMTICKET_UPDATETEXT => ServerPacket::GmTicketUpdated {
response: gm_ticket::read_gm_ticket_response(&mut r)?,
},
opcode::SMSG_GMTICKET_DELETETICKET => ServerPacket::GmTicketDeleted {
response: gm_ticket::read_gm_ticket_response(&mut r)?,
},
opcode::SMSG_GMTICKET_SYSTEMSTATUS => ServerPacket::GmTicketSystemStatus {
status: gm_ticket::read_gm_ticket_system_status(&mut r)?,
},
opcode::SMSG_GM_TICKET_STATUS_UPDATE => ServerPacket::GmTicketStatusUpdate {
status: gm_ticket::read_gm_ticket_response(&mut r)?,
},
opcode::SMSG_BINDER_CONFIRM => ServerPacket::BinderConfirm {
binder: binder::read_binder_confirm(&mut r)?,
},
@@ -0,0 +1,79 @@
//! The Help window's five sends — the whole client half of the GM trouble-ticket flow
//! (decision 1673).
//!
//! Three of the five have empty bodies, which is the shape of the feature: the client holds no
//! ticket state of its own, so "show me my ticket", "abandon it" and "is the queue up?" are each a
//! bare opcode and the server's answer is the only truth. The two that carry a body — file and
//! edit — are byte-verified against the real client's own senders (`0x5ef740` / `0x5efac0`); see
//! [`crate::messages::gm_ticket`] for why the category is a single byte in both.
//!
//! **Nothing here may be retried on a timeout.** vmangos answers several refusals with silence
//! rather than a response code (queue off, under `GMTickets.MinLevel`, category >= 11 —
//! `GMTicketHandler.cpp:91,106-113`), and it treats more than two `CMSG_GMTICKET_UPDATETEXT` in a
//! single world tick as flooding, whose default sanction is a **kick**
//! (`WorldSession.cpp:1316-1342`, `Antiflood.Sanction = 8`). So a client that re-sent an edit
//! because no answer came would disconnect itself. The Help window asks once per click, exactly as
//! the reference does.
use anyhow::Result;
use crate::messages::{self, opcode};
use super::WorldWriter;
impl WorldWriter {
/// File a new ticket (`CMSG_GMTICKET_CREATE`) — the Help window's Submit.
///
/// `category` is a `GMTicketCategory.dbc` id (1..10, the clicked row); `map`/`pos` are where
/// the player is standing, which is what lets a GM `.tele` to the reported spot. Answered by
/// `SMSG_GMTICKET_CREATE` — **or by nothing at all**, on the several vmangos paths that refuse
/// silently (module doc).
pub fn gm_ticket_create(
&mut self,
category: u8,
map: u32,
pos: [f32; 3],
text: &str,
) -> Result<()> {
self.send(
opcode::CMSG_GMTICKET_CREATE,
&messages::gm_ticket_create(category, map, pos, text),
)
}
/// Edit the open ticket's text (`CMSG_GMTICKET_UPDATETEXT`) — the Help window's "Save Changes".
///
/// Carries the category byte the client carries even though vmangos reads and discards it; the
/// category of an existing ticket cannot actually be changed this way. Answered by
/// `SMSG_GMTICKET_UPDATETEXT`. **Rate-limited server-side** — see the module doc.
pub fn gm_ticket_updatetext(&mut self, category: u8, text: &str) -> Result<()> {
self.send(
opcode::CMSG_GMTICKET_UPDATETEXT,
&messages::gm_ticket_updatetext(category, text),
)
}
/// Ask for the open ticket (`CMSG_GMTICKET_GETTICKET`, empty body) — `GetGMTicket()`.
///
/// The client's own poll: once on world entry and every 10 minutes while the ticket status
/// toast is up. Answered by `SMSG_GMTICKET_GETTICKET`, and on vmangos **also** by an
/// unsolicited `SMSG_QUERY_TIME_RESPONSE` — its handler calls `SendQueryTimeResponse()` as its
/// first statement (`GMTicketHandler.cpp:34`), so two packets come back for this one request.
pub fn gm_ticket_get(&mut self) -> Result<()> {
self.send(opcode::CMSG_GMTICKET_GETTICKET, &[])
}
/// Abandon the open ticket (`CMSG_GMTICKET_DELETETICKET`, empty body) — the
/// `HELP_TICKET_ABANDON_CONFIRM` dialog's Yes. Answered by `SMSG_GMTICKET_DELETETICKET`
/// carrying 9, or — with no ticket to delete — by nothing at all on vmangos.
pub fn gm_ticket_delete(&mut self) -> Result<()> {
self.send(opcode::CMSG_GMTICKET_DELETETICKET, &[])
}
/// Ask whether the petition queue is taking tickets (`CMSG_GMTICKET_SYSTEMSTATUS`, empty body)
/// — `GetGMStatus()`, which the Help window calls from its own OnShow. Answered by
/// `SMSG_GMTICKETSYSTEMSTATUS`.
pub fn gm_ticket_system_status(&mut self) -> Result<()> {
self.send(opcode::CMSG_GMTICKET_SYSTEMSTATUS, &[])
}
}
@@ -29,6 +29,7 @@
//! | [`items`] | bags and equipment: use, equip, swap/split/destroy, the template ask |
//! | [`loot`] | the loot window: open, take, coin, close, roll |
//! | [`death`] | the corpse run: release, corpse query, reclaim, spirit healer, res answer |
//! | [`gm_ticket`] | the Help window: file, edit, ask, abandon, queue status |
//! | [`gossip`] | the front door to every NPC service window |
//! | [`vendor`] | buy, sell, buy back, repair |
//! | [`bank`] | open, buy a slot, deposit, withdraw |
@@ -65,6 +66,7 @@ mod chat;
mod death;
mod duel;
mod gameobject;
mod gm_ticket;
mod gossip;
mod group;
mod guild;
+164
View File
@@ -0,0 +1,164 @@
//! The GM trouble-ticket **Era API surface** (decision 1673) — the seven globals `HelpFrame.lua`
//! is written against, and nothing else.
//!
//! All seven are engine bindings in 1.12 (`reference/1.12-globals.tsv` lists each as
//! `function`/`engine`), and the set is closed: the shipped Help window calls exactly
//! `GetGMTicketCategories`, `GetGMStatus`, `GetGMTicket`, `NewGMTicket`, `UpdateGMTicket`,
//! `DeleteGMTicket` and `Stuck`, and nothing else in the shipped UI calls any of them.
//!
//! **The five ticket verbs share ONE ordered queue** ([`GmTicketIntent`]), rather than a counter
//! each. Call order is the wire order in the real client, and a per-verb drain cannot preserve it:
//! with separate queues, `DeleteGMTicket(); GetGMTicket()` in a single chunk puts the *get* on the
//! wire first and the answer then describes the state before the delete. The shipped Help window
//! never issues two in one frame, so nothing on screen would ever have shown it — which is exactly
//! why it is worth getting right rather than leaving as a latent trap for the next addon, or for
//! our own probe (which hit it).
//!
//! A queue, not a set: **two calls are two sends**. That matters more here than for
//! [`super::binder`], because the ticket toast **re-polls `GetGMTicket()` every 10 minutes** — a
//! deduplicating drain would silently stop the poll. `Stuck()` keeps its own counter, because it
//! is not a ticket verb at all (see its registration below) and shares no ordering with them.
//!
//! **The category list is DBC data, pushed once.** `GetGMTicketCategories()` returns a flat
//! `id1, name1, id2, name2, …` vararg list, which `HelpFrameGM_UpdateCategories(...)` walks with
//! Lua 5.0's `arg`/`arg.n`. The ids are `GMTicketCategory.dbc`'s own and they are the values that
//! go on the wire, so this is an ordered list of pairs rather than an indexable table — see
//! [`benilla_formats::gm_ticket_category`] for why renumbering would misfile every ticket.
//!
//! **There is no `GetGMTicket` *getter*.** The reference's `GetGMTicket()` is a SEND, not a read:
//! the answer comes back as the `UPDATE_TICKET` event's arguments, and the window keeps whatever
//! it needs in its own frame fields. So nothing here holds a ticket snapshot — the same shape as
//! [`super::duel`] and [`super::binder`], and for the same reason.
use mlua::{Lua, MultiValue, Value};
use super::Model;
/// One ticket verb the window called, in call order — the queue [`super::UiScript::take_gm_ticket_intents`]
/// drains. Ordered rather than counted so the wire sees what Lua did, in the order Lua did it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GmTicketIntent {
/// `GetGMTicket()` — `CMSG_GMTICKET_GETTICKET`.
Ask,
/// `GetGMStatus()` — `CMSG_GMTICKET_SYSTEMSTATUS`.
AskStatus,
/// `DeleteGMTicket()` — `CMSG_GMTICKET_DELETETICKET`.
Delete,
/// `NewGMTicket`/`UpdateGMTicket` — create or edit.
Write(GmTicketWrite),
}
/// One `NewGMTicket` / `UpdateGMTicket` call, drained by the app.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GmTicketWrite {
/// The `GMTicketCategory.dbc` id the window had selected — `HelpFrameOpenTicket.ticketType`,
/// which is the clicked category row's own id.
pub category: u32,
/// The text as typed. The window's EditBox caps it at 500 letters; the engine's own cap is
/// 1999, so nothing here truncates what the UI allowed.
pub text: String,
/// `true` for `NewGMTicket` (file), `false` for `UpdateGMTicket` (edit). Two different
/// opcodes with two different answers, so the distinction survives to the app rather than
/// being re-derived from whether a ticket is believed to exist — the reference picks the verb
/// from its own `HelpFrameOpenTicket.hasTicket` flag and we must not second-guess it.
pub is_new: bool,
}
impl super::UiScript {
/// Publish the ticket categories `GetGMTicketCategories()` returns — the `GMTicketCategory.dbc`
/// rows, in file order. Pushed once at load; the table is static client data.
pub fn set_gm_ticket_categories(&mut self, categories: Vec<(u32, String)>) {
self.model_mut().gm_ticket_categories = categories;
}
/// Drain every ticket verb the window called since the last drain, **in call order** — the
/// module doc's reason. Each entry is exactly one packet.
pub fn take_gm_ticket_intents(&mut self) -> Vec<GmTicketIntent> {
std::mem::take(&mut self.model_mut().gm_ticket_intents)
}
/// Drain the `Stuck()` calls — each is one cast of spell 7355.
pub fn take_stuck_casts(&mut self) -> u32 {
std::mem::take(&mut self.model_mut().stuck_casts)
}
}
/// Register the seven Help-window globals.
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let g = lua.globals();
// GetGMTicketCategories() — the flat (id, name) vararg list, consumed by
// `HelpFrameGM_UpdateCategories` as `arg[i]`/`arg[i+1]` pairs. Empty until the app has pushed
// the DBC (a bare-XML harness, or a run with no client data): the window then simply paints no
// category rows, which is the honest rendering of "we have no catalog" and cannot error.
g.set(
"GetGMTicketCategories",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let mut out = Vec::with_capacity(model.gm_ticket_categories.len() * 2);
for (id, name) in &model.gm_ticket_categories {
out.push(Value::Integer(i64::from(*id)));
out.push(Value::String(lua.create_string(name)?));
}
Ok(MultiValue::from_vec(out))
})?,
)?;
// The three payload-free verbs. Each pushes onto the ONE ordered queue, so a chunk that calls
// two of them sends two packets in the order it called them.
// GetGMTicket() — "send me my ticket"; a send, answered as UPDATE_TICKET.
// GetGMStatus() — "is the petition queue up?"; answered as UPDATE_GM_STATUS.
// DeleteGMTicket() — the HELP_TICKET_ABANDON_CONFIRM dialog's Yes.
for (name, intent) in [
("GetGMTicket", GmTicketIntent::Ask),
("GetGMStatus", GmTicketIntent::AskStatus),
("DeleteGMTicket", GmTicketIntent::Delete),
] {
g.set(
name,
lua.create_function(move |lua, ()| {
lua.app_data_mut::<Model>()
.expect("model app_data")
.gm_ticket_intents
.push(intent.clone());
Ok(())
})?,
)?;
}
// NewGMTicket(category, text) / UpdateGMTicket(category, text) — the window's Submit and its
// Save Changes. Same signature, different opcode; the reference's own usage string is
// `Usage: UpdateGMTicket(type, text)`, so (number, string) is the declared arity for both.
for (name, is_new) in [("NewGMTicket", true), ("UpdateGMTicket", false)] {
g.set(
name,
lua.create_function(move |lua, (category, text): (u32, String)| {
lua.app_data_mut::<Model>()
.expect("model app_data")
.gm_ticket_intents
.push(GmTicketIntent::Write(GmTicketWrite {
category,
text,
is_new,
}));
Ok(())
})?,
)?;
}
// Stuck() — the Help window's "Auto-Unstuck". NOT a ticket verb despite living only in this
// window: it casts spell 7355 "Stuck", whose SPELL_EFFECT_STUCK teleports the player to their
// last safe position server-side. It is registered here because HelpFrame is its only caller
// in the whole shipped UI, and a module for one binding would be worse than this note.
g.set(
"Stuck",
lua.create_function(|lua, ()| {
lua.app_data_mut::<Model>()
.expect("model app_data")
.stuck_casts += 1;
Ok(())
})?,
)?;
Ok(())
}
+3
View File
@@ -71,6 +71,7 @@ mod extract;
mod follow;
pub(crate) mod font;
mod font_block;
mod gm_ticket;
mod gossip;
mod guild;
mod handler_prof;
@@ -168,6 +169,7 @@ pub use death::{DeathAction, DeathUiState};
pub use dressup::DressUpIntent;
pub use duel::DuelRequest;
pub use follow::FollowRequest;
pub use gm_ticket::{GmTicketIntent, GmTicketWrite};
pub use gossip::{GossipMenu, GossipOptionView, GossipQuestRow};
pub use guild::{
GuildMemberInfo, GuildRankEdit, GuildRankInfo, GuildRequest, GuildState, LastOnline, UnitGuild,
@@ -543,6 +545,7 @@ impl UiScript {
social::install(&lua)?;
guild::install(&lua)?;
binder::install(&lua)?;
gm_ticket::install(&lua)?;
duel::install(&lua)?;
follow::install(&lua)?;
session::install(&lua)?;
+16
View File
@@ -1321,6 +1321,19 @@ pub(crate) struct Model {
/// `ConfirmBinder()` calls queued since the app last drained them — each is one
/// `CMSG_BINDER_ACTIVATE`. A COUNT for [`Self::played_time_asks`]'s reason: the intent carries
/// no payload (the app holds the innkeeper's guid), so two calls are two sends.
/// The `GMTicketCategory.dbc` rows behind `GetGMTicketCategories()` — `(id, name)` in file
/// order, pushed once by the app ([`super::UiScript::set_gm_ticket_categories`]). The ids are
/// the wire values, so this is an ordered pair list rather than an indexable table
/// (decision 1673).
pub(crate) gm_ticket_categories: Vec<(u32, String)>,
/// Every ticket verb the window called since the app's last drain, **in call order** — ask,
/// status-ask, delete, create, edit, one packet each. ONE queue rather than a counter per verb
/// so that `DeleteGMTicket(); GetGMTicket()` in a single chunk reaches the wire in that order;
/// per-verb drains cannot express it, and the get would answer with the pre-delete state
/// (decision 1673).
pub(crate) gm_ticket_intents: Vec<super::gm_ticket::GmTicketIntent>,
/// `Stuck()` calls queued — each is one cast of spell 7355, the Help window's Auto-Unstuck.
pub(crate) stuck_casts: u32,
pub(crate) binder_confirms: u32,
/// Is an innkeeper's bind question still live and in range — the answer `CheckBinderDist()`
/// gives, pushed by the app each frame ([`super::UiScript::set_binder_pending`]). The
@@ -1658,6 +1671,9 @@ impl Model {
screenshot_asks: 0,
realm_name: String::new(),
bind_location: String::new(),
gm_ticket_categories: Vec::new(),
gm_ticket_intents: Vec::new(),
stuck_casts: 0,
binder_confirms: 0,
binder_pending: false,
framerate: 0.0,
@@ -0,0 +1,163 @@
//! The Help window's seven engine globals ([`crate::script::gm_ticket`], decision 1673).
//!
//! Two claims carry the whole feature and each has a test named after it: the category list is
//! **flat pairs consumed by Lua 5.0 varargs** (that is what `HelpFrameGM_UpdateCategories` reads),
//! and the four payload-free verbs **count** rather than latch (that is what keeps the window's
//! 10-minute ticket poll alive).
use super::common::script;
use crate::script::{GmTicketIntent, GmTicketWrite, UiScript};
/// The catalog as the app pushes it: `GMTicketCategory.dbc`'s own ids and order.
fn with_categories() -> UiScript {
let mut s = script();
s.set_gm_ticket_categories(vec![
(1, "Stuck".into()),
(2, "Behavior/Harassment".into()),
(3, "Guild".into()),
]);
s
}
/// **`GetGMTicketCategories()` returns a FLAT (id, name) vararg list**, which the shipped window
/// walks as `arg[i]` / `arg[i+1]` pairs with `arg.n` — the Lua 5.0 convention. A table here, or a
/// list of names alone, would leave every category button unlabelled or unclickable.
#[test]
fn the_categories_come_back_as_a_flat_id_name_vararg_list() {
let s = with_categories();
let n: i64 = s
.eval("local f = function(...) return arg.n end return f(GetGMTicketCategories())")
.unwrap();
assert_eq!(n, 6, "three categories = six varargs");
let (id1, name1, id3, name3): (i64, String, i64, String) = s
.eval(
"local f = function(...) return arg[1], arg[2], arg[5], arg[6] end \
return f(GetGMTicketCategories())",
)
.unwrap();
assert_eq!((id1, name1.as_str()), (1, "Stuck"));
// The THIRD pair is at 5/6 — pairs, not a parallel pair of lists.
assert_eq!((id3, name3.as_str()), (3, "Guild"));
}
/// **The ids are the DBC's, not indices.** `HelpFrameGM_UpdateCategories` stores `arg[index]` as
/// both `button.key` (an index into `HELPFRAME_FRAMES`) and `button.ticketType` (what goes on the
/// wire), so a renumbered list would misfile every ticket under the wrong heading.
#[test]
fn the_ids_are_the_dbc_ids_not_list_positions() {
let mut s = script();
// A deliberately gappy, unsorted catalog — nothing may re-index it.
s.set_gm_ticket_categories(vec![(10, "Character".into()), (4, "Item".into())]);
let (a, b): (i64, i64) = s
.eval("local f = function(...) return arg[1], arg[3] end return f(GetGMTicketCategories())")
.unwrap();
assert_eq!((a, b), (10, 4), "ids and order pass through untouched");
}
/// No catalog is not an error: the window paints no category rows and cannot raise. This is the
/// bare-XML harness, and a run with no client data.
#[test]
fn an_absent_catalog_returns_nothing_rather_than_erroring() {
let s = script();
let n: i64 = s
.eval("local f = function(...) return arg.n end return f(GetGMTicketCategories())")
.unwrap();
assert_eq!(n, 0);
}
/// **The verbs queue, they do not latch.** Two `GetGMTicket()` calls are two packets, because the
/// ticket toast re-polls every 10 minutes from `TicketStatus_OnUpdate` — a latch would collapse the
/// second poll into the first and the window would look hung.
#[test]
fn the_payload_free_verbs_queue_rather_than_latch() {
let mut s = script();
s.run("GetGMTicket() GetGMTicket() GetGMStatus() DeleteGMTicket() Stuck() Stuck() Stuck()")
.unwrap();
assert_eq!(
s.take_gm_ticket_intents(),
vec![
GmTicketIntent::Ask,
GmTicketIntent::Ask,
GmTicketIntent::AskStatus,
GmTicketIntent::Delete,
]
);
assert_eq!(s.take_stuck_casts(), 3, "Stuck is not a ticket verb");
// Drained means drained — the next frame must not re-send.
assert!(s.take_gm_ticket_intents().is_empty());
assert_eq!(s.take_stuck_casts(), 0);
}
/// **Call order is wire order.** A chunk that abandons and then re-asks must reach the server in
/// that order — reversed, the get answers with the state *before* the delete and the window is
/// told it still has the ticket it just abandoned. Per-verb drains cannot express this, which is
/// why there is one queue (decision 1673).
#[test]
fn the_queue_preserves_call_order_across_different_verbs() {
let mut s = script();
s.run("DeleteGMTicket() GetGMTicket()").unwrap();
assert_eq!(
s.take_gm_ticket_intents(),
vec![GmTicketIntent::Delete, GmTicketIntent::Ask],
"delete first, exactly as Lua called them"
);
// And the other way round, so the test cannot pass on a fixed per-verb ordering that merely
// happens to match one of the two cases.
s.run("GetGMTicket() DeleteGMTicket()").unwrap();
assert_eq!(
s.take_gm_ticket_intents(),
vec![GmTicketIntent::Ask, GmTicketIntent::Delete]
);
// Writes interleave with the bare verbs on the same queue.
s.run("NewGMTicket(1, \"a\") GetGMTicket()").unwrap();
assert!(matches!(
s.take_gm_ticket_intents().as_slice(),
[GmTicketIntent::Write(_), GmTicketIntent::Ask]
));
}
/// **The window's verb survives to the app.** `NewGMTicket` and `UpdateGMTicket` share a signature
/// but not an opcode, and the shipped window picks between them from its own `hasTicket` flag —
/// so the choice is carried, never re-derived from what the client believes.
#[test]
fn new_and_update_are_distinguishable_and_keep_their_order() {
let mut s = script();
s.run("NewGMTicket(4, \"My sword vanished.\") UpdateGMTicket(4, \"Still gone.\")")
.unwrap();
assert_eq!(
s.take_gm_ticket_intents(),
vec![
GmTicketIntent::Write(GmTicketWrite {
category: 4,
text: "My sword vanished.".into(),
is_new: true,
}),
GmTicketIntent::Write(GmTicketWrite {
category: 4,
text: "Still gone.".into(),
is_new: false,
}),
]
);
}
/// The Era arity is `(number, string)` — the reference's own usage string is
/// `Usage: UpdateGMTicket(type, text)`. An empty ticket is the *window's* refusal to make, not
/// ours: the binding queues whatever it is handed, and the engine's own emptiness check sits
/// upstream of the send.
#[test]
fn an_empty_ticket_body_is_queued_not_swallowed() {
let mut s = script();
s.run("NewGMTicket(1, \"\")").unwrap();
assert_eq!(
s.take_gm_ticket_intents(),
vec![GmTicketIntent::Write(GmTicketWrite {
category: 1,
text: String::new(),
is_new: true,
})]
);
}
@@ -16,6 +16,7 @@ mod events;
mod font_object;
mod frame_api;
mod generic_for;
mod gm_ticket;
mod handler_prof;
mod input;
mod keyboard;