Files
benilla-octo/crates/benilla-app/src/names.rs
T
SamandClaude Opus 5.5 c2d6f7c858 the spell owns its state, and a malformed file is refused, not a crash
- the spell owns its lifecycle state: the in-flight cast, the local cancel, the cooldowns and the spell-modifier tables live in one module (2328)
- a malformed MPQ, DBC, M2 or WDT file is refused instead of crashing the client, nine read sites bounded
- a test that needs the install or the addon corpus fails instead of skipping when the data is there; the Bagnon render suite runs again (2329)
- the dev crash injector is armed on every platform, and no instrument writes a path relative to the working directory

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 12:08:33 +07:00

1104 lines
53 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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.
//!
//! **Two of the three live for one world session; one persists.** Creature templates are keyed by
//! a template entry that means the same thing forever, and the reference writes them to
//! `creaturecache.wdb` — so ours persist too (decision 1689, corrected scope). Player names and
//! pet names are keyed by a guid and a pet number, which name *one character* and *one spawn*, and
//! the reference neither writes them to disk nor carries them across a world entry — it clears
//! both stores at world-session start ([`NameCache::clear_world_session`]). Persisting them was
//! benilla's own addition, and it is what answered a wiped server's brand-new character with a
//! deleted one's name (report B386, decision 2223).
use std::collections::HashMap;
use bevy::prelude::*;
use benilla_protocol::guid;
use crate::net::{ClientCommand, NetCommands, ObjectStore};
use crate::query_cache::QueryCache;
/// 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: QueryCache<u64, 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. Two consumers now: 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.
///
/// **An entry here is present only when the server answered for it.** A name learned any other
/// way (our own, seeded from the login) has no entry, and that absence is the live shape
/// decision 1549 rests on — not a gap to fill with a zero triple.
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: QueryCache<u32, 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: QueryCache<u32, String>,
/// 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. Born because `is_changed` could not say this while the
/// resolves took `&mut self` every frame; 2288 made them `&self` (the in-flight marks live
/// behind [`QueryCache`]'s lock), and the counter stays as the one number the three caches
/// share.
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(&self, guid_val: u64, commands: &NetCommands) -> Option<&str> {
if guid::is_player(guid_val) {
self.players
.get_or_ask(guid_val, || {
debug!("names: asking player name (guid {guid_val})");
let _ = commands.0.send(ClientCommand::NameQuery { guid: guid_val });
})
.map(String::as_str)
} 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(&self, pet_number: u32, guid: u64, commands: &NetCommands) -> Option<&str> {
self.pets
.get_or_ask(pet_number, || {
debug!("names: asking pet name (pet {pet_number})");
let _ = commands
.0
.send(ClientCommand::PetNameQuery { pet_number, guid });
})
.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(
&self,
entry: u32,
guid: u64,
commands: &NetCommands,
) -> Option<&str> {
self.creatures
.get_or_ask(entry, || {
debug!("names: asking creature name (entry {entry})");
let _ = commands
.0
.send(ClientCommand::CreatureQuery { entry, guid });
})
.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).map(String::as_str)
} 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)?)
.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.
///
/// **A `None` therefore *clears* any traits already filed under this guid**, rather than
/// leaving them: the triple describes the character the name belongs to, and this call is
/// replacing that name. Keeping the old one would leave a record whose name says one character
/// and whose race says another — a contradiction manufactured by the cache itself.
pub(crate) fn insert_player(&mut self, guid: u64, name: String, traits: Option<(u8, u8, u8)>) {
match traits.filter(|_| !name.is_empty()) {
Some(t) => self.player_traits.insert(guid, t),
None => self.player_traits.remove(&guid),
};
self.players
.insert(guid, (!name.is_empty()).then_some(name));
self.generation = self.generation.wrapping_add(1);
}
/// **Drop everything that names one character or one spawn — the reference's world-session
/// wipe** (`0x555740`, the `0x5557ad call 0x560400` arm for the player cache).
///
/// The reference walks all thirteen `DBCache<T>` singletons at world-session start and clears
/// each one **whose persistence is disabled** — the byte at `[cache+0x39]`, tested inverted:
/// non-zero skips, zero clears. Four stores pass `0` to the ctor and are therefore both
/// never-written-to-disk and wiped here: `'WNAM'` (player names, `0x554cd0` — three `push 0x0`
/// against `'WNPC'`/`'WIDB'`'s `push 0x1; push 0x1`), `'WGLD'`, `'WPNM'` and `'WPTN'`. A real
/// 1.12 install's `WDB/` holds exactly the enabled ones and none of those four.
///
/// The rule behind the list is the key's meaning. A creature template **entry** names the same
/// creature on every realm, forever, so its record survives both the wipe and the process. A
/// player **guid** names one character on one realm, and a **pet number** names one live spawn
/// — and nothing on the 1.12 wire ever says either has been handed to somebody else. So the
/// reference does not keep them, and benilla keeping them is what let a server's brand-new
/// character wear a deleted one's name (B386, decision 2223).
///
/// Called at world entry, **before** the login seeds our own name — the seed is about the
/// session we are starting, not the one we just left.
pub(crate) fn clear_world_session(&mut self) {
self.players.clear();
self.player_traits.clear();
self.pets.clear();
self.generation = self.generation.wrapping_add(1);
}
/// **Install a realm cache read off disk — its CREATURE templates, and nothing else**
/// (decision 2260).
///
/// The persisted file has carried creature templates alone since 2223, so this is the whole of
/// what a load can legitimately say. It used to be spelled `*names = loaded` in
/// [`crate::name_persist::load_name_cache`], which is the same thing only when the loader is
/// the first writer of the session — and it is not. The loader fires the first frame the realm
/// identity is known, which is the frame the *pick* is in flight; the login's
/// `SMSG_..._VERIFY_WORLD` seed of our OWN name ([`Self::insert_player`], from
/// `net::session::connected`) lands in the same neighbourhood, and whichever arrives
/// second wins. When the load won, it discarded the player's own name — so `UnitName("player")`
/// answered nil for one wire round-trip while the feed re-asked for a guid it had just been
/// told about, and every addon reading it in that window saw nil (the KLHThreatMeter
/// `table index is nil` report; reproduced live 3 runs in 4).
///
/// Once per process, because `NameCacheFile::realm` only goes `None` → `Some` once — which is
/// exactly the "first login of a fresh game start, and never again" shape the report named.
///
/// So the rule is not an ordering to get right, it is a *scope*: this touches what the file
/// carries. Dropping the guid-keyed stores across a realm change is
/// [`Self::clear_world_session`]'s, and it runs at every world entry regardless.
///
/// Replace rather than merge — the realm-change case, 2223's own reasoning: the previous
/// realm's templates are not this realm's. The generation ticks because a landed record is
/// exactly what the gated feeds watch for (1439).
pub(crate) fn install_persisted(&mut self, loaded: NameCache) {
self.creatures = loaded.creatures;
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.pets.insert(pet_number, Some(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.pets.evict(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.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)?.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)
}
/// 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> {
Some(self.creatures.get(entry)?.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.players.clear_pending();
self.creatures.clear_pending();
self.pets.clear();
}
}
impl crate::query_cache::AskOnce for NameCache {
fn clear_pending(&mut self) {
NameCache::clear_pending(self);
}
}
// ─────────────────────────────────────────────────────────────────────────────────────────────
// 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.
///
/// **2** — the player (`P`/`p`) and pet (`E`) records are gone (decision 2223). They were never the
/// reference's to write: `'WNAM'` and `'WPNM'` are constructed with persistence disabled and are
/// wiped at world-session start instead ([`NameCache::clear_world_session`]). A version-1 file
/// still holding them is discarded whole, which is the header's job and exactly the right thing —
/// its player lines are the stale identities B386 was about.
const CACHE_FORMAT: u32 = 2;
/// 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 (entry, rec) in self.creatures.iter() {
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")),
}
}
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("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(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.evict(guid);
self.player_traits.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()
}
}
/// The name-query answers (in the net handler table since 2325, moved out of the drain's names
/// arm file) — the three `*_QUERY_RESPONSE` packets that fill the ask-once cache
/// (`NameCache::resolve` is what asked for them), and the invalidation that unsticks a player's
/// name. Registered from [`crate::net::NetPlugin`], which initialises the cache.
pub(crate) mod net {
use benilla_protocol::{SessionEvent, SessionEventKind};
use bevy::prelude::*;
use super::{CreatureRecord, NameCache};
use crate::net::NetHandlerApp;
/// Register the four handlers.
pub(crate) fn register(app: &mut App) {
use SessionEventKind as K;
app.net_handler(K::PlayerName, on_player_name)
.net_handler(K::PetName, on_pet_name)
.net_handler(K::CreatureName, on_creature_name)
.net_handler(K::InvalidatePlayer, on_invalidate_player);
}
fn on_player_name(In(ev): In<SessionEvent>, mut names: ResMut<NameCache>) {
if let SessionEvent::PlayerName {
guid,
name,
race,
class,
gender,
} = ev
{
player_name(guid, name, race, class, gender, &mut names);
}
}
fn on_pet_name(In(ev): In<SessionEvent>, mut names: ResMut<NameCache>) {
if let SessionEvent::PetName { pet_number, name } = ev {
pet_name(pet_number, name, &mut names);
}
}
fn on_creature_name(In(ev): In<SessionEvent>, mut names: ResMut<NameCache>) {
if let SessionEvent::CreatureName {
entry,
name,
subname,
creature_type,
pet_family,
rank,
type_flags,
display_id,
civilian,
racial_leader,
} = ev
{
creature_name(
entry,
name,
subname,
creature_type,
pet_family,
rank,
type_flags,
civilian,
racial_leader,
display_id,
&mut names,
);
}
}
fn on_invalidate_player(In(ev): In<SessionEvent>, mut names: ResMut<NameCache>) {
if let SessionEvent::InvalidatePlayer { guid } = ev {
invalidate_player(guid, &mut names);
}
}
/// `SMSG_NAME_QUERY_RESPONSE` — a player's name, plus the race/gender/class that ride the same
/// answer. Those three now have a consumer: the `$`-macro expander's subject fallback for a player
/// who isn't streamed ([`crate::npc_text::subject_for_guid`]).
fn player_name(
guid: u64,
name: String,
race: u32,
class: u32,
gender: u32,
names: &mut NameCache,
) {
names.insert_player(guid, name, Some((race as u8, class as u8, gender as u8)));
}
/// `SMSG_PET_NAME_QUERY_RESPONSE` — keyed by pet *number*, not by template entry.
fn pet_name(pet_number: u32, name: String, names: &mut NameCache) {
names.insert_pet(pet_number, name);
}
/// `SMSG_INVALIDATE_PLAYER` — drop this guid so the next resolve re-asks (decision 1689).
///
/// The name cache has **no TTL** (wow-re `dbcache.md`: eviction is explicit only), so this is the
/// one thing that unsticks a player's name. It matters more to benilla than it did before the
/// cache persisted: in memory a stale name lasted a session, on disk it lasts until something
/// evicts it.
fn invalidate_player(guid: u64, names: &mut NameCache) {
debug!("net: invalidating cached name for player {guid:#x}");
names.invalidate_player(guid);
}
/// `SMSG_CREATURE_QUERY_RESPONSE` — the template's name plus the hover line's fields. A `None` name
/// is the server's "no such entry", cached as such so the ask never repeats; the remaining fields
/// then carry their miss defaults.
fn creature_name(
entry: u32,
name: Option<String>,
subname: Option<String>,
creature_type: Option<u32>,
pet_family: u32,
rank: u32,
type_flags: u32,
civilian: bool,
racial_leader: bool,
display_id: u32,
names: &mut NameCache,
) {
names.insert_creature(
entry,
name.map(|n| CreatureRecord {
name: n,
subname,
creature_type: creature_type.unwrap_or(0),
pet_family,
rank,
type_flags,
civilian,
racial_leader,
display_id,
}),
);
}
}
#[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 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 "the server said no" negative, which is the one a naive serializer drops:
/// cached as absent, it is what stops us re-asking an unknown entry on every login, forever.
///
/// The *player* negative used to be here too and is gone with the player records themselves
/// (2223): a guid the server did not know this week is not a guid it will not know next week,
/// because the guid may by then belong to somebody it does.
#[test]
fn the_cache_round_trips_through_its_file_format() {
let mut cache = NameCache::default();
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
let text = cache.to_tsv("Hydraxian Waterlords");
let back = NameCache::from_tsv(&text, "Hydraxian Waterlords").expect("header matches");
// A cached negative must survive as a negative — present-and-None, not absent.
assert!(back.creatures.answered(1234));
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(), 2);
// 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);
}
/// **B386 — the file carries creature templates and NOTHING that names one character.**
///
/// The reference constructs `'WNAM'` (player names) and `'WPNM'` (pet names) with persistence
/// **disabled** — `0x554cd0`'s three `push 0x0` against `'WNPC'`/`'WIDB'`'s `push 0x1; push
/// 0x1` — and a real 1.12 install's `WDB/` holds `creaturecache.wdb` and no `namecache.wdb`.
/// A creature template entry means the same creature forever; a player guid means one
/// character on one realm, and a wiped server hands it to the next one it creates.
#[test]
fn the_file_keeps_creature_templates_and_no_identities() {
let mut cache = NameCache::default();
cache.insert_player(0x11, "Moamtester".into(), Some((1, 2, 0)));
cache.insert_pet(7, "Rex".into());
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,
}),
);
let text = cache.to_tsv("R");
assert!(
!text.contains("Moamtester") && !text.contains("Rex"),
"no guid- or spawn-keyed name may reach the disk:\n{text}"
);
let back = NameCache::from_tsv(&text, "R").expect("header matches");
assert_eq!(
back.peek(0x11),
None,
"a player name never survives the file"
);
assert_eq!(back.player_traits(0x11), None);
assert_eq!(
back.creature_record(69).map(|r| r.name.as_str()),
Some("Stable Master Kitrik"),
"the template entry is the key that keeps meaning what it meant",
);
}
/// **World entry drops every guid- and spawn-keyed name, and keeps the templates** — the
/// reference's `0x555740`, which clears exactly the stores whose `[cache+0x39]` enable byte is
/// zero (tested inverted: `0x55579f`–`0x5557ad` for the player cache at `0xc0e228`).
#[test]
fn world_entry_clears_the_identities_and_keeps_the_templates() {
let (cmds, rx) = commands();
let mut cache = NameCache::default();
cache.insert_player(0x11, "Moamtester".into(), Some((1, 2, 0)));
cache.insert_pet(7, "Rex".into());
cache.insert_creature(69, None);
cache.clear_world_session();
assert_eq!(cache.peek(0x11), None);
assert_eq!(cache.player_traits(0x11), None);
assert!(
cache.creatures.answered(69),
"templates outlive the world session"
);
// And the ask-once markers went with them, so the next sighting asks rather than sitting
// behind a pending flag from a session that is over.
assert_eq!(cache.resolve(0x11, &cmds), None);
assert!(matches!(
rx.try_recv(),
Ok(ClientCommand::NameQuery { guid: 0x11 })
));
}
/// **Replacing a name drops the traits that belonged to the old one.** They describe the
/// character the name names; leaving them behind manufactures exactly the record the
/// descriptor check exists to catch — one whose name says one character and whose race says
/// another.
#[test]
fn a_name_replaced_without_traits_does_not_keep_the_old_ones() {
let mut cache = NameCache::default();
cache.insert_player(0x7, "Moamtester".into(), Some((1, 1, 0)));
cache.insert_player(0x7, "Corances".into(), None);
assert_eq!(cache.player_traits(0x7), 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_creature(1234, 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!("{}\nC\tnotanumber\t1\t2\t0\tX\nc\t1234\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");
// **A version-1 file is discarded whole, and its player lines with it** (2223). The format
// field does that on its own — but say it here, because those lines are exactly the stale
// identities B386 was about and "the old file is simply dropped" is the property that
// matters, not that the `P` arm no longer parses.
let v1 = format!(
"{CACHE_MAGIC}\t1\t{CACHE_BUILD}\t{CACHE_LOCALE}\tHydraxian Waterlords\n\
P\t7\t1\t1\t0\tMoamtester\n"
);
assert!(NameCache::from_tsv(&v1, "Hydraxian Waterlords").is_none());
}
/// `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, .. })
));
}
}