your own name survives the first login, and four cold pipelines warm

- your character's own name no longer goes missing on the first login of a fresh start, so KLHThreatMeter stops erroring: the four "player" verbs read the login record, as the reference does (2260, 2261, 2263)
- four pipelines that compiled live mid-play now warm under the loading screen: the model tile atlas's orthographic view, the minimap composite's quad, the glow combine pair and the indoor pool's liquid layout (2262, 2264)
- a hoverable sign seats its tooltip where its template says, at the cursor or in the corner, not by its object type: Brill's signpost still follows the cursor (2259)
- three warnings that fired when nothing was wrong are quiet, and the lane-coverage gates walk every crate instead of one (2264)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-09-17 16:37:17 +07:00
co-authored by Claude Fable 5.1
parent e0f752eb73
commit cbe8c8557c
25 changed files with 1426 additions and 96 deletions
+25
View File
@@ -267,6 +267,31 @@ impl super::UiScript {
///
/// Set at world entry from the realm the session actually connected to. Idempotent, and the
/// empty string is a legitimate value (no realm yet), not a "clear".
/// Seed the **local player record** — the reference's `0xc27d80`, copied from the char-enum
/// row at the character-select Enter World commit (decisions 2261/2263, and see
/// [`super::PlayerRecord`] for the bytes and the four verbs that read it).
///
/// Called from the world-entry UI load beside [`Self::set_realm_name`], and for the same
/// reason 1195 put the realm there: the values have to be in the VM before a single addon file
/// runs, because `local currentPlayer = UnitName("player")` at file scope is the corpus idiom.
/// The reference has them a whole login earlier still.
///
/// **A record with no name is refused, not stored.** The only unset state is "no Enter World
/// has been committed in this process"; once the record holds a character, nothing in the
/// reference's image ever empties it again, so a caller with nothing to say must leave the
/// last answer standing rather than blank it. The write is whole-record because the
/// reference's is one `rep movsd`: these four fields describe one character and can never
/// legitimately be seeded from two.
pub fn set_player_record(&mut self, record: super::PlayerRecord) {
if record.name.is_empty() {
return;
}
let mut model = self.model_mut();
if model.player_record != record {
model.player_record = record;
}
}
pub fn set_realm_name(&mut self, realm: &str) {
{
let mut model = self.model_mut();
+2 -1
View File
@@ -279,7 +279,8 @@ pub use types::{
};
pub(crate) use types::{FontExplicit, MeasuredText, RegionData};
pub use unit::{
grey_band, level_reads_unknown, power_token, unit_is_grey, SelectionRequest, UnitState,
grey_band, level_reads_unknown, power_token, unit_is_grey, PlayerRecord, SelectionRequest,
UnitState,
};
pub use weapon_enchant::WeaponEnchant;
pub use who_sort::{WhoSortChain, WhoSortKey};
+21
View File
@@ -1726,6 +1726,26 @@ pub(crate) struct Model {
/// payload, so two calls in a frame are two captures.
pub(crate) screenshot_asks: u32,
pub(crate) realm_name: String,
/// **The local player record — `0xc27d80`, ours** (decisions 2261 and 2263).
///
/// The bytes, the four verbs that read it and why `UnitLevel` is not among them are on
/// [`super::PlayerRecord`] itself; what belongs here is the *lifetime*, because that is what
/// this field's position in the `Model` decides.
///
/// **Two writers, and the asymmetry between them is the mechanism.** The world-entry UI load
/// seeds it from the roster row of the pick in flight — our copy of the same char-enum record
/// — beside [`Self::realm_name`] and before the addon walk (1195's slot, 1230's source); and
/// [`super::UiScript::set_unit`] keeps it in step for any `"player"` push that **carries** the
/// field. A push that does not carry it leaves it standing. So the record can be corrected but
/// never blanked, which is the reference's never-cleared property expressed where it can be
/// relied on — and decision 2260 is why that matters: a name-cache miss for our own guid
/// reached Lua as a nameless player snapshot and took KLHThreatMeter down with it.
///
/// Our VM is rebuilt per login (1290) where the reference's record simply persists, so each new
/// VM is told once. That is the whole of the difference, and it is unobservable: the four verbs
/// do not exist between logins either — they live only in the in-game table `0x850438`, which
/// `UI_Init` installs and the teardown nils, and `GlueScript_RegisterBindings 0x46abb0` skips.
pub(crate) player_record: super::PlayerRecord,
/// The hearthstone bind location's NAME, behind `GetBindLocation()` — the app resolves the
/// `SMSG_BINDPOINTUPDATE` area id through the same AreaTable catalog the hearthstone's `$z`
/// token already uses, and pushes the resolved string here.
@@ -2224,6 +2244,7 @@ impl Model {
played_time_asks: 0,
screenshot_asks: 0,
realm_name: String::new(),
player_record: super::PlayerRecord::default(),
bind_location: String::new(),
gm_ticket_categories: Vec::new(),
gm_ticket_intents: Vec::new(),
+84 -11
View File
@@ -21,9 +21,36 @@ use super::super::binding_abi::flag;
use super::super::Model;
use super::{
check_unit_token, classification_word, grey_band, level_reads_unknown, pick_unit_token,
unit_predicate, unknownobject, with_unit, SelectionRequest,
unit_predicate, unknownobject, with_unit, PlayerRecord, SelectionRequest,
};
/// **The `"player"` fast path, shared by the four verbs that take it** (decision 2263).
///
/// `Some(x)` = this token is `"player"` and `x` is the record's answer; `None` = it is any other
/// token and the caller falls through to the unit resolver. That is the reference's own shape —
/// a full-string, case-insensitive compare of the token against `0x847894` (`b"player\0"`) whose
/// **match is the fall-through** — and it is one function rather than four copies for the reason
/// [`super::super::names::gated_rank`](crate)'s sibling comment gives about rank: four readers of
/// one record, each testing the token itself, is how they drift apart. `UnitName`, `UnitRace`,
/// `UnitClass` and `UnitSex` are the whole set; `UnitLevel` is **not** one of them.
///
/// The compare is a full string, not a prefix: `"playerfoo"` is a recognised-but-unresolvable
/// token ([`super::token_recognised`]) and goes to the resolver, not here.
fn player_record_arm<T>(
lua: &Lua,
token: &Option<String>,
f: impl FnOnce(&PlayerRecord) -> T,
) -> Option<T> {
if !token
.as_deref()
.is_some_and(|t| t.eq_ignore_ascii_case("player"))
{
return None;
}
let model = lua.app_data_ref::<Model>().expect("model app_data");
Some(f(&model.player_record))
}
/// The two class ids `GetComboPoints 0x51a190` accepts — the literals `4` and `0xb` it compares
/// the class byte `[[player+0x110]+0x79]` against. That byte is `UNIT_FIELD_BYTES_0` byte 1, the
/// same value [`PlayerReqState::class_id`](super::super::PlayerReqState) carries; `UnitClass`'s own
@@ -180,8 +207,11 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
//
// Value 1 — and the ONLY two nils a recognised token can produce (`0x517020`, wow-re
// `ui/scratch/binding-shape-arity-law.md` §2.1): the `"player"` fast path reads the
// local name buffer and pushes nil when it is empty (`0x517083` → `0x5abdc0`), and a
// token that resolves to GUID 0 pushes nil (`0x5170c0`). EVERY other path ends in a
// local name buffer and pushes nil when it is empty (`0x51708c` → `0x5abdc0`; the
// `0x517083` this used to cite is the token's string *compare*, `call 0x64a4c0` —
// corrected 2261), and a token that resolves to GUID 0 pushes nil (`0x5170c0`).
// The empty-buffer nil is not an explicit push either: `0x517095` is
// `lua_pushstring`, which falls through on NULL at `0x6f3895` into `lua_pushnil`. EVERY other path ends in a
// string — the cached name, or `FrameScript_GetText("UNKNOWNOBJECT")`: `0x517220` for
// a GUID with no object and no cache row, `0x609324` inside `CGUnit_C::GetUnitName`
// for a unit whose name cache has not answered or is stale (a pet's is
@@ -195,17 +225,30 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
// named gap), so a not-selectable unit reads its name there with `UnitExists` nil; the
// name resolver's own nil is GUID 0 and nothing else, and a feed that seats a token
// has resolved it. Decision 2002.
//
// **The `"player"` arm is the BUFFER, not the snapshot** (decision 2261). `0x51708c`
// reads `0x5abdc0` — the local name buffer at `0xc27d88` — and returns; it never
// reaches the resolver, so no object, no descriptor and no name-query answer is
// involved on this path at all. Modelling it off the snapshot (as this did until
// 2261) put the one name the client always knows behind the one cache that can miss:
// decision 2260's realm-cache load dropped our own guid, the feed pushed a nameless
// player over the roster seat, and `UnitName("player")` read nil in the world.
//
// The buffer's emptiness is still the reference's own nil — it just means something
// a snapshot cannot say, and something a client that is in the world never is:
// "no Enter World has been committed in this process".
if let Some(seeded) = player_record_arm(lua, &token, |r| r.name.clone()) {
let name = if seeded.is_empty() {
Value::Nil
} else {
Value::String(lua.create_string(&seeded)?)
};
return Ok((name, Value::Nil));
}
let name = with_unit(lua, &token, None, |u| Some(u.name.clone()))?;
let name = match name {
None => Value::Nil,
Some(Some(n)) => Value::String(lua.create_string(&n)?),
Some(None)
if token
.as_deref()
.is_some_and(|t| t.eq_ignore_ascii_case("player")) =>
{
Value::Nil
}
Some(None) => Value::String(unknownobject(lua)?),
};
Ok((name, Value::Nil))
@@ -862,6 +905,18 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
token,
r#"Usage: UnitRace("unit")"#,
)?);
// The `"player"` arm is the RECORD, not the snapshot (decision 2263): `0x518269`
// reads `0x5abdd0` — `0xc27e80`, the char-enum row's race byte — and resolves it
// through `ChrRaces`, never reaching the unit resolver. See [`PlayerRecord`].
if let Some(pair) = player_record_arm(lua, &token, |r| r.race.clone()) {
return match pair {
Some((loc, file)) => Ok((
Value::String(lua.create_string(&loc)?),
Value::String(lua.create_string(&file)?),
)),
None => Ok((Value::Nil, Value::Nil)),
};
}
let pair = with_unit(lua, &token, None, |u| {
u.race.clone().zip(u.race_file.clone())
})?;
@@ -882,6 +937,17 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
token,
r#"Usage: UnitClass("unit")"#,
)?);
// The `"player"` arm is the RECORD (decision 2263): `0x5183b9` → `0x5abde0`,
// `0xc27e81` through `ChrClasses`. Second return is the UPPERCASE token.
if let Some(pair) = player_record_arm(lua, &token, |r| r.class.clone()) {
return match pair {
Some((loc, file)) => Ok((
Value::String(lua.create_string(&loc)?),
Value::String(lua.create_string(&file)?),
)),
None => Ok((Value::Nil, Value::Nil)),
};
}
let pair = with_unit(lua, &token, None, |u| {
u.class.clone().zip(u.class_file.clone())
})?;
@@ -945,7 +1011,14 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
token,
r#"Usage: UnitSex("unit")"#,
)?);
let sex = with_unit(lua, &token, 0u8, |u| u.sex)?;
// The `"player"` arm is the RECORD (decision 2263): `0x517ef9` → `0x5abdf0`,
// `0xc27e82`. **No bounds check anywhere on that path** — the byte indexes
// `fild [4*eax+0x808be4]` over `{2,3,1,6}` directly — so an unset record answers `2`
// ("male") in the reference too, which is what our `0` maps to below.
let sex = match player_record_arm(lua, &token, |r| r.sex) {
Some(sex) => sex,
None => with_unit(lua, &token, 0u8, |u| u.sex)?,
};
Ok(Value::Integer(i64::from(if sex == 0 { 2 } else { sex })))
})?,
)?;
+80
View File
@@ -53,6 +53,59 @@ pub enum SelectionRequest {
LastEnemy,
}
/// **The local player record — `0xc27d80`, ours** (decision 2263).
///
/// A copy of ONE `SMSG_CHAR_ENUM` `CHARACTER_INFO` row, and the source the reference answers four
/// `"player"` verbs from. Each opens with the identical full-string, case-insensitive compare of
/// the token against `0x847894` (`b"player\0"`) whose **match is the fall-through**, and each is
/// unconditional — there is no arm that re-routes `"player"` to the unit resolver, so the
/// descriptor never supersedes this record even once the object exists:
///
/// | verb | fast path | accessor | byte |
/// |---|---|---|---|
/// | `UnitName` `0x517020` | `0x51708c` | `0x5abdc0` | `+0x08` name |
/// | `UnitRace` `0x518200` | `0x518269` | `0x5abdd0` | `+0x100` → `ChrRaces` |
/// | `UnitClass` `0x518350` | `0x5183b9` | `0x5abde0` | `+0x101` → `ChrClasses` |
/// | `UnitSex` `0x517e90` | `0x517ef9` | `0x5abdf0` | `+0x102` gender |
///
/// One writer image-wide — `0x5abd9e` (`rep movsd`, 0x44 dwords) inside `CGlueMgr::EnterWorld`,
/// at the character-select commit — and **no instruction anywhere clears it**. It is filled before
/// FrameXML loads and before `CMSG_PLAYER_LOGIN` is sent, which is why the reference can run addon
/// `OnUpdate` for many frames with no local player object and still answer all four (wow-re
/// `ui/scratch/unitname-player-seed-window.md` §6).
///
/// **`UnitLevel` is deliberately NOT here.** The record carries the level at `+0x108` and the
/// client ships an accessor for it, `0x5abe00`, that **nothing calls** — so `UnitLevel("player")`
/// reads the descriptor and answers `0` in exactly the window the four answer in.
///
/// **Resolved strings, not the raw bytes**, because this crate has no DBC: the reference resolves
/// `+0x100`/`+0x101` through `ChrRaces`/`ChrClasses` at call time, and the app does that same
/// resolution once when it seeds the record (it is the identical lookup
/// `ui_unit::race_names`/`class_names` already does for the unit snapshot, so the two cannot
/// disagree about what a race byte means).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct PlayerRecord {
/// `+0x08` — the NUL-terminated name buffer at `0xc27d88`. Empty is the record's only unset
/// state, and the reference's only `UnitName("player")` nil: `lua_pushstring(NULL)` at
/// `0x517095` tail-jumps into `lua_pushnil`. Unreachable from Lua in the real client, because
/// the verb is not registered until `UI_Init` and only an Enter World commit gets there.
pub name: String,
/// `+0x100` resolved — `UnitRace`'s `(localized, file token)` pair, e.g.
/// `("Night Elf", "NightElf")`. `None` is the all-zero record: race 0 has no `ChrRaces` row,
/// and the reference's bound/NULL-row arms push `nil, nil` rather than reaching the resolver.
pub race: Option<(String, String)>,
/// `+0x101` resolved — `UnitClass`'s `(localized, UPPERCASE token)` pair, e.g.
/// `("Warrior", "WARRIOR")`. `None` for the all-zero record, same shape as [`Self::race`].
pub class: Option<(String, String)>,
/// `+0x102` on `UnitSex`'s scale (2 male, 3 female) — **not** the wire's 0/1.
///
/// `0` is our unset marker and reads as `2`, which is not a fudge: `0x517ef9`'s accessor feeds
/// `fild [4*eax+0x808be4]` over `{2,3,1,6}` with **no bounds check at all**, so an all-zero
/// record answers `2` ("male") in the reference too. It is the one of the four with no
/// validity guard.
pub sex: u8,
}
/// One unit-token's game-state snapshot, pushed by the app each frame and read by the `Unit*`
/// bindings. Plain data (no mlua handles, no ECS types) — the engine-free seam decision 0068 §3
/// draws between the app's net/ECS feed and the Lua API.
@@ -539,6 +592,33 @@ impl super::UiScript {
// canonical lowercase token, so a feed that ever pushed `"Target"` could not create
// a second, shadowing entry.
Some(s) => {
// **A `"player"` push keeps the record in step, field by field** (2261,
// widened by 2263). The reference writes `0xc27d80` once per Enter World
// commit and never clears it; our VM is rebuilt per login, so the entry load
// seeds it — and this keeps it true for any later push that KNOWS a field,
// without ever letting one that doesn't blank it.
//
// That asymmetry is the whole mechanism: 2260's nameless player snapshot is
// exactly the push that must not be able to reach these four verbs. It is also
// what keeps every fixture in the workspace correct without touching them — a
// test that seats a full player is, by this rule, also seating the record.
if token.eq_ignore_ascii_case("player") {
let rec = &mut model.player_record;
if let Some(name) = s.name.as_deref().filter(|n| !n.is_empty()) {
if rec.name != name {
rec.name = name.to_string();
}
}
if let Some(race) = s.race.clone().zip(s.race_file.clone()) {
rec.race = Some(race);
}
if let Some(class) = s.class.clone().zip(s.class_file.clone()) {
rec.class = Some(class);
}
if s.sex != 0 {
rec.sex = s.sex;
}
}
model.units_by_lower.insert(token.to_ascii_lowercase(), s);
}
None => {
+168 -1
View File
@@ -1,6 +1,6 @@
//! The `Unit*` binding tests (the parent module is the unit under test).
use crate::script::{PartyState, UiScript, UnitState};
use crate::script::{PartyState, PlayerRecord, UiScript, UnitState};
fn player() -> UnitState {
UnitState {
@@ -1995,3 +1995,170 @@ fn unit_on_taxi_is_one_or_nil_and_raises_the_reference_usage() {
"the gate's own message, not mlua's type error: {err}"
);
}
/// **The four `"player"` verbs read the seeded record, and nothing can blank it** (2261/2263).
///
/// The reference's `"player"` arm never reaches the resolver: `0x51708c` reads `0xc27d88`, a copy
/// of the char-enum row written once at the Enter World commit and never cleared anywhere in the
/// image. So the name survives everything that can happen to the *snapshot* — which is the whole
/// of decision 2260's bug: a name-cache miss for our own guid produced a nameless player push, it
/// replaced the roster seat, and the verb went nil in the world. That push is modelled here
/// exactly, and it must now be invisible to this verb.
#[test]
fn the_player_record_outlives_every_snapshot() {
let mut s = UiScript::new().unwrap();
// Before any Enter World commit — the buffer's one empty state, and the reference's nil.
assert!(s
.eval::<bool>(r#"return UnitName("player") == nil"#)
.unwrap());
// The world-entry load seeds it from the roster row, before a single addon file runs.
s.set_player_record(PlayerRecord {
name: "Nelprifour".into(),
race: Some(("Night Elf".into(), "NightElf".into())),
class: Some(("Priest".into(), "PRIEST".into())),
sex: 2,
});
assert_eq!(
s.eval::<String>(r#"return UnitName("player")"#).unwrap(),
"Nelprifour",
"the buffer answers with no player snapshot at all — the reference runs addon OnUpdate \
for many frames with no local player object and this verb still answers"
);
// 2260's push, verbatim: the descriptor landed, the name cache missed for our own guid.
let nameless = UnitState {
exists: true,
has_object: true,
name: None,
level: 5,
..Default::default()
};
s.set_unit("player", Some(nameless));
assert_eq!(
s.eval::<String>(r#"return UnitName("player")"#).unwrap(),
"Nelprifour",
"a nameless snapshot must not reach this verb — that push is the reported bug"
);
assert_eq!(
s.eval::<String>(r#"return UnitRace("player")"#).unwrap(),
"Night Elf",
"the same holds for the other three fields of the record (2263)"
);
assert_eq!(
s.eval::<String>(r#"local _, t = UnitClass("player"); return t"#)
.unwrap(),
"PRIEST",
"UnitClass's second return is the UPPERCASE token"
);
assert_eq!(s.eval::<i64>(r#"return UnitSex("player")"#).unwrap(), 2);
// …and the snapshot is still the snapshot for everything else. `UnitLevel` is the boundary:
// the record carries a level byte and the client's accessor for it (`0x5abe00`) is DEAD, so
// this one reads the descriptor even for `"player"`.
assert_eq!(s.eval::<i64>(r#"return UnitLevel("player")"#).unwrap(), 5);
// The token going away entirely (the logout despawn frames) does not empty the buffer either.
s.set_unit("player", None);
assert_eq!(
s.eval::<String>(r#"return UnitName("player")"#).unwrap(),
"Nelprifour"
);
assert!(
s.eval::<bool>(r#"return UnitExists("player") == nil"#)
.unwrap(),
"the name and the unit are different questions — which is exactly what the buffer buys"
);
// An empty seed is refused, not stored: nothing in the reference ever empties this record.
s.set_player_record(PlayerRecord::default());
assert_eq!(
s.eval::<String>(r#"return UnitName("player")"#).unwrap(),
"Nelprifour"
);
assert_eq!(
s.eval::<String>(r#"return UnitRace("player")"#).unwrap(),
"Night Elf"
);
// A NAMED push does keep it in step — a second login as somebody else (1290's per-login VM
// is the usual route, but a push must never leave the two disagreeing).
let mut other = player();
other.name = Some("Onewarrior".into());
s.set_unit("player", Some(other));
assert_eq!(
s.eval::<String>(r#"return UnitName("player")"#).unwrap(),
"Onewarrior"
);
assert_eq!(
s.eval::<String>(r#"local _, t = UnitClass("player"); return t"#)
.unwrap(),
"WARRIOR",
"a push that carries the field updates it; one that does not leaves it standing"
);
assert_eq!(
s.eval::<i64>(r#"return UnitSex("player")"#).unwrap(),
3,
"player() is female — the record follows the push"
);
// Still the family's two returns, the second nil (1840).
assert_eq!(
s.eval::<i64>(r#"local t = {UnitName("player")}; return table.getn(t)"#)
.unwrap(),
1
);
// …and the case fold on the token holds on the fast path too.
assert_eq!(
s.eval::<String>(r#"return UnitName("PLAYER")"#).unwrap(),
"Onewarrior"
);
}
/// **The unset record's four answers, which are not uniform** (decision 2263).
///
/// The state is "no Enter World has been committed in this process" — unreachable from Lua in the
/// reference, because the four verbs only exist inside `UI_Init`'s table and only an Enter World
/// commit gets there. It is reachable *here* (a bare VM, a capture, a test world with no pick), so
/// it is pinned to what the bytes do rather than left to whatever falls out:
///
/// - `UnitName` → nil, via `lua_pushstring(NULL)` at `0x517095` tail-jumping into `lua_pushnil`;
/// - `UnitRace`/`UnitClass` → `nil, nil`, via the DBC bound and NULL-row arms — race 0 and class 0
/// have no row, and those arms reach `pushnil` **without** falling back to the unit resolver;
/// - `UnitSex` → the number **2**, because `0x517ef9`'s accessor feeds `fild [4*eax+0x808be4]`
/// over `{2,3,1,6}` with **no bounds check at all**. It is the one of the four with no validity
/// guard, and "male" is what an all-zero record means to it.
#[test]
fn an_unseeded_player_record_answers_the_references_four_ways() {
let s = UiScript::new().unwrap();
assert!(s
.eval::<bool>(r#"return UnitName("player") == nil"#)
.unwrap());
assert!(s
.eval::<bool>(r#"return UnitRace("player") == nil"#)
.unwrap());
assert!(s
.eval::<bool>(r#"local _, t = UnitRace("player"); return t == nil"#)
.unwrap());
assert!(s
.eval::<bool>(r#"return UnitClass("player") == nil"#)
.unwrap());
assert!(s
.eval::<bool>(r#"local _, t = UnitClass("player"); return t == nil"#)
.unwrap());
assert_eq!(
s.eval::<i64>(r#"return UnitSex("player")"#).unwrap(),
2,
"the one of the four with no bounds check — an all-zero record reads male, not nil"
);
// And the fast path is the TOKEN, not a prefix: `"playerfoo"` is recognised but resolves to
// nothing, so it goes to the resolver and answers the resolver's nil.
assert!(s
.eval::<bool>(r#"return UnitName("playerfoo") == nil"#)
.unwrap());
assert_eq!(
s.eval::<i64>(r#"return UnitSex("playerfoo")"#).unwrap(),
2,
"…which for UnitSex is the same 2, by the binding's own unresolved-token default"
);
}