Comments in benilla-ui: one line of fact each, no history, no record numbers (#143)
Part of #41: the comments of `benilla-ui` (the Lua UI engine, its FrameXML loader and its widgets) are rewritten to the rule in `docs/METHOD.md`, which is what the code does and the reference fact behind it, in a line. 43,140 comment lines become 15,161. - **The mechanical strip.** The citations that were only a private record number go first, and each edit is shown to be a pure removal. - **Wrong facts corrected.** The rewrite corrects the comments that stated wrong facts. Among them: - `0x64a480` is the case-sensitive compare (a byte `repz cmpsb`), and `0x64a4c0` the case-insensitive one. - A frame's id sits at `+0xb0`, not `+0xb4`. `EnableKeyboard`/`IsKeyboardEnabled` are `0x776ec0`/`0x776f90`. - A region's unpinned edge has no fallback to its owner's rect: the owner supplies only the scale. - `$parent` expands to the nearest named ancestor, or "Top" when none is named (`0x76c5b0`), not simply to the parent. - A StatusBar fills from its internal normalised fraction, not from `GetValue()`. - The addresses of `StartDuel` and `StartDuelUnit` were swapped. - Many "untraced" notes now give the traced reference fact: the drag threshold, the per-grip sizing edges, the empty-`autoFocus` skip, and `SetTexCoordModifiesRect`'s default. - Stock FrameXML line citations are checked against the stock 1.12 files and corrected. `ref-` file names become the stock file names. - **Deviation labels.** "Deviation:" now marks only a choice with a reason. A surface that exists only for the retired Era addon target says so plainly: it is not 1.12. - **Verification.** Every file strips to identical code, and `cargo fmt --check` is clean. Nine second readers compared every file old against new, restored the facts the rewrite had dropped, and checked each new citation against the code, the stock files, vmangos and the reference. Behaviour differences the rewrite surfaced are filed as their own issues (#131–#142), and more are being validated. The comments here state what the code does. String literals are untouched; they are #92. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
8505187668
commit
fd386e75ae
@@ -1,97 +1,24 @@
|
||||
//! The trainer bindings (decision 0237) — the Era-shaped class/profession trainer surface driving a
|
||||
//! faithful port of the real 1.12 `ClassTrainerFrame` (extracted from `interface.MPQ`:
|
||||
//! `Interface\FrameXML\ClassTrainerFrame.{xml,lua}`). Same two-way seam as [`super::merchant`]: the
|
||||
//! app pushes a **trainer snapshot** ([`UiScript::set_trainer`] — the wire services already resolved
|
||||
//! to name/icon/cost/state/requirements by the app), and the Lua `BuyTrainerService`/`CloseTrainer`
|
||||
//! calls queue outbound **intents** the app drains ([`UiScript::take_trainer_buys`] returns the
|
||||
//! chosen services' **spell ids** / [`UiScript::take_trainer_close`]). The engine holds no trainer
|
||||
//! knowledge — a service is name/subtext/icon/cost/state/gates/description, all app-resolved.
|
||||
//!
|
||||
//! ## The Era API shape (matched to the real `ClassTrainerFrame.lua`)
|
||||
//!
|
||||
//! The reference window's `.lua` is transcribed onto these bindings, so the return *shapes* match it
|
||||
//! verbatim: `GetTrainerServiceInfo → name, subText, serviceType, isExpanded`;
|
||||
//! `GetTrainerServiceCost → money, talentPointCost, professionPointCost`;
|
||||
//! `GetTrainerServiceSkillReq → skill, rank, hasReq`; `GetTrainerServiceAbilityReq → name, hasReq`;
|
||||
//! `IsTrainerServiceLearnSpell → isLearnSpell, isPetLearnSpell`. `serviceType` is either the string
|
||||
//! `"header"` (a skill-line group header, with `isExpanded` set) or the service's colour state
|
||||
//! `"available"`/`"unavailable"`/`"used"` (green/red/gray, `isExpanded` nil).
|
||||
//!
|
||||
//! ## The tree (decisions 0247 + 1124 — the byte-verified grouping/sort model, **per trainer type**)
|
||||
//!
|
||||
//! The 1.12 wire (`SMSG_TRAINER_LIST`) is a **flat** service list; the client builds a **collapsible
|
||||
//! tree** on top of it, and `index` is **1-based into that visible tree**, not the wire order.
|
||||
//! [`UiScript::set_trainer`] takes the flat services (each already carrying its app-resolved
|
||||
//! [`TrainerService::group_key`]/[`TrainerService::group_name`]) and synthesizes the tree: one
|
||||
//! **header row per distinct group key**, then that group's services in the group's own order. State
|
||||
//! is a per-row **colour**, never the grouping key.
|
||||
//!
|
||||
//! **The `trainerType` selects everything** — the list finalizer `0x4d8410` picks the row comparator
|
||||
//! by `ds:0xb73a08` (`dec eax; je` chain @ `0x4d8561`) and the builder `0x4d7560` picks both the
|
||||
//! group key and the header comparator by the same dword (`0x4d7786`, `0x4d79eb`). Neither the group
|
||||
//! key nor the sort is one law with a "later refinement" — they are four laws, and 1124 is where
|
||||
//! benilla stopped applying the class one to all of them:
|
||||
//!
|
||||
//! | type | rows | headers | group key |
|
||||
//! |---|---|---|---|
|
||||
//! | 0 class, 3 pet | `0x4d85c0` — level → skill value → name → rank | `0x4d7b90`, by name | taught spell's `SkillLine` |
|
||||
//! | 1 mount ("talent") | `0x4d8850` — **state byte → name** | `0x4d7b90`, `-1` first then by name | `-1` when the service is `used`, else the `SkillLine` |
|
||||
//! | 2 tradeskill | `0x4d8760` — **skill value → name**, no level key, no rank key | `0x4d7c30`, by **raw key asc** | **1 or 2**, see below |
|
||||
//!
|
||||
//! At type 2 the group key is **not a skill line and never resolves one**: the builder defaults it
|
||||
//! to 2 and sets it to 1 iff the **wire** spell's own `Spell.dbc Effect[0..2]` contains `44`
|
||||
//! `SKILL_STEP` (`0x4d77b6`), which makes exactly two groups — `TRADESKILL_SERVICE_STEP` ("Development
|
||||
//! Skills", the profession-learn services) and `TRADESKILL_SERVICE_LEARN` ("Recipes") — and means
|
||||
//! **no row is ever dropped at a tradeskill trainer**. That partition, not a level key, is what puts
|
||||
//! "Apprentice Blacksmith" at the top of a blacksmithing trainer: `reqLevel` is inert at type 2.
|
||||
//! At the other types a service whose group key is `0` (unresolved skill line) is still dropped,
|
||||
//! matching the client.
|
||||
//!
|
||||
//! A service hides when its state fails the dropdown filter ([`Model::trainer_filter`]) or its group
|
||||
//! is collapsed ([`Model::trainer_collapsed`]) — but **hiding is not removing**: the client parks a
|
||||
//! hidden record in a tail behind the visible ones and keeps its index, so `GetNumTrainerServices`
|
||||
//! answers with the *visible* count while every getter's index space is the *whole* array ([`rows`]).
|
||||
//! An index past the count is a legal hidden row, and the stock window is handed exactly one after a
|
||||
//! purchase ([`selected_row`]). `Collapse/ExpandTrainerSkillLine(id)` take the **display index of a
|
||||
//! header row** (`id == 0` = all groups — the collapse-all button); they toggle that group's
|
||||
//! visibility, never reorder.
|
||||
//!
|
||||
//! The **selection** is likewise the selected service's **spell id** (`ds:0xb73a0c`), re-resolved to
|
||||
//! a position on every `GetTrainerSelectionIndex` — never a stored row number ([`selected_row`]).
|
||||
//!
|
||||
//! ## Faithful stubs (no wire data yet — kept so the ported XML runs)
|
||||
//!
|
||||
//! `GetTrainerServiceStepReq` returns nil (no tradeskill-step data on the wire), and per-requirement
|
||||
//! `hasReq`/`met` flags are derived from the service's overall `category` (an unavailable service has
|
||||
//! an unmet gate) rather than a per-gate wire bit, which 5875 does not send.
|
||||
//!
|
||||
//! One thing the client does that this engine does not, named rather than left to be discovered: a
|
||||
//! group whose row counters go to zero **vanishes entirely, header included** (`0x4d8460`,
|
||||
//! `0x4d8528`–`0x4d8549`) — which is how a type-1 trainer whose every service is `used` shows one
|
||||
//! "My Talents" header and no skill-line headers at all. benilla always keeps a header. The case
|
||||
//! that is *reachable* here (does the state-filter dropdown emptying a group also take its header?)
|
||||
//! is an open question; the build-time case above cannot arise, because benilla builds
|
||||
//! a group only from services that exist.
|
||||
//! The trainer bindings the stock `Blizzard_TrainerUI` window calls, over the snapshot the app
|
||||
//! pushes ([`UiScript::set_trainer`]). `SMSG_TRAINER_LIST` is flat; the client builds a collapsible
|
||||
//! tree of header and service rows over it, every Lua index is 1-based into that tree, and the
|
||||
//! trainer type (`ds:0xb73a08`) picks the row comparator (`0x4d8561`), the group key and the
|
||||
//! header comparator (`0x4d7786`, `0x4d79eb`).
|
||||
|
||||
use mlua::{Lua, MultiValue, Value};
|
||||
|
||||
use super::Model;
|
||||
|
||||
/// The green/red/gray state of a service (the wire's `TrainerSpellState`, decision 0237), surfaced to
|
||||
/// Lua as the Era `category` string `GetTrainerServiceInfo` returns.
|
||||
/// A service's state (the wire's `TrainerSpellState`): green learnable, red gated, gray known.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum TrainerServiceCategory {
|
||||
/// Green — learnable now.
|
||||
#[default]
|
||||
Available,
|
||||
/// Red — a requirement (level / skill / prerequisite) is unmet.
|
||||
Unavailable,
|
||||
/// Gray — already known.
|
||||
Used,
|
||||
}
|
||||
|
||||
impl TrainerServiceCategory {
|
||||
/// The Era `category` string (`ClassTrainerFrame`'s colour switch reads exactly these).
|
||||
/// `GetTrainerServiceInfo`'s type string, which the stock window colours each row by.
|
||||
fn era_str(self) -> &'static str {
|
||||
match self {
|
||||
TrainerServiceCategory::Available => "available",
|
||||
@@ -109,10 +36,8 @@ impl TrainerServiceCategory {
|
||||
}
|
||||
}
|
||||
|
||||
/// The row's **state byte** `[+0x30]` — the value `0x4d8ba0` maps to the colour string (`0` →
|
||||
/// available, `2` → used, anything else → unavailable). It is a colour on every trainer type but
|
||||
/// one: at type 1 it is also the third sort key ([`talent_order`]), which is why this exists
|
||||
/// separately from the filter slot it numerically coincides with.
|
||||
/// The record's state byte `[+0x30]`, which `0x4d8ba0` maps to the type string; at type 1 it is
|
||||
/// also a sort key ([`talent_order`]), so it stays apart from the equal filter slot.
|
||||
fn state_key(self) -> u8 {
|
||||
match self {
|
||||
TrainerServiceCategory::Available => 0,
|
||||
@@ -121,7 +46,6 @@ impl TrainerServiceCategory {
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse the Era filter-type string the sort checkboxes pass.
|
||||
fn from_filter_str(s: &str) -> Option<Self> {
|
||||
match s {
|
||||
"available" => Some(TrainerServiceCategory::Available),
|
||||
@@ -132,8 +56,8 @@ impl TrainerServiceCategory {
|
||||
}
|
||||
}
|
||||
|
||||
/// A skill-line requirement on a service (`GetTrainerServiceSkillReq`): the skill's display name, the
|
||||
/// rank required, and whether the player meets it (`hasReq`, derived from the service `category`).
|
||||
/// A service's skill requirement (`GetTrainerServiceSkillReq`). The wire has no per-gate bit, so
|
||||
/// `met` comes from the service's `category`.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct TrainerSkillReq {
|
||||
pub name: String,
|
||||
@@ -141,38 +65,27 @@ pub struct TrainerSkillReq {
|
||||
pub met: bool,
|
||||
}
|
||||
|
||||
/// A prerequisite-ability requirement (`GetTrainerServiceAbilityReq`): the ability's display name and
|
||||
/// whether the player has it (`hasReq`).
|
||||
/// A prerequisite ability (`GetTrainerServiceAbilityReq`); `met` is whether the player knows it.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct TrainerAbilityReq {
|
||||
pub name: String,
|
||||
pub met: bool,
|
||||
}
|
||||
|
||||
/// What `GameTooltip:SetTrainerService` describes for one row — the pre-resolved output of the
|
||||
/// **app-side** tooltip law (`ui_trainer::service_tooltip`, transcribing `SetTrainerService
|
||||
/// 0x5338b0`). The engine holds no DBC, so the app decides the subject and the engine only picks a
|
||||
/// renderer; the binding in the real client does exactly that too — it emits no line of its own and
|
||||
/// is a three-way selector into the shared spell builder `0x52e610` or item builder `0x52b650`.
|
||||
///
|
||||
/// This is deliberately **not** derivable from [`TrainerService::texture`]: the tooltip and the icon
|
||||
/// disagree, by design and in both directions (`0x4d8f50` gates on trainer-type, `0x5338b0` does
|
||||
/// not — the icon needs a trainer-type gate the tooltip does not have, and the icon pins the *wire*
|
||||
/// wrapper where the tooltip hops to the *taught* spell). On ~806 of the shipped corpus's trainer
|
||||
/// services the reference client visibly shows one spell's icon above another spell's tooltip.
|
||||
/// What `GameTooltip:SetTrainerService` (`0x5338b0`) describes for a row, resolved by the app; the
|
||||
/// reference only picks the spell builder `0x52e610` or the item builder `0x52b650`. Not derivable
|
||||
/// from [`TrainerService::texture`]: the icon (`0x4d8f50`) gates on the trainer type and shows the
|
||||
/// wire spell, while the tooltip describes the taught one.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum TrainerTooltip {
|
||||
/// The ITEM builder, on this item id — the taught spell carries `SPELL_ATTR_IS_TRADESKILL`.
|
||||
/// `0` (or a template still in flight) renders an EMPTY tooltip, which is the client's own
|
||||
/// behaviour: the builder early-outs on a cache miss and the query is enqueued.
|
||||
/// The item builder on this item id, for a taught spell with `SPELL_ATTR_IS_TRADESKILL`; 0 or
|
||||
/// an uncached template renders empty, as the reference's builder does on a cache miss.
|
||||
Item(u32),
|
||||
/// The SPELL builder, on the taught spell — or on the wire spell itself when no learn-wrapper
|
||||
/// slot resolved, the only path that describes the wrapper.
|
||||
/// The spell builder on the taught spell, or on the wire spell when no learn slot resolved.
|
||||
Spell {
|
||||
spell_id: u32,
|
||||
/// `param5` altCaster — ONE gate suppressing BOTH the totems and the reagents lines
|
||||
/// (byte-verified at `0x52ed43` and `0x52f393`). Set exactly when the matched wrapper slot
|
||||
/// was `SPELL_EFFECT_LEARN_PET_SPELL`.
|
||||
/// The builder's altCaster argument, which drops the totem and reagent lines (`0x52ed43`,
|
||||
/// `0x52f393`); set when the matched slot is `SPELL_EFFECT_LEARN_PET_SPELL`.
|
||||
alt_caster: bool,
|
||||
},
|
||||
}
|
||||
@@ -186,88 +99,63 @@ impl Default for TrainerTooltip {
|
||||
}
|
||||
}
|
||||
|
||||
/// One trainer service row, resolved by the app from the wire `TrainerSpell` (decision 0237). Plain
|
||||
/// data — position in [`TrainerState::services`] is the *unsorted* wire order; the 1-based index the
|
||||
/// Lua uses is a position in the visible display tree ([`rows`], built from [`TrainerState::groups`]).
|
||||
/// One service, resolved by the app from the wire `TrainerSpell`; its place in
|
||||
/// [`TrainerState::services`] is wire order, not display order.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct TrainerService {
|
||||
/// The service spell id — what the app sends for `CMSG_TRAINER_BUY_SPELL` when this row is bought.
|
||||
/// The wire spell id, which `CMSG_TRAINER_BUY_SPELL` names.
|
||||
pub spell_id: u32,
|
||||
/// The spell name (`GetTrainerServiceInfo`'s first return); `None` while `Spell.dbc` is still
|
||||
/// loading (the API reports `nil`, the XML shows `UNKNOWN`).
|
||||
/// The wire spell's name; `None` (before `Spell.dbc` loads) answers nil, shown as `UNKNOWN`.
|
||||
pub name: Option<String>,
|
||||
/// The rank/subtext line (`Spell.dbc` NameSubtext — "Rank N"); `None`/empty shows no second line.
|
||||
/// The wire spell's rank text, such as "Rank 2".
|
||||
pub subtext: Option<String>,
|
||||
/// Icon texture path (`Interface\Icons\…`); `None` while the catalog answer is in flight.
|
||||
pub texture: Option<String>,
|
||||
/// The spell description shown in the detail pane (`GetTrainerServiceDescription`); `""` when the
|
||||
/// app has no description source yet.
|
||||
pub description: String,
|
||||
/// Money cost in copper (already reputation-discounted server-side).
|
||||
/// In copper, already reputation-discounted by the server.
|
||||
pub cost: u32,
|
||||
/// A primary-profession first rank — the real window's `cpCost2 > 0` (the profession-point cost
|
||||
/// that raises the "you can only have two professions" confirm dialog).
|
||||
/// A primary profession's first rank: the stock window's `cpCost2 > 0`, which asks to confirm.
|
||||
pub prof_first_rank: bool,
|
||||
/// The green/red/gray colour ([`TrainerServiceCategory`]).
|
||||
pub category: TrainerServiceCategory,
|
||||
/// Character level required (0 = none).
|
||||
pub level_req: u32,
|
||||
/// The skill-line requirement, or `None` when the service has no skill gate.
|
||||
pub skill_req: Option<TrainerSkillReq>,
|
||||
/// Prerequisite-ability requirements (resolved from the wire's req-spell ids); empty when none.
|
||||
pub ability_reqs: Vec<TrainerAbilityReq>,
|
||||
/// A tradeskill step (`IsTrainerServiceTradeSkill`) vs. a plain learn-spell.
|
||||
pub is_trade_skill: bool,
|
||||
/// The tree's grouping key, resolved app-side by the **trainer type's own** builder law
|
||||
/// (module doc, decision 1124): the taught spell's `SkillLine` at types 0/1/3, or the
|
||||
/// `SKILL_STEP` partition's `1`/`2` at type 2. `0` = unresolved: the service is **dropped** from
|
||||
/// the tree (the client's `skillLine == 0 → drop`) — reachable only at types 0/1/3.
|
||||
/// The tree group: the taught spell's `SkillLine` at types 0, 1 and 3, where 0 (unresolved)
|
||||
/// drops the service and a known service at type 1 takes `TRAINER_GROUP_KNOWN`; at type 2, 1
|
||||
/// when the wire spell has a `SKILL_STEP` (44) effect, else 2 (`0x4d77b6`), so a tradeskill
|
||||
/// trainer drops nothing.
|
||||
pub group_key: u32,
|
||||
/// The group's localized display name — the header row's text. `SkillLine.dbc`'s name at types
|
||||
/// 0/1/3; the `TRADESKILL_SERVICE_STEP`/`_LEARN` global strings at type 2.
|
||||
/// The header text: the skill line's name, `KNOWN_TALENTS_HEADER` for the known group, or at
|
||||
/// type 2 `TRADESKILL_SERVICE_STEP`/`_LEARN`.
|
||||
pub group_name: String,
|
||||
/// What the detail-icon hover describes ([`TrainerTooltip`]) — resolved app-side, because the
|
||||
/// law reads `Spell.dbc` fields the engine cannot see. Independent of [`Self::texture`].
|
||||
pub tooltip: TrainerTooltip,
|
||||
}
|
||||
|
||||
/// One group in the display tree (decisions 0247/1124): the header's key + name and the positions
|
||||
/// (into [`TrainerState::services`]) of the group's services, pre-sorted by the trainer type's
|
||||
/// within-group comparator. Synthesized by [`UiScript::set_trainer`] — the app pushes only the flat
|
||||
/// services.
|
||||
/// One header and its services, built by [`UiScript::set_trainer`].
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct TrainerGroup {
|
||||
/// The group key (also the collapse key).
|
||||
/// Also the collapse key.
|
||||
pub key: u32,
|
||||
/// The header's display name.
|
||||
pub name: String,
|
||||
/// The group's services, as positions into [`TrainerState::services`], in display order.
|
||||
/// Positions into [`TrainerState::services`], in display order.
|
||||
pub services: Vec<usize>,
|
||||
}
|
||||
|
||||
/// One open trainer window: the services, the greeting line, and the wire trainer type. Pushed whole
|
||||
/// by the app; `None` means no trainer is open (the window is closed).
|
||||
/// The open trainer, pushed whole by the app.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct TrainerState {
|
||||
pub services: Vec<TrainerService>,
|
||||
/// The trainer's greeting/title line (`SMSG_TRAINER_LIST`'s trailing string).
|
||||
/// `SMSG_TRAINER_LIST`'s trailing string.
|
||||
pub greeting: String,
|
||||
/// `SMSG_TRAINER_LIST`'s `trainerType` (`ds:0xb73a08`), verbatim — **the** switch: it selects the
|
||||
/// row comparator, the header comparator and the group-key law (module doc), and it is what the
|
||||
/// two whole-trainer predicates test (`IsTradeskillTrainer 0x4d8ea0`: `== 2`; `IsTalentTrainer
|
||||
/// 0x4d8ed0`: `== 1`). 0 class · 1 mount · 2 tradeskill · 3 pet.
|
||||
/// `SMSG_TRAINER_LIST`'s trainer type (`ds:0xb73a08`): 0 class, 1 mount, 2 tradeskill, 3 pet.
|
||||
pub trainer_type: u32,
|
||||
/// The sorted skill-line groups over `services` — **built by [`UiScript::set_trainer`]**, not
|
||||
/// pushed by the app (default-empty on a bare `TrainerState`). The display tree walks these.
|
||||
/// Built by [`UiScript::set_trainer`], not pushed by the app.
|
||||
pub groups: Vec<TrainerGroup>,
|
||||
}
|
||||
|
||||
impl super::UiScript {
|
||||
/// Push (or clear, with `None`) the open trainer's service snapshot. On a push, the engine
|
||||
/// **builds the skill-line tree** ([`build_groups`]) from the flat services and prunes the
|
||||
/// collapse set to the lines that still exist (so a collapse survives a content update — a buy
|
||||
/// re-lists — but a switch to a different trainer starts expanded). Clearing resets the selection
|
||||
/// and the collapse set (a closed window holds nothing).
|
||||
/// Push the open trainer, or clear it with `None`. A push builds the tree ([`build_groups`])
|
||||
/// and keeps only the collapsed groups that still exist, so a fold survives a content update.
|
||||
pub fn set_trainer(&mut self, state: Option<TrainerState>) {
|
||||
let mut model = self.model_mut();
|
||||
match state {
|
||||
@@ -285,37 +173,15 @@ impl super::UiScript {
|
||||
}
|
||||
}
|
||||
|
||||
/// **Reset what one `SMSG_TRAINER_LIST` arriving resets — the state filter, the collapse set,
|
||||
/// and the selection.**
|
||||
/// Reset what each `SMSG_TRAINER_LIST` resets in the reference (`0x4d7560`): the filter to mask
|
||||
/// 3, or 5 at a mount trainer (`0x4d75d9`), the collapse set and the selection. Call it per
|
||||
/// packet, never per snapshot: the repaint path `0x4d7d40` touches none of the three.
|
||||
///
|
||||
/// Byte-verified (decision 1128): the list builder writes the filter mask itself on every
|
||||
/// packet — `0x4d75d9 mov ds:0xb73a1c,3`
|
||||
/// (available|unavailable, "already known" OFF), or `5` (available|used) when `trainerType == 1`
|
||||
/// — alongside `ds:0xb73a20 = ds:0xb73a24 = 0xffffffff`, which is "no group collapsed". So the
|
||||
/// player's filter choice does NOT live in the engine across trainer visits in the reference: it
|
||||
/// lives in the saved variable `TRAINER_FILTER_*`, and the window's show handler pushes it back
|
||||
/// over this reset (decision 1128; `TrainerFrame.xml`'s `BenillaTrainerFrame_ApplyFilter`).
|
||||
///
|
||||
/// **The selection is the same edge and the same law.** `0x4d7560`'s tail selects record 0 —
|
||||
/// `0x4d7b40 xor ecx,ecx` → `0x4d7b42 call 0x4d74f0`, after the sort — and since row 0 is always
|
||||
/// a group header, whose record carries the id `0xffffffff` (`0x4d7b05`), the getter's scan
|
||||
/// finds the first header and answers **1**. Clearing is the same thing to every caller: the
|
||||
/// stock window only ever asks `GetTrainerSelectionIndex() > 1`, which 0 and 1 both fail, so
|
||||
/// both send it down `ClassTrainer_SelectFirstLearnableSkill`. Without this a re-opened trainer
|
||||
/// could inherit a selection from the last visit — the same spell id, still in the list, still
|
||||
/// resolving — which the reference cannot do.
|
||||
///
|
||||
/// The repaint path `0x4d7d40` touches **none** of the three (a `ds:0xb73a0c` census over the
|
||||
/// whole image returns four references, all in the setter/getter/teardown), which is why a
|
||||
/// purchase leaves the selection sitting on the service it just greyed out.
|
||||
///
|
||||
/// This is the **packet** edge, not the content edge — [`Self::set_trainer`] runs on every
|
||||
/// snapshot change (an item template landing, a name resolving), and the reference's repaint path
|
||||
/// `0x4d7d40` does not touch either mask. Reset there and a filter would evaporate mid-window.
|
||||
/// Deviation: the selection clears to 0, because the header the reference re-selects
|
||||
/// (`0x4d7b42`, reading 1) has no spell id; the stock window's `> 1` test treats 0 and 1 alike.
|
||||
pub fn reset_trainer_list_state(&mut self, trainer_type: u32) {
|
||||
let mut model = self.model_mut();
|
||||
// Mask 5 at a mount/"talent" trainer: available + already-known, which is what makes a
|
||||
// known mount visible under its "My Talents" header at all (decision 1124's group -1).
|
||||
// Mask 5 at a mount trainer shows available and known services, so the known group shows.
|
||||
model.trainer_filter = if trainer_type == TRAINER_TYPE_MOUNT {
|
||||
[true, false, true]
|
||||
} else {
|
||||
@@ -325,59 +191,47 @@ impl super::UiScript {
|
||||
model.trainer_selection = None;
|
||||
}
|
||||
|
||||
/// Drain the **spell ids** `BuyTrainerService` queued since the last call (the engine resolves each
|
||||
/// clicked filtered row to its service's spell id, so the app sends `CMSG_TRAINER_BUY_SPELL`
|
||||
/// without needing to know the filter/index mapping).
|
||||
/// Drain the spell ids `BuyTrainerService` queued, each already resolved from its row.
|
||||
pub fn take_trainer_buys(&mut self) -> Vec<u32> {
|
||||
std::mem::take(&mut self.model_mut().trainer_buys)
|
||||
}
|
||||
|
||||
/// Whether `CloseTrainer` was called since the last drain (and clear the flag). vanilla's
|
||||
/// client-side close sends no packet — the app just clears its local trainer state.
|
||||
/// Whether `CloseTrainer` was called since the last drain; the close sends no packet.
|
||||
pub fn take_trainer_close(&mut self) -> bool {
|
||||
std::mem::take(&mut self.model_mut().trainer_close)
|
||||
}
|
||||
}
|
||||
|
||||
/// One visible row of the display tree: a skill-line **header** (carrying its group index) or a
|
||||
/// **service** (carrying its position into [`TrainerState::services`]).
|
||||
/// One row: a header by group index, or a service by its position in [`TrainerState::services`].
|
||||
#[derive(Clone, Copy)]
|
||||
enum Row {
|
||||
Header(usize),
|
||||
Service(usize),
|
||||
}
|
||||
|
||||
/// The WoW enUS collator (`0x64a480`), approximated: case-insensitive alphabetical, with the raw
|
||||
/// bytes as a stable tie-break so equal-when-folded names keep a deterministic order.
|
||||
/// Folds case, raw bytes breaking ties. The reference's row comparators call the case-sensitive
|
||||
/// `0x64a480`, its header comparator the case-insensitive `0x64a4c0` (`0x4d7c12`).
|
||||
fn collate(a: &str, b: &str) -> std::cmp::Ordering {
|
||||
a.to_lowercase()
|
||||
.cmp(&b.to_lowercase())
|
||||
.then_with(|| a.cmp(b))
|
||||
}
|
||||
|
||||
/// `SMSG_TRAINER_LIST`'s tradeskill `trainerType` (module doc's table).
|
||||
const TRAINER_TYPE_TRADESKILL: u32 = 2;
|
||||
/// `SMSG_TRAINER_LIST`'s mount `trainerType` — the one the client's own vocabulary calls "talent"
|
||||
/// (`IsTalentTrainer 0x4d8ed0` tests it).
|
||||
/// The mount trainer type, which the client calls "talent".
|
||||
const TRAINER_TYPE_MOUNT: u32 = 1;
|
||||
|
||||
/// The already-known group a **type 1** trainer buckets its `used` services into — the client's
|
||||
/// signed `-1` group key (`0x4d77e8`), whose header takes the `KNOWN_TALENTS_HEADER` global string
|
||||
/// ("My Talents", 1.12.1 enUS). Modelled as `u32::MAX` because the key is otherwise a skill-line id;
|
||||
/// the header comparator `0x4d7b90` tests for it explicitly and sorts it **first**, ahead of the
|
||||
/// name ordering that ranks every other header.
|
||||
/// The group a type 1 trainer files its `used` services under: the client's signed -1 key
|
||||
/// (`0x4d77e8`), headed `KNOWN_TALENTS_HEADER` and sorted before every other header by `0x4d7b90`.
|
||||
pub const TRAINER_GROUP_KNOWN: u32 = u32::MAX;
|
||||
|
||||
/// A service's required-skill value (`[+0x1c]`, the wire's `reqSkillValue`) — `0` when the service
|
||||
/// carries no skill gate.
|
||||
/// The record's required-skill value `[+0x1c]`, 0 with no skill gate.
|
||||
fn skill_value(s: &TrainerService) -> u32 {
|
||||
s.skill_req.as_ref().map_or(0, |r| r.rank)
|
||||
}
|
||||
|
||||
/// The within-group comparator for a **class** (type 0) or **pet** (type 3) trainer — keys ④–⑦ of
|
||||
/// the verified `0x4d85c0`: required level (`[+0x14]` byte) → required-skill value (`[+0x1c]` u32) →
|
||||
/// localized name → localized rank/subtext, all ascending. Pet is not special-cased anywhere in the
|
||||
/// selection chain at `0x4d8561`; it lands here with class.
|
||||
/// Rows at a class or pet trainer (`0x4d85c0`; `0x4d8561` sends pet here too): required level,
|
||||
/// skill value, name, then rank, all ascending.
|
||||
fn class_order(a: &TrainerService, b: &TrainerService) -> std::cmp::Ordering {
|
||||
let name = |s: &TrainerService| s.name.clone().unwrap_or_default();
|
||||
let rank = |s: &TrainerService| s.subtext.clone().unwrap_or_default();
|
||||
@@ -388,13 +242,8 @@ fn class_order(a: &TrainerService, b: &TrainerService) -> std::cmp::Ordering {
|
||||
.then_with(|| collate(&rank(a), &rank(b)))
|
||||
}
|
||||
|
||||
/// The within-group comparator for a **tradeskill** trainer (type 2) — the whole of `0x4d8760`'s
|
||||
/// row half: required-skill value **ascending** (`0x4d87d9`/`0x4d8801`), then the localized name.
|
||||
///
|
||||
/// The two keys it pointedly does *not* have are the interesting ones, and both were in benilla's
|
||||
/// output until 1124: there is **no `[+0x14]` required-level key** (which is what used to sink the
|
||||
/// profession-learn row — the only row with a level — to the bottom of every profession trainer),
|
||||
/// and **no `[+0x204]` rank tie-break**.
|
||||
/// Rows at a tradeskill trainer (`0x4d8760`): required skill value ascending (`0x4d87d9`), then
|
||||
/// name. It has no level key and no rank key.
|
||||
fn tradeskill_order(a: &TrainerService, b: &TrainerService) -> std::cmp::Ordering {
|
||||
let name = |s: &TrainerService| s.name.clone().unwrap_or_default();
|
||||
skill_value(a)
|
||||
@@ -402,11 +251,8 @@ fn tradeskill_order(a: &TrainerService, b: &TrainerService) -> std::cmp::Orderin
|
||||
.then_with(|| collate(&name(a), &name(b)))
|
||||
}
|
||||
|
||||
/// The within-group comparator for a **mount** trainer (type 1, the client's "talent") — the whole
|
||||
/// of `0x4d8850`'s row half: the **state byte** `[+0x30]` ascending (`0x4d88f1`, so available →
|
||||
/// unavailable → used), then the localized name. The one comparator on which the green/red/gray is a
|
||||
/// sort key rather than only a colour; like the tradeskill one it has no level, skill-value or rank
|
||||
/// key.
|
||||
/// Rows at a mount trainer (`0x4d8850`): the state byte ascending (`0x4d88f1`: available,
|
||||
/// unavailable, used), then name.
|
||||
fn talent_order(a: &TrainerService, b: &TrainerService) -> std::cmp::Ordering {
|
||||
let name = |s: &TrainerService| s.name.clone().unwrap_or_default();
|
||||
a.category
|
||||
@@ -415,17 +261,9 @@ fn talent_order(a: &TrainerService, b: &TrainerService) -> std::cmp::Ordering {
|
||||
.then_with(|| collate(&name(a), &name(b)))
|
||||
}
|
||||
|
||||
/// Build the display tree from the flat services (decisions 0247/1124), by the trainer type's own
|
||||
/// three laws:
|
||||
///
|
||||
/// * **group** on the app-resolved [`TrainerService::group_key`], dropping the unresolved `0` (which
|
||||
/// a type-2 list never produces — its partition is total);
|
||||
/// * **order each group's services** with that type's row comparator;
|
||||
/// * **order the headers** by raw key ascending at type 2 (`0x4d7c30`: one key, no name, no
|
||||
/// tie-break), else by localized name with the key breaking a name tie (`0x4d7b90`).
|
||||
///
|
||||
/// The service positions index back into the unchanged `services` slice, so every getter still
|
||||
/// resolves a row to its full service data.
|
||||
/// Group the services by [`TrainerService::group_key`], dropping key 0, and sort each group's rows
|
||||
/// by the type's comparator; headers sort by raw key at type 2 (`0x4d7c30`), else by name
|
||||
/// (`0x4d7b90`).
|
||||
fn build_groups(services: &[TrainerService], trainer_type: u32) -> Vec<TrainerGroup> {
|
||||
let mut map: std::collections::HashMap<u32, TrainerGroup> = std::collections::HashMap::new();
|
||||
for (i, s) in services.iter().enumerate() {
|
||||
@@ -454,9 +292,8 @@ fn build_groups(services: &[TrainerService], trainer_type: u32) -> Vec<TrainerGr
|
||||
if trainer_type == TRAINER_TYPE_TRADESKILL {
|
||||
groups.sort_by_key(|g| g.key);
|
||||
} else {
|
||||
// `0x4d7b90`: the `-1` (already-known) group first, then localized name. Its second key —
|
||||
// `hdr[+0x24] == 0` first, between those two — is uncarved and unreachable here: benilla's
|
||||
// headers are one per distinct skill line, so nothing ties on the name that it would break.
|
||||
// `0x4d7b90`: the known group first, then name. Its middle key, `hdr[+0x24] == 0` first, is
|
||||
// untraced and not built.
|
||||
groups.sort_by(|a, b| {
|
||||
(a.key != TRAINER_GROUP_KNOWN)
|
||||
.cmp(&(b.key != TRAINER_GROUP_KNOWN))
|
||||
@@ -467,42 +304,12 @@ fn build_groups(services: &[TrainerService], trainer_type: u32) -> Vec<TrainerGr
|
||||
groups
|
||||
}
|
||||
|
||||
/// The client's whole row array, and how much of its front is **on screen** — the visible rows in
|
||||
/// display order (decisions 0247/1124), then every hidden one in a **tail** behind them.
|
||||
///
|
||||
/// **Hiding a row is not removing it**, and that is the shape of the real array rather than a
|
||||
/// convenience here. The finalizer `0x4d8410` writes only a per-record visible flag — `[+0x34] = 1`
|
||||
/// at `0x4d84c6`, `= 0` at `0x4d8546` — decrements the *visible* count `ds:0xb73a18` (`0x4d8549`),
|
||||
/// and then `qsort`s **all `ds:0xb73a10`** records (`0x4d857e`) with that flag as the comparator's
|
||||
/// PRIMARY key (`0x4d85ce` … `0x4d8744 setne al` / `0x4d874e lea eax,[eax+eax-1]` → ±1). A hidden
|
||||
/// row therefore keeps a record, a position and an index — it is simply parked behind the visible
|
||||
/// ones. `GetNumTrainerServices` (`0x4d8d90`) answers with the visible count `ds:0xb73a18`, while
|
||||
/// the index space every getter is bounds-checked against is the **total** `ds:0xb73a10`: the
|
||||
/// thirteen service getters all route through one shared accessor gate, `0x4d89b0`'s
|
||||
/// `cmp ecx, dword ptr [0xb73a10]`, and `GetTrainerServiceInfo` (`0x4d8dc0`) reaches the same gate
|
||||
/// through its four siblings (`0x4d8aa2`, `0x4d8b52`, `0x4d8ba2`, `0x4d8c32`). The visible count has
|
||||
/// **five references image-wide** — the finalizer seeding and decrementing it, the buy-ALL loop, and
|
||||
/// `GetNumTrainerServices` — and **no getter is among them**. So an index past
|
||||
/// `GetNumTrainerServices()` is a legal, resolvable, *hidden* row — which is exactly the index the
|
||||
/// stock window is handed after a purchase, and reading it as out-of-range is what used to strand
|
||||
/// the detail pane ([`selected_row`]). An all-boxes-off window is empty because the Lua stops
|
||||
/// iterating, not because the rows stopped existing.
|
||||
///
|
||||
/// The tail's own ORDER is the comparator's remaining keys in the client and tree order here. It is
|
||||
/// unobservable through the stock window — nothing iterates past `GetNumTrainerServices()`, and the
|
||||
/// single index that ever reaches in is the selection's, resolved by identity — so the difference
|
||||
/// costs nothing until something walks the tail.
|
||||
///
|
||||
/// **The filter and the collapse are deliberately asymmetric**, and that asymmetry is structural in
|
||||
/// the client rather than incidental. The finalizer builds a per-group flag `hdr[+0x1c]` by walking
|
||||
/// the group's per-state member counts through the **live** state mask `ds:0xb73a1c` (`0x4d8431`,
|
||||
/// `0x4d8447`), and the hide it drives at `0x4d8535` has **no header-row exemption** — so a group
|
||||
/// every one of whose rows the dropdown hides disappears entirely, header included, and unchecking
|
||||
/// every box empties the window (`GetNumTrainerServices() == 0`). The collapse test three
|
||||
/// instructions later (`0x4d8541`) *does* exempt headers (`0x4d853d`) and reads a different field
|
||||
/// (`hdr[+0x20]`), so a collapsed group keeps its header — which is what makes it re-expandable.
|
||||
/// benilla had both cases keeping the header, with a test asserting it; 1124 inverted the filter
|
||||
/// half.
|
||||
/// The client's whole row array and its visible count: visible rows in display order, then every
|
||||
/// hidden row in a tail. The finalizer `0x4d8410` only clears a hidden record's visible flag
|
||||
/// (`0x4d8546`) and sorts it behind (`0x4d857e`), and the getters bound on the total
|
||||
/// (`0x4d89b0`, `ds:0xb73a10`), so a hidden row keeps its index. A group the filter empties hides
|
||||
/// its header too (`0x4d8535`); a collapsed one keeps it (`0x4d853d`). The tail's order here is
|
||||
/// tree order, not the client's; the stock window reaches the tail only by the selection's index.
|
||||
fn rows(model: &Model) -> (Vec<Row>, usize) {
|
||||
let Some(t) = model.trainer.as_ref() else {
|
||||
return (Vec::new(), 0);
|
||||
@@ -516,7 +323,7 @@ fn rows(model: &Model) -> (Vec<Row>, usize) {
|
||||
.copied()
|
||||
.partition(|&si| model.trainer_filter[t.services[si].category.filter_slot()]);
|
||||
if passes.is_empty() {
|
||||
// The whole group is filtered away — header with it (1124's inverted half).
|
||||
// The filter hid every service, so the header goes too.
|
||||
tail.push(Row::Header(gi));
|
||||
} else {
|
||||
shown.push(Row::Header(gi));
|
||||
@@ -535,10 +342,8 @@ fn rows(model: &Model) -> (Vec<Row>, usize) {
|
||||
(shown, visible)
|
||||
}
|
||||
|
||||
/// The service at a 1-based visible index, or `None` when that row is a **header** (or OOB / no
|
||||
/// trainer) — so the getters/buy that read a service safely no-op on a header row. `pub(super)`:
|
||||
/// `GameTooltip:SetTrainerService` lives in the tooltip channel and must resolve its index through
|
||||
/// the same VISIBLE mapping, never a raw `services[]` position.
|
||||
/// The service at a 1-based row index; `None` for a header. `GameTooltip:SetTrainerService`
|
||||
/// resolves its index through this too, never through a raw `services` position.
|
||||
pub(super) fn service(model: &Model, index: usize) -> Option<&TrainerService> {
|
||||
let n = index.checked_sub(1)?;
|
||||
match rows(model).0.get(n)? {
|
||||
@@ -547,43 +352,16 @@ pub(super) fn service(model: &Model, index: usize) -> Option<&TrainerService> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `GetNumTrainerServices`' answer — the count of rows **on screen** (headers + unfiltered,
|
||||
/// uncollapsed services), which is the client's `ds:0xb73a18` and not the length of the row array
|
||||
/// the indices run over ([`rows`]).
|
||||
/// `GetNumTrainerServices` (`0x4d8d90`): the visible count `ds:0xb73a18`, not the array's length.
|
||||
fn num_services(model: &Model) -> usize {
|
||||
rows(model).1
|
||||
}
|
||||
|
||||
/// The selected service's 1-based position in the row array ([`rows`]) — which is **past
|
||||
/// `GetNumTrainerServices()` when that service is hidden**, and `None` only when nothing is selected
|
||||
/// or the service has left the trainer's list altogether.
|
||||
///
|
||||
/// **The selection is the service, not the row it happens to sit on.** `SelectTrainerService`
|
||||
/// (`0x4d8e60` → `0x4d74f0`) stores the record's **spell id** at `ds:0xb73a0c` — 0 when the index is
|
||||
/// out of range, never a clamp — and this getter (`0x4d8f10` → `0x4d7520`) finds it again by linear
|
||||
/// scan over the whole array, `cmp dword ptr [edi], esi` at `0x4d7543`, answering `-1` → Lua `0`
|
||||
/// only when the scan runs out. Three separate things renumber the rows under a stored number — the
|
||||
/// state filter, a collapse, a re-list — so an index stored on Monday is a different service on
|
||||
/// Tuesday, and one merely *clamped* to the row count is the worst answer available: plausible, in
|
||||
/// range, and wrong. benilla stored the number.
|
||||
///
|
||||
/// It is load-bearing far beyond bookkeeping, because the stock `Blizzard_TrainerUI.lua` repaints
|
||||
/// its detail pane through exactly **one** door. `ClassTrainerTrainButton_OnClick` clears
|
||||
/// `ClassTrainerFrame.showSkillDetails`, and `ClassTrainer_SetSelection` early-returns while that is
|
||||
/// nil — so on `TRAINER_UPDATE` only the `GetTrainerSelectionIndex() > 1` **else** branch,
|
||||
/// `ClassTrainer_SelectFirstLearnableSkill`, puts the flag back and rewrites the name, icon,
|
||||
/// `Requires:` line and cost. Answering with a *live, visible* row number for a service that has
|
||||
/// left the screen therefore takes the silent branch and leaves the pane describing the spell the
|
||||
/// player just learned, under a highlight sitting on the row that slid up into its place — the
|
||||
/// director's report.
|
||||
///
|
||||
/// The reference's own answer there is the hidden row's index, and its consequence is worth stating
|
||||
/// because it is what a player sees: `> 1` is true, so the window resets the scroll, calls
|
||||
/// `ClassTrainer_SetSelection` on a row nothing displays, and the update loop — which asks only
|
||||
/// whether the selection is *on screen* — finds it is not and hides the detail pane. **After you
|
||||
/// train a spell the reference blanks the pane and highlights nothing**; it does not advance to the
|
||||
/// next spell. Answering `0` instead would select the first learnable and repaint, which is the
|
||||
/// fresh-window behaviour, not the post-purchase one.
|
||||
/// `GetTrainerSelectionIndex`: the selected service's 1-based row, past the visible count while it
|
||||
/// is hidden; `None` when nothing is selected or it left the list. The selection is the record's
|
||||
/// spell id (`0x4d8e60` stores it at `ds:0xb73a0c`, `0x4d8f10` scans for it at `0x4d7543`), so no
|
||||
/// filter, collapse or update moves it to another service. After a purchase hides the service,
|
||||
/// the stock window blanks its detail pane and highlights nothing, as the reference does.
|
||||
fn selected_row(model: &Model) -> Option<usize> {
|
||||
let want = model.trainer_selection?;
|
||||
let t = model.trainer.as_ref()?;
|
||||
@@ -597,32 +375,10 @@ fn selected_row(model: &Model) -> Option<usize> {
|
||||
.checked_add(1)
|
||||
}
|
||||
|
||||
/// Collapse (`collapse = true`) or expand a skill line by the **display index of its header row**
|
||||
/// (decision 0247's `Collapse/ExpandTrainerSkillLine`). `id == 0` targets **all** groups (the
|
||||
/// collapse-all button); `id > 0` resolves the header at that index to its skill line. A non-header
|
||||
/// (or out-of-range) index is a no-op.
|
||||
///
|
||||
/// Like every other index in this surface it runs over the whole row array, so a header sitting in
|
||||
/// the hidden tail resolves too ([`rows`]). That is the shape of the accessor gate the service
|
||||
/// getters were carved to share (`0x4d89b0`, bounded by the total); these two bindings' own gate was
|
||||
/// **not** carved, and the difference is unobservable through the stock window, which only ever
|
||||
/// passes a visible header's index or `0`.
|
||||
/// Queue `TRAINER_UPDATE` — **the repaint the reference fires from the mask-commit thunk itself**,
|
||||
/// not from Lua (decision 2244).
|
||||
///
|
||||
/// `SetTrainerServiceTypeFilter`'s four legs all commit through `0x4d8c90`, whose whole body is
|
||||
/// `mov ds:0xb73a1c,ecx; call 0x4d8410; mov ecx,0x136; jmp 0x703e50` — write the mask, re-run the
|
||||
/// finalizer, **fire event `0x136` = `TRAINER_UPDATE`**. Its siblings `0x4d8cb0` (the skill-line
|
||||
/// mask) and `0x4d8cd0` (the expand mask, which is what Collapse/ExpandTrainerSkillLine commit)
|
||||
/// follow `0x4d8c90`'s same shape.
|
||||
///
|
||||
/// That matters because **nothing in the stock window repaints after a filter click**:
|
||||
/// `ClassTrainerFrameFilterDropDown_OnClick` sets the saved global, calls the filter verb, and then
|
||||
/// only does `ClassTrainerListScrollFrameScrollBar:SetValue(0)` — a no-op when the bar is already at
|
||||
/// zero. The list is repainted by `ClassTrainerFrame_OnEvent`'s `TRAINER_UPDATE` arm, and by nothing
|
||||
/// else. While our own `TrainerFrame.xml` ran (before 1957) its click handler repainted explicitly,
|
||||
/// which is why this only became visible when the window went stock: the checkboxes moved and the
|
||||
/// list underneath did not.
|
||||
/// Queue `TRAINER_UPDATE`, which the reference fires from its mask commits, not from Lua:
|
||||
/// `0x4d8c90` writes the filter mask, re-runs the finalizer and fires event `0x136`, and the
|
||||
/// skill-line and expand-mask commits (`0x4d8cb0`, `0x4d8cd0`, the collapse verbs') do the same.
|
||||
/// The stock window's filter click (`Blizzard_TrainerUI.lua:524`) does not repaint.
|
||||
fn queue_trainer_update(model: &mut Model) {
|
||||
model
|
||||
.pending_events
|
||||
@@ -661,7 +417,6 @@ fn set_collapsed(model: &mut Model, id: usize, collapse: bool) {
|
||||
}
|
||||
}
|
||||
|
||||
/// An optional string as a Lua value (`nil` when absent).
|
||||
fn opt_str(lua: &Lua, s: Option<&String>) -> mlua::Result<Value> {
|
||||
Ok(match s {
|
||||
Some(s) => Value::String(lua.create_string(s)?),
|
||||
@@ -669,7 +424,7 @@ fn opt_str(lua: &Lua, s: Option<&String>) -> mlua::Result<Value> {
|
||||
})
|
||||
}
|
||||
|
||||
/// A `bool` as the Era `1`/`nil` shape.
|
||||
/// A `bool` as the reference's `1` or nil.
|
||||
fn era_bool(b: bool) -> Value {
|
||||
if b {
|
||||
Value::Integer(1)
|
||||
@@ -682,7 +437,6 @@ fn era_bool(b: bool) -> Value {
|
||||
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
let g = lua.globals();
|
||||
|
||||
// → the number of services the open trainer offers under the current filter (0 when closed).
|
||||
g.set(
|
||||
"GetNumTrainerServices",
|
||||
lua.create_function(|lua, ()| {
|
||||
@@ -691,9 +445,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// GetTrainerServiceInfo(index) → name, subText, serviceType, isExpanded. `index` 1-based into the
|
||||
// visible tree; out of range → a single nil. A HEADER row returns (skillLineName, nil, "header",
|
||||
// isExpanded); a SERVICE row returns (name, subText, stateString, nil) (decision 0247).
|
||||
// GetTrainerServiceInfo(index) → name, subText, serviceType, isExpanded; one nil past the end.
|
||||
g.set(
|
||||
"GetTrainerServiceInfo",
|
||||
lua.create_function(|lua, index: usize| {
|
||||
@@ -732,13 +484,8 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// GetTrainerServiceSkillLine(index) → the skill line the service teaches into, by name — the
|
||||
// group the tree files it under (`TrainerService::group_name`: `SkillLine.dbc`'s name at
|
||||
// trainer types 0/1/3, 1124's builder law). The stock window's one caller is the
|
||||
// CONFIRM_PROFESSION dialog, which formats "learn <profession>?" with it
|
||||
// (Blizzard_TrainerUI.lua l.19-24). A header row or an out-of-range index answers nil. The
|
||||
// binding is registered (`0x4d9160`, 399 bytes) but its return law is not confirmed beyond
|
||||
// "delegates"; this is the call site's reading (1957).
|
||||
// GetTrainerServiceSkillLine(index): the group name, nil for a header. The reference's return
|
||||
// (`0x4d9160`) is untraced; the stock `CONFIRM_PROFESSION` dialog shows it as the profession.
|
||||
g.set(
|
||||
"GetTrainerServiceSkillLine",
|
||||
lua.create_function(|lua, index: usize| {
|
||||
@@ -759,7 +506,6 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// GetTrainerServiceIcon(index) → texture path (nil while in flight / OOB).
|
||||
g.set(
|
||||
"GetTrainerServiceIcon",
|
||||
lua.create_function(|lua, index: usize| {
|
||||
@@ -768,7 +514,6 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// GetTrainerServiceDescription(index) → the spell description ("" when the app has none / OOB).
|
||||
g.set(
|
||||
"GetTrainerServiceDescription",
|
||||
lua.create_function(|lua, index: usize| {
|
||||
@@ -780,8 +525,8 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// GetTrainerServiceCost(index) → money, talentPointCost, professionPointCost. Talent cost is
|
||||
// always 0 (no talent trainer); profession cost is 1 for a primary-profession first rank.
|
||||
// GetTrainerServiceCost(index) → money, talentPointCost, professionPointCost; the talent cost
|
||||
// is always 0, the profession cost 1 for a primary profession's first rank.
|
||||
g.set(
|
||||
"GetTrainerServiceCost",
|
||||
lua.create_function(|lua, index: usize| {
|
||||
@@ -797,7 +542,6 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// GetTrainerServiceLevelReq(index) → required character level (0 = none).
|
||||
g.set(
|
||||
"GetTrainerServiceLevelReq",
|
||||
lua.create_function(|lua, index: usize| {
|
||||
@@ -806,7 +550,6 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// GetTrainerServiceSkillReq(index) → skillName, skillRank, hasReq (nil when no skill gate / OOB).
|
||||
g.set(
|
||||
"GetTrainerServiceSkillReq",
|
||||
lua.create_function(|lua, index: usize| {
|
||||
@@ -822,7 +565,6 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// GetTrainerServiceNumAbilityReq(index) → how many prerequisite abilities the service lists.
|
||||
g.set(
|
||||
"GetTrainerServiceNumAbilityReq",
|
||||
lua.create_function(|lua, index: usize| {
|
||||
@@ -831,7 +573,6 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// GetTrainerServiceAbilityReq(index, i) → abilityName, hasReq (nil OOB).
|
||||
g.set(
|
||||
"GetTrainerServiceAbilityReq",
|
||||
lua.create_function(|lua, (index, i): (usize, usize)| {
|
||||
@@ -848,13 +589,12 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// GetTrainerServiceStepReq(index) → step, met — always nil (no tradeskill-step data on the wire).
|
||||
// GetTrainerServiceStepReq(index) → step, met: always nil, as the wire carries no step data.
|
||||
g.set(
|
||||
"GetTrainerServiceStepReq",
|
||||
lua.create_function(|_, _index: usize| Ok(Value::Nil))?,
|
||||
)?;
|
||||
|
||||
// IsTrainerServiceTradeSkill(index) → 1/nil (a tradeskill step vs. a learn-spell).
|
||||
g.set(
|
||||
"IsTrainerServiceTradeSkill",
|
||||
lua.create_function(|lua, index: usize| {
|
||||
@@ -865,8 +605,8 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// IsTrainerServiceLearnSpell(index) → isLearnSpell, isPetLearnSpell. Pet-learn is always nil
|
||||
// (benilla drives no pet trainer); a non-tradeskill service is a learn-spell.
|
||||
// IsTrainerServiceLearnSpell(index) → isLearnSpell, isPetLearnSpell: any service that is not a
|
||||
// tradeskill is a learn spell; isPetLearnSpell is not built and always answers nil.
|
||||
g.set(
|
||||
"IsTrainerServiceLearnSpell",
|
||||
lua.create_function(|lua, index: usize| {
|
||||
@@ -876,8 +616,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// IsTradeskillTrainer() → 1/nil for the whole trainer (drives the tradeskill-vs-class layout):
|
||||
// `0x4d8ea0` tests trainerType == 2.
|
||||
// IsTradeskillTrainer(): trainer type 2 (`0x4d8ea0`).
|
||||
g.set(
|
||||
"IsTradeskillTrainer",
|
||||
lua.create_function(|lua, ()| {
|
||||
@@ -888,9 +627,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// IsTalentTrainer() → 1/nil: `0x4d8ed0` tests trainerType == 1 — vmangos's MOUNT trainers, which
|
||||
// the client's own vocabulary calls "talent" (decision 1124). It used to return a hardcoded nil
|
||||
// on the belief that benilla drives no such trainer; the shipped world has 23 of them.
|
||||
// IsTalentTrainer(): trainer type 1 (`0x4d8ed0`), which vmangos gives its mount trainers.
|
||||
g.set(
|
||||
"IsTalentTrainer",
|
||||
lua.create_function(|lua, ()| {
|
||||
@@ -901,7 +638,6 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// GetTrainerGreetingText() → the trainer's greeting line ("" when no trainer is open).
|
||||
g.set(
|
||||
"GetTrainerGreetingText",
|
||||
lua.create_function(|lua, ()| {
|
||||
@@ -915,8 +651,6 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// GetTrainerServiceTypeFilter(type) → 1/nil (whether that category is shown). SetTrainerService-
|
||||
// TypeFilter(type, on) toggles it — the sort checkboxes' real client-side filtering.
|
||||
g.set(
|
||||
"GetTrainerServiceTypeFilter",
|
||||
lua.create_function(|lua, kind: String| {
|
||||
@@ -932,19 +666,18 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
lua.create_function(|lua, (kind, on): (String, Value)| {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
if let Some(c) = TrainerServiceCategory::from_filter_str(&kind) {
|
||||
// Era passes 1 / 0 (or true/nil); anything truthy-but-not-0 enables.
|
||||
// The stock window passes 1 or 0; nil, false and 0 disable.
|
||||
let enable = !matches!(on, Value::Nil | Value::Integer(0) | Value::Boolean(false));
|
||||
model.trainer_filter[c.filter_slot()] = enable;
|
||||
// …and the repaint, from here rather than from Lua ([`queue_trainer_update`]).
|
||||
// Unconditional, like the thunk: it fires whether or not the bit moved.
|
||||
// Fires even when the bit did not move, as the reference's thunk does.
|
||||
queue_trainer_update(&mut model);
|
||||
}
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// Collapse/ExpandTrainerSkillLine(id) — fold a skill line by the display index of its header row
|
||||
// (id 0 = all groups, the collapse-all button); a non-header index no-ops (decision 0247).
|
||||
// Collapse/ExpandTrainerSkillLine(id): fold the group headed by row `id`, or all at 0. `id`
|
||||
// spans the whole row array like the getters' gate (`0x4d89b0`); their own bound is untraced.
|
||||
g.set(
|
||||
"CollapseTrainerSkillLine",
|
||||
lua.create_function(|lua, id: usize| {
|
||||
@@ -964,14 +697,10 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// SelectTrainerService(index) — select the SERVICE at that row, by its spell id. An index out
|
||||
// of range clears the selection (`0x4d74f0`'s `cmp ecx, ds:0xb73a10` / `mov [0xb73a0c], 0` —
|
||||
// cleared, never clamped); [`selected_row`] is why the id and not the row number.
|
||||
//
|
||||
// A HEADER row clears here too, where the reference would store the header record's own id,
|
||||
// `0xffffffff` (`0x4d7b05`), and find the FIRST header again on the way back out — answering 1
|
||||
// rather than 0. The two are the same answer to the only question the stock window asks of this
|
||||
// number (`GetTrainerSelectionIndex() > 1`), and nothing else reads it.
|
||||
// SelectTrainerService(index): select the row's service by spell id; past the end clears it
|
||||
// (`0x4d74f0` stores 0, never a clamp). Deviation: a header clears it too, because it has no
|
||||
// spell id; the reference stores its `0xffffffff` (`0x4d7b05`) and reads back 1, which the
|
||||
// stock window's `> 1` test treats the same as 0.
|
||||
g.set(
|
||||
"SelectTrainerService",
|
||||
lua.create_function(|lua, index: usize| {
|
||||
@@ -981,9 +710,6 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// GetTrainerSelectionIndex() → the selected service's current 1-based row — **past
|
||||
// `GetNumTrainerServices()` while that service is hidden**, and 0 only when nothing is selected
|
||||
// or the service has left the list entirely ([`selected_row`] has the law and what rides on it).
|
||||
g.set(
|
||||
"GetTrainerSelectionIndex",
|
||||
lua.create_function(|lua, ()| {
|
||||
@@ -992,19 +718,10 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// BuyTrainerService(index) — queue that row's SPELL ID for purchase (the app sends
|
||||
// CMSG_TRAINER_BUY_SPELL). A header row, an out-of-range index, or an ALREADY-KNOWN row is
|
||||
// silently ignored: the single-row path `0x4d89d0` resolves through the same total-bounded
|
||||
// accessor gate as every getter, then refuses on the state byte — `0x4d89e1 cmp esi,ebx; je`
|
||||
// (no record) and `0x4d89e5 cmp byte ptr [esi+0x30], bl; jne` (state != 0, i.e. anything but
|
||||
// `available`), both jumping to the same do-nothing exit.
|
||||
//
|
||||
// Deliberately NOT built: the reference's buy-ALL convenience `0x4d8a70`, the one
|
||||
// visible-count-bounded loop in the whole surface (`0x4d8a71`/`0x4d8a88` read `ds:0xb73a18`).
|
||||
// The split `0x4da244 dec eax; 0x4da245 jns` is SIGNED, so it is every Lua index **<= 0** that
|
||||
// lands there — `BuyTrainerService(0)` buys the player's entire visible list in one call. Here
|
||||
// index 0 is a no-op instead. Nothing in the stock window passes a non-positive index, and a
|
||||
// verb that spends the whole purse on a typo is not one to reproduce on the strength of that.
|
||||
// BuyTrainerService(index): queue the row's spell id. `0x4d89d0` bounds through the getters'
|
||||
// gate and refuses any state byte but 0 (`0x4d89e5`), so only an available service queues.
|
||||
// Deviation: index 0 is a no-op, because one stray call would spend the purse; the reference
|
||||
// buys every visible service for any index at or below 0 (`0x4da244`, `0x4d8a70`).
|
||||
g.set(
|
||||
"BuyTrainerService",
|
||||
lua.create_function(|lua, index: usize| {
|
||||
@@ -1019,7 +736,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// CloseTrainer() — client-side close (no packet, vanilla): flag it so the app clears its state.
|
||||
// CloseTrainer(): sends no packet; the app clears its state on the flag.
|
||||
g.set(
|
||||
"CloseTrainer",
|
||||
lua.create_function(|lua, ()| {
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
//! The trainer tree's tests — the per-`trainerType` ordering laws (decisions 0247/1124) pinned
|
||||
//! against emulated runs of the real finalizer, plus the filter/collapse/intent
|
||||
//! surface. Split out of `mod.rs` when the four comparators pushed it past the file budget.
|
||||
//! Tests for the trainer tree: the per-type row orders, the filter, the collapse and the intents.
|
||||
|
||||
use super::*;
|
||||
use crate::script::UiScript;
|
||||
@@ -14,8 +12,6 @@ fn svc(
|
||||
) -> TrainerService {
|
||||
TrainerService {
|
||||
spell_id,
|
||||
// The fixture's default subject: the wire spell, no hop (the "no learn wrapper"
|
||||
// fallback); the tooltip tests set the arm they mean explicitly.
|
||||
tooltip: TrainerTooltip::Spell {
|
||||
spell_id,
|
||||
alt_caster: false,
|
||||
@@ -36,10 +32,8 @@ fn svc(
|
||||
}
|
||||
}
|
||||
|
||||
/// A two-line warrior trainer (type 0). Groups sort by name (Arms < Fury); within Arms, the level
|
||||
/// keys order Heroic Strike (level 1) before Cleave (level 20). The default-expanded tree is thus:
|
||||
/// `[H:Arms, Heroic Strike(avail), Cleave(used), H:Fury, Bloodrage(unavail)]` — a 5-row list whose
|
||||
/// service rows sit at indices 2, 3, 5.
|
||||
/// A class trainer whose tree is `[H:Arms, Heroic Strike, Cleave, H:Fury, Bloodrage]`, the three
|
||||
/// services available, used and unavailable.
|
||||
fn trainer() -> TrainerState {
|
||||
let mut hs = svc(
|
||||
78,
|
||||
@@ -66,7 +60,7 @@ fn trainer() -> TrainerState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Every visible row as `(name, serviceType)` — the shape the ported XML actually renders.
|
||||
/// Every visible row as `(name, serviceType)`.
|
||||
fn visible(s: &mut UiScript) -> Vec<(String, String)> {
|
||||
let n = s.eval::<i64>("return GetNumTrainerServices()").unwrap();
|
||||
(1..=n)
|
||||
@@ -88,10 +82,8 @@ fn tree_interleaves_headers_and_ordered_services() {
|
||||
.unwrap());
|
||||
|
||||
s.set_trainer(Some(trainer()));
|
||||
// 2 headers + 3 services = 5 visible rows.
|
||||
assert_eq!(s.eval::<i64>("return GetNumTrainerServices()").unwrap(), 5);
|
||||
|
||||
// Row 1 is the "Arms" header: name, nil subtext, "header", isExpanded=1.
|
||||
let (hn, hsub, ht, hexp) = s
|
||||
.eval::<(String, Option<String>, String, Option<i64>)>(
|
||||
"local n,s,t,e = GetTrainerServiceInfo(1) return n,s,t,e",
|
||||
@@ -100,7 +92,6 @@ fn tree_interleaves_headers_and_ordered_services() {
|
||||
assert_eq!((hn.as_str(), ht.as_str()), ("Arms", "header"));
|
||||
assert_eq!((hsub, hexp), (None, Some(1)));
|
||||
|
||||
// Rows 2..5: services (name/state) then the Fury header then its service — the sorted tree.
|
||||
let info = |s: &mut UiScript, i: i64| {
|
||||
s.eval::<(String, String)>(&format!(
|
||||
"local n,_,t = GetTrainerServiceInfo({i}) return n,t"
|
||||
@@ -120,7 +111,7 @@ fn tree_interleaves_headers_and_ordered_services() {
|
||||
fn service_getters_read_the_row_at_a_visible_index() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
let mut t = trainer();
|
||||
// Heroic Strike (services[0], the row at index 2) carries a full gate set.
|
||||
// Heroic Strike, row 2, gets a full gate set.
|
||||
t.services[0].skill_req = Some(TrainerSkillReq {
|
||||
name: "Blacksmithing".into(),
|
||||
rank: 100,
|
||||
@@ -132,7 +123,6 @@ fn service_getters_read_the_row_at_a_visible_index() {
|
||||
}];
|
||||
s.set_trainer(Some(t));
|
||||
|
||||
// Cost/level/desc/reqs all resolve at the SERVICE index 2 (not the header at 1).
|
||||
assert_eq!(
|
||||
s.eval::<(i64, i64, i64)>("return GetTrainerServiceCost(2)")
|
||||
.unwrap(),
|
||||
@@ -161,7 +151,7 @@ fn service_getters_read_the_row_at_a_visible_index() {
|
||||
.eval::<bool>("local l,p = IsTrainerServiceLearnSpell(2) return l==1 and p==nil")
|
||||
.unwrap());
|
||||
|
||||
// A HEADER row (index 1) has no service data — the getters no-op to defaults.
|
||||
// Row 1 is a header, so the getters answer their defaults.
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return GetTrainerServiceLevelReq(1)")
|
||||
.unwrap(),
|
||||
@@ -175,17 +165,13 @@ fn service_getters_read_the_row_at_a_visible_index() {
|
||||
.unwrap());
|
||||
}
|
||||
|
||||
/// The state filter takes a group's **header with it** once it hides the group's last service — the
|
||||
/// finalizer's `[+0x1c]` hide has no header-row exemption (`0x4d8528`/`0x4d8535`, decision 1124).
|
||||
/// This asserted the opposite until 1124: benilla rendered bare headers over empty groups, and an
|
||||
/// all-boxes-off filter left a window full of headings and nothing else.
|
||||
/// The reference's filter hide exempts no header (`0x4d8528`, `0x4d8535`).
|
||||
#[test]
|
||||
fn state_filter_takes_a_groups_header_with_its_last_service() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_trainer(Some(trainer()));
|
||||
assert_eq!(s.eval::<i64>("return GetNumTrainerServices()").unwrap(), 5);
|
||||
|
||||
// Hide "used": Cleave drops (5 → 4), and BOTH headers stay — Arms still has Heroic Strike.
|
||||
s.run("SetTrainerServiceTypeFilter('used', 0)").unwrap();
|
||||
assert_eq!(
|
||||
visible(&mut s)
|
||||
@@ -195,8 +181,6 @@ fn state_filter_takes_a_groups_header_with_its_last_service() {
|
||||
["header", "available", "header", "unavailable"]
|
||||
);
|
||||
|
||||
// Now hide "available" too: Arms loses its last row, so the ARMS HEADER GOES WITH IT — only
|
||||
// Fury's header and its unavailable service remain.
|
||||
s.run("SetTrainerServiceTypeFilter('available', 0)")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
@@ -207,17 +191,12 @@ fn state_filter_takes_a_groups_header_with_its_last_service() {
|
||||
]
|
||||
);
|
||||
|
||||
// Every box off renders NOTHING — not a list of bare headings.
|
||||
s.run("SetTrainerServiceTypeFilter('unavailable', 0)")
|
||||
.unwrap();
|
||||
assert_eq!(s.eval::<i64>("return GetNumTrainerServices()").unwrap(), 0);
|
||||
|
||||
// …but the *rows* are still there behind it, and the getters still serve them (2231). This line
|
||||
// asserted `GetTrainerServiceInfo(1) == nil` until the accessor gate was confirmed: the single
|
||||
// one every service getter shares, `0x4d89b0`, bounds against the TOTAL `ds:0xb73a10`, and the
|
||||
// visible count `ds:0xb73a18` has exactly five references image-wide — the finalizer seeding and
|
||||
// decrementing it, the buy-ALL loop, and `GetNumTrainerServices`. No getter reads it. An empty
|
||||
// window is empty because the Lua stops iterating, not because the rows stopped existing.
|
||||
// The rows remain: the getters bound on the total `ds:0xb73a10` (`0x4d89b0`), not the visible
|
||||
// count `ds:0xb73a18`.
|
||||
assert_eq!(
|
||||
s.eval::<String>("return (GetTrainerServiceInfo(1))")
|
||||
.unwrap(),
|
||||
@@ -231,15 +210,12 @@ fn state_filter_takes_a_groups_header_with_its_last_service() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Collapse is the *asymmetric* case, and deliberately so: it hides a group's services but keeps the
|
||||
/// header — a different field (`hdr[+0x20]`) tested three instructions after the filter's, with a
|
||||
/// header-row exemption the filter's lacks (`0x4d853d`). Without it a collapsed group could never be
|
||||
/// re-expanded, having no header left to click.
|
||||
/// The reference's collapse test exempts headers (`0x4d853d`), where the filter's does not.
|
||||
#[test]
|
||||
fn collapse_keeps_the_header_the_filter_would_remove() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_trainer(Some(trainer()));
|
||||
s.run("CollapseTrainerSkillLine(1)").unwrap(); // fold Arms — both its services hide
|
||||
s.run("CollapseTrainerSkillLine(1)").unwrap(); // fold Arms
|
||||
assert_eq!(
|
||||
visible(&mut s),
|
||||
[
|
||||
@@ -248,7 +224,6 @@ fn collapse_keeps_the_header_the_filter_would_remove() {
|
||||
("Bloodrage".to_string(), "unavailable".to_string()),
|
||||
]
|
||||
);
|
||||
// Collapse-all keeps every header; the filter's empty case keeps none.
|
||||
s.run("CollapseTrainerSkillLine(0)").unwrap();
|
||||
assert_eq!(s.eval::<i64>("return GetNumTrainerServices()").unwrap(), 2);
|
||||
}
|
||||
@@ -258,8 +233,6 @@ fn collapse_by_header_index_and_collapse_all() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_trainer(Some(trainer()));
|
||||
|
||||
// Collapse the Arms group by its header's display index (1): its two services vanish, the
|
||||
// header stays and now reports isExpanded=nil. 5 → 3 (H:Arms, H:Fury, Bloodrage).
|
||||
s.run("CollapseTrainerSkillLine(1)").unwrap();
|
||||
assert_eq!(s.eval::<i64>("return GetNumTrainerServices()").unwrap(), 3);
|
||||
assert!(s
|
||||
@@ -272,14 +245,11 @@ fn collapse_by_header_index_and_collapse_all() {
|
||||
"Arms' services are folded; Fury's header is now row 2"
|
||||
);
|
||||
|
||||
// Expand it back by the same header index.
|
||||
s.run("ExpandTrainerSkillLine(1)").unwrap();
|
||||
assert_eq!(s.eval::<i64>("return GetNumTrainerServices()").unwrap(), 5);
|
||||
|
||||
// Collapse-all (id 0): both groups fold → just the two headers.
|
||||
s.run("CollapseTrainerSkillLine(0)").unwrap();
|
||||
assert_eq!(s.eval::<i64>("return GetNumTrainerServices()").unwrap(), 2);
|
||||
// Expand-all (id 0): back to the full tree.
|
||||
s.run("ExpandTrainerSkillLine(0)").unwrap();
|
||||
assert_eq!(s.eval::<i64>("return GetNumTrainerServices()").unwrap(), 5);
|
||||
}
|
||||
@@ -291,7 +261,7 @@ fn collapse_survives_a_content_update_and_resets_on_close() {
|
||||
s.run("CollapseTrainerSkillLine(1)").unwrap(); // fold Arms
|
||||
assert_eq!(s.eval::<i64>("return GetNumTrainerServices()").unwrap(), 3);
|
||||
|
||||
// A re-list (same skill lines) keeps the fold — a buy re-lists and the tree shouldn't jump open.
|
||||
// A content update over the same groups keeps the fold.
|
||||
s.set_trainer(Some(trainer()));
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return GetNumTrainerServices()").unwrap(),
|
||||
@@ -299,31 +269,23 @@ fn collapse_survives_a_content_update_and_resets_on_close() {
|
||||
"Arms stays collapsed across a content update"
|
||||
);
|
||||
|
||||
// Close → the collapse set resets; a re-open is fully expanded.
|
||||
s.set_trainer(None);
|
||||
s.set_trainer(Some(trainer()));
|
||||
assert_eq!(s.eval::<i64>("return GetNumTrainerServices()").unwrap(), 5);
|
||||
}
|
||||
|
||||
/// A buy queues the row's **spell id**, and only a green row can be bought — the single-row path
|
||||
/// `0x4d89d0` resolves the index through the shared total-bounded gate and then refuses on the state
|
||||
/// byte (`0x4d89e5 cmp byte ptr [esi+0x30], bl; jne`), so red and gray rows are silently dropped on
|
||||
/// the floor rather than sent to the server (2231). This used to queue whatever the index resolved
|
||||
/// to, which is how a `used` row could still put a `CMSG_TRAINER_BUY_SPELL` on the wire.
|
||||
/// The reference's buy refuses any state byte but 0 (`0x4d89d0`, `0x4d89e5`).
|
||||
#[test]
|
||||
fn buy_queues_an_available_services_spell_id_and_refuses_every_other_row() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_trainer(Some(trainer()));
|
||||
// Row 2 is Heroic Strike (spell 78, available). Buying it queues its spell id, not its index.
|
||||
s.run("BuyTrainerService(2)").unwrap();
|
||||
assert_eq!(s.take_trainer_buys(), vec![78]);
|
||||
assert!(s.take_trainer_buys().is_empty(), "drained");
|
||||
|
||||
// Buying a HEADER row (index 1) queues nothing.
|
||||
s.run("BuyTrainerService(1)").unwrap();
|
||||
assert!(s.take_trainer_buys().is_empty(), "a header is not buyable");
|
||||
|
||||
// Nor does a gray row (Cleave, already known) or a red one (Bloodrage, gated).
|
||||
s.run("BuyTrainerService(3)").unwrap();
|
||||
s.run("BuyTrainerService(5)").unwrap();
|
||||
assert!(
|
||||
@@ -345,7 +307,7 @@ fn selection_and_close_intents() {
|
||||
s.eval::<i64>("return GetTrainerSelectionIndex()").unwrap(),
|
||||
2
|
||||
);
|
||||
s.run("SelectTrainerService(9)").unwrap(); // OOB (past the 5 rows) clears
|
||||
s.run("SelectTrainerService(9)").unwrap(); // past the 5 rows: clears
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return GetTrainerSelectionIndex()").unwrap(),
|
||||
0
|
||||
@@ -357,9 +319,7 @@ fn selection_and_close_intents() {
|
||||
assert!(!s.take_trainer_close(), "drained");
|
||||
}
|
||||
|
||||
/// The two whole-trainer predicates fork on the wire `trainer_type` — `IsTradeskillTrainer` on
|
||||
/// `== 2` (`0x4d8ea0`), `IsTalentTrainer` on `== 1` (`0x4d8ed0`) — so at most one is ever true, and
|
||||
/// at a class trainer neither is.
|
||||
/// `IsTradeskillTrainer` tests type 2 (`0x4d8ea0`), `IsTalentTrainer` type 1 (`0x4d8ed0`).
|
||||
#[test]
|
||||
fn tradeskill_and_talent_flags() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
@@ -367,13 +327,12 @@ fn tradeskill_and_talent_flags() {
|
||||
.eval::<bool>("return IsTradeskillTrainer() == nil")
|
||||
.unwrap());
|
||||
|
||||
// A class trainer: neither predicate.
|
||||
s.set_trainer(Some(trainer()));
|
||||
assert!(s
|
||||
.eval::<bool>("return IsTradeskillTrainer() == nil and IsTalentTrainer() == nil")
|
||||
.unwrap());
|
||||
|
||||
// A tradeskill trainer — one "Recipes" group, so its one service is the row at index 2.
|
||||
// One "Recipes" group, so its service is row 2.
|
||||
let mut recipe = svc(
|
||||
2743,
|
||||
"Copper Chain Pants",
|
||||
@@ -395,7 +354,6 @@ fn tradeskill_and_talent_flags() {
|
||||
.eval::<bool>("return IsTrainerServiceTradeSkill(2) == 1")
|
||||
.unwrap());
|
||||
|
||||
// A mount trainer — the "talent" predicate, which used to be a hardcoded nil.
|
||||
s.set_trainer(Some(TrainerState {
|
||||
greeting: String::new(),
|
||||
trainer_type: 1,
|
||||
@@ -417,7 +375,6 @@ fn tradeskill_and_talent_flags() {
|
||||
fn unresolved_skill_line_is_dropped() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
let mut t = trainer();
|
||||
// A service whose skill line didn't resolve (0) is dropped from the tree entirely.
|
||||
t.services.push(svc(
|
||||
999,
|
||||
"Orphan Spell",
|
||||
@@ -426,19 +383,11 @@ fn unresolved_skill_line_is_dropped() {
|
||||
"",
|
||||
));
|
||||
s.set_trainer(Some(t));
|
||||
// Still 5 rows — the orphan contributes neither a header nor a service row.
|
||||
assert_eq!(s.eval::<i64>("return GetNumTrainerServices()").unwrap(), 5);
|
||||
}
|
||||
|
||||
/// **The selection is the service, never the row it sits on** — and a hidden service keeps a row.
|
||||
///
|
||||
/// `SelectTrainerService` stores the record's spell id (`ds:0xb73a0c`, `0x4d74f0`) and
|
||||
/// `GetTrainerSelectionIndex` finds it again by scanning the **whole** array (`0x4d7520`, bounded by
|
||||
/// the total `ds:0xb73a10`, not the visible `ds:0xb73a18`). Filtering and collapsing only clear a
|
||||
/// record's visible flag `[+0x34]` and sort it into a tail, so the answer for an off-screen
|
||||
/// selection is an index *past* `GetNumTrainerServices()` — never a clamp, and never a live row
|
||||
/// number belonging to some other service. benilla stored the row number and clamped it, which is
|
||||
/// the director's "the info and img below don't update" report: see [`super::selected_row`].
|
||||
/// The reference stores the selection as a spell id (`0x4d74f0`) and scans the whole array for it
|
||||
/// (`0x4d7520`), so a hidden selection reads past the visible count.
|
||||
#[test]
|
||||
fn the_selection_follows_its_service_and_lands_in_the_tail_when_it_is_hidden() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
@@ -450,13 +399,11 @@ fn the_selection_follows_its_service_and_lands_in_the_tail_when_it_is_hidden() {
|
||||
.unwrap()
|
||||
};
|
||||
|
||||
// Bloodrage sits at row 5 of `[H:Arms, Heroic Strike, Cleave, H:Fury, Bloodrage]`.
|
||||
s.run("SelectTrainerService(5)").unwrap();
|
||||
assert_eq!(sel(&mut s), 5);
|
||||
assert_eq!(at(&mut s, 5), "Bloodrage");
|
||||
|
||||
// Hide the already-known state: Cleave leaves and everything under it slides up one. The row
|
||||
// number 5 now names nothing on screen; the selection is still Bloodrage, at row 4.
|
||||
// Hiding Cleave moves Bloodrage up to row 4.
|
||||
s.run("SetTrainerServiceTypeFilter('used', 0)").unwrap();
|
||||
assert_eq!(at(&mut s, 4), "Bloodrage");
|
||||
assert_eq!(
|
||||
@@ -465,9 +412,7 @@ fn the_selection_follows_its_service_and_lands_in_the_tail_when_it_is_hidden() {
|
||||
"it followed its service, it did not stay put"
|
||||
);
|
||||
|
||||
// Fold Bloodrage's group away. It is off screen but not gone: its record goes to the tail, so
|
||||
// the answer is an index past the visible count — which is what sends the stock window down its
|
||||
// "the selection is not on screen" path instead of silently repainting someone else's row.
|
||||
// Row 3 is now the Fury header.
|
||||
s.run("CollapseTrainerSkillLine(3)").unwrap();
|
||||
let folded = sel(&mut s);
|
||||
assert!(
|
||||
@@ -478,8 +423,7 @@ fn the_selection_follows_its_service_and_lands_in_the_tail_when_it_is_hidden() {
|
||||
s.run("ExpandTrainerSkillLine(3)").unwrap();
|
||||
assert_eq!(sel(&mut s), 4, "and back on screen when the group unfolds");
|
||||
|
||||
// He learns it. The re-list brings it back gray and the filter hides it — the tail again, never
|
||||
// the row number of whatever moved up into its place.
|
||||
// Learned, it comes back gray and the filter hides it.
|
||||
let mut learned = trainer();
|
||||
learned.services[2].category = TrainerServiceCategory::Used;
|
||||
s.set_trainer(Some(learned));
|
||||
@@ -488,23 +432,18 @@ fn the_selection_follows_its_service_and_lands_in_the_tail_when_it_is_hidden() {
|
||||
"the learned spell is off screen, not at some live row"
|
||||
);
|
||||
|
||||
// A service that has left the trainer's list altogether is the one case that reads 0.
|
||||
let mut shorter = trainer();
|
||||
shorter.services.remove(2);
|
||||
s.set_trainer(Some(shorter));
|
||||
assert_eq!(sel(&mut s), 0);
|
||||
|
||||
// A header row is not a selection, and neither is a row past the end.
|
||||
s.set_trainer(Some(trainer()));
|
||||
s.run("SelectTrainerService(1)").unwrap();
|
||||
assert_eq!(sel(&mut s), 0, "row 1 is the Arms header");
|
||||
}
|
||||
|
||||
/// A new `SMSG_TRAINER_LIST` resets the selection with the rest of the builder's state — `0x4d7560`
|
||||
/// selects record 0 after the sort (`0x4d7b40 xor ecx,ecx` → `0x4d7b42 call 0x4d74f0`), which is
|
||||
/// always a group header and so reads back as 1. Clearing is the same answer to the only question
|
||||
/// the stock window asks (`GetTrainerSelectionIndex() > 1`, which 0 and 1 both fail); what matters
|
||||
/// is that a re-opened trainer cannot inherit the last visit's selection.
|
||||
/// The reference's list builder re-selects a header (`0x4d7560`, `0x4d7b42`), so a re-opened
|
||||
/// trainer never keeps the last visit's selection.
|
||||
#[test]
|
||||
fn a_new_list_packet_clears_the_selection() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
@@ -536,23 +475,12 @@ fn clearing_empties_and_resets_selection() {
|
||||
);
|
||||
}
|
||||
|
||||
/// **The tradeskill order, pinned against an emulated run of the real finalizer** (decision
|
||||
/// 1124). The fixture is creature 957 "Dane Lindgren" — the blacksmithing trainer in the director's
|
||||
/// report — with his 19 real `npc_trainer` rows: 18 recipes on skill line 164 with ascending
|
||||
/// `reqSkillValue`, plus the profession-learn service 2020 ("Apprentice Blacksmith", `reqLevel 5`,
|
||||
/// no skill gate). This exact set was fed through the builder `0x4d7560` + finalizer `0x4d8410`
|
||||
/// with `0xb73a08 = 2` and the real `qsort`/collator, and got the 21 rows asserted below.
|
||||
///
|
||||
/// Two things this pins that benilla had wrong, and one it never had:
|
||||
/// - the learn row is **first among services**, under its own `Development Skills` header — placed
|
||||
/// by the `SKILL_STEP` group partition, *not* by any level key;
|
||||
/// - `reqLevel` is **inert** at type 2 (the class comparator's first key, which used to sink 2020 to
|
||||
/// the bottom of the list);
|
||||
/// - recipes ascend by `reqSkillValue`, ties broken by name.
|
||||
/// Creature 957's 19 vmangos `npc_trainer` rows, in the order the reference's builder `0x4d7560`
|
||||
/// and finalizer `0x4d8410` give them at type 2: the `SKILL_STEP` learn row first under its own
|
||||
/// header, `reqLevel` inert, the recipes by `reqSkillValue` then name.
|
||||
#[test]
|
||||
fn tradeskill_trainer_matches_the_emulated_reference_order() {
|
||||
// (wire spell, name, reqSkillValue) — the recipes, in wire order (ascending here, but the
|
||||
// fixture is shuffled below to prove the order is the comparator's and not the input's).
|
||||
// (wire spell, name, reqSkillValue), reversed below so the order must come from the sort.
|
||||
let recipes: &[(u32, &str, u32)] = &[
|
||||
(2743, "Copper Chain Pants", 1),
|
||||
(2754, "Copper Mace", 15),
|
||||
@@ -576,7 +504,7 @@ fn tradeskill_trainer_matches_the_emulated_reference_order() {
|
||||
let mut services: Vec<TrainerService> = recipes
|
||||
.iter()
|
||||
.map(|&(id, name, req)| {
|
||||
// Group key 2 = TRADESKILL_SERVICE_LEARN: no SKILL_STEP effect on the wire spell.
|
||||
// Group key 2, `TRADESKILL_SERVICE_LEARN`: the wire spell has no `SKILL_STEP` effect.
|
||||
let mut s = svc(id, name, TrainerServiceCategory::Unavailable, 2, "Recipes");
|
||||
s.subtext = None;
|
||||
s.level_req = 0;
|
||||
@@ -588,8 +516,8 @@ fn tradeskill_trainer_matches_the_emulated_reference_order() {
|
||||
s
|
||||
})
|
||||
.collect();
|
||||
services.reverse(); // wire order is not display order — and must not be
|
||||
// Group key 1 = TRADESKILL_SERVICE_STEP: spell 2020 carries Effect 44 SKILL_STEP.
|
||||
services.reverse();
|
||||
// Group key 1, `TRADESKILL_SERVICE_STEP`: spell 2020 has effect 44, `SKILL_STEP`.
|
||||
let mut learn = svc(
|
||||
2020,
|
||||
"Apprentice Blacksmith",
|
||||
@@ -598,7 +526,7 @@ fn tradeskill_trainer_matches_the_emulated_reference_order() {
|
||||
"Development Skills",
|
||||
);
|
||||
learn.subtext = None;
|
||||
learn.level_req = 5; // the key that used to decide everything, and decides nothing here
|
||||
learn.level_req = 5; // inert at type 2
|
||||
learn.prof_first_rank = true;
|
||||
services.insert(services.len() / 2, learn);
|
||||
|
||||
@@ -638,10 +566,7 @@ fn tradeskill_trainer_matches_the_emulated_reference_order() {
|
||||
assert_eq!(got, expected);
|
||||
}
|
||||
|
||||
/// **The mount (type 1, the client's "talent") order, pinned against an emulated run**
|
||||
/// (decision 1124): the already-known services fold into the `-1` "My Talents" group, which the
|
||||
/// header comparator puts **first**, ahead of the name-ordered skill-line headers; within a group
|
||||
/// the state byte sorts available → unavailable → used, then the name.
|
||||
/// At a mount trainer the known group's header sorts first (`0x4d7b90`), the rest by name.
|
||||
#[test]
|
||||
fn mount_trainer_folds_known_services_into_my_talents() {
|
||||
let known = |id: u32, name: &str| {
|
||||
@@ -700,16 +625,13 @@ fn mount_trainer_folds_known_services_into_my_talents() {
|
||||
],
|
||||
"the -1 group leads; every other header is name-ordered"
|
||||
);
|
||||
// And the type predicates fork on the same dword the sort does.
|
||||
assert!(s.eval::<bool>("return IsTalentTrainer() == 1").unwrap());
|
||||
assert!(s
|
||||
.eval::<bool>("return IsTradeskillTrainer() == nil")
|
||||
.unwrap());
|
||||
}
|
||||
|
||||
/// The **state byte is a sort key at type 1 and only there** — the same three services, ordered by
|
||||
/// state (available → unavailable → used) inside one group, where the class cascade would order
|
||||
/// them by level and the tradeskill one by skill value.
|
||||
/// Only at type 1 is the state byte a sort key (`0x4d8850`).
|
||||
#[test]
|
||||
fn talent_order_sorts_on_state_within_a_group() {
|
||||
let one = |id: u32, name: &str, cat: TrainerServiceCategory| svc(id, name, cat, 762, "Riding");
|
||||
@@ -728,8 +650,7 @@ fn talent_order_sorts_on_state_within_a_group() {
|
||||
let got: Vec<String> = visible(&mut s).into_iter().map(|(n, _)| n).collect();
|
||||
assert_eq!(got, ["Riding", "Cee", "Bee", "Aye"]);
|
||||
|
||||
// The same three at a CLASS trainer sort by name instead (equal levels, no skill gates) — the
|
||||
// proof that the comparator really is selected by the type and not shared.
|
||||
// At a class trainer the same three sort by name: equal levels, no skill gates.
|
||||
s.set_trainer(Some(TrainerState {
|
||||
greeting: String::new(),
|
||||
trainer_type: 0,
|
||||
|
||||
Reference in New Issue
Block a user