loot and pet refusals become message ids, and waterfalls flow again

- a loot refusal is a message id, not a sentence: `loot_error_text` composed eight sentences for `SMSG_LOOT_RESPONSE`'s refusals and six of them said something 1.12 never says — "too far away to loot that." for a string that ends "that corpse.", "You can't loot that from there." for "You must be facing the corpse to loot it.", and an invented "Those pockets are already empty." for a code the client has no string for; it and the `LootErrors` queue are deleted, and the refusal is raised by GlobalStrings key into `UiErrorKeys` at the net bridge, the way `fish_verdict` two functions above already did, off the reference's own eleven-entry jump table at `0x5ebc64`; a composed string had no record to read a surface or a voice off, so all eleven — `MsgKind::Error`, the red `UIErrorsFrame` line — had been going into the chat window as yellow SYSTEM lines, and the four spoken in the player's own race and gender (`DIDNT_KILL`, `BAD_FACING`, `LOCKED`, `TOO_FAR`) were silent; a second bug fell out of reading the leg: codes 4–9, 11 and the default fall through a release tail at `0x5ebac2` that zeroes `[player+0x1d28]` — our `LootLatch` — while 10, 12, 13 and 14 jump past it, so a master looter refused a hand-off no longer stands up out of the kneel at a still-open corpse; the default arm's `ja 0x5ebabd` lands on code 7's arm, so every unnamed code prints `ERR_LOOT_DIDNT_KILL` and releases (2035); and the pet has its own two message tables, not the player's with a flag: `SMSG_PET_ACTION_FEEDBACK`'s handler `0x4bdb70` decrements the byte, bounds it at 3 and jump-tables it through `0x4bdbe8` to `ERR_PET_SPELL_DEAD`, `ERR_NO_ATTACK_TARGET`, `ERR_INVALID_ATTACK_TARGET`, `ERR_PET_SPELL_NOPATH` — one per vmangos `PetFeedback` value, where our two-entry map had printed "No path available for your pet" for a dead pet, "Out of range" for no target and nothing for 3 and 4; `SMSG_PET_CAST_FAILED` is `0x6e8eb0`, a separate 142-byte index table that overrides ten reasons, six of them to `ERR_PET_SPELL_*` rows with no other raise site in the client, so `CastFail` grows a `Caster` and picks its table from it while layers 1 and 3 stay shared, as the binary shares them; `0x6e8eb0` calls neither the combat-log formatter `0x62c360` nor the error sound `0x458a50`, so a pet's refused Growl no longer prints "You fail to cast Growl: …" as the player's own failure; and the source walk that exists to catch a non-catalog key was blind to both wrong keys because all 465 catalog keys begin `ERR_`, so the tripwire moves to the funnel — `Shown::keyed` warns on a key `by_key` cannot resolve (2033)
- the hunter-pet feedback cluster, five opcodes carved off the binary where four had sat name-table-only: `SMSG_PET_TAME_FAILURE` (`0x173`) is two GlobalStrings lookups in order — `0x6e6a20` maps `reason − 1` through the eleven-arm jump table at `0x6e6ac0` to a `PETTAME_*` key and hands the resolved string to `DisplayError(ERR_TAME_FAILED)`, anything outside `1..=11` taking `PETTAME_UNKNOWNERROR`, and Call Pet and Revive Pet raise it too; `SMSG_PET_NAME_INVALID` (`0x178`) raises `ERR_INVALID_PETNAME` from the shared dispatcher `0x5e38c0`'s arm `0x5e3e33` — 1066 had called a refused rename silent on a carve that gave `0x178` the handler `0x4bdc00`, which is `0x2AF SMSG_PET_BROKEN`'s and raises `ERR_PET_BROKEN`, body unread; `SMSG_PET_ACTION_SOUND` (`0x324`) is a bark, not a sound id — its two talk values reach the bark dispatcher `0x623a40` at states 1 and 2, i.e. `CreatureSoundData` columns 28 and 27, and the shipped DBC names them from the other side: exactly four of its 406 rows carry them, the Imp, Succubus, Doomguard and Voidwalker voices, as `A_<demon>_ORDER`/`A_<demon>_KILL`; `Latch::Voice` now carries the bark state as a number and every one-shot creature vocal goes through one `play_bark` that is `0x623a40` — aggro at 0, the pet barks at 1 and 2, the death cry at 4, which had been on a bare `play_kit` off the slot so a pet bark could talk over it — with the reference's `jle` priority rule, because under the old boolean slot a demon's voice would have lost every race against its own aggro flares and been inaudible; it is a warlock-demon surface end to end — vmangos only sends it for a summoned pet, at a 10% roll per order, and a hunter's wolf has neither the columns nor the packet; and the fifth opcode surfaced because the cross-check went looking for column 29's consumer: `SMSG_PET_DISMISS_SOUND` (`0x325`), handler `0x604140`, reads column 29 twice (`0x6041c4`/`0x6041dc`) off a row resolved fresh by `CreatureModelData` id rather than the cached `[unit+0xb40]` — which is why a census over the cache had called the column dead — and plays `A_<demon>_DISMISS` at the packet's point with `z + 1.0`, no unit and no latch; column 29 is loaded, the column nothing reads is 22 (zero on all 406 rows), and the dismiss handler is built although vmangos never sends the packet (2039)
- every UV scroll and animated tint in the world had been frozen for three days: 1940's deferred realization parks a freshly built `WowModelMaterial` behind a reserved handle until something view-visible binds it, and `doodad_anim::register_uv`/`register_tint` still reached for the store alone — `Assets::get_mut` answered `None` for every registration, the slot was freed, the registry stayed empty and the shader read the pinned-zero identity row for every batch, silently, because the `else` branch was a bare `return`; the fix is the address, not the timing: `Parked::value_mut`/`value`/`holds` reach a value wherever it currently lives and `lazy::with_material_mut`/`with_material`/`holds` publish that, so the one-shot build stamp lands in the parked value and rides realization in — realizing it to stamp it would have thrown away exactly what deferral buys; the tick's eviction on `Assets::contains` read a parked material as dead and asks `lazy::holds` now, the empty-registry early-out says so out loud when marked parts exist, and two instruments ship — `WOW_MATANIM_PROBE=<secs>` prints per animated part whether it is registered, whether its loop moves, what its table row holds and whether it is drawn, and `WOW_WORLDVIEW_AT=x,y[,z]` opens the server-less viewer anywhere; measured at the reported waterfall: 0 uv / 0 tint entries before, 87 uv / 6 tint around the Elwynn valley after, its two sheets on slots 1 and 2 with live rows −0.2122 and −0.4246 — the authored 1:2 ratio (2038); and the paper doll and the inspect pane are lit from the model's right: `CGLight+0x24` is the direction the light PROPAGATES — the `CharacterModelBase` ctor `0x505680` writes `(0, 1, 0)` and `0x71bce0` negates all three components before building the SH basis — so `model_pane_light()` hands the packer the ctor's vector as written where 0638 had negated it on the way in and lit both panes from the left; the ambient `(0.7, 0.7, 0.7)`, the diffuse `(0.8, 0.8, 0.64)` and the side-light analysis all hold, which is why the A/B that approved 0638 could not tell the two readings apart (2034)
- a unit tooltip whose name query has not answered titles `UNKNOWNOBJECT`, not a blank gold row: the reference's builder `0x529fe0` reads its NAME line through `CGUnit_C::GetUnitName 0x609210` — the same function `UnitName 0x517020` delegates to — and every miss on its three legs, the null creature record included, ends at the one `FrameScript_GetText("UNKNOWNOBJECT")` tail at `0x609324`, so `unknownobject` moves into `unit` proper and the plate and the verb share it, read out of the VM's `_G` with the binary's "Unknown Being" behind a missing global; the faction line stops waiting for the creature record — its entry gate `0x612610` returns 1 when there is none, so it resolves off `UNIT_FIELD_FACTIONTEMPLATE` and keeps only `HIDE_FACTION_TOOLTIP` for when the record lands; the reference's placeholder sticks for the whole hover (stock `GameTooltip.xml` registers `UPDATE_MOUSEOVER_UNIT` alone) and our line-change repaint, which replaces it when the answer lands, is kept (2040); a unit predicate answers the NUMBER 1 or nil, never a Lua boolean: `UnitExists`, `UnitIsDead`, `UnitIsGhost`, `UnitIsDeadOrGhost` and `UnitOnTaxi` pushed a boolean and twenty-three more open-coded the right shape in eighteen places, where the reference pushes through `lua_pushnumber 0x6f3810` or `lua_pushnil 0x6f37f0`; all twenty-eight now reach 1830's one push site `binding_abi::predicate` — via `unit::unit_predicate` for a `UnitState` field, directly otherwise — `UnitIsCharmed`'s `Number(1.0)` and `ClearTarget`'s hand-branched nil included, and `UnitOnTaxi` gains the reference's `Usage:` raise (gate `0x517a48`) where a nil token had raised mlua's type error; truthiness is why it survived (`if UnitIsDead(u)` reads the same either way, and `== nil` inverts), nine `eval::<bool>` tests were vacuous under mlua's coercion and `taxi.rs` had pinned `== false`/`== true` — the one shape the reference cannot emit — so a table-driven test covers all twenty-eight on both legs, and `UnitIsAFK`/`UnitIsDND` now say they are ours (zero occurrences image-wide) (2043); and the quest log's stock title width cap — `275 − 15 − tagWidth`, live off the chain since 1944 — ellipsizes on one line rather than wrapping into the 16-unit row: 1873 declined it reading an explicit width as a wrap width, but the title FontString declares `y="10"`, and a declared height arms both overflow regimes, so a 258 px title in a 206 px box paints as one line ending in three ASCII dots — the reference's own result, its ellipsis gate at `0x771ec0` being `boxW > 0 && (maxLines != 0 || boxH > 0)`, which only an auto-height FontString escapes; two tests pin the render law on the real FRIZQT face and the geometry the stock file hands it, including the cap's per-row-button stickiness, which is the reference's too (2036)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-09-06 19:39:11 +07:00
co-authored by Claude Fable 5.1
parent 4040166138
commit 8f0bece3be
41 changed files with 3488 additions and 547 deletions
+2 -2
View File
@@ -50,7 +50,7 @@ fn taxi_snapshot_surfaces_and_intents_drain() {
assert(GetNumRoutes(1) == 0 and GetNumRoutes(2) == 1)
assert(math.abs(TaxiGetSrcX(2, 1) - 0.5) < 1e-6)
assert(math.abs(TaxiGetDestY(2, 1) - 0.8) < 1e-6)
assert(UnitOnTaxi("player") == false)
assert(UnitOnTaxi("player") == nil) -- 1/nil, never a boolean (2043)
TakeTaxiNode(2)
CloseTaxiMap()
@@ -74,6 +74,6 @@ fn taxi_snapshot_surfaces_and_intents_drain() {
// The ride flag reaches UnitOnTaxi.
s.set_on_taxi(true);
s.run(r#"assert(UnitOnTaxi("player") == true)"#).unwrap();
s.run(r#"assert(UnitOnTaxi("player") == 1)"#).unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -56,6 +56,133 @@ fn creature_line_law() {
assert!(s.take_errors().is_empty());
}
/// The snapshot the app pushes for a creature the client has only just seen: the DESCRIPTOR's
/// fields are in (level, reaction, the skinnable flag) and everything the creature record carries
/// — name, subtitle, type word, rank, civilian/leader — is still absent, because they all arrive
/// together in the one `SMSG_CREATURE_QUERY_RESPONSE` that fills `CGUnit+0xb30`.
fn unqueried_wolf() -> UnitState {
UnitState {
exists: true,
has_object: true,
name: None,
health: 30,
max_health: 50,
level: 10,
reaction: 2, // hostile
skinnable: true,
guid: 0xF130_0000_0000_0001,
..Default::default()
}
}
/// **A name still in flight titles the plate `UNKNOWNOBJECT` — never an empty line** (decision
/// 2040, closing 2002's residue).
///
/// The builder's name read is `CGUnit_C::GetUnitName 0x609210` (`0x52a187`), the same resolver
/// `UnitName 0x517020` delegates to, and every one of its misses — a null `CGUnit+0xb30`
/// (`0x609353 je 0x609324`) among them — ends at the same `FrameScript_GetText("UNKNOWNOBJECT")`
/// tail. So the verb and the plate answer the same string for the same instant, and because the
/// string is read out of the VM's `_G` it translates with `GlobalStrings.lua`.
///
/// The builder has NO counterpart to `UnitName`'s two nils: the `"player"` fast path is the
/// *binding's* (`0x517083`, before any resolve), so a player whose name has not arrived titles
/// `UNKNOWNOBJECT` too; and a token resolving to nothing never reaches a builder at all — the
/// entry gate hands back no object, `SetUnit` answers nil and no plate is drawn.
///
/// Failure looks like the empty first line the residue named: `TextLeft1` reads `""`, the plate
/// measures to a blank row, and the level line reads as the tooltip's title.
#[test]
fn a_pending_name_titles_unknownobject_and_the_answer_replaces_it() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.set_unit("target", Some(unqueried_wolf()));
s.set_player_req_state(PlayerReqState {
level: 12,
..Default::default()
});
s.run(
r#"
UNKNOWNOBJECT = "Unknown" -- GlobalStrings.lua:4444, enUS
local a = CreateFrame("Button", "UF1"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10)
local tt = CreateFrame("GameTooltip", "TT")
tt:SetOwner(a, "ANCHOR_RIGHT")
assert(tt:SetUnit("target") == 1, "a resolved unit shows, name or no name")
assert(TTTextLeft1:GetText() == UNKNOWNOBJECT,
"the title is the GlobalString, got '" .. tostring(TTTextLeft1:GetText()) .. "'")
-- The record's other lines are absent with it: no subtitle, and the level line takes the
-- bare TOOLTIP_UNIT_LEVEL template because both the CLASS and TYPE slots are the record's.
assert(TTTextLeft2:GetText() == "Level 10", "got " .. TTTextLeft2:GetText())
-- The descriptor's own lines are NOT the record's and show anyway.
assert(TTTextLeft3:GetText() == "Skinnable")
"#,
)
.unwrap();
// The query answers: the very next render replaces the placeholder with the real name and
// fills the record's lines. (Live, the hover feed re-drives this without the mouse moving —
// its rebuild key is the whole line-affecting snapshot, `ui_tooltip::lines_view`.)
let mut answered = unqueried_wolf();
answered.name = Some("Timber Wolf".into());
answered.subtitle = Some("Alpha".into());
answered.creature_type_name = Some("Beast".into());
answered.rank = 2;
s.set_unit("target", Some(answered));
s.run(
r#"
assert(TT:SetUnit("target") == 1)
assert(TTTextLeft1:GetText() == "Timber Wolf", "the answer replaced UNKNOWNOBJECT")
assert(TTTextLeft2:GetText() == "Alpha")
assert(TTTextLeft3:GetText() == "Level 10 Beast (Elite)", "got " .. TTTextLeft3:GetText())
assert(TTTextLeft4:GetText() == "Skinnable")
"#,
)
.unwrap();
// An EMPTY (or absent) global is the same miss as no global — `0x609324`'s own check, which
// falls to the binary's literal `0x860fa4`.
s.set_unit("target", Some(unqueried_wolf()));
s.run(
r#"
UNKNOWNOBJECT = ""
assert(TT:SetUnit("target") == 1)
assert(TTTextLeft1:GetText() == "Unknown Being", "got " .. TTTextLeft1:GetText())
"#,
)
.unwrap();
// The `"player"` fast path is the BINDING's, not the builder's: a player whose name-cache row
// has not answered titles the same placeholder, where `UnitName("player")` would push nil.
s.set_unit(
"player",
Some(UnitState {
exists: true,
has_object: true,
name: None,
level: 12,
is_player: true,
player_controlled: true,
guid: 0x0000_0000_0000_0007,
..Default::default()
}),
);
s.run(
r#"
UNKNOWNOBJECT = "Unknown"
assert(UnitName("player") == nil, "the binding's own fast path still pushes nil (2002)")
assert(TT:SetUnit("player") == 1)
assert(TTTextLeft1:GetText() == UNKNOWNOBJECT,
"the builder has no player fast path, got " .. tostring(TTTextLeft1:GetText()))
"#,
)
.unwrap();
// The GUID-0 counterpart: a recognised token naming nothing resolves to no object, so no
// builder runs — nil, and no plate. (There is no third answer; the builder never sees it.)
s.run(r#"assert(TT:SetUnit("party4") == nil, "an absent unit draws no plate at all")"#)
.unwrap();
assert!(s.take_errors().is_empty());
}
/// The faction-name line sits between the level line and "PvP" (the builder-tail block the §2
/// order omitted — the director's Marshal McBride reference: Level, Stormwind, PvP); the
/// CIVILIAN line is the dishonorable-kill warning, whole gate (`0x612550`): PvP bit + civilian