- a NOT_SELECTABLE unit takes no target: Stratholme's Baron Rivendare, pre-event, is spawned by vmangos with `UNIT_FLAG_NOT_SELECTABLE | UNIT_FLAG_SPAWNING`, and where the reference can neither damage nor target him benilla refused the damage (`CanAttack` already read bit 25) and took him as a target anyway — most visibly through the ATTACKERSTATEUPDATE self-defence acquire, which hands you your attacker the moment he opens on you with no click and no hover involved, and every other writer (left-click, right-click, TAB, `TargetUnit`/`/target`/`/assist`, the attack auto-acquire) had the same hole; the reference's gate is the third early-out of `SetSelection` `0x493540` (`0x4935ee`/`0x4935f3`): the resolved object's `+0x58` vcall to `0x60be60` `IsSelectable` = `!(UNIT_FIELD_FLAGS & 0x02000000) || UNIT_FIELD_CREATEDBY == your guid`, sitting BEFORE the dedup so a refusal is a complete no-op — no `CMSG_ATTACKSTOP`, no `CMSG_SET_SELECTION`, the target already in hand survives — and skipped entirely for an unresolved object, which is what keeps an out-of-range party member selectable off the roster; `target::relations::is_selectable` transcribes it whole, the CREATEDBY clause (your own totem stays clickable, somebody else's does not) and the xor-eax base stub every non-unit vtable carries at `+0x58` included, and `scan::commit` runs it first behind a mandatory `target_store` parameter so none of its eight call sites can forget a gate it does not compute; the hover is the SAME predicate at a second site, byte-verified by the wow-re round this dispatched: the hover grader `0x4828d0` calls slot 21 directly on the pick's winner at `0x482982`, and a false answer jumps to `0x482090(0,0)` + `ResetCursor` `0x523d30`, skipping the publisher `0x492890` that holds the mouseover globals' only two writers image-wide — so no tooltip, no cursor, no emissive brighten and no `mouseover` token, all from one rule; it is a GRADER, not a filter, and the difference is observable — a census over the pick itself (`0x480a50`, `0x480d90`, `0x713cb0`, `0x481190`, `0x480df0`) finds zero reads of the unit flags against four inside `CanAttack`, so the flagged unit wins the trace and is then thrown away, which `Hovered::refused` carries with the pick's distance kept so a trigger stalker in front of a chest takes the pick and the chest behind it does not inherit the mouseover; graded at `update_hover`'s publish rather than in a system of its own, because `drive_mouseover_tooltip` is ordered only after the net stage and a second system would have bought a one-frame name plate for a unit that must never have one; the V-plate suppression turns out to be load-bearing, since a plate hover publishes the mouseover directly (`0x7cb869 call 0x492890`) with no grading at all; and a left-click on a refused unit no longer clears what you had — the grader had dropped it into `select_on_click`'s arm beside "clicked the sky", where the reference's deselect lives in the terrain and NOTHING legs only: the down-edge pick `0x481f00` still found the object, so the release takes the object leg `0x4925d0` whose tail hits `SetSelection`'s early-out and changes nothing, the reasoning the corpse guard beside it already carried and which gets its first test here (2060) - three things a landed hit showed that the reference does not: a lootable corpse took blood, flinch, impact sounds and a floating number — the victim dispatcher `0x624530`, which owns every victim-side consequence of a swing, opens with `test byte [[victim+0x110]+0x224],1` (`UNIT_DYNAMIC_FLAGS` bit `0x1`) and jumps past all four — but the obvious fix, gating the victim, is wrong: benilla fans that dispatcher out into one message emitted on three occasions and the reference gates the DISPATCHER, and an image-wide census of the number emitter `0x6243e0` (exactly three call sites, zero tail-jumps, zero address-takes, earned against a positive control) puts `0x624566` inside the gate but the `SMSG_ATTACKSTOP` flush `0x624ea7` and the supersede flush `0x625893` outside it, so a lootable victim still floats a number for a superseded record; gated: the impact-tag dispatch (record still consumed first, as `0x6247d0` clears the GUIDs before calling) and the unresolved-attacker fallback `0x625a6d`; ungated: both flushes and the whiff slow-down, `0x624ca0` being its own function past the dispatcher's end; the flush's own predicate `0x625e40` reads an unnamed `[rec+0xac]` and stays unbuilt rather than guessed (2066); a crit played a full CombatCritical clip on a fighting unit — vmangos sends `SMSG_EMOTE(EMOTE_ONESHOT_WOUNDCRITICAL)` to the victim of every damaging crit (`Unit.cpp:1601`), `Emotes.dbc` resolves it to AnimID 10, and on the director's 90 s brawl trace that was 7 one-shots plus 9 fast-path entries on the warrior cutting its own swing (`fastpath cur=87 req=10`), 19 across all units; the reference's shared emote player `0x5fcd20` refuses at `0x5fcd96` when `0x60ecd0` is true — `UNIT_FIELD_FLAGS` bit `0x800` OR an auto-attack target `[+0xc48]` — and 1469 had built the flag half alone, dormant on vmangos (its `0x800` is `PET_IN_COMBAT`; a fighting player or creature carries `IN_COMBAT 0x80000`, a bit the client never tests), so `emote_anim::play_eligible` takes `engaged` and both producers, `SMSG_EMOTE` and the chat gesture, pass `Has<Engaged>` through one `PerformerQuery`: a crit on a fighting unit shows only as the wound overlay, and the wound trace lines carry `unit=` (2067); and an ending effect's particles popped — the teardown `0x6203e0` that both the completion callback and the same-slot replace reach hides the node and queues it rather than freeing it, and whether that is visible turned on a half no wow-re note covered, dispatched rather than assumed: the draw path's own admission test is the live-particle count (`0x7b4b46 mov eax,[esi+0x64]`) and the model enqueues onto two scene lists on two fields — MESH on the visible flag (`0x719207`), PARTICLE on the aggregate `CM2Model+0x3d8` (`0x71922c`), set while any particle lives and the same word `0x61f680` frees on — so for `Strike_Impact_Chest` a node destroyed at 0.10 s still shows ~30 particles at 0.20 s, gone by 0.334 s, and benilla cut up to 0.30 s of a 0.334 s effect; the three points where an effect ENDS — expiry, the same-slot replace, the reap's immediate destroy — now hand their emitters to the drain by the instance root they already carry as anchor (`drain_instance_emitters`, `ParticleEmitter::drain_on_owner_loss`) and the root still despawns, the reference dropping the mesh the same frame; flipped at the ending rather than set at spawn because the other way an owner vanishes is the model dtor (gear change, display swap, stream-out), where the reference frees synchronously and draining that case is what stranded ghost clouds in the air (0826/0833), and the collateral path still frees (2070) - a creature whose template carries `DO_NOT_PLAY_WOUND_ANIM` takes no flinch and no parry, and the wound grunt is rolled: the creature template's `type_flags` carries the bit at `0x8`, benilla consumed the two bits either side — `0x10 NO_FACTION_TOOLTIP` in the unit tooltip, `0x20 MORE_AUDIBLE` in the parked-event gate — with neither reader naming its bit, and `wound.rs` carried the gap in a doc comment claiming "no template cache" that had been false since the cache landed; the reference refuses the flinch inside `0x60ea70` itself (`0x60ea9f`, so every trigger is covered) through the getter `0x6125f0`, whose null-record leg fails OPEN, so `names::type_flags` names all eight bits (a call site could say `& 0x10` and look right — that is what let it be wrong) and the wound trigger refuses a flagged victim as its first gate, keyed off the descriptor's `OBJECT_FIELD_ENTRY` the way `0x60b160` keys the cache; animation only, since no creature-type mechanism suppresses the blood spurt — a flagged creature bleeds (2068); the vocal half was dispatched rather than built, because wow-re's published "makes no pain sound" called the flag's second consumer `0x60ec00` a pain-sound dispatcher while three of its own notes said otherwise, and the cross-check came back with all seven questions VERIFIED: `0x60ec00` is an ANIMATION dispatcher — `GetWeapon(0,0)`, the mainhand subclass through the 18-entry remap `0x60ec98` and jump table `0x60ec84` to Parry1H/2H/2HL/Unarmed, handed to `0x5fe2f0` = `PlayAnimation`, whose 40 call sites push literals in `[16, 202]` and which reaches no sound band — so the flag gates two animations and no sound, and the parry now rides it (victimState 3 only; Dodge 30 and Block 24 reach `PlayAnimation` directly from `0x624a90`/`0x624a74`, and our subclass map agrees with the raw remap on all 18 rows); nothing suppresses the injury vocal — of the fourteen functions in the wound-vocal chain the only one dereferencing `[unit+0xb30]` is `0x623b70`, testing bit 5 for a bus — but the ordinary grunt IS rolled: `0x623490` runs the class chance roll at `0x8626d4[2] = 60` for a creature and `0x86424c[2] = 30` for a player, by the VICTIM's type, while classes 3 (critical) and 9 (crushing) carry 100 and always sound, so `INJURY_CHANCE_CREATURE`/`INJURY_CHANCE_PLAYER` thin the grunt under sustained melee where benilla had played all three every time — 40 grunts per 100 too many on a creature, 70 on a player — and a crushing blow's class 9 punches through a server-pushed object sound's suppression, which stops at class 8 (`0x6234bb`/`0x6234bf`/`0x6234c4`); a pet gets a template record at last, since `NameCache::resolve` routes a `HIGHGUID_PET` guid to the pet-NAME query and a tamed unit had none — 18 of the 471 flagged templates on the world DB carry a `pet_family` — so the create arm also asks by the descriptor's entry, the reference's own key (`[[unit+8]+0xc]`); 2068's claim that templates are queried lazily is corrected, the create arm having warmed the cache for every streamed unit all along, at the reference's own timing (`0x60afb0` → `0x60b157`, on the create path) rather than as the convenience its comment called it; and the skeletons of the report still flinch, and should — neither vmangos nor cmangos flags them (2 of 62 templates in each), `Skeleton.m2` authors CombatWound and CombatCritical, so what changes for them is the grunt's rate, not its existence (2073) - the realm list is a dialog, not a screen: the first live run of 2056's realm selection reported no hover highlight, Cancel and the X throwing you out of the session instead of back to character select, the character screen gone from behind it, and then a login that hung on "Connecting" forever with nothing in the log — three of the four are one mistake, since `GlueScreenInfo` names all seven glue screens and `RealmList` is not among them: `RealmList.xml` is a `frameStrata="DIALOG"` frame with a full-screen 75%-black quad that `Show()`s over whatever screen is up and `RealmList_OnCancel` only `Hide()`s it, while 2056 had built it as a `ClientState`, so Cancel had to pick somewhere to go, the screen behind was torn down, and — the one that mattered — a roster arriving while that state was up moved nothing because `to_select_on_roster` only ever leaves `Login`; Okay looked inert while the IO thread walked on to the character park, the next Cancel answered a park two ahead of the thread, and every later login queued behind a `pick_rx.recv()` that was never coming, silently, reproduced with a scripted walk before anything changed; the realm list is a `shown` flag now, served in place by the character park over `select!` — refreshing on the realmd socket the cycle still holds, since `RequestRealmList` (`0x46b8d0`) begins `COP_GET_REALMS` on a socket closed only at world entry (`0x46b70c`) and only `ConnectToRealm` (`0x46b210`) drops the world connection — and leaving only when a realm is picked, `HeldLogon` and the resumed cycle gone with it; Cancel sends one `Abandon` and the park that hears it decides, which is the reference's own shape: `RealmListDialogCancelled` (`0x46ed20` → `0x46b810`) compares the current screen name against "login" and returns immediately when it differs, tail-jumping `CLoginMgr::Cancel` (`0x5b3320`) only from the login screen; the hover was never a realm-list oversight — `Hilight` was "the owning screen's to drive", four screens drove it, two folding selection into the same expression and one scoped so the `GlueCloseButton` X on every panel could never light, and the realm list made five by having none — so one owner now, `glue_hilights`, lit by hover or `LockHighlight` and walking UP to the nearest enclosing button because a modal dialog's root is itself a button, with `art_swaps`, `glue_button_visuals`, `glue_hilights` and `sync_outlines` registered once in `GluePlugin` instead of hand-copied per screen; the IO thread names each park it stops at, a logout comes back through the same park a first login does, so the policy ladder puts the realm the player is currently on ahead of the persisted `realmName`, which benilla writes only at world entry, the reproduction is now a smoke leg (Change Realm → Okay → Change Realm → Cancel → Okay against the live server) that fails on the old code by construction, and two divergences are stated: the X abandons like Cancel because our thread parks on the question rather than polling for it, and Change Realm shows the list already in hand at once rather than waiting a round trip behind a status dialog (2072) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
850 lines
42 KiB
Rust
850 lines
42 KiB
Rust
//! Unit-name resolution — the query-cache seam of decision 0068 §3.
|
||
//!
|
||
//! The 1.12 wire carries **no names in descriptors**: a player's name answers `CMSG_NAME_QUERY`
|
||
//! (keyed by guid), a creature's answers `CMSG_CREATURE_QUERY` (keyed by the template *entry*
|
||
//! embedded in its guid, shared by every spawn of that template — exactly how the real client
|
||
//! recovers it). This module owns the cache and the **ask-once** discipline: a consumer calls
|
||
//! [`NameCache::resolve`], which returns the name when known and otherwise issues the query (deduped
|
||
//! while in flight) and reports "not yet". The net bridge ([`crate::net`]) fills the cache from the
|
||
//! decoded `PlayerName`/`CreatureName` events and clears the in-flight sets on disconnect (a query
|
||
//! dropped by a dead writer must be re-askable after reconnect).
|
||
//!
|
||
//! A *negative* answer (the server doesn't know the guid/entry) is cached too — resolving to
|
||
//! "unknown, and asking again won't help" — so a bad id can never turn into a query loop.
|
||
|
||
use std::collections::{HashMap, HashSet};
|
||
|
||
use bevy::prelude::*;
|
||
|
||
use benilla_protocol::guid;
|
||
|
||
use crate::net::{ClientCommand, NetCommands, ObjectStore};
|
||
|
||
/// The creature template's **`type_flags`** word, bit by bit — the dword the server ships in
|
||
/// `SMSG_CREATURE_QUERY_RESPONSE` and the reference caches at `[CGUnit+0xb30] + 0x14` (its async
|
||
/// creature-query record, **not** a DBC row and **not** `CreatureType.dbc`).
|
||
///
|
||
/// Named because the bits are one-apart neighbours with wholly unrelated meanings, and reading a
|
||
/// literal `& 0x10` at a call site is how the wound gate below spent months testing the faction
|
||
/// tooltip's bit instead of its own (decision 2068). The reference gives each bit its own getter,
|
||
/// which is the shape mirrored here — every consumer names the bit it wants:
|
||
///
|
||
/// | bit | reference getter | meaning |
|
||
/// |---|---|---|
|
||
/// | `0x1` | `0x529d15` | TAMEABLE |
|
||
/// | `0x2` | `0x605f70` | VISIBLE_TO_GHOSTS |
|
||
/// | `0x4` | `0x612530` | BOSS_MOB |
|
||
/// | `0x8` | `0x6125f0` | [`DO_NOT_PLAY_WOUND_ANIM`] |
|
||
/// | `0x10` | `0x612610` (inverted) | [`NO_FACTION_TOOLTIP`] |
|
||
/// | `0x20` | `0x623b70` | [`MORE_AUDIBLE`] |
|
||
/// | `0x40` | `0x60d840` | SPELL_ATTACKABLE / no harmful vertex colouring |
|
||
/// | `0x80` | `0x613230` = `CanInteractWhileDead` | INTERACT_WHILE_DEAD |
|
||
///
|
||
/// (wow-re `object-layer/scratch/melee-blood-spurt-suppression.md` §6, bit numbering VERIFIED at
|
||
/// each reader; the names corroborated by vmangos `CreatureDefines.h:146-152`.)
|
||
pub(crate) mod type_flags {
|
||
/// `0x8` — **DO_NOT_PLAY_WOUND_ANIM**: this creature has nothing to flinch with. The
|
||
/// reference refuses the victim wound-flinch overlay outright for it (`0x60ea9f` inside the
|
||
/// flinch `0x60ea70`), whatever the trigger — a skeleton, a ghost, an elemental takes hits
|
||
/// without recoiling. It gates **only** the animation: the blood spurt is unaffected
|
||
/// (wow-re §8 Q1: "no blood-, creature-type- or display-record-keyed mechanism exists" —
|
||
/// a skeleton bleeds), and so is the floating combat text. Decision 2068.
|
||
pub(crate) const DO_NOT_PLAY_WOUND_ANIM: u32 = 0x8;
|
||
|
||
/// `0x10` — **NO_FACTION_TOOLTIP**: the unit tooltip drops its faction-name line
|
||
/// (the reference's `0x612610`, which returns the bit inverted).
|
||
pub(crate) const NO_FACTION_TOOLTIP: u32 = 0x10;
|
||
|
||
/// `0x20` — **MORE_AUDIBLE**: the pass-2 election re-links an off-screen creature for
|
||
/// tick-only so its combat stays audible (`0x607da0`'s `0x623b70` arm — wow-re
|
||
/// `outdoor-object-pass-election.md` §4, decision 1482).
|
||
pub(crate) const MORE_AUDIBLE: u32 = 0x20;
|
||
}
|
||
|
||
/// The name cache: players by guid, creatures by template entry, plus the in-flight ask-once sets.
|
||
/// Filled by the net bridge; read (and query-triggered) through [`Self::resolve`].
|
||
#[derive(Resource, Default)]
|
||
pub(crate) struct NameCache {
|
||
/// Player names by guid. `Some(None)`-shaped answers are stored as `None`: the server was asked
|
||
/// and didn't know (an empty wire name) — cached so we never re-ask a dead guid.
|
||
players: HashMap<u64, Option<String>>,
|
||
/// `(race, class, gender)` from the same `SMSG_NAME_QUERY_RESPONSE` that carried the name — the
|
||
/// wire has always sent these three and we used to drop them. They exist for one reason: the
|
||
/// `$`-macro expander's non-player-subject path. The reference resolves a macro subject from the
|
||
/// object manager first and falls back to **this** cache record when the unit isn't streamed
|
||
/// (`questtext-macro-expander.md` §1), so without them `$R`/`$C`/`$G` against an off-screen
|
||
/// player would silently read race 0.
|
||
player_traits: HashMap<u64, (u8, u8, u8)>,
|
||
/// Creature template records by entry; the outer `None` = the server flagged the entry
|
||
/// unknown. The subname is the overhead/tooltip title line ("Stable Master", …); the type is
|
||
/// the `CreatureType.dbc` id the TAB-target critter filter reads; rank/civilian feed the
|
||
/// unit tooltip's level-line word + CIVILIAN line (decision 0276).
|
||
creatures: HashMap<u32, Option<CreatureRecord>>,
|
||
/// Pet names by **pet number** — the third naming path (see [`Self::resolve`]'s pet branch).
|
||
/// No negative entry exists: the server answers for a live pet or says nothing at all, so an
|
||
/// unanswered ask stays unanswered, exactly as it does for the real client.
|
||
pets: HashMap<u32, String>,
|
||
pending_players: HashSet<u64>,
|
||
pending_creatures: HashSet<u32>,
|
||
pending_pets: HashSet<u32>,
|
||
/// Bumped by every **landed** answer (and the pet-rename eviction) — never by an ask. The
|
||
/// gated feeds' watch counter (decision 1439): a feed that resolved a miss re-runs when the
|
||
/// answer lands, and only then. `is_changed` cannot say this — the resolves themselves take
|
||
/// `&mut self` every frame (the ask-once marker), so the resource reads as changed whenever
|
||
/// anything is still pending.
|
||
generation: u64,
|
||
}
|
||
|
||
/// One cached creature template head (see [`NameCache::creatures`]).
|
||
#[derive(Clone, Debug)]
|
||
pub(crate) struct CreatureRecord {
|
||
pub(crate) name: String,
|
||
pub(crate) subname: Option<String>,
|
||
pub(crate) creature_type: u32,
|
||
/// `CreatureFamily.dbc` id — the pet paper doll's family word and, through the row's food
|
||
/// mask, its diet tooltip (decision 1062). `0` on everything that is neither a tameable beast
|
||
/// nor a warlock minion, which is most of the table; the family table has no row 0, so a `0`
|
||
/// resolves to nil without needing a sentinel of its own.
|
||
pub(crate) pet_family: u32,
|
||
/// Elite rank 0..4 **as the template declares it** — read it through [`gated_rank`], never
|
||
/// directly, unless you specifically want the ungated template value.
|
||
pub(crate) rank: u32,
|
||
/// Template type flags — the wire dword, read bit by bit through [`type_flags`]. Never test a
|
||
/// literal against it: its bits are unrelated neighbours (decision 2068).
|
||
pub(crate) type_flags: u32,
|
||
pub(crate) civilian: bool,
|
||
/// Racial leader — the tooltip's white LEADER line (`0x6125c0`).
|
||
pub(crate) racial_leader: bool,
|
||
/// `CreatureDisplayInfo.dbc` id — the template's model. The **only** way to draw a creature
|
||
/// with no world object to read `UNIT_FIELD_DISPLAYID` off, which is exactly a stabled pet
|
||
/// (decision 1676): its wire row names a template entry and nothing else. `0` when the
|
||
/// template ships none.
|
||
///
|
||
/// For anything actually on screen the unit's own descriptor field still wins — vmangos picks
|
||
/// among a template's four display ids at spawn, and this is the first.
|
||
///
|
||
/// **Read by the stable window's model pane** ([`crate::ui_stable`]'s `feed_stable_booth`),
|
||
/// which is what 1676 deferred and named this field for: the booth points at a
|
||
/// [`crate::portrait::PortraitStandIn`] built from this id when the selected pet has no world
|
||
/// object — every stabled pet, and a dismissed one in slot 0.
|
||
pub(crate) display_id: u32,
|
||
}
|
||
|
||
/// The client's **creature-rank getter**, `0x605620` — 33 bytes, and the single source every rank
|
||
/// reader in the real client goes through (decision 0782):
|
||
///
|
||
/// ```text
|
||
/// 605620: mov eax,[ecx+0xb30] ; the cached creature record
|
||
/// test eax,eax / je → 0 ; not queried yet
|
||
/// mov ecx,[ecx+0x110] ; the unit descriptor block
|
||
/// mov edx,[ecx+0x214] ; UNIT_FIELD_PETNUMBER
|
||
/// test edx,edx / jne → 0 ; somebody's pet or charm
|
||
/// mov eax,[eax+0x20] ; record->rank
|
||
/// ```
|
||
///
|
||
/// Two gates, and the pet gate is the one nobody expects: a **charmed or enslaved unit reports rank
|
||
/// 0** whatever its template says. Because all three of the client's rank consumers call this one
|
||
/// getter — `UnitClassification` (the target frame's elite/rare border), the unit tooltip's
|
||
/// ELITE/BOSS word, and `UnitLevel`'s world-boss `−1` (the target frame *and* nameplate skull) — a
|
||
/// mind-controlled world boss loses its dragon, its BOSS word and its skull together. Applying the
|
||
/// gate per-reader is how those three silently drift apart, so this function is the only place any
|
||
/// of them may read a rank from.
|
||
///
|
||
/// `store: None` (no descriptor streamed) cannot prove a pet number, so it reads as not-a-pet —
|
||
/// matching the client, whose descriptor block is zero-initialized.
|
||
pub(crate) fn gated_rank(rec: Option<&CreatureRecord>, store: Option<&ObjectStore>) -> u32 {
|
||
match rec {
|
||
Some(rec) if !store.is_some_and(|s| s.0.unit_is_pet_or_charm()) => rec.rank,
|
||
_ => 0,
|
||
}
|
||
}
|
||
|
||
impl NameCache {
|
||
/// The name for `guid`, if known. On a miss, sends the right query (once per guid/entry per
|
||
/// connection) and returns `None` — call again after the answer lands. A guid family that has no
|
||
/// name on the 1.12 wire (GameObjects resolve via their own query, not modeled yet) is `None`
|
||
/// without a query.
|
||
pub(crate) fn resolve(&mut self, guid_val: u64, commands: &NetCommands) -> Option<&str> {
|
||
if guid::is_player(guid_val) {
|
||
if !self.players.contains_key(&guid_val) {
|
||
if self.pending_players.insert(guid_val) {
|
||
debug!("names: asking player name (guid {guid_val})");
|
||
let _ = commands.0.send(ClientCommand::NameQuery { guid: guid_val });
|
||
}
|
||
return None;
|
||
}
|
||
self.players.get(&guid_val).and_then(|n| n.as_deref())
|
||
} else if let Some(pet_number) = guid::pet_number(guid_val) {
|
||
// A pet is a `TYPEID_UNIT` like any creature, but it carries a pet number where a
|
||
// creature carries its template entry, so it has its own query. Asking the creature
|
||
// query for that number is what left every summoned pet nameless.
|
||
self.resolve_pet(pet_number, guid_val, commands)
|
||
} else if guid::is_creature_or_pet(guid_val) {
|
||
let entry = guid::entry(guid_val)?;
|
||
self.resolve_creature(entry, guid_val, commands)
|
||
} else {
|
||
None
|
||
}
|
||
}
|
||
|
||
/// The name for a live pet by its `pet_number` — the pet twin of [`Self::resolve_creature`],
|
||
/// same ask-once discipline. Unlike the creature/player queries this one has **no negative
|
||
/// answer**: vmangos returns early without a packet when the guid is not a live pet bearing that
|
||
/// number (`PetHandler.cpp:190-192`), so an unanswered ask simply stays unresolved rather than
|
||
/// caching a "server doesn't know" — and is re-asked after a reconnect like the others.
|
||
fn resolve_pet(&mut self, pet_number: u32, guid: u64, commands: &NetCommands) -> Option<&str> {
|
||
if !self.pets.contains_key(&pet_number) {
|
||
if self.pending_pets.insert(pet_number) {
|
||
debug!("names: asking pet name (pet {pet_number})");
|
||
let _ = commands
|
||
.0
|
||
.send(ClientCommand::PetNameQuery { pet_number, guid });
|
||
}
|
||
return None;
|
||
}
|
||
self.pets.get(&pet_number).map(String::as_str)
|
||
}
|
||
|
||
/// The name for a creature template `entry`, if known — the entry-keyed twin of
|
||
/// [`Self::resolve`]'s creature branch (same cache, same ask-once discipline), for a caller that
|
||
/// has no live spawn guid to decode an entry from: a quest objective names its kill target only
|
||
/// by the template's raw `creature_or_go` entry (`crate::ui_quest_log`). `guid` rides along for
|
||
/// the query body when a real spawn is known, `0` otherwise — the server answers by entry
|
||
/// regardless of which spawn asked (the same template-only convention as
|
||
/// [`crate::items::Items::template`]'s `guid: 0`).
|
||
pub(crate) fn resolve_creature(
|
||
&mut self,
|
||
entry: u32,
|
||
guid: u64,
|
||
commands: &NetCommands,
|
||
) -> Option<&str> {
|
||
if !self.creatures.contains_key(&entry) {
|
||
if self.pending_creatures.insert(entry) {
|
||
debug!("names: asking creature name (entry {entry})");
|
||
let _ = commands
|
||
.0
|
||
.send(ClientCommand::CreatureQuery { entry, guid });
|
||
}
|
||
return None;
|
||
}
|
||
self.creatures
|
||
.get(&entry)
|
||
.and_then(|n| n.as_ref().map(|r| r.name.as_str()))
|
||
}
|
||
|
||
/// The cached name for `guid`, read-only — no query on a miss (the trace/diagnostic twin of
|
||
/// [`Self::resolve`], for callers that must not mutate the ask-once state).
|
||
pub(crate) fn peek(&self, guid_val: u64) -> Option<&str> {
|
||
if guid::is_player(guid_val) {
|
||
self.players.get(&guid_val).and_then(|n| n.as_deref())
|
||
} else if let Some(pet_number) = guid::pet_number(guid_val) {
|
||
self.pets.get(&pet_number).map(String::as_str)
|
||
} else if guid::is_creature_or_pet(guid_val) {
|
||
self.creatures
|
||
.get(&guid::entry(guid_val)?)
|
||
.and_then(|n| n.as_ref().map(|r| r.name.as_str()))
|
||
} else {
|
||
None
|
||
}
|
||
}
|
||
|
||
/// Record a player-name answer. An empty wire name means the server doesn't know the guid —
|
||
/// cached as a negative answer.
|
||
///
|
||
/// `traits` is the `(race, class, gender)` triple that rides `SMSG_NAME_QUERY_RESPONSE`, kept
|
||
/// for [`Self::player_traits`]. It is `None` for the seams that learn a name **without** that
|
||
/// packet — the login-time seed of our own name, and the capture fixtures — rather than having
|
||
/// them invent a zero triple that would read as a real (and wrong) race.
|
||
pub(crate) fn insert_player(&mut self, guid: u64, name: String, traits: Option<(u8, u8, u8)>) {
|
||
self.pending_players.remove(&guid);
|
||
if !name.is_empty() {
|
||
if let Some(t) = traits {
|
||
self.player_traits.insert(guid, t);
|
||
}
|
||
}
|
||
self.players
|
||
.insert(guid, (!name.is_empty()).then_some(name));
|
||
self.generation = self.generation.wrapping_add(1);
|
||
}
|
||
|
||
/// The landed-answer counter — see the [`Self::generation`] field.
|
||
pub(crate) fn generation(&self) -> u64 {
|
||
self.generation
|
||
}
|
||
|
||
/// `(race, class, gender)` for a player guid the name query has answered for — the macro
|
||
/// expander's fallback when the unit isn't streamed. See [`NameCache::player_traits`].
|
||
pub(crate) fn player_traits(&self, guid: u64) -> Option<(u8, u8, u8)> {
|
||
self.player_traits.get(&guid).copied()
|
||
}
|
||
|
||
/// Record a pet-name answer (`SMSG_PET_NAME_QUERY_RESPONSE`), keyed by pet number.
|
||
pub(crate) fn insert_pet(&mut self, pet_number: u32, name: String) {
|
||
self.pending_pets.remove(&pet_number);
|
||
self.pets.insert(pet_number, name);
|
||
self.generation = self.generation.wrapping_add(1);
|
||
}
|
||
|
||
/// Forget a pet's cached name so the next [`Self::resolve`] asks for it again (decision 1066).
|
||
///
|
||
/// **The one hole in the ask-once discipline, and the one place it has to have one.** A player's
|
||
/// or a creature's name cannot change under a cache entry; a pet's can — its owner renames
|
||
/// it — and nothing on the wire pushes the new one, because a pet's name does not ride its
|
||
/// descriptor. What *does* arrive is a bumped `UNIT_FIELD_PET_NAME_TIMESTAMP`, and observing
|
||
/// that is what calls this (`crate::ui_pet::unit`). Clearing the pending marker matters too: a
|
||
/// rename racing an in-flight query would otherwise have its re-ask deduped away and leave the
|
||
/// stale name in place forever.
|
||
pub(crate) fn forget_pet(&mut self, pet_number: u32) {
|
||
self.pending_pets.remove(&pet_number);
|
||
self.pets.remove(&pet_number);
|
||
self.generation = self.generation.wrapping_add(1);
|
||
}
|
||
|
||
/// Record a creature-name answer (`SMSG_CREATURE_QUERY_RESPONSE`); `None` = unknown entry.
|
||
pub(crate) fn insert_creature(&mut self, entry: u32, record: Option<CreatureRecord>) {
|
||
self.pending_creatures.remove(&entry);
|
||
self.creatures.insert(entry, record);
|
||
self.generation = self.generation.wrapping_add(1);
|
||
}
|
||
|
||
/// The cached subname (the overhead/tooltip title line) for a creature entry — read-only: the
|
||
/// nameplate asks only after [`Self::resolve`] already returned the name (same answer packet).
|
||
pub(crate) fn creature_subname(&self, entry: u32) -> Option<&str> {
|
||
self.creatures
|
||
.get(&entry)?
|
||
.as_ref()
|
||
.and_then(|r| r.subname.as_deref())
|
||
}
|
||
|
||
/// The whole cached record for a creature entry — the unit tooltip's read (subtitle, type,
|
||
/// rank word, civilian — decision 0276's level-line law), and the one route to a live unit's
|
||
/// [`type_flags`]. Read-only, the subname's ask-once discipline.
|
||
///
|
||
/// **Key it off the unit's descriptor `OBJECT_FIELD_ENTRY`, never the entry in its guid**
|
||
/// (decision 2068): the reference registers this record under `[[unit+8]+0xc]` (`0x60b160`),
|
||
/// there is exactly one record per unit, and only the descriptor carries a template entry for
|
||
/// a `HIGHGUID_PET` guid — whose entry slot holds a pet number instead. A guid-keyed twin of
|
||
/// this read used to exist and is gone: two keys onto one field is how the readers drift.
|
||
///
|
||
/// `None` = no template answer yet, and **each consumer decides what that means, because the
|
||
/// reference's per-bit getters do**: the parked-event gate (`MORE_AUDIBLE`, decision 1482)
|
||
/// fails CLOSED like `0x623b70`'s null leg, the wound gate (`DO_NOT_PLAY_WOUND_ANIM`, 2068)
|
||
/// fails OPEN like `0x6125f0`'s.
|
||
pub(crate) fn creature_record(&self, entry: u32) -> Option<&CreatureRecord> {
|
||
self.creatures.get(&entry)?.as_ref()
|
||
}
|
||
|
||
/// The cached `CreatureType.dbc` id for a creature entry — read-only, same ask-once discipline
|
||
/// as the subname (the TAB-target scan reads it; an unresolved entry is `None`, which the scan
|
||
/// treats as targetable — the client's own out-of-range skip).
|
||
pub(crate) fn creature_type(&self, entry: u32) -> Option<u32> {
|
||
self.creatures
|
||
.get(&entry)?
|
||
.as_ref()
|
||
.map(|r| r.creature_type)
|
||
}
|
||
|
||
/// Forget the in-flight asks (a disconnect may have dropped them on the writer floor). Resolved
|
||
/// player/creature names stay — those are identities, stable across sessions. Resolved **pet**
|
||
/// names do not: a pet number names one live spawn rather than a template, and nothing that
|
||
/// answered before the disconnect is still on the map after it.
|
||
pub(crate) fn clear_pending(&mut self) {
|
||
self.pending_players.clear();
|
||
self.pending_creatures.clear();
|
||
self.pending_pets.clear();
|
||
self.pets.clear();
|
||
}
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
||
// Persistence — the `.wdb` law, in benilla's own folder (decision 1689)
|
||
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
||
|
||
/// The file's first line. Every field is compared by **equality** on load and any mismatch
|
||
/// discards the whole file — the reference's own rule for its `.wdb` header, whose 20 bytes are
|
||
/// `[FourCC | build 0x16f3 | locale | recordSize | version 1]` and carry **no checksum, no
|
||
/// timestamp and no TTL** (wow-re `system/dbcache/dbcache.md`, Contracts). Reproducing the
|
||
/// *absence* matters as much as the presence: a cache that expired entries on a clock would
|
||
/// re-ask for names the server has no reason to have changed, and one that trusted a checksum
|
||
/// over a build would deserialize last patch's record layout into this one's struct.
|
||
const CACHE_MAGIC: &str = "benilla-namecache";
|
||
/// Our own record-layout version — the analogue of the header's `recordSize`+`version` pair. Bump
|
||
/// it whenever a column below is added, removed or reordered; the old file is then discarded
|
||
/// rather than misparsed.
|
||
const CACHE_FORMAT: u32 = 1;
|
||
/// The client build the names were answered by — the reference's `0x16f3`. A different build is a
|
||
/// different server protocol, so its answers are not ours.
|
||
const CACHE_BUILD: u32 = 5875;
|
||
/// The locale the names are in. benilla is enUS-only today (every DBC read takes locale slot 0),
|
||
/// so this is a constant — but it is *in* the header because that is the reference's law, and the
|
||
/// day benilla speaks a second locale a stale enUS cache must be discarded rather than shown.
|
||
const CACHE_LOCALE: &str = "enUS";
|
||
|
||
impl NameCache {
|
||
/// Serialize the cache as the TSV this module persists — a header line, then one line per
|
||
/// record. Tab-separated because a name may contain spaces but never a tab or a newline, and
|
||
/// because a player who opens `benilla-config/` should be able to read what we kept about them.
|
||
///
|
||
/// **`pending_*` is deliberately not written.** An in-flight ask is a fact about this
|
||
/// connection, not about the names; persisting it would make a cache reloaded mid-query
|
||
/// believe an answer was coming that never will.
|
||
pub(crate) fn to_tsv(&self, realm: &str) -> String {
|
||
let mut out =
|
||
format!("{CACHE_MAGIC}\t{CACHE_FORMAT}\t{CACHE_BUILD}\t{CACHE_LOCALE}\t{realm}\n");
|
||
for (guid, name) in &self.players {
|
||
match name {
|
||
// `P` — a known player. The traits ride the same line they arrived on.
|
||
Some(name) => {
|
||
let (race, class, gender) =
|
||
self.player_traits.get(guid).copied().unwrap_or((0, 0, 0));
|
||
out.push_str(&format!("P\t{guid}\t{race}\t{class}\t{gender}\t{name}\n"));
|
||
}
|
||
// `p` — asked, and the server did not know. Worth keeping for the same reason it
|
||
// is worth caching in memory: it stops us re-asking a dead guid forever.
|
||
None => out.push_str(&format!("p\t{guid}\n")),
|
||
}
|
||
}
|
||
for (entry, rec) in &self.creatures {
|
||
match rec {
|
||
Some(r) => out.push_str(&format!(
|
||
"C\t{entry}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\n",
|
||
r.creature_type,
|
||
r.pet_family,
|
||
r.rank,
|
||
r.type_flags,
|
||
u8::from(r.civilian),
|
||
u8::from(r.racial_leader),
|
||
r.display_id,
|
||
r.name,
|
||
r.subname.as_deref().unwrap_or("")
|
||
)),
|
||
None => out.push_str(&format!("c\t{entry}\n")),
|
||
}
|
||
}
|
||
for (pet_number, name) in &self.pets {
|
||
out.push_str(&format!("E\t{pet_number}\t{name}\n"));
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Rebuild a cache from [`Self::to_tsv`]'s output, or `None` if the header does not match this
|
||
/// build/locale/format **exactly**. A malformed or truncated line is skipped rather than
|
||
/// failing the load: a half-written cache should cost a re-query, never a broken session.
|
||
pub(crate) fn from_tsv(text: &str, realm: &str) -> Option<Self> {
|
||
let mut lines = text.lines();
|
||
let header: Vec<&str> = lines.next()?.split('\t').collect();
|
||
// The equality gate. Five fields, all of them, no tolerance — see `CACHE_MAGIC`.
|
||
if header.len() != 5
|
||
|| header[0] != CACHE_MAGIC
|
||
|| header[1] != CACHE_FORMAT.to_string()
|
||
|| header[2] != CACHE_BUILD.to_string()
|
||
|| header[3] != CACHE_LOCALE
|
||
|| header[4] != realm
|
||
{
|
||
return None;
|
||
}
|
||
let mut cache = NameCache::default();
|
||
for line in lines {
|
||
let f: Vec<&str> = line.split('\t').collect();
|
||
match f.first().copied() {
|
||
Some("P") if f.len() >= 6 => {
|
||
let Ok(guid) = f[1].parse::<u64>() else {
|
||
continue;
|
||
};
|
||
// The name is the LAST field, not `f[5]`: a name cannot contain a tab, so the
|
||
// split is exact — but taking the tail keeps this honest if a column is ever
|
||
// added before it and the format version is forgotten.
|
||
cache.players.insert(guid, Some(f[5].to_string()));
|
||
if let (Ok(race), Ok(class), Ok(gender)) =
|
||
(f[2].parse(), f[3].parse(), f[4].parse())
|
||
{
|
||
cache.player_traits.insert(guid, (race, class, gender));
|
||
}
|
||
}
|
||
Some("p") if f.len() >= 2 => {
|
||
if let Ok(guid) = f[1].parse::<u64>() {
|
||
cache.players.insert(guid, None);
|
||
}
|
||
}
|
||
Some("C") if f.len() >= 11 => {
|
||
let Ok(entry) = f[1].parse::<u32>() else {
|
||
continue;
|
||
};
|
||
let (Ok(creature_type), Ok(pet_family), Ok(rank), Ok(type_flags)) =
|
||
(f[2].parse(), f[3].parse(), f[4].parse(), f[5].parse())
|
||
else {
|
||
continue;
|
||
};
|
||
let Ok(display_id) = f[8].parse() else {
|
||
continue;
|
||
};
|
||
cache.creatures.insert(
|
||
entry,
|
||
Some(CreatureRecord {
|
||
name: f[9].to_string(),
|
||
// An empty subname is NO subname, the same mapping the wire decode
|
||
// makes — `Some("")` would paint an empty tooltip line.
|
||
subname: (!f[10].is_empty()).then(|| f[10].to_string()),
|
||
creature_type,
|
||
pet_family,
|
||
rank,
|
||
type_flags,
|
||
civilian: f[6] == "1",
|
||
racial_leader: f[7] == "1",
|
||
display_id,
|
||
}),
|
||
);
|
||
}
|
||
Some("c") if f.len() >= 2 => {
|
||
if let Ok(entry) = f[1].parse::<u32>() {
|
||
cache.creatures.insert(entry, None);
|
||
}
|
||
}
|
||
Some("E") if f.len() >= 3 => {
|
||
if let Ok(pet_number) = f[1].parse::<u32>() {
|
||
cache.pets.insert(pet_number, f[2].to_string());
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
Some(cache)
|
||
}
|
||
|
||
/// Drop a player's cached name (`SMSG_INVALIDATE_PLAYER`) so the next resolve re-asks — the
|
||
/// reference's remove-by-key (`0x556ff0`, wow-re `dbcache.md` Contracts: eviction is
|
||
/// **explicit only**, there is no TTL).
|
||
///
|
||
/// This is the safety valve persistence needs. In memory a stale name lasts a session; on disk
|
||
/// it lasts forever, so the one packet that says "forget this guid" has to be honoured. Note
|
||
/// that vmangos never sends it — so against our own server this is the mechanism being present
|
||
/// and correct rather than a path we exercise.
|
||
pub(crate) fn invalidate_player(&mut self, guid: u64) {
|
||
self.players.remove(&guid);
|
||
self.player_traits.remove(&guid);
|
||
self.pending_players.remove(&guid);
|
||
self.generation = self.generation.wrapping_add(1);
|
||
}
|
||
|
||
/// How many records the cache holds — the persistence layer's "is this worth writing" read.
|
||
pub(crate) fn len(&self) -> usize {
|
||
self.players.len() + self.creatures.len() + self.pets.len()
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crossbeam_channel::TryRecvError;
|
||
|
||
/// Compose a guid the way the server does: `counter | (entry << 24) | (high << 48)`.
|
||
fn compose(high: u16, entry: u32, counter: u32) -> u64 {
|
||
u64::from(counter) | (u64::from(entry) << 24) | (u64::from(high) << 48)
|
||
}
|
||
|
||
fn commands() -> (NetCommands, crossbeam_channel::Receiver<ClientCommand>) {
|
||
let (tx, rx) = crossbeam_channel::unbounded();
|
||
(NetCommands(tx), rx)
|
||
}
|
||
|
||
/// The gated feeds' counter (1439): a landed answer (and the rename eviction) moves it; an
|
||
/// ask-once miss — the read gated feeds run every frame — never does, or the outstanding
|
||
/// query would hold every name-watching gate open until it answered.
|
||
#[test]
|
||
fn the_generation_counts_landings_never_asks() {
|
||
let (cmds, _rx) = commands();
|
||
let mut cache = NameCache::default();
|
||
let g0 = cache.generation();
|
||
|
||
let player = compose(0, 0, 7);
|
||
assert_eq!(cache.resolve(player, &cmds), None);
|
||
assert_eq!(
|
||
cache.generation(),
|
||
g0,
|
||
"the miss asked, and asking is not a landing"
|
||
);
|
||
|
||
cache.insert_player(player, "Benilla".into(), None);
|
||
let landed = cache.generation();
|
||
assert_ne!(g0, landed, "the answer landing is the edge");
|
||
|
||
assert_eq!(cache.resolve(player, &cmds), Some("Benilla"));
|
||
assert_eq!(cache.generation(), landed, "a cache hit moves nothing");
|
||
|
||
cache.insert_pet(137, "Voidwalker".into());
|
||
let pet = cache.generation();
|
||
assert_ne!(landed, pet);
|
||
cache.forget_pet(137);
|
||
assert_ne!(
|
||
pet,
|
||
cache.generation(),
|
||
"the rename eviction is a landing-shaped edge"
|
||
);
|
||
}
|
||
|
||
/// A summoned pet resolves through `CMSG_PET_NAME_QUERY`, keyed by the pet number in its guid —
|
||
/// NOT through the creature query, which would ask for a template entry that does not exist and
|
||
/// leave the pet nameless (the reported bug: an NPC-summoned voidwalker with no name).
|
||
#[test]
|
||
fn a_pet_asks_the_pet_query_not_the_creature_query() {
|
||
let (cmds, rx) = commands();
|
||
let mut cache = NameCache::default();
|
||
let voidwalker = compose(guid::HIGH_PET, 137, 9);
|
||
|
||
assert_eq!(cache.resolve(voidwalker, &cmds), None);
|
||
assert!(matches!(
|
||
rx.try_recv(),
|
||
Ok(ClientCommand::PetNameQuery {
|
||
pet_number: 137,
|
||
..
|
||
})
|
||
));
|
||
// Ask-once, like every other name.
|
||
assert_eq!(cache.resolve(voidwalker, &cmds), None);
|
||
assert!(matches!(rx.try_recv(), Err(TryRecvError::Empty)));
|
||
|
||
cache.insert_pet(137, "Voidwalker".into());
|
||
assert_eq!(cache.resolve(voidwalker, &cmds), Some("Voidwalker"));
|
||
assert_eq!(cache.peek(voidwalker), Some("Voidwalker"));
|
||
// Pet names are per-spawn, so a disconnect drops them.
|
||
cache.clear_pending();
|
||
assert_eq!(cache.peek(voidwalker), None);
|
||
}
|
||
|
||
/// **A stable list warms the pet-name cache, so an unstabled pet is named on arrival**
|
||
/// (decision 1688). The list carries the same `(pet_number, name)` pair the pet-name query
|
||
/// would answer with, and the guid slot benilla reads that number out of is the very one
|
||
/// vmangos packs it into (`Object::_Create(guidlow, petNumber, HIGHGUID_PET)`).
|
||
///
|
||
/// The window this closes is a Lua error the director hit: `PetStable_Update` hands a bare
|
||
/// `UnitName("pet")` to `GameTooltip:SetText`, whose byte-pinned signature REQUIRES a string
|
||
/// and raises on nil. The reference's own line is unguarded too — it survives because
|
||
/// `petnamecache.wdb` persists across sessions and benilla has no such file.
|
||
#[test]
|
||
fn a_stable_list_names_the_pet_before_it_is_ever_summoned() {
|
||
let (cmds, rx) = commands();
|
||
let mut cache = NameCache::default();
|
||
// The guid the pet will have once summoned: its pet number rides the ENTRY slot.
|
||
let rex = compose(guid::HIGH_PET, 7, 42);
|
||
|
||
// Cold: nil, and an ask goes out — the state that raised the error.
|
||
assert_eq!(cache.resolve(rex, &cmds), None);
|
||
assert!(matches!(
|
||
rx.try_recv(),
|
||
Ok(ClientCommand::PetNameQuery { pet_number: 7, .. })
|
||
));
|
||
|
||
// The list lands and seeds the name. Now the pet resolves with NO further query, which is
|
||
// the whole point: it is named the frame it is summoned, not a round trip later.
|
||
let mut warm = NameCache::default();
|
||
warm.insert_pet(7, "Rex".into());
|
||
assert_eq!(warm.resolve(rex, &cmds), Some("Rex"));
|
||
assert!(
|
||
matches!(rx.try_recv(), Err(TryRecvError::Empty)),
|
||
"a seeded pet must not re-ask"
|
||
);
|
||
}
|
||
|
||
/// **The round trip keeps every record kind and every shape of answer** (decision 1689) —
|
||
/// including the two "the server said no" negatives, which are the ones a naive serializer
|
||
/// drops: cached as absent, they are what stops us re-asking a dead guid or an unknown entry
|
||
/// on every single login, forever.
|
||
#[test]
|
||
fn the_cache_round_trips_through_its_file_format() {
|
||
let (cmds, _rx) = commands();
|
||
let mut cache = NameCache::default();
|
||
cache.insert_player(0x11, "Sam".into(), Some((1, 2, 0)));
|
||
cache.insert_player(0x22, String::new(), None); // the server did not know
|
||
cache.insert_creature(
|
||
69,
|
||
Some(CreatureRecord {
|
||
name: "Stable Master Kitrik".into(),
|
||
subname: Some("Stable Master".into()),
|
||
creature_type: 7,
|
||
pet_family: 0,
|
||
rank: 1,
|
||
type_flags: 0x10,
|
||
civilian: true,
|
||
racial_leader: false,
|
||
display_id: 533,
|
||
}),
|
||
);
|
||
cache.insert_creature(1234, None); // the server flagged the entry unknown
|
||
cache.insert_pet(7, "Rex".into());
|
||
|
||
let text = cache.to_tsv("Hydraxian Waterlords");
|
||
let mut back = NameCache::from_tsv(&text, "Hydraxian Waterlords").expect("header matches");
|
||
|
||
assert_eq!(back.resolve(0x11, &cmds), Some("Sam"));
|
||
assert_eq!(back.player_traits(0x11), Some((1, 2, 0)));
|
||
// A cached negative must survive as a negative — present-and-None, not absent.
|
||
assert_eq!(back.resolve(0x22, &cmds), None);
|
||
assert_eq!(back.peek(0x22), None);
|
||
let rec = back.creature_record(69).expect("creature survived");
|
||
assert_eq!(rec.name, "Stable Master Kitrik");
|
||
assert_eq!(rec.subname.as_deref(), Some("Stable Master"));
|
||
assert_eq!((rec.creature_type, rec.rank, rec.type_flags), (7, 1, 0x10));
|
||
assert_eq!((rec.civilian, rec.racial_leader), (true, false));
|
||
assert_eq!(rec.display_id, 533, "the model a stabled pet is drawn from");
|
||
assert!(back.creature_record(1234).is_none());
|
||
assert_eq!(back.len(), 5);
|
||
|
||
// An EMPTY subname must come back as no subname, the same mapping the wire decode makes —
|
||
// `Some("")` paints an empty tooltip line whose zero-extent slot spills the ones below it.
|
||
cache.insert_creature(
|
||
70,
|
||
Some(CreatureRecord {
|
||
name: "Boar".into(),
|
||
subname: None,
|
||
creature_type: 1,
|
||
pet_family: 5,
|
||
rank: 0,
|
||
type_flags: 0,
|
||
civilian: false,
|
||
racial_leader: false,
|
||
display_id: 1,
|
||
}),
|
||
);
|
||
let back = NameCache::from_tsv(&cache.to_tsv("R"), "R").expect("header");
|
||
assert_eq!(back.creature_record(70).unwrap().subname, None);
|
||
}
|
||
|
||
/// **The header is an equality gate with no tolerance** — the reference's own `.wdb` rule, and
|
||
/// the reason its cache carries no checksum and no clock. Each field is rejected on its own:
|
||
/// a different build speaks a different protocol, a different locale means different strings,
|
||
/// a different format version means these columns are not those columns, and a different realm
|
||
/// means every key in the file addresses somebody else.
|
||
#[test]
|
||
fn a_header_that_differs_in_any_field_discards_the_whole_file() {
|
||
let mut cache = NameCache::default();
|
||
cache.insert_player(0x11, "Sam".into(), None);
|
||
let good = cache.to_tsv("Hydraxian Waterlords");
|
||
assert!(NameCache::from_tsv(&good, "Hydraxian Waterlords").is_some());
|
||
|
||
// The realm — the field the reference does NOT have, and the one that would silently
|
||
// serve another realm's names.
|
||
assert!(NameCache::from_tsv(&good, "Another Realm").is_none());
|
||
|
||
let head = good.lines().next().unwrap();
|
||
for (field, bad) in [
|
||
(0, "not-benilla"),
|
||
(1, "999"), // format version
|
||
(2, "5876"), // client build
|
||
(3, "frFR"), // locale
|
||
] {
|
||
let mut parts: Vec<&str> = head.split('\t').collect();
|
||
parts[field] = bad;
|
||
let text = format!("{}\n{}", parts.join("\t"), good.lines().nth(1).unwrap());
|
||
assert!(
|
||
NameCache::from_tsv(&text, "Hydraxian Waterlords").is_none(),
|
||
"header field {field} = {bad} must discard the file"
|
||
);
|
||
}
|
||
|
||
// Garbage and truncation cost a re-query, never a broken session.
|
||
assert!(NameCache::from_tsv("", "R").is_none());
|
||
assert!(NameCache::from_tsv("nonsense", "R").is_none());
|
||
// A good header with a mangled record line keeps the header's other records.
|
||
let text = format!("{}\nP\tnotanumber\t1\t2\t0\tX\nE\t7\tRex\n", head);
|
||
let back = NameCache::from_tsv(&text, "Hydraxian Waterlords").expect("header still good");
|
||
assert_eq!(back.len(), 1, "the bad line is skipped, the good one kept");
|
||
}
|
||
|
||
/// `SMSG_INVALIDATE_PLAYER`'s eviction — the safety valve persistence needs. The cache ages
|
||
/// nothing out, so without this a name written to disk would outlive any change to it.
|
||
#[test]
|
||
fn invalidating_a_player_makes_the_next_resolve_ask_again() {
|
||
let (cmds, rx) = commands();
|
||
let mut cache = NameCache::default();
|
||
cache.insert_player(0x11, "Sam".into(), Some((1, 2, 0)));
|
||
assert_eq!(cache.resolve(0x11, &cmds), Some("Sam"));
|
||
assert!(matches!(rx.try_recv(), Err(TryRecvError::Empty)), "no ask");
|
||
|
||
let before = cache.generation();
|
||
cache.invalidate_player(0x11);
|
||
assert!(
|
||
cache.generation() != before,
|
||
"an eviction is a landed change"
|
||
);
|
||
assert_eq!(
|
||
cache.player_traits(0x11),
|
||
None,
|
||
"the traits go with the name"
|
||
);
|
||
|
||
// The next resolve is a genuine miss again — it asks.
|
||
assert_eq!(cache.resolve(0x11, &cmds), None);
|
||
assert!(matches!(
|
||
rx.try_recv(),
|
||
Ok(ClientCommand::NameQuery { guid: 0x11 })
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn creature_miss_queries_once_then_serves_the_answer() {
|
||
let (cmds, rx) = commands();
|
||
let mut cache = NameCache::default();
|
||
let wolf_a = compose(guid::HIGH_UNIT, 69, 1);
|
||
let wolf_b = compose(guid::HIGH_UNIT, 69, 2);
|
||
|
||
assert_eq!(cache.resolve(wolf_a, &cmds), None);
|
||
// Same entry, different spawn: no second query.
|
||
assert_eq!(cache.resolve(wolf_b, &cmds), None);
|
||
assert!(matches!(
|
||
rx.try_recv(),
|
||
Ok(ClientCommand::CreatureQuery { entry: 69, .. })
|
||
));
|
||
assert!(matches!(rx.try_recv(), Err(TryRecvError::Empty)));
|
||
|
||
cache.insert_creature(
|
||
69,
|
||
Some(CreatureRecord {
|
||
name: "Young Wolf".into(),
|
||
subname: None,
|
||
creature_type: 0,
|
||
pet_family: 0,
|
||
rank: 0,
|
||
type_flags: 0,
|
||
civilian: false,
|
||
racial_leader: false,
|
||
display_id: 0,
|
||
}),
|
||
);
|
||
assert_eq!(cache.resolve(wolf_b, &cmds), Some("Young Wolf"));
|
||
}
|
||
|
||
#[test]
|
||
fn player_negative_answer_is_cached() {
|
||
let (cmds, rx) = commands();
|
||
let mut cache = NameCache::default();
|
||
let g = compose(guid::HIGH_PLAYER, 0, 7);
|
||
|
||
assert_eq!(cache.resolve(g, &cmds), None);
|
||
assert!(matches!(
|
||
rx.try_recv(),
|
||
Ok(ClientCommand::NameQuery { guid }) if guid == g
|
||
));
|
||
cache.insert_player(g, String::new(), None); // server: unknown guid
|
||
assert_eq!(cache.resolve(g, &cmds), None);
|
||
// …and no re-ask.
|
||
assert!(matches!(rx.try_recv(), Err(TryRecvError::Empty)));
|
||
}
|
||
|
||
#[test]
|
||
fn clear_pending_allows_a_reconnect_reask() {
|
||
let (cmds, rx) = commands();
|
||
let mut cache = NameCache::default();
|
||
let g = compose(guid::HIGH_UNIT, 100, 1);
|
||
|
||
assert_eq!(cache.resolve(g, &cmds), None);
|
||
let _ = rx.try_recv();
|
||
// The answer never lands (disconnect); pending cleared → the next resolve re-asks.
|
||
cache.clear_pending();
|
||
assert_eq!(cache.resolve(g, &cmds), None);
|
||
assert!(matches!(
|
||
rx.try_recv(),
|
||
Ok(ClientCommand::CreatureQuery { entry: 100, .. })
|
||
));
|
||
}
|
||
}
|