names are a session, Martin Fury fires, and Windows gets its clocks

- player and pet names are a world session, not a file: the reference never persists them, so a character created after a server wipe no longer wears a deleted character's name (2223)
- Martin Fury and every item whose spell targets where you stand raise the cursor and cast: the targeting word is a mask, not one bit (2218)
- the guild line prints ahead of the realm's welcome lines by declared order, as the reference's chat latch paints it (2221)
- the performance journal's CPU clocks answer on Windows, and a run with no install no longer panics on the cinematic narration (2219)
- SetInventoryItem's nameOnly argument builds the reference's compact tooltip (2224)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-09-15 16:56:48 +07:00
co-authored by Claude Fable 5.1
parent b44c3923e4
commit 0cc11b66a0
26 changed files with 1306 additions and 485 deletions
Generated
+1
View File
@@ -543,6 +543,7 @@ dependencies = [
"symphonia",
"toml",
"wgpu",
"windows-sys 0.59.0",
]
[[package]]
+6
View File
@@ -186,3 +186,9 @@ objc2-foundation = { version = "0.2", features = ["NSArray", "NSData", "NSGeomet
# posture the old `stub.rs` had, without a second file to rot.
[target.'cfg(not(target_os = "macos"))'.dependencies]
cpal = "0.17"
# Windows: the journal's and the probes' CPU clocks (decision 2219, `perf::clock`) —
# `GetProcessTimes`, `GetThreadTimes` and `GetSystemTimes`, the twins of the `getrusage`,
# `clock_gettime` and `host_statistics64` arms. The version is one the graph already carries.
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_System_Threading"] }
+18 -11
View File
@@ -4,13 +4,19 @@
//! ## Why this exists at all
//!
//! Three of benilla's caches are filled by a server round trip: player names, creature templates
//! and pet names. The reference persists all three (`namecache.wdb`, `creaturecache.wdb`,
//! `petnamecache.wdb`), so on a machine that has played before, an answer is usually *already
//! there* when the packet that needs it arrives. benilla asked fresh every login, which made a
//! race the reference has practically closed wide open for us — the director hit it as a Lua error
//! when unstabling a pet, where `PetStable_Update` hands a nil `UnitName("pet")` to a
//! `GameTooltip:SetText` whose signature requires a string (decision 1688 closed that one path by
//! seeding from the stable list; this closes the class).
//! and pet names. **The reference persists exactly one of the three** — `creaturecache.wdb` — and
//! that is what this module keeps. `'WNAM'` and `'WPNM'` are constructed with persistence
//! **disabled** (`0x554cd0`'s three `push 0x0`, against `'WNPC'`/`'WIDB'`'s `push 0x1; push 0x1`)
//! and are cleared at world-session start instead; a real 1.12 install's `WDB/` holds
//! `creaturecache.wdb`, `npccache.wdb`, `itemcache.wdb` … and no `namecache.wdb`. 1689 read the
//! carve as "all three" and wrote player and pet names to disk too, which is what answered a wiped
//! server's brand-new character with a deleted one's name (B386); **decision 2223** took them back
//! out and put the wipe in ([`NameCache::clear_world_session`]).
//!
//! What survives is the half that carried the value anyway: a city's worth of creature-template
//! queries on zone-in, answered from disk instead of the wire. The **key** is the whole argument —
//! a template entry means the same creature on every realm forever, while a player guid means one
//! character and a pet number one live spawn.
//!
//! ## The law, from the carve
//!
@@ -22,9 +28,8 @@
//! 20 bytes are `[FourCC | build 0x16f3 | locale | recordSize | version 1]`. A mismatch discards
//! the file wholesale. Ours is a header line with the same job ([`NameCache::to_tsv`]).
//! - **Eviction is explicit only** — a high-bit key in a response, or `SMSG_INVALIDATE_PLAYER`
//! (`0x31C` → remove-by-key `0x556ff0`). Nothing ages out. So a persisted name lives until the
//! server says otherwise, which is why that opcode had to be wired before anything was written
//! to disk: in memory a stale name costs a session, on disk it costs forever.
//! (`0x31C`). Nothing ages out. That is survivable for a record whose key cannot change meaning,
//! which is precisely why the stores whose keys *can* are not persisted at all.
//!
//! ## Where it lives, and the one place we deviate
//!
@@ -101,7 +106,9 @@ fn load_name_cache(
let n = loaded.len();
// Replace rather than merge: this is the *start* of a realm's session, so there is
// nothing of this realm's in memory to preserve, and a merge would silently keep the
// previous realm's records alive under their own keys.
// previous realm's records alive under their own keys. (What it replaces is creature
// templates — the file carries nothing else since 2223 — and world entry clears the
// guid-keyed stores a moment later regardless.)
*names = loaded;
file.saved_generation = names.generation();
debug!(
+169 -66
View File
@@ -11,6 +11,15 @@
//!
//! 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, HashSet};
@@ -69,11 +78,15 @@ pub(crate) struct NameCache {
/// 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.
/// 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
@@ -255,18 +268,50 @@ impl NameCache {
/// 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)>) {
self.pending_players.remove(&guid);
if !name.is_empty() {
if let Some(t) = traits {
self.player_traits.insert(guid, t);
}
}
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.pending_players.clear();
self.pending_pets.clear();
self.generation = self.generation.wrapping_add(1);
}
/// The landed-answer counter — see the [`Self::generation`] field.
pub(crate) fn generation(&self) -> u64 {
self.generation
@@ -371,7 +416,13 @@ 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;
///
/// **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;
@@ -391,19 +442,6 @@ impl NameCache {
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!(
@@ -421,9 +459,6 @@ impl NameCache {
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
}
@@ -447,25 +482,6 @@ impl NameCache {
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;
@@ -500,11 +516,6 @@ impl NameCache {
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());
}
}
_ => {}
}
}
@@ -646,15 +657,15 @@ mod tests {
}
/// **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.
/// 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 (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 {
@@ -670,16 +681,12 @@ mod tests {
}),
);
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");
let 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);
assert!(back.creatures.contains_key(&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"));
@@ -687,7 +694,7 @@ mod tests {
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);
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.
@@ -709,6 +716,92 @@ mod tests {
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.contains_key(&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,
@@ -717,7 +810,7 @@ mod tests {
#[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);
cache.insert_creature(1234, None);
let good = cache.to_tsv("Hydraxian Waterlords");
assert!(NameCache::from_tsv(&good, "Hydraxian Waterlords").is_some());
@@ -745,9 +838,19 @@ mod tests {
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 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
+8
View File
@@ -1054,6 +1054,14 @@ pub(crate) enum ClientCommand {
spell_id: u32,
dest: [f32; 3],
},
/// Cast a spell at a **source point** (`CMSG_CAST_SPELL` with `TARGET_FLAG_SOURCE_LOCATION`,
/// decision 2218): the same terrain click, one bit over — `BindLocation 0x6e60f0` binds bit 5
/// to `SPELLCAST+0x30` where it binds bit 6 to `+0x3c`. `src` is the clicked world point in
/// **WoW coords**. Answered by `SMSG_CAST_RESULT`.
CastSpellAtSource {
spell_id: u32,
src: [f32; 3],
},
/// Cancel one of our own auras (`CMSG_CANCEL_AURA`, decision 0257): the right-click-a-buff wire,
/// carrying the **spell id** (the server cancels by spell, not slot). No answer packet — the
/// removal comes back as a `UNIT_FIELD_AURA` delta. Sent by the aura feed's cancel drain.
@@ -129,6 +129,13 @@ pub(super) fn connected(
status.connected = true;
status.last_reason = None;
info!("net: in world as {name} (guid {guid})");
// **The reference's world-session wipe, first** (`0x555740`'s `0x5557ad` arm): the player-name
// and pet-name stores are cleared at every world entry, because a guid names one character and
// a pet number one spawn, and nothing on the wire says either has been handed to somebody else
// since we last looked (decision 2223 — a wiped server's new character wearing a deleted one's
// name, B386). Creature templates are keyed by an entry that means the same thing forever and
// survive this, exactly as they survive the process.
names.clear_world_session();
// Our own name came with the login — seed the cache so "player" never queries.
names.insert_player(guid, name, None);
// Seated before the world-entry UI load reads it (2175), and overwritten every login so a
+3
View File
@@ -1274,6 +1274,9 @@ fn writer_loop(
ClientCommand::CastSpellAtDest { spell_id, dest } => {
w.cast_spell_at_dest(spell_id, dest)
}
ClientCommand::CastSpellAtSource { spell_id, src } => {
w.cast_spell_at_source(spell_id, src)
}
ClientCommand::CancelAura { spell_id } => w.cancel_aura(spell_id),
ClientCommand::SetActionButton { button, packed } => {
w.set_action_button(button, packed)
+87 -6
View File
@@ -16,7 +16,8 @@
/// report is written in ("250 % CPU at 59 fps" against 1.12.1's "100 % at 160"), so a probe that
/// prints it can be compared against a reporter's number directly.
///
/// Non-unix returns `None`: the probes print the field only where the platform answers.
/// Unix answers through `getrusage`, Windows through `GetProcessTimes` (decision 2219);
/// elsewhere `None`: the probes print the field only where the platform answers.
pub(crate) fn process_cpu_secs() -> Option<f64> {
#[cfg(unix)]
{
@@ -31,12 +32,49 @@ pub(crate) fn process_cpu_secs() -> Option<f64> {
Some(secs(ru.ru_utime) + secs(ru.ru_stime))
}
}
#[cfg(not(unix))]
#[cfg(windows)]
{
use windows_sys::Win32::System::Threading::{GetCurrentProcess, GetProcessTimes};
// SAFETY: `GetProcessTimes` writes four fully-initialised `FILETIME`s into its out-params
// and reads nothing from them; zeroed is a valid starting value for two plain integers,
// and the pseudo-handle `GetCurrentProcess` returns is never closed.
unsafe {
let mut creation = std::mem::zeroed();
let mut exit = std::mem::zeroed();
let mut kernel = std::mem::zeroed();
let mut user = std::mem::zeroed();
if GetProcessTimes(
GetCurrentProcess(),
&mut creation,
&mut exit,
&mut kernel,
&mut user,
) == 0
{
return None;
}
Some(filetime_secs(kernel) + filetime_secs(user))
}
}
#[cfg(not(any(unix, windows)))]
{
None
}
}
/// A `FILETIME` — 100-nanosecond ticks — as an integer, for the differences and sums the
/// Windows arms take before converting.
#[cfg(windows)]
fn filetime_ticks(t: windows_sys::Win32::Foundation::FILETIME) -> u64 {
(u64::from(t.dwHighDateTime) << 32) | u64::from(t.dwLowDateTime)
}
/// A `FILETIME` as seconds.
#[cfg(windows)]
fn filetime_secs(t: windows_sys::Win32::Foundation::FILETIME) -> f64 {
filetime_ticks(t) as f64 * 1e-7
}
/// The process's page-fault counters so far — `(minor, major)` from `getrusage`. A minor fault
/// is a page the kernel had to map or zero-fill on first touch: memory the allocator handed back
/// to the OS and then asked for again. It is CPU time the process pays in the kernel with no
@@ -73,7 +111,9 @@ pub(crate) fn process_faults() -> Option<(u64, u64)> {
/// becomes "whichever worker happened to run this system", which is noise shaped like a
/// measurement.
///
/// Non-unix returns `None`, like its twin.
/// Unix answers through `clock_gettime`, Windows through `GetThreadTimes` on the calling
/// thread's pseudo-handle — the same "whichever thread calls" contract (decision 2219);
/// elsewhere `None`, like its twin.
pub(crate) fn main_thread_cpu_secs() -> Option<f64> {
#[cfg(unix)]
{
@@ -87,7 +127,30 @@ pub(crate) fn main_thread_cpu_secs() -> Option<f64> {
Some(ts.tv_sec as f64 + ts.tv_nsec as f64 * 1e-9)
}
}
#[cfg(not(unix))]
#[cfg(windows)]
{
use windows_sys::Win32::System::Threading::{GetCurrentThread, GetThreadTimes};
// SAFETY: as in `process_cpu_secs` — four out-params fully written, nothing read, and
// `GetCurrentThread` is a pseudo-handle valid on the thread that asked for it.
unsafe {
let mut creation = std::mem::zeroed();
let mut exit = std::mem::zeroed();
let mut kernel = std::mem::zeroed();
let mut user = std::mem::zeroed();
if GetThreadTimes(
GetCurrentThread(),
&mut creation,
&mut exit,
&mut kernel,
&mut user,
) == 0
{
return None;
}
Some(filetime_secs(kernel) + filetime_secs(user))
}
}
#[cfg(not(any(unix, windows)))]
{
None
}
@@ -116,7 +179,9 @@ pub(crate) fn main_thread_cpu_secs() -> Option<f64> {
/// had not caught up with. Cross-checked against an independent out-of-process sampler over the
/// same window — 44 % vs 44.5 % — so where the two disagree, this is the one that is right.
///
/// Non-macOS returns `None`: the probes print the field only where the platform answers.
/// macOS answers through `host_statistics64`, Windows through `GetSystemTimes` (decision 2219
/// — its kernel time INCLUDES idle, so busy is kernel − idle + user); elsewhere `None`: the
/// probes print the field only where the platform answers.
///
/// The `deprecated` allow is `libc::mach_host_self`, whose deprecation note says "use the `mach2`
/// crate instead". Checked, and it does not apply here: `mach2` 0.5.0 carries `mach_host_self` and
@@ -148,7 +213,23 @@ pub(crate) fn system_cpu_ticks() -> Option<(u64, u64)> {
Some((total - idle, total))
}
}
#[cfg(not(target_os = "macos"))]
#[cfg(windows)]
{
use windows_sys::Win32::System::Threading::GetSystemTimes;
// SAFETY: `GetSystemTimes` writes three fully-initialised `FILETIME`s and reads nothing.
unsafe {
let mut idle = std::mem::zeroed();
let mut kernel = std::mem::zeroed();
let mut user = std::mem::zeroed();
if GetSystemTimes(&mut idle, &mut kernel, &mut user) == 0 {
return None;
}
// All three are summed across every processor, and kernel time includes idle.
let total = filetime_ticks(kernel) + filetime_ticks(user);
Some((total.saturating_sub(filetime_ticks(idle)), total))
}
}
#[cfg(not(any(target_os = "macos", windows)))]
{
None
}
+10 -1
View File
@@ -73,7 +73,16 @@ impl CinematicVoice {
pub(super) fn plugin(app: &mut App) {
// In `Present`, after the cinematic driver in `Input` has settled this frame's shot: the
// narration follows the picture rather than racing it.
app.add_systems(Update, drive_narration.in_set(WorldStage::Present));
// Gated on the kit catalog: `SoundKits` is inserted only once the install's kits have
// loaded, and a run with no install (an instrument leg pointed at the wrong data path, 2211)
// reached this system's bare `ResMut<SoundKits>` and panicked the app instead of playing
// nothing. The narration needs a catalog to play from; without one there is nothing to do.
app.add_systems(
Update,
drive_narration
.in_set(WorldStage::Present)
.run_if(resource_exists::<SoundKits>),
);
}
/// The narration channel, plus its line in the voice budget.
@@ -112,8 +112,13 @@ pub(crate) struct CastLadder<'w, 's> {
/// `BindTarget 0x6e5b40` can fill into a standing flag_word once the ladder has already run.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) enum TargetedBind {
/// The terrain click's point, in WoW coords (decision 0792).
/// The terrain click's point, in WoW coords (decision 0792) — `BindLocation 0x6e60f0`'s
/// bit-6 arm.
Dest([f32; 3]),
/// The same terrain click's point, bound to the **source** slot instead — `BindLocation
/// 0x6e60f0`'s bit-5 arm (`6e6105`–`6e6126`), which the reference tests *first* and which
/// writes `SPELLCAST+0x30` and the wire bit `0x0020`. Decision 2218.
Source([f32; 3]),
/// The bag / paper-doll click's item guid (decision 0923).
Item(u64),
/// The world click's GameObject guid (decision 0939) — a chest, a door, a vein, a herb.
@@ -140,6 +145,7 @@ impl CastLadder<'_, '_> {
// in different builders), one on the item side — the block itself is the same block.
CastCommit::Spell => match bound {
TargetedBind::Dest(dest) => ClientCommand::CastSpellAtDest { spell_id, dest },
TargetedBind::Source(src) => ClientCommand::CastSpellAtSource { spell_id, src },
TargetedBind::Item(item_guid) => ClientCommand::CastSpellItem {
spell_id,
item_guid,
@@ -162,6 +168,7 @@ impl CastLadder<'_, '_> {
spell_index,
target: match bound {
TargetedBind::Dest(dest) => UseItemTarget::Dest(dest),
TargetedBind::Source(src) => UseItemTarget::Source(src),
TargetedBind::Item(guid) => UseItemTarget::Item(guid),
TargetedBind::Object(guid) => UseItemTarget::Object(guid),
},
+64 -18
View File
@@ -36,9 +36,12 @@
//! GameObject (decision 0939). A LOCKED spell — Opening, Pick Lock, Mining, Herb Gathering —
//! satisfies the last **two**, and the click decides which leg it was, exactly as `BindTarget
//! 0x6e5b40` decides: by the clicked object's typemask (`6e5f17` item bit 1, `6e5f52` GameObject
//! bit 5), each arm then re-testing the word. Still deferred, refused-not-guessed: the pure SOURCE
//! word (0x20 — NPC-cast data only), STRING bit 13, and the *unit* hand-cursor mode (the
//! residual-unit-word machine behind the autoSelfCast stand-in above).
//! bit 5), each arm then re-testing the word. The terrain seam likewise serves **both** location
//! bits — `TargetingWantsLocation`'s mask is `0x60`, and `BindLocation 0x6e60f0` has an arm for
//! each (decision 2218, closing B388: Martin Fury's spell 265 is a pure `0x20` word, and reading
//! that seam as dest-only refused the only three shipped items that carry one). Still deferred,
//! refused-not-guessed: STRING bit 13, and the *unit* hand-cursor mode (the residual-unit-word
//! machine behind the autoSelfCast stand-in above).
use benilla_formats::SpellDisplay;
use bevy::ecs::system::SystemParam;
@@ -78,9 +81,16 @@ pub(crate) const ERR_INVALID_TARGET: u8 = 0x0A;
/// why it travels as [`CastWireTarget::RefusedAtArm`]. Decision 1554.
pub(crate) const ERR_MAINHAND_EMPTY: u8 = 0x2d;
/// The dest-location bit — the ground-cast wire mask (`BindLocation 0x6e60f0`'s bit-6 arm; the
/// source bit 5 completes `TargetingWantsLocation 0x6e6320`'s `0x60`, still refused below).
/// The dest-location bit — the ground-cast wire mask (`BindLocation 0x6e60f0`'s bit-6 arm).
const TF_DEST_LOCATION: u16 = 0x0040;
/// The source-location bit — `BindLocation 0x6e60f0`'s **other** arm (`6e6105`–`6e6126`), tested
/// one instruction before the dest one and writing `SPELLCAST+0x30` instead of `+0x3c`. Together
/// the two are `TargetingWantsLocation 0x6e6320`'s `0x60`: the terrain click serves both
/// (decision 2218).
const TF_SOURCE_LOCATION: u16 = 0x0020;
/// `TargetingWantsLocation 0x6e6320`'s whole mask (`6e6328: testb $0x60, %cl`) — the one
/// predicate the terrain seam asks the standing word.
const LOCATION_BITS: u16 = TF_SOURCE_LOCATION | TF_DEST_LOCATION;
/// The item bit — the *other* half of the targeting cursor (decision 0923). Its predicate twin is
/// `TargetingWantsItem 0x6e6330` (`flag_word & 0x4010`), which the bag and paper-doll click seams
/// consult before binding the clicked item ([`super::targeting`]).
@@ -438,13 +448,14 @@ pub(crate) fn resolve_cast_target(
}
}
// Bits outside the unit family (item/gameobject/location/string) have no candidate here.
// The DEST-location word (Blizzard's bare `Targets = 0x40`, default switch arm) is the
// targeting cursor's location half (decision 0792) — in the ref it falls out of the failed
// bind walk into cursor mode (`6e50c8`); real 5875 data never combines location bits with
// unit bits (live spell_template sweep: `Targets & 0x60` rows are exactly 0x20 or 0x40
// alone), so deferring before the unit walk is byte-equivalent. The pure SOURCE word (0x20)
// is NPC-cast data (Aura of Fear kin), unreachable from a player's book, and keeps the
// refusal with the item/GO/string machines.
// A location word — Blizzard's bare `Targets = 0x40`, Martin Fury's bare `0x20` — is the
// targeting cursor's location half (decisions 0792, 2218): in the ref it falls out of the
// failed bind walk into cursor mode (`6e50c8`), and deferring before the unit walk is
// byte-equivalent **only while no unit bit stands beside it**, which is why the location arm
// below carries that condition where the item/GO one is a bare mask test. One shipped row
// breaks the old "never combined" reading (`Summon Fallout Slime` 28218, word `0x42`,
// NPC-cast): there the ref binds the unit at arm time and the cursor takes only the residue,
// so that word keeps the refusal rather than guessing at a shape nothing can reach.
if word & !UNIT_BITS != 0 {
// Hand the whole word to the cursor when any seam can serve it — written as the
// reference's own three predicates rather than as equalities on bare words. The reference
@@ -460,7 +471,9 @@ pub(crate) fn resolve_cast_target(
// of them (0928's live probe: a Dull Iron Key drew "Invalid target" instead of the
// cursor) — and a single-verdict enum then had to *choose* item-or-world for a word the
// reference lets satisfy both. Both bits ride along; the click picks the leg.
if word == TF_DEST_LOCATION || word & (TF_ITEM | TF_LOCKED | TF_GAMEOBJECT) != 0 {
if word & LOCATION_BITS != 0 && word & UNIT_BITS == 0
|| word & (TF_ITEM | TF_LOCKED | TF_GAMEOBJECT) != 0
{
return CastWireTarget::Targeting(word);
}
return CastWireTarget::Refused(ERR_INVALID_TARGET);
@@ -643,13 +656,46 @@ mod tests {
);
}
/// The still-deferred non-unit masks (source-location, string, the 0x60 pair) refuse instead
/// of shipping a guess — the machines named in the module docs. `0x60` is the *pair*: the
/// reference binds source then dest across two clicks (`BindLocation 0x6e60f0`'s bit-5 arm
/// takes priority and only the second click sends), and our terrain seam binds dest only.
/// **The SOURCE word raises the cursor, exactly as the DEST word does** (decision 2218,
/// closing B388). `TargetingWantsLocation 0x6e6320` is `word & 0x60` — one predicate over both
/// bits — and `BindLocation 0x6e60f0` has an arm for each, so a bare `0x20` is a terrain click
/// waiting to happen, not a refusal. Martin Fury's spell 265 (`Targets 0x20`, implicit 15 =
/// default arm) is the shipped case; a selection changes nothing, since no unit candidate can
/// clear a location bit.
#[test]
fn the_source_location_word_enters_targeting_mode() {
let area_death = spell(0x20, 15);
assert_eq!(
resolve_cast_target(
Some(&area_death),
&cands(Some(42), Some(1)),
true,
&rel_none()
),
CastWireTarget::Targeting(TF_SOURCE_LOCATION)
);
assert_eq!(
resolve_cast_target(Some(&area_death), &cands(None, Some(1)), true, &rel_none()),
CastWireTarget::Targeting(TF_SOURCE_LOCATION),
"and with nothing selected — autoSelfCast has no location to give either"
);
// The pair, the shape `BindLocation` walks across two clicks. No 5875 row carries it; the
// resolver still hands the whole word over rather than choosing a leg here.
let both = spell(0x60, 0);
assert_eq!(
resolve_cast_target(Some(&both), &cands(None, Some(1)), true, &rel_none()),
CastWireTarget::Targeting(LOCATION_BITS)
);
}
/// What still refuses rather than shipping a guess — the machines named in the module docs.
/// STRING (bit 13) has no seam at all; a location bit with a **unit** bit beside it (`0x42`,
/// `Summon Fallout Slime` 28218, NPC-cast) is the one shipped word where deferring before the
/// unit walk would NOT be byte-equivalent: the reference binds the unit at arm time and the
/// cursor takes only the residue.
#[test]
fn non_unit_masks_refuse() {
for targets in [0x20u32, 0x2000, 0x60] {
for targets in [0x2000u32, 0x42] {
let s = spell(targets, 0);
assert_eq!(
resolve_cast_target(Some(&s), &cands(Some(42), Some(1)), true, &rel_none()),
@@ -194,6 +194,22 @@ impl SpellTargeting {
.map(|t| (t.spell_id, t.commit))
}
/// `BindLocation 0x6e60f0`'s own fork, as one read: which of the two location slots the
/// terrain click binds this point to. Bit 5 (SOURCE) is tested **before** bit 6 (DEST) and
/// the arms are exclusive (wow-re `wave-cast.md` §0x6e60f0 + its caller census — a word
/// carrying both takes two clicks and sends on the second; no 5875 spell carries both, so
/// only the precedence is transcribed here, not the two-click walk). Decision 2218.
fn location_bind(&self, point: [f32; 3]) -> Option<super::cast_send::TargetedBind> {
let word = self.0.as_ref()?.word;
if word & 0x0020 != 0 {
Some(super::cast_send::TargetedBind::Source(point))
} else if word & 0x0040 != 0 {
Some(super::cast_send::TargetedBind::Dest(point))
} else {
None
}
}
pub(crate) fn clear(&mut self) {
self.0 = None;
}
@@ -68,15 +68,23 @@ pub(crate) fn commit_ground_cast_on_click(
// stays, exactly like the UnableCast cursor said it would.
return;
};
let dest = bevy_to_wow(point);
let at = bevy_to_wow(point);
// `BindLocation 0x6e60f0` has an arm per location bit and tests SOURCE first — the one click
// binds `SPELLCAST+0x30` (wire `0x0020`) for a `Targets & 0x20` spell and `+0x3c` (wire
// `0x0040`) for a `& 0x40` one (decision 2218). `None` cannot happen behind
// `pending_for(Location)`, whose mask is the same `0x60`; it stays a `let else` rather than an
// unwrap so a future seam edit fails closed.
let Some(bound) = ladder.ground.location_bind(at) else {
return;
};
debug!(
"ui_action: ground cast {spell_id} committed at wow ({:.2}, {:.2}, {:.2})",
dest[0], dest[1], dest[2]
"ui_action: ground cast {spell_id} committed at wow ({:.2}, {:.2}, {:.2}) as {bound:?}",
at[0], at[1], at[2]
);
// The shared commit tail — same block, two opcodes (`SendCast 0x6e54f0`'s one discriminator
// survives the cursor, decision 0914: a thrown grenade commits as `CMSG_USE_ITEM` with the
// DEST block), then the pending arm, the GCD, and the word cleared.
ladder.commit_targeted(spell_id, commit, TargetedBind::Dest(dest));
// location block), then the pending arm, the GCD, and the word cleared.
ladder.commit_targeted(spell_id, commit, bound);
}
/// The world click's **GameObject** commit — the object leg (decision 0939). While targeting, a
@@ -202,6 +210,87 @@ mod tests {
world.run_system(id).expect("the object commit runs");
}
/// **`BindLocation 0x6e60f0`'s two arms, from one terrain click** (decision 2218). The same
/// click, the same point, the same commit tail — the standing word alone decides whether the
/// point is bound to the SOURCE slot (`Targets 0x20`: Martin Fury's spell 265) or the DEST one
/// (`Targets 0x40`: Blizzard). Before this, every ground commit wrote DEST, and a `0x20` word
/// never reached the cursor at all — it drew "Invalid target" (B388).
#[test]
fn the_terrain_click_binds_source_or_dest_by_the_standing_word() {
const BLIZZARD: u32 = 10;
const AREA_DEATH: u32 = 265;
let commit = crate::ui_action::cast_send::CastCommit::Spell;
let ground = |world: &mut World| {
world.resource_mut::<crate::target::PressPick>().occlusion =
crate::target::PickOcclusion {
distance: 5.0,
point: Some(Vec3::new(1.0, 2.0, 3.0)),
};
};
let run = |world: &mut World, id: SystemId| {
world
.resource_mut::<Messages<WorldClick>>()
.write(WorldClick);
world.run_system(id).expect("the ground commit runs");
};
// DEST word → the dest opcode, as before.
let (mut world, rx, _) = fixture();
let id = world.register_system(commit_ground_cast_on_click);
world
.resource_mut::<super::super::SpellTargeting>()
.enter(BLIZZARD, commit, 0x0040);
ground(&mut world);
run(&mut world, id);
assert!(matches!(
rx.try_recv(),
Ok(ClientCommand::CastSpellAtDest {
spell_id: BLIZZARD,
..
})
));
// SOURCE word → the source opcode, from the identical click.
let (mut world, rx, _) = fixture();
let id = world.register_system(commit_ground_cast_on_click);
world
.resource_mut::<super::super::SpellTargeting>()
.enter(AREA_DEATH, commit, 0x0020);
ground(&mut world);
run(&mut world, id);
let sent = rx.try_recv().expect("a source word still commits");
assert!(
matches!(
sent,
ClientCommand::CastSpellAtSource {
spell_id: AREA_DEATH,
..
}
),
"a 0x20 word binds the SOURCE slot, not the dest: {sent:?}"
);
assert!(
!world.resource::<super::super::SpellTargeting>().active(),
"and the commit clears the one word"
);
// Both bits standing: SOURCE wins — the reference tests bit 5 before bit 6 and the arms
// are exclusive. No 5875 row carries both; the precedence is transcribed, not the
// two-click walk.
let (mut world, rx, _) = fixture();
let id = world.register_system(commit_ground_cast_on_click);
world
.resource_mut::<super::super::SpellTargeting>()
.enter(AREA_DEATH, commit, 0x0060);
ground(&mut world);
run(&mut world, id);
assert!(matches!(
rx.try_recv(),
Ok(ClientCommand::CastSpellAtSource { .. })
));
}
/// The whole gesture, end to end: a lock word standing, a chest under the cursor, one
/// left-click ⇒ the OPEN_LOCK cast at that GameObject, and the cursor is down.
#[test]
+27
View File
@@ -151,10 +151,37 @@ impl Plugin for UiChatPlugin {
// still a frame away. Measured: one login in six drained the burst there and ate a
// line of the realm's own welcome. The early return above `mem::take` is what holds
// the queue for the frames after it, and this is what holds it for that one.
//
// **…and after the world-enter cascade, because that is the reference's own order**
// (decision 2221, carved in wow-5875-re for this — their `ca5f7d38`). The real client does not print
// login chat when it arrives either: `[0x8435fc]` is a latch that ships statically
// `1`, so every `SMSG_MESSAGECHAT` in the login burst is queued into
// `__AUPENDINGCHAT__` (`0x49db5c`/`0x49db62` → `0x49cae0`) instead of displayed. It is
// cleared in exactly one place — `0x490974`, INSIDE the world-enter cascade, after
// `PLAYER_LOGIN` (`0x490959`) and `PLAYER_ENTERING_WORLD` (`0x49096a`) — and the same
// call drains the queue. `SMSG_GUILD_EVENT` 0x02 has no such latch: it fires
// `GUILD_MOTD` synchronously in its own handler (`0x5e7288`). So the reference paints
// the guild line FIRST and the realm's welcome lines after it, inverting the wire
// order — and that is what the report asked for ("before server messages").
//
// Our held `ChatLog` queue IS that latch: the early return above `mem::take` is what
// keeps the login burst waiting. This says when it drains. Without these two edges
// the order was whatever the scheduler picked between two systems that declared
// nothing about each other — right today, by luck, exactly as the drain above was
// safe by luck until it wasn't.
//
// The edges are on the two feeds that fire the cascade's events, because that is
// where `0x490974` sits — DOWNSTREAM of both `0x703e50` calls, not merely inside the
// cascade. `UnitFeed` carries `PLAYER_ENTERING_WORLD`, `GuildFeed` carries
// `GUILD_MOTD`. (The reference's drain is one contiguous walk — `0x49d230`, every
// entry through the live path's own composer `0x49a870` — so the welcome lines land
// as a block, after those events and before the rest of the cascade.)
.add_systems(
Update,
feed::feed_chat
.before(UiInput)
.after(crate::ui_unit::UnitFeed)
.after(crate::ui_guild::GuildFeed)
.run_if(crate::ui_script::ingame_ui_up),
)
// The last-input stamp `[0xcf0bc8]`. Deliberately NOT in-world-gated and
+8 -1
View File
@@ -769,6 +769,13 @@ pub(crate) mod apply {
}
}
/// **The guild feed, as an orderable thing** — so a system that must run after the guild events
/// have fired can say so without reaching for the function (and dragging its private memo type
/// into the crate's surface). Its one consumer is the chat drain, which the reference orders
/// after the world-enter cascade's events: `ui_chat`'s registration has the addresses.
#[derive(bevy::ecs::schedule::SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub(crate) struct GuildFeed;
/// The guild windows' session: the wire mirror, the VM feed, and the outbound intents.
pub(crate) struct UiGuildPlugin;
@@ -779,7 +786,7 @@ impl Plugin for UiGuildPlugin {
.add_systems(
Update,
(
feed::feed_guild.before(UiInput),
feed::feed_guild.before(UiInput).in_set(GuildFeed),
feed::drain_guild.after(UiInput),
)
// **Never against the boot VM** (1348/1978, and B376's half of it): the feed
@@ -735,3 +735,115 @@ fn the_watch_bar_shows_the_watched_factions_progress_and_swaps_at_max_level() {
assert!(!shown(&mut s, "MainMenuExpBar"));
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **B368 — "Show as Experience Bar", the whole round trip.** The reported symptom is the one thing
/// no test here covered: every check above either drives `SetWatchedFactionIndex` from Lua or pushes
/// `watched` in by hand, so the box itself — the only thing a player touches — had no guard at all
/// between the pointer and the wire.
///
/// What the round trip is, and why it LOOKS broken when a link is missing: the box's `<OnClick>`
/// (stock `ReputationFrame.xml` l.839-848) calls `SetWatchedFactionIndex(GetSelectedFaction())` and
/// then `ReputationWatchBar_Update()` — and that update reads `GetWatchedFactionInfo()`, which is
/// still nil, because watching is **not optimistic** (`0x4d6b60`: the slot is
/// `PLAYER_FIELD_WATCHED_FACTION_INDEX`, a server field with no client mirror). So the click's own
/// frame moves NOTHING on screen by design; the bar comes up one round trip later, when the
/// descriptor update lands as a fresh push plus `UPDATE_FACTION`. A server that never answers the
/// opcode therefore reads exactly as "the box does nothing", and so does anything between the
/// pointer and the send.
///
/// Driven through the real pointer pipeline, so a frame eating the box's clicks fails here.
#[test]
fn the_show_as_experience_bar_box_sends_the_watch_and_the_server_brings_the_bar_up() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = shown_reputation_page();
// The row's checkmark is anchored past `factionName:GetStringWidth()`, so this test needs a
// font: without a measurer installed every metric reads 0 (`script::measure`'s "absent by
// default" state) and the mark would land on the name's first letter for that reason rather
// than for a real one. 6 units a character, the file-wide stand-in.
s.set_text_measurer(Box::new(super::FixedWidthFont(6.0)));
let _ = s.take_reputation_sends();
// Ironforge (visible row 2, reputation slot 20) — select it and open the popup by clicking it.
click_center(&mut s, "ReputationBar2");
assert!(shown(&mut s, "ReputationDetailFrame"), "the popup opened");
assert!(
!s.eval::<bool>("return ReputationDetailMainScreenCheckBox:GetChecked() and true or false")
.unwrap(),
"nothing watched, so the box starts clear"
);
assert!(
!shown(&mut s, "ReputationWatchBar"),
"and the strip is down"
);
// THE CLICK. The engine ticks the box before the handler runs (the reference's own order), so
// the handler takes the `GetChecked()` branch that watches rather than the one that clears.
click_center(&mut s, "ReputationDetailMainScreenCheckBox");
assert_eq!(
s.take_reputation_sends(),
[benilla_ui::script::ReputationSend::Watch(Some(20))],
"one send, carrying Ironforge's reputation SLOT — not its visible row"
);
assert!(
!shown(&mut s, "ReputationWatchBar"),
"and nothing on screen moved: watching is not optimistic"
);
// The server's answer: the descriptor update arrives as a fresh push, and the event with it.
let mut watched = state();
watched.watched = Some(20);
s.set_reputation(watched);
s.fire_event("UPDATE_FACTION", vec![]);
s.resolve();
assert!(
s.eval::<bool>("return ReputationDetailMainScreenCheckBox:GetChecked() and true or false")
.unwrap(),
"NOW the box reads ticked — off the server's field, not off the click"
);
assert!(shown(&mut s, "ReputationWatchBar"), "and the strip is up");
assert_eq!(
text_of(&mut s, "ReputationWatchStatusBarText"),
"Ironforge 1000 / 6000"
);
// The list's own witness: the row grows a checkmark, re-anchored past the name's string width
// (ref `ReputationFrame.lua` l.96-103) — the half a player sees without leaving the pane.
assert!(
shown(&mut s, "ReputationBar2Check"),
"the watched row wears the checkmark"
);
assert!(
!shown(&mut s, "ReputationBar3Check"),
"and no other row does"
);
let (check_l, name_l) = s
.eval::<(f32, f32)>(
"return ReputationBar2Check:GetLeft(), ReputationBar2FactionName:GetLeft()",
)
.unwrap();
// "Ironforge" is nine characters, so the stand-in font makes the offset exactly 54 — the
// reference's `factionName:GetStringWidth()`, not a guess at one.
assert_eq!(
check_l - name_l,
54.0,
"the mark sits the name's own string width to its right"
);
// Untick: the same box, the same funnel, and `None` — NOT slot 0, which is a real faction.
click_center(&mut s, "ReputationDetailMainScreenCheckBox");
assert_eq!(
s.take_reputation_sends(),
[benilla_ui::script::ReputationSend::Watch(None)]
);
let mut cleared = state();
cleared.watched = None;
s.set_reputation(cleared);
s.fire_event("UPDATE_FACTION", vec![]);
s.resolve();
assert!(!shown(&mut s, "ReputationWatchBar"), "the strip goes down");
assert!(!shown(&mut s, "ReputationBar2Check"), "and the row's mark");
assert!(!s
.eval::<bool>("return ReputationDetailMainScreenCheckBox:GetChecked() and true or false")
.unwrap());
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -585,6 +585,10 @@ const TARGET_FLAG_UNIT: u16 = 0x0002;
/// `TARGET_FLAG_DEST_LOCATION` — a ground point, the same bit and the same three `f32` WoW coords
/// a ground-targeted `CMSG_CAST_SPELL` writes ([`super::spells::cast_spell_at_dest`]).
const TARGET_FLAG_DEST_LOCATION: u16 = 0x0040;
/// `TARGET_FLAG_SOURCE_LOCATION` — the dest bit's twin one place down, and the same three `f32`
/// ([`super::spells::cast_spell_at_source`]). Both come out of the one binder `BindLocation
/// 0x6e60f0` (decision 2218).
const TARGET_FLAG_SOURCE_LOCATION: u16 = 0x0020;
/// `TARGET_FLAG_ITEM` — a bound *item* target, the same bit and the same packed guid an
/// item-targeted `CMSG_CAST_SPELL` writes (`super::spells::cast_spell_on_item`). One block
/// builder, two opcodes (decision 0923).
@@ -623,6 +627,12 @@ pub enum UseItemTarget {
/// `BindTarget 0x6e5b40` a unit goes through (`0x495d60` @ `496056`), so the block is the same
/// block; only which bit is set differs.
Item(u64),
/// `TARGET_FLAG_SOURCE_LOCATION` + three `f32` WoW coords — [`Self::Dest`]'s twin, one bit
/// over: `BindLocation 0x6e60f0`'s bit-5 arm writes the clicked point to `SPELLCAST+0x30` and
/// ORs `0x0020` into the wire mask, where its bit-6 arm writes `+0x3c` and ORs `0x0040`. Three
/// shipped items reach it — Martin Fury (17), 192 and 5417, all carrying spell 265 "Area Death
/// (TEST)" (decision 2218). vmangos reads this triple **before** the dest one.
Source([f32; 3]),
}
/// Body of `CMSG_USE_ITEM` (VERIFIED vmangos `UseItem::ReadFromWorldPacket` + opcode 171
@@ -659,6 +669,13 @@ pub fn use_item(bag_index: u8, slot: u8, spell_slot: u8, target: UseItemTarget)
}
return body;
}
UseItemTarget::Source(src) => {
body.extend_from_slice(&TARGET_FLAG_SOURCE_LOCATION.to_le_bytes());
for c in src {
body.extend_from_slice(&c.to_le_bytes());
}
return body;
}
};
crate::wire::write_packed_guid(guid, &mut body).expect("write to Vec cannot fail");
body
+2 -2
View File
@@ -234,8 +234,8 @@ pub use social::{
};
pub use spellbook::SpellCooldown;
pub use spells::{
cancel_aura, cast_spell, cast_spell_at_dest, cast_spell_gameobject, cast_spell_item,
CastOutcome, SpellCastTargets, SpellChainTargets, SpellGo, SpellStart,
cancel_aura, cast_spell, cast_spell_at_dest, cast_spell_at_source, cast_spell_gameobject,
cast_spell_item, CastOutcome, SpellCastTargets, SpellChainTargets, SpellGo, SpellStart,
};
pub use stable::{
buy_stable_slot, list_stabled_pets, stable_pet, stable_result, stable_swap_pet, unstable_pet,
@@ -402,6 +402,26 @@ pub fn cast_spell_at_dest(spell_id: u32, dest: [f32; 3]) -> Vec<u8> {
body
}
/// Body of `CMSG_CAST_SPELL` aimed at a **source point** (decision 2218): the targeting-cursor
/// commit for a `Targets & 0x20` spell — the *other* half of `BindLocation 0x6e60f0`, whose bit-5
/// arm writes `SPELLCAST+0x30..0x38` and ORs `SOURCE_LOCATION (0x0020)` into the wire mask
/// (`6e6105`–`6e6126`), exactly as its bit-6 arm does for the dest at `+0x3c..0x44`. Same block,
/// same three `f32` WoW world coords, one bit over (vmangos `SpellCastTargets::read`,
/// `SpellCastTargetsInfo.cpp:161-167`: `SOURCE_LOCATION → x,y,z`, `IsValidMapCoord`-gated, read
/// **before** the dest triple).
///
/// The server centres the AoE on it: `TARGET_ENUM_UNITS_ENEMY_AOE_AT_SRC_LOC (15)` fills its
/// target map with `FillAreaTargets(…, PUSH_SRC_CENTER, …)` (`Spell.cpp:2265`).
pub fn cast_spell_at_source(spell_id: u32, src: [f32; 3]) -> Vec<u8> {
let mut body = Vec::with_capacity(18);
body.extend_from_slice(&spell_id.to_le_bytes());
body.extend_from_slice(&TARGET_FLAG_SOURCE_LOCATION.to_le_bytes());
for c in src {
body.extend_from_slice(&c.to_le_bytes());
}
body
}
/// Body of `CMSG_CANCEL_AURA` (vmangos `WorldPackets::Spell::CancelAura`, `Server/Packets/Spell.h:55-62`):
/// one `u32` spell id. The server cancels **by spell, not by slot** — `HandleCancelAuraOpcode`
/// (`SpellHandler.cpp:333-405`) looks the spell up, refuses passives, `SPELL_ATTR_NO_AURA_CANCEL`
@@ -448,6 +468,37 @@ mod tests {
);
}
#[test]
fn cast_spell_at_source_body_golden() {
// spell_id 265 (Area Death (TEST) — Martin Fury's on-use, LE) + mask SOURCE_LOCATION
// 0x0020 (LE `20 00`) + the source Vec3 as three f32 LE. VERIFIED against vmangos
// `SpellCastTargets::read` (`SpellCastTargetsInfo.cpp:161-167`), which reads the SOURCE
// triple BEFORE the dest one and `IsValidMapCoord`-gates it. Decision 2218.
assert_eq!(
cast_spell_at_source(265, [1.0, -2.5, 3.0]),
[
0x09, 0x01, 0x00, 0x00, // spell id 265
0x20, 0x00, // TARGET_FLAG_SOURCE_LOCATION
0x00, 0x00, 0x80, 0x3F, // x = 1.0
0x00, 0x00, 0x20, 0xC0, // y = -2.5
0x00, 0x00, 0x40, 0x40, // z = 3.0
],
"CMSG_CAST_SPELL (ground source) body"
);
// One bit apart from its twin, and nothing else: the two bodies differ in exactly the
// mask byte.
let (src, dest) = (
cast_spell_at_source(265, [1.0, -2.5, 3.0]),
cast_spell_at_dest(265, [1.0, -2.5, 3.0]),
);
assert_eq!(src.len(), dest.len());
assert_eq!(
src.iter().zip(&dest).filter(|(a, b)| a != b).count(),
1,
"source and dest bodies differ only in the mask"
);
}
#[test]
fn aura_bodies_golden() {
// CMSG_CANCEL_AURA: a lone u32 spell id, LE. 1126 (Mark of the Wild) = 0x0000_0466. The
@@ -71,6 +71,17 @@ impl WorldWriter {
)
}
/// Cast a spell at a **source point** (`CMSG_CAST_SPELL` with `TARGET_FLAG_SOURCE_LOCATION` +
/// the Vec3 in WoW world coords — [`messages::cast_spell_at_source`]): the targeting-cursor
/// commit for a `Targets & 0x20` spell (decision 2218). The dest verb's twin, one bit over;
/// the server centres the AoE on the point it carries.
pub fn cast_spell_at_source(&mut self, spell_id: u32, src: [f32; 3]) -> Result<()> {
self.send(
opcode::CMSG_CAST_SPELL,
&messages::cast_spell_at_source(spell_id, src),
)
}
/// Cancel a named in-flight cast (`CMSG_CANCEL_CAST`: one `u32` spell id — vmangos
/// `HandleCancelCastOpcode`). Sent by the wand-only auto-repeat handoff (`0x6095b8`) and by
/// the cast bar's local self-cancel (movement/Esc mid-cast, `benilla::ui_cast`).
+10
View File
@@ -69,6 +69,16 @@ fn item_query_wire() {
hx("13030140000000803f0000004000004040")
);
// The SOURCE form (decision 2218): the same terrain click one bit over — the three shipped
// items carrying spell 265 "Area Death (TEST)" (Martin Fury 17, plus 192 and 5417), whose
// `Targets` is a bare 0x20. `BindLocation 0x6e60f0` writes SPELLCAST+0x30 and ORs 0x0020
// where its dest arm writes +0x3c and ORs 0x0040; vmangos reads this triple first. Martin
// Fury is worn, so the wire position is the player array (255) + EQUIPMENT_SLOT_BODY (3).
assert_eq!(
messages::use_item(255, 3, 0, messages::UseItemTarget::Source([1.0, 2.0, 3.0])),
hx("ff030020000000803f0000004000004040")
);
// The ITEM form (decision 0923): the targeting cursor's item commit — a poison / sharpening
// stone / weapon oil applied to the weapon you clicked. TARGET_FLAG_ITEM (0x0010) + the packed
// guid, the same block cast_spell_on_item writes; the reference reaches both through the one
@@ -64,6 +64,23 @@ pub(crate) fn number_arg(lua: &Lua, v: Value, usage: &'static str) -> mlua::Resu
}
}
/// **Shape D** — a `lua_isnumber`-gated *flag* whose failure edge does NOT raise: the local keeps
/// the zero it was seeded with and the binding carries on. `SetInventoryItem`'s optional third
/// argument, the one the image's own usage string (`0x8552dc`) calls `nameOnly`, is this repo's
/// example, and it is stricter than the other shapes on one point: after the
/// `is-number(L, idx)` guard (`0x6f34d0`, so a numeric STRING passes it) the value is compared
/// `> 0.0` **strictly** — `0x53303f fcomp qword [0x802970]` against a literal `0.0`, then
/// `0x533047 test ah,0x41; 0x53304a jne` onto the block that keeps the zero. Masked AH is `0x00`
/// only for greater-than; less (`0x01`), equal (`0x40`) and unordered (`0x41`) all jump. So `0`,
/// a negative and NaN are false, and `1`, `0.5` and `"1"` are true.
///
/// The distinction from [`number_arg`] is the whole point: shape A *raises* `Usage:` on a
/// non-number, shape D shrugs. Which shape an argument takes is per binding — wow-re
/// `ui/scratch/tooltip-nameonly-p4-census.md` §3 for this one.
pub(crate) fn positive_number_flag(lua: &Lua, v: Value) -> mlua::Result<bool> {
Ok(lua.coerce_number(v)?.is_some_and(|n| n > 0.0))
}
/// **Shape C** — a numeric argument the binding reads with a bare `lua_tonumber 0x6f3620` and NO
/// `lua_isnumber` guard, so it cannot fail: absent, `nil`, `true`, a table, a function, or an
/// unparseable string all land on **`0.0`** and the binding completes.
@@ -206,6 +206,112 @@ fn merchant_compare_renders_the_compare_shape_and_no_hover_seats_a_plate() {
assert!(s.take_errors().is_empty());
}
/// **`nameOnly`** — `SetInventoryItem`'s optional third argument, p4 of the builder, and the ONLY
/// door onto the compact render in 1.12.1 (wow-re `ui/scratch/tooltip-nameonly-p4-census.md`: 27
/// of 31 call sites pass a provable zero, one forwards, and the three that carry a flag are all
/// this binding's). No stock FrameXML caller passes it — all 8 stock call sites are
/// two-argument — so this is addon surface, and it is live code, not a dead arm.
///
/// The mode is **trimmed, not bare**, and that is the half a plausible implementation gets wrong:
/// two non-contiguous cuts plus an early return. Gone: the bind/lock region, the whole stat body,
/// and everything past the cooldown line. Kept: the name (white), the slot/type cell, durability,
/// every requirement line and the spell triggers.
#[test]
fn set_inventory_item_name_only_is_the_trimmed_build() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
let mut inv: InventorySlots = Default::default();
inv[16] = Some(InvSlotView {
durability: Some((50, 90)),
item_id: 8100,
name: Some("Sealed Blade".into()),
quality: 4,
..Default::default()
});
s.set_inventory_slots(inv);
s.set_item_template(
8100,
ItemTemplateView {
name: "Sealed Blade".into(),
quality: 4,
class: 2,
subclass: 7,
inventory_type: 13,
// One line from each of the three regions p4 treats differently: CONJURED / the bind
// line / UNIQUE / LOCKED are CUT, ARMOR is CUT, and the type cell, durability, the
// level requirement and the trigger all SURVIVE.
flags: 0x2,
bonding: 1,
max_count: 1,
lock_id: 7,
armor: 100,
max_durability: 90,
required_level: 40,
spell_triggers: vec![(1, 100, "Zap".into())],
description: "Sealed.".into(),
..Default::default()
},
);
s.run(
r#"
local a = CreateFrame("Button", "Slot13"); a:SetPoint("CENTER", 0, 0)
a:SetWidth(10); a:SetHeight(10)
local tt = CreateFrame("GameTooltip", "TT")
tt:SetOwner(a, "ANCHOR_RIGHT")
function has(needle)
for i = 1, TT:NumLines() do
local t = getglobal("TTTextLeft" .. i):GetText()
if t and string.find(t, needle, 1, true) then return true end
end
return false
end
-- ── no third argument: the full build ──────────────────────────────────────────────
assert(tt:SetInventoryItem("player", 16) == 1)
assert(has("[ITEM_CONJURED]") and has("[ITEM_BIND_ON_PICKUP]")
and has("[ITEM_UNIQUE]") and has("[LOCKED]"), "full: the bind/lock region")
assert(has("[ARMOR 100]"), "full: the stat body")
assert(has("[INVTYPE_WEAPON]") and has("[DURABILITY 50/90]")
and has("[MIN_LEVEL 40]") and has("Zap"), "full: the kept lines")
assert(has("\"Sealed.\""), "full: the description")
local r, g, b = TTTextLeft1:GetTextColor()
assert(math.abs(r - 0.639) < 0.01 and math.abs(g - 0.208) < 0.01
and math.abs(b - 0.933) < 0.01, "full: the name is epic purple")
-- ── nameOnly = 1: trimmed, not bare ───────────────────────────────────────────────
assert(tt:SetInventoryItem("player", 16, 1) == 1, "nameOnly still answers 1")
assert(TTTextLeft1:GetText() == "Sealed Blade")
local r2, g2, b2 = TTTextLeft1:GetTextColor()
assert(r2 == 1 and g2 == 1 and b2 == 1, "nameOnly: the NAME goes white (0x52b8b3)")
assert(not has("[ITEM_CONJURED]") and not has("[ITEM_BIND_ON_PICKUP]")
and not has("[ITEM_UNIQUE]") and not has("[LOCKED]"),
"nameOnly: the bind/lock region is cut (0x52bac3)")
assert(not has("[ARMOR 100]"), "nameOnly: the stat body is cut (0x52c225)")
assert(not has("\"Sealed.\""), "nameOnly: the early return (0x52e14e) drops the tail")
assert(has("[INVTYPE_WEAPON]"), "nameOnly KEEPS the slot/type cell — between the two cuts")
assert(has("[DURABILITY 50/90]") and has("[MIN_LEVEL 40]") and has("Zap"),
"nameOnly KEEPS durability, the requirements and the triggers")
-- ── the gate's polarity: is-number AND strictly > 0 (0x53304a) ────────────────────
-- Absent, nil, 0, a negative and a non-numeric string all leave the flag at its zero
-- seed, and none of them RAISES — this argument is not `number_arg`'s shape.
local full = { nil, 0, -1, "nope", {}, false }
for i = 1, 6 do
tt:SetInventoryItem("player", 16, full[i])
assert(has("[ARMOR 100]"), "a non-positive third argument builds the FULL tooltip")
end
-- A numeric STRING passes lua_isnumber, and a fraction is still > 0.
tt:SetInventoryItem("player", 16, "1")
assert(not has("[ARMOR 100]"), "a numeric string is a number to lua_isnumber")
tt:SetInventoryItem("player", 16, 0.5)
assert(not has("[ARMOR 100]"), "strictly greater than zero, not >= 1")
"#,
)
.unwrap();
assert!(s.take_errors().is_empty());
}
/// `SetInventoryItem` outside a compare: the FULL line law (quality name, description — no cut),
/// return 1 on an occupied slot, nil on empty/foreign units.
#[test]
+136 -108
View File
@@ -29,6 +29,16 @@
//! image, so `PaperDollFrame.lua`'s slot listener is dead code there (wow-re
//! `merchant-compare-item-law.md` §8).
//!
//! **`nameOnly`** (p4 `[arg+0x14]`, the flag 2216 had tangled with the header; built by 2224):
//! the builder's
//! compact mode, and the census that settled it found exactly one door onto it — this module's
//! `SetInventoryItem`, third argument, a number `> 0`. It is what the image's own usage string
//! `0x8552dc` calls it. No stock FrameXML caller passes it (all 8 sites are two-argument), so it
//! is addon surface; it is nonetheless live code, and the mode is **trimmed, not bare** — the
//! bind/lock region and the stat body are jumped and the tail is cut, but the slot/type cell,
//! durability, every requirement line and the spell triggers all still print. Both flags ride in
//! [`render::BuilderFlags`], a struct precisely so they cannot be collapsed again.
//!
//! **The red "you can't use this" law** (the director's explicit ask) — byte-verified §1-RED:
//! red is the AddLine color `0xffff2020`, applied to requirement lines the ACTIVE player fails
//! (level, class/race lists, skill rank, required spell), to LOCKED, to broken durability, and
@@ -52,7 +62,7 @@ mod names;
mod render;
use names::{quality_color, GRAY, WHITE};
use render::render_view;
use render::{render_view, BuilderFlags};
/// The compare tooltips' shared body — what `SetMerchantCompareItem 0x536080` and
/// `SetAuctionCompareItem 0x535d70` both run once their wrapper has an offered template and a
@@ -211,7 +221,7 @@ fn render_by_id(
fire_cleared(lua, h);
match view_of(lua, item_id) {
Some(v) => {
render_view(lua, this, &v, false, None)?;
render_view(lua, this, &v, BuilderFlags::default(), None)?;
}
None => {
if let Some(name) = fb_name {
@@ -396,7 +406,7 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
};
match view_of(lua, id) {
Some(v) => {
render_view(lua, &this, &v, false, Some(&inst))?;
render_view(lua, &this, &v, BuilderFlags::default(), Some(&inst))?;
// Unchanged from the `render_by_id` this leg used to share: a link dropped on
}
None => {
@@ -447,105 +457,123 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
// (`mov eax,2` @0x534985). A carve of `0x532ee0`'s tail would upgrade this line.
m.set(
"SetInventoryItem",
lua.create_function(|lua, (this, unit, slot): (Table, String, usize)| {
let h = frame_handle_of(lua, &this)?;
let (item_id, name, quality, inst, currently_equipped) = {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let armed = match super::tooltip::tip_mut(&mut model, h) {
Ok(t) => std::mem::take(&mut t.equipped_header_armed),
Err(_) => false,
};
let view = model
.inv_slot(&unit, slot)
.filter(|s| s.item_id != 0)
.map(|s| {
(
s.item_id,
s.name.clone(),
s.quality,
render::ItemInstance {
// The slot's own name — app-composed, so it carries the
// random-suffix roll off `ITEM_FIELD_RANDOM_PROPERTIES_ID`.
name: s.name.clone(),
durability: s.durability,
creator: s.creator.clone(),
has_text: false,
flags: s.flags,
already_bound: s.already_bound,
// No petition line on the doll: a charter has `InventoryType = 0`
// and cannot be equipped, so this hover can never be over one.
petition: None,
enchants: s.enchants.clone(),
// `SetInventoryItem 0x532ee0` also has p6=0 legs (`0x533106`,
// `0x5332ad`) — the "this binding can never emit OPENABLE" claim
// is dead here too (wow-re `right-click-open.md` §1.2). Which leg
// each takes is not pinned, and the case is unobservable anyway:
// nothing openable is equippable, so the doll hover has no clam to
// show. Left `false` deliberately — inventing a selector we have
// not read would be the §4 trade, and there is nothing to gain.
openable_source: false,
duration_ms: s.duration_ms,
},
)
});
match view {
Some((id, name, q, inst)) => (id, name, q, inst, armed),
// An EMPTY slot answers `nil` for hasItem — and still pushes the other two,
// because the reference's own caller destructures all three unconditionally
// and only then tests `hasItem`. Answering one value here would hand
// `PaperDollItemSlotButton_OnEnter` a nil `repairCost` on every empty slot,
// which its `repairCost and (repairCost > 0)` guard survives but pfUI's bare
// `totalRep + repCost` does not.
None => {
return Ok(MultiValue::from_vec(vec![
Value::Nil,
Value::Nil,
Value::Integer(0),
]))
}
}
};
{
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
clear_content(&mut model, h);
}
fire_cleared(lua, h);
match view_of(lua, item_id) {
Some(v) => render_view(lua, &this, &v, currently_equipped, Some(&inst))?,
None => {
// Template in flight — the slot view's own name holds the plate (the same
// 0138 posture as SetBagItem's miss path).
// The compare header is a key like every other sentence (2045); this
// fallback path shows it for exactly the reason the full render does.
if currently_equipped {
if let Some(t) = crate::strings::global(lua, "CURRENTLY_EQUIPPED") {
append_line(lua, &this, (t, GRAY), None, false)?;
lua.create_function(
|lua, (this, unit, slot, name_only): (Table, String, usize, Value)| {
let h = frame_handle_of(lua, &this)?;
// **The third argument the binary names itself**: `0x8552dc` carries
// `"Usage: SetInventoryItem(unit, slot [, nameOnly])"`, and `0x533027`–`0x53304c` is
// its gate — the flag seeds 0 and becomes 1 only on `lua_isnumber(L,4)` AND
// `lua_tonumber(L,4) > 0.0` STRICTLY. A miss does not raise; it leaves the zero and
// builds the ordinary tooltip. Three of this binding's four builder legs pass it
// through (wow-re `ui/scratch/tooltip-nameonly-p4-census.md` §3).
let name_only = super::binding_abi::positive_number_flag(lua, name_only)?;
let (item_id, name, quality, inst, currently_equipped) = {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let armed = match super::tooltip::tip_mut(&mut model, h) {
Ok(t) => std::mem::take(&mut t.equipped_header_armed),
Err(_) => false,
};
let view = model
.inv_slot(&unit, slot)
.filter(|s| s.item_id != 0)
.map(|s| {
(
s.item_id,
s.name.clone(),
s.quality,
render::ItemInstance {
// The slot's own name — app-composed, so it carries the
// random-suffix roll off `ITEM_FIELD_RANDOM_PROPERTIES_ID`.
name: s.name.clone(),
durability: s.durability,
creator: s.creator.clone(),
has_text: false,
flags: s.flags,
already_bound: s.already_bound,
// No petition line on the doll: a charter has `InventoryType = 0`
// and cannot be equipped, so this hover can never be over one.
petition: None,
enchants: s.enchants.clone(),
// `SetInventoryItem 0x532ee0` also has p6=0 legs (`0x533106`,
// `0x5332ad`) — the "this binding can never emit OPENABLE" claim
// is dead here too (wow-re `right-click-open.md` §1.2). Which leg
// each takes is not pinned, and the case is unobservable anyway:
// nothing openable is equippable, so the doll hover has no clam to
// show. Left `false` deliberately — inventing a selector we have
// not read would be the §4 trade, and there is nothing to gain.
openable_source: false,
duration_ms: s.duration_ms,
},
)
});
match view {
Some((id, name, q, inst)) => (id, name, q, inst, armed),
// An EMPTY slot answers `nil` for hasItem — and still pushes the other two,
// because the reference's own caller destructures all three unconditionally
// and only then tests `hasItem`. Answering one value here would hand
// `PaperDollItemSlotButton_OnEnter` a nil `repairCost` on every empty slot,
// which its `repairCost and (repairCost > 0)` guard survives but pfUI's bare
// `totalRep + repCost` does not.
None => {
return Ok(MultiValue::from_vec(vec![
Value::Nil,
Value::Nil,
Value::Integer(0),
]))
}
}
if let Some(name) = name {
let color = quality_color(quality.max(0) as u32);
append_line(lua, &this, (name, color), None, false)?;
};
// p5 and p4, carried as one value but never as one flag (2216).
let flags = BuilderFlags {
currently_equipped,
name_only,
};
{
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
clear_content(&mut model, h);
}
fire_cleared(lua, h);
match view_of(lua, item_id) {
Some(v) => render_view(lua, &this, &v, flags, Some(&inst))?,
None => {
// Template in flight — the slot view's own name holds the plate (the same
// 0138 posture as SetBagItem's miss path).
// The compare header is a key like every other sentence (2045); this
// fallback path shows it for exactly the reason the full render does.
if flags.currently_equipped {
if let Some(t) = crate::strings::global(lua, "CURRENTLY_EQUIPPED") {
append_line(lua, &this, (t, GRAY), None, false)?;
}
}
if let Some(name) = name {
let color = if flags.name_only {
WHITE
} else {
quality_color(quality.max(0) as u32)
};
append_line(lua, &this, (name, color), None, false)?;
}
}
}
}
show_or_hide_empty(lua, h);
Ok(MultiValue::from_vec(vec![
Value::Integer(1),
// `hasCooldown` — **broader than its name**, and this is the one arm of it we can
// answer. The builder's Lua return is `[ebp-0x38]`, set by FOUR sites, only one of
// which is the cooldown line: LOCKED_WITH_ITEM, the temporary-enchant countdown,
// **the item's own duration line** (`0x52ce0d`), and ITEM_COOLDOWN_TIME. So a
// duration-bearing item answers truthy here with no cooldown of any kind, and an
// addon can see it (decision 1933's fold-back). The equipped-cooldown feed is
// still the INTERIM above; this is the half that now exists.
if inst.duration_ms.is_some() {
Value::Boolean(true)
} else {
Value::Nil
},
Value::Integer(0),
]))
})?,
show_or_hide_empty(lua, h);
Ok(MultiValue::from_vec(vec![
Value::Integer(1),
// `hasCooldown` — **broader than its name**, and this is the one arm of it we can
// answer. The builder's Lua return is `[ebp-0x38]`, set by FOUR sites, only one of
// which is the cooldown line: LOCKED_WITH_ITEM, the temporary-enchant countdown,
// **the item's own duration line** (`0x52ce0d`), and ITEM_COOLDOWN_TIME. So a
// duration-bearing item answers truthy here with no cooldown of any kind, and an
// addon can see it (decision 1933's fold-back). The equipped-cooldown feed is
// still the INTERIM above; this is the half that now exists.
if inst.duration_ms.is_some() {
Value::Boolean(true)
} else {
Value::Nil
},
Value::Integer(0),
]))
},
)?,
)?;
// GameTooltip:SetBagItem(bag, slot) → hasCooldown, repairCost — the real-instance hover.
@@ -614,7 +642,7 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
fire_cleared(lua, h);
match view_of(lua, item_id) {
Some(v) => {
render_view(lua, &this, &v, false, Some(&inst))?;
render_view(lua, &this, &v, BuilderFlags::default(), Some(&inst))?;
let (merchant_open, repairing) = {
let model = lua.app_data_mut::<Model>().expect("model app_data");
(model.merchant.is_some(), model.repair_mode)
@@ -699,7 +727,7 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
..Default::default()
};
match view_of(lua, item.item_id) {
Some(v) => render_view(lua, &this, &v, false, Some(&inst))?,
Some(v) => render_view(lua, &this, &v, BuilderFlags::default(), Some(&inst))?,
None => {
if let Some(name) = item.name.clone() {
append_line(
@@ -749,7 +777,7 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
..Default::default()
};
match view_of(lua, row.item_id) {
Some(v) => render_view(lua, &this, &v, false, Some(&inst))?,
Some(v) => render_view(lua, &this, &v, BuilderFlags::default(), Some(&inst))?,
// Template in flight — the row's name holds the plate (SetBagItem's 0138 posture;
// the re-enter loop repaints when the push lands).
None => {
@@ -802,7 +830,7 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
..Default::default()
};
match view_of(lua, entry.item_id) {
Some(v) => render_view(lua, &this, &v, false, Some(&inst))?,
Some(v) => render_view(lua, &this, &v, BuilderFlags::default(), Some(&inst))?,
None => {
if let Some(name) = entry.name.clone() {
append_line(
@@ -1009,7 +1037,7 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
fire_cleared(lua, h);
match view_of(lua, row.item_id) {
Some(v) => {
render_view(lua, &this, &v, false, None)?;
render_view(lua, &this, &v, BuilderFlags::default(), None)?;
// **The vendor tab arms nothing** — `MerchantFrame.xml:67-80` seats
// `ShoppingTooltip1/2` itself on every hover of it, unconditionally, and
// that is the reference's own live compare. Arming here would put the
@@ -1038,7 +1066,7 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
block: head.block,
..Default::default()
};
render_view(lua, &this, &v, false, None)?;
render_view(lua, &this, &v, BuilderFlags::default(), None)?;
}
}
show_or_hide_empty(lua, h);
@@ -1081,7 +1109,7 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
..Default::default()
};
if let Some(v) = view_of(lua, item_id) {
render_view(lua, &this, &v, false, Some(&inst))?;
render_view(lua, &this, &v, BuilderFlags::default(), Some(&inst))?;
}
show_or_hide_empty(lua, h);
Ok(())
@@ -1130,7 +1158,7 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
..Default::default()
};
if let Some(v) = view_of(lua, item_id) {
render_view(lua, &this, &v, false, Some(&inst))?;
render_view(lua, &this, &v, BuilderFlags::default(), Some(&inst))?;
}
show_or_hide_empty(lua, h);
Ok(())
@@ -1166,7 +1194,7 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
}
fire_cleared(lua, h);
if let Some(v) = view_of(lua, item_id) {
render_view(lua, &this, &v, false, None)?;
render_view(lua, &this, &v, BuilderFlags::default(), None)?;
}
show_or_hide_empty(lua, h);
Ok(())
@@ -1197,7 +1225,7 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
}
fire_cleared(lua, h);
if let Some(v) = view_of(lua, item_id) {
render_view(lua, &this, &v, false, None)?;
render_view(lua, &this, &v, BuilderFlags::default(), None)?;
}
show_or_hide_empty(lua, h);
Ok(())
@@ -116,6 +116,29 @@ pub(super) struct ItemInstance {
/// blank rows.
const SET_SPACER: &str = " \n";
/// The builder's two independent render flags, p4 and p5 of `0x52b650` — a struct rather than two
/// adjacent `bool`s because collapsing them into one was decision 2216's bug, and two positional
/// bools at a call site is the same mistake with an extra step.
#[derive(Clone, Copy, Default)]
pub(super) struct BuilderFlags {
/// **p5 `[arg+0x18]`** — prepend the gray `CURRENTLY_EQUIPPED` line. Set by the two shopping
/// plates (`SetMerchantCompareItem`, `SetAuctionCompareItem`) and by nothing else in the
/// image; those two sites pass [`name_only`](Self::name_only) ZERO.
pub currently_equipped: bool,
/// **p4 `[arg+0x14]`** — the compact mode the binary itself calls `nameOnly`
/// (`0x8552dc`: `"Usage: SetInventoryItem(unit, slot [, nameOnly])"`). Reachable from exactly
/// one binding — `SetInventoryItem`'s optional third argument, a number `> 0` — and from no
/// stock FrameXML caller at all, but it is live code an addon can ask for (wow-re
/// `ui/scratch/tooltip-nameonly-p4-census.md`, §5 trio + orchestrator, 2026-09-13; benilla
/// decision 2224).
///
/// It is *trimmed*, not bare, and the trim is two non-contiguous jumps plus an early return:
/// the name goes white, the bind/lock region and the whole stat body are cut, and everything
/// after the cooldown line is skipped. What survives is the name, the slot/type cell,
/// durability, duration, every requirement line, the spell triggers and the set block.
pub name_only: bool,
}
/// Render one item template into the tooltip — the BYTE-VERIFIED emission law of the shared
/// renderer `0x52b650` (wow-re `ui/scratch/tooltip-content-law.md`, §5-cross-checked 2026-07-10;
/// the proficiency-cell and SET legs byte-read directly 2026-07-11; the creator/readable
@@ -126,7 +149,7 @@ pub(super) fn render_view(
lua: &Lua,
this: &Table,
v: &ItemTemplateView,
currently_equipped: bool,
flags: BuilderFlags,
// `None` = a template/link source (the ref's no-object path).
inst: Option<&ItemInstance>,
) -> mlua::Result<()> {
@@ -186,13 +209,21 @@ pub(super) fn render_view(
// renders a 'compare mode' abbreviated tooltip here is wrong"), and `tooltip-content-law.md`
// §1's per-call-site census over all 31 sites of `0x52b650` is what settles it: those two are
// the only sites in the image passing a literal non-zero p5, and both pass p4 zero.
if currently_equipped {
if flags.currently_equipped {
keyed("CURRENTLY_EQUIPPED", &[], GRAY, false)?;
}
let name = inst
.and_then(|i| i.name.clone())
.unwrap_or_else(|| v.name.clone());
add((name, quality_color(v.quality)))?;
// The NAME's one recolor: `nameOnly` paints it white (`0x52b8b3`, the `je 0x52b8ca` target
// being the quality arm `lea ecx,[eax*4+0xc0d3c8]`). Nothing else ever recolors it — an
// unusable item's name stays quality-colored.
let name_color = if flags.name_only {
WHITE
} else {
quality_color(v.quality)
};
add((name, name_color))?;
// Line 3 — the petition block, ABOVE the green line and below the name: "Guild Name: X" then
// "Guild Master: Y" for a charter, "Petition: X" / "Created by Y" for a plain petition. The
// keys are picked by the record's own charter bit, the same bit `GetPetitionInfo`'s first
@@ -219,43 +250,47 @@ pub(super) fn render_view(
if v.flags & 0x2000 != 0 {
keyed("ITEM_SIGNABLE", &[], GREEN, false)?;
}
// ITEM_CONJURED (Flags bit 0x2).
if v.flags & 0x2 != 0 {
keyed("ITEM_CONJURED", &[], WHITE, false)?;
}
// The bind line (§6, white, one line). Bonding `[record+0x194]` ∈ {1..5} is what decides
// whether a line prints at ALL — a Bonding-0 item says nothing here however it is held.
// Within that, a **runtime-bound instance** (`0x5da2c0` — [`ItemInstance::already_bound`])
// overrides the whole line to ITEM_SOULBOUND, and to ITEM_BIND_QUEST for the two quest
// kinds, which is the same text 4|5 print anyway; only then does the jump table `0x52e4fc`
// pick 1→picked up · 2→equipped · 3→used · 4/5→Quest Item.
//
// Before this arm an equipped Binds-when-equipped piece kept saying *Binds when equipped*
// forever (B310, Frostshake): the template's `bonding` never changes when the item binds —
// the instance's flag is the only thing that does.
match v.bonding {
4 | 5 => keyed("ITEM_BIND_QUEST", &[], WHITE, false)?,
1..=3 if inst.is_some_and(|i| i.already_bound) => {
keyed("ITEM_SOULBOUND", &[], WHITE, false)?
// **p4's FIRST cut** (`0x52babe`; `0x52bac3 jne 0x52bfad` skips `[0x52bac9, 0x52bfad)`) — the
// whole bind/lock region. `ITEM_SIGNABLE` just above is NOT in the jumped range and survives.
if !flags.name_only {
// ITEM_CONJURED (Flags bit 0x2).
if v.flags & 0x2 != 0 {
keyed("ITEM_CONJURED", &[], WHITE, false)?;
}
// The bind line (§6, white, one line). Bonding `[record+0x194]` ∈ {1..5} is what decides
// whether a line prints at ALL — a Bonding-0 item says nothing here however it is held.
// Within that, a **runtime-bound instance** (`0x5da2c0` — [`ItemInstance::already_bound`])
// overrides the whole line to ITEM_SOULBOUND, and to ITEM_BIND_QUEST for the two quest
// kinds, which is the same text 4|5 print anyway; only then does the jump table `0x52e4fc`
// pick 1→picked up · 2→equipped · 3→used · 4/5→Quest Item.
//
// Before this arm an equipped Binds-when-equipped piece kept saying *Binds when equipped*
// forever (B310, Frostshake): the template's `bonding` never changes when the item binds —
// the instance's flag is the only thing that does.
match v.bonding {
4 | 5 => keyed("ITEM_BIND_QUEST", &[], WHITE, false)?,
1..=3 if inst.is_some_and(|i| i.already_bound) => {
keyed("ITEM_SOULBOUND", &[], WHITE, false)?
}
1 => keyed("ITEM_BIND_ON_PICKUP", &[], WHITE, false)?,
2 => keyed("ITEM_BIND_ON_EQUIP", &[], WHITE, false)?,
3 => keyed("ITEM_BIND_ON_USE", &[], WHITE, false)?,
_ => {}
}
match v.max_count {
1 => keyed("ITEM_UNIQUE", &[], WHITE, false)?,
n if n > 1 => keyed("ITEM_UNIQUE_MULTIPLE", &[Arg::D(n.into())], WHITE, false)?,
_ => {}
}
if v.start_quest != 0 {
keyed("ITEM_STARTS_QUEST", &[], WHITE, false)?;
}
// LOCKED (red) — suppressed once the INSTANCE carries UNLOCKED `0x4` (the law's "and the
// item is not already unlocked"; the same bit the openable sub-gate reads). The key-item
// "Requires %s" sub-line joins with the Lock.dbc resolve (the GO-locks follow-up).
if v.lock_id != 0 && inst.is_none_or(|i| i.flags & 0x4 == 0) {
keyed("LOCKED", &[], RED, false)?;
}
1 => keyed("ITEM_BIND_ON_PICKUP", &[], WHITE, false)?,
2 => keyed("ITEM_BIND_ON_EQUIP", &[], WHITE, false)?,
3 => keyed("ITEM_BIND_ON_USE", &[], WHITE, false)?,
_ => {}
}
match v.max_count {
1 => keyed("ITEM_UNIQUE", &[], WHITE, false)?,
n if n > 1 => keyed("ITEM_UNIQUE_MULTIPLE", &[Arg::D(n.into())], WHITE, false)?,
_ => {}
}
if v.start_quest != 0 {
keyed("ITEM_STARTS_QUEST", &[], WHITE, false)?;
}
// LOCKED (red) — suppressed once the INSTANCE carries UNLOCKED `0x4` (the law's "and the
// item is not already unlocked"; the same bit the openable sub-gate reads). The key-item
// "Requires %s" sub-line joins with the Lock.dbc resolve (the GO-locks follow-up).
if v.lock_id != 0 && inst.is_none_or(|i| i.flags & 0x4 == 0) {
keyed("LOCKED", &[], RED, false)?;
}
// Slot | type — or, for a bag, the single CONTAINER_SLOTS line in the same seat. The type
// cell is suppressed for cloaks (InventoryType 16) and displayFlags-hidden subclasses
@@ -344,241 +379,246 @@ pub(super) fn render_view(
_ => {}
}
}
// **The damage block** — five slots, a five-arm template matrix and a first/PLUS_ flag
// (wow-re `tooltip-damage-matrix-and-container-slots.md` §D1, VERIFIED at
// `[0x52c22b, 0x52c5a1)`; §5 trio). Every arm is a key, and every hole's shape is the
// binary's own push list — this block composed its English in Rust until it was converted.
//
// The three predicates and the flag: **hasSchool** is the slot's school field being nonzero
// (school 0 takes the no-school subtree — `SPELL_SCHOOL0_CAP` is never looked up);
// **isAmmo** is `ItemClass == 6` and *only* that (not InventoryType, not a flag);
// **isSingle** is `floor(min) == ceil(max)` on the ROUNDED integers, tested only on the
// no-school non-ammo leaf (there is no `SINGLE_…_WITH_SCHOOL`); and **isFirst** is per-ITEM,
// cleared on the first EMITTED slot, so a skipped slot does not consume it.
let mut first = true;
let mut dps_acc = 0.0f32;
for &(min, max, school) in v.damages.iter().take(5) {
// `floor(min)` / `ceil(max)` — NOT a round-half pair, which is what this block used to
// do on both bounds. The reference biases by `0x808120` (0.9999899864196777, neither 0.5
// nor 1.0 — the epsilon is what stops an exactly-integral max being bumped) and converts
// with `__ftol`'s truncate-toward-zero. Fang of the Mystics' 38.7–85.7 reads "38 - 86";
// rounding to nearest would say "39 - 86" (43 shipped items carry fractional damage).
let (lo, hi) = (floor_min(min), ceil_max(max));
// A slot is emitted iff either rounded bound is nonzero (`0x52c292`).
if lo == 0 && hi == 0 {
continue;
// **p4's SECOND cut** (`0x52c220`; `0x52c225 jne 0x52cc5b` skips `[0x52c22b, 0x52cc5b)`) —
// damage/speed/DPS, armor, block, the stat mods, the resistances and the whole enchant family.
// The two cuts are not contiguous: the slot/type cell between them is emitted either way.
if !flags.name_only {
// **The damage block** — five slots, a five-arm template matrix and a first/PLUS_ flag
// (wow-re `tooltip-damage-matrix-and-container-slots.md` §D1, VERIFIED at
// `[0x52c22b, 0x52c5a1)`; §5 trio). Every arm is a key, and every hole's shape is the
// binary's own push list — this block composed its English in Rust until it was converted.
//
// The three predicates and the flag: **hasSchool** is the slot's school field being nonzero
// (school 0 takes the no-school subtree — `SPELL_SCHOOL0_CAP` is never looked up);
// **isAmmo** is `ItemClass == 6` and *only* that (not InventoryType, not a flag);
// **isSingle** is `floor(min) == ceil(max)` on the ROUNDED integers, tested only on the
// no-school non-ammo leaf (there is no `SINGLE_…_WITH_SCHOOL`); and **isFirst** is per-ITEM,
// cleared on the first EMITTED slot, so a skipped slot does not consume it.
let mut first = true;
let mut dps_acc = 0.0f32;
for &(min, max, school) in v.damages.iter().take(5) {
// `floor(min)` / `ceil(max)` — NOT a round-half pair, which is what this block used to
// do on both bounds. The reference biases by `0x808120` (0.9999899864196777, neither 0.5
// nor 1.0 — the epsilon is what stops an exactly-integral max being bumped) and converts
// with `__ftol`'s truncate-toward-zero. Fang of the Mystics' 38.7–85.7 reads "38 - 86";
// rounding to nearest would say "39 - 86" (43 shipped items carry fractional damage).
let (lo, hi) = (floor_min(min), ceil_max(max));
// A slot is emitted iff either rounded bound is nonzero (`0x52c292`).
if lo == 0 && hi == 0 {
continue;
}
// The school WORD is the resolved key; the school NUMBER is what picks the arm. A chain
// that carries no `SPELL_SCHOOL%d_CAP` still takes the with-school template and fills
// the hole with the empty string, exactly as `FrameScript_GetText` does.
let school_name = school_key(school).and_then(|k| get(&k)).unwrap_or_default();
// `avg` is the two ROUNDED bounds averaged, round-tripped through f32 — and it is NOT
// divided by anything: `AMMO_DAMAGE_TEMPLATE`'s shipped "Adds %g damage per second" is
// FrameXML's phrasing over a number the binary never makes per-second (Rough Arrow's
// 1–2 reads "Adds 1.5 damage per second").
let avg = f64::from((lo + hi) as f32 * 0.5);
let plus = |k: &str| {
if first {
k.to_string()
} else {
format!("PLUS_{k}")
}
};
let (key, args): (String, Vec<Arg<'_>>) = if school != 0 {
if v.class == 6 {
(
plus("AMMO_SCHOOL_DAMAGE_TEMPLATE"),
vec![Arg::F(avg), Arg::S(&school_name)],
)
} else {
(
plus("DAMAGE_TEMPLATE_WITH_SCHOOL"),
vec![Arg::D(lo.into()), Arg::D(hi.into()), Arg::S(&school_name)],
)
}
} else if v.class == 6 {
(plus("AMMO_DAMAGE_TEMPLATE"), vec![Arg::F(avg)])
} else if lo == hi {
(plus("SINGLE_DAMAGE_TEMPLATE"), vec![Arg::D(lo.into())])
} else {
(
plus("DAMAGE_TEMPLATE"),
vec![Arg::D(lo.into()), Arg::D(hi.into())],
)
};
// The RIGHT cell is the FIRST emitted line's alone, and weapons' alone (`0x52c494`/
// `0x52c49c` — the only two conjuncts): every later damage line, and every line of a
// non-weapon, renders left-text-only. `"%s %.2f"` is an `.rdata` LITERAL, not a key —
// only the word "Speed" is looked up — over `Delay × 0.001`.
let speed = (first && v.class == 2).then(|| {
let word = get("SPEED").unwrap_or_default();
let secs = f64::from(v.delay_ms) * f64::from(0.001_f32);
format!("{word} {secs:.2}")
});
// The damage line is white in BOTH cells unconditionally — it never reddens for an item
// the player cannot use (`0x52c4fa`–`0x52c516`).
if let Some(t) = get(&key) {
let line = (fill(&t, &args), WHITE);
match speed {
Some(s) => add2(line, (s, WHITE))?,
None => add(line)?,
}
}
// The DPS accumulator runs on the emit path, and it uses the RAW floats — so the
// printed range and the printed DPS are computed from different numbers by design.
dps_acc += (max + min) * 0.5;
first = false;
}
// The school WORD is the resolved key; the school NUMBER is what picks the arm. A chain
// that carries no `SPELL_SCHOOL%d_CAP` still takes the with-school template and fills
// the hole with the empty string, exactly as `FrameScript_GetText` does.
let school_name = school_key(school).and_then(|k| get(&k)).unwrap_or_default();
// `avg` is the two ROUNDED bounds averaged, round-tripped through f32 — and it is NOT
// divided by anything: `AMMO_DAMAGE_TEMPLATE`'s shipped "Adds %g damage per second" is
// FrameXML's phrasing over a number the binary never makes per-second (Rough Arrow's
// 1–2 reads "Adds 1.5 damage per second").
let avg = f64::from((lo + hi) as f32 * 0.5);
let plus = |k: &str| {
if first {
k.to_string()
} else {
format!("PLUS_{k}")
}
};
let (key, args): (String, Vec<Arg<'_>>) = if school != 0 {
if v.class == 6 {
(
plus("AMMO_SCHOOL_DAMAGE_TEMPLATE"),
vec![Arg::F(avg), Arg::S(&school_name)],
)
} else {
(
plus("DAMAGE_TEMPLATE_WITH_SCHOOL"),
vec![Arg::D(lo.into()), Arg::D(hi.into()), Arg::S(&school_name)],
)
}
} else if v.class == 6 {
(plus("AMMO_DAMAGE_TEMPLATE"), vec![Arg::F(avg)])
} else if lo == hi {
(plus("SINGLE_DAMAGE_TEMPLATE"), vec![Arg::D(lo.into())])
} else {
(
plus("DAMAGE_TEMPLATE"),
vec![Arg::D(lo.into()), Arg::D(hi.into())],
)
};
// The RIGHT cell is the FIRST emitted line's alone, and weapons' alone (`0x52c494`/
// `0x52c49c` — the only two conjuncts): every later damage line, and every line of a
// non-weapon, renders left-text-only. `"%s %.2f"` is an `.rdata` LITERAL, not a key —
// only the word "Speed" is looked up — over `Delay × 0.001`.
let speed = (first && v.class == 2).then(|| {
let word = get("SPEED").unwrap_or_default();
// DPS — two conjuncts: a line was emitted AND `ItemClass == 2`. Ammo can satisfy neither
// (its arms require class 6), so an arrow gets no DPS line and no Speed cell.
if !first && v.class == 2 {
// The precision is DPS_TEMPLATE's own `%.1f`, not ours — a locale that respells it gets
// its own number of decimals with no code change (law §12: "the print precision is
// FRAMEXML-DATA"). There is no divide-by-zero guard in the reference either.
let secs = f64::from(v.delay_ms) * f64::from(0.001_f32);
format!("{word} {secs:.2}")
});
// The damage line is white in BOTH cells unconditionally — it never reddens for an item
// the player cannot use (`0x52c4fa`–`0x52c516`).
if let Some(t) = get(&key) {
let line = (fill(&t, &args), WHITE);
match speed {
Some(s) => add2(line, (s, WHITE))?,
None => add(line)?,
}
}
// The DPS accumulator runs on the emit path, and it uses the RAW floats — so the
// printed range and the printed DPS are computed from different numbers by design.
dps_acc += (max + min) * 0.5;
first = false;
}
// DPS — two conjuncts: a line was emitted AND `ItemClass == 2`. Ammo can satisfy neither
// (its arms require class 6), so an arrow gets no DPS line and no Speed cell.
if !first && v.class == 2 {
// The precision is DPS_TEMPLATE's own `%.1f`, not ours — a locale that respells it gets
// its own number of decimals with no code change (law §12: "the print precision is
// FRAMEXML-DATA"). There is no divide-by-zero guard in the reference either.
let secs = f64::from(v.delay_ms) * f64::from(0.001_f32);
keyed(
"DPS_TEMPLATE",
&[Arg::F(f64::from(dps_acc) / secs)],
WHITE,
false,
)?;
}
if v.armor > 0 {
keyed("ARMOR_TEMPLATE", &[Arg::D(v.armor.into())], WHITE, false)?;
}
if v.block > 0 {
keyed(
"SHIELD_BLOCK_TEMPLATE",
&[Arg::D(v.block.into())],
WHITE,
false,
)?;
}
// Stat mods (+N Stamina …) in the client's DISPLAY order — the `0x808e88` table (byte-read:
// 4,3,7,5,6,1,0 then 8,9,2,10 + zero padding; the builder's outer loop walks the table,
// the inner loop scans the item's raw slots — `0x52c6b0..0x52c801`). So Strength, Agility,
// Stamina, Intellect, Spirit, Health, Mana — never the wire order. (The table's trailing
// ZERO entries would re-match a mana slot once per pass — a dormant client quirk nothing
// shipped can reach: the only mana-stat item in the whole 1.12 DB is the internal "Test MP
// Ring" 6674. Not emulated.)
const STAT_DISPLAY_ORDER: [u32; 7] = [4, 3, 7, 5, 6, 1, 0];
for &want in &STAT_DISPLAY_ORDER {
for &(t, val) in &v.stats {
if t != want || val == 0 {
continue;
}
// The `ITEM_MOD_*` template is the whole line, sign hole included
// (`"%c%d Agility"`) — the sign is an argument, not a prefix we glue on.
let Some(key) = stat_key(t) else { continue };
let sign = if val > 0 { "+" } else { "-" };
keyed(key, &[Arg::S(sign), Arg::D(val.abs().into())], WHITE, false)?;
}
}
// Resistances: six equal nonzero values collapse to the ALL line; otherwise one line per
// nonzero school with HOLY excluded from the singles loop (both byte-verified). The six
// fields are schools 1..6, so slot `i` names `SPELL_SCHOOL{i+1}_CAP` — the same `%d`-composed
// key the damage line uses one block up.
let first_res = v.resistances[0];
if first_res != 0 && v.resistances.iter().all(|&r| r == first_res) {
let sign = if first_res > 0 { "+" } else { "-" };
keyed(
"ITEM_RESIST_ALL",
&[Arg::S(sign), Arg::D(first_res.abs().into())],
WHITE,
false,
)?;
} else {
// **The singles come out in the BUILDER's order, not the field order.** `0x52c8ad–
// 0x52c93e` runs `edi` 1..5 and reads school `esi = (edi == 1) ? 6 : edi`, so the lines
// are Arcane, Fire, Nature, Frost, Shadow — and that 1→6 remap is *how* Holy is excluded:
// it is displaced by Arcane rather than skipped by a test. Read while converting the
// block's keys and named in decision 2080; we emitted plain field order (Fire first,
// Arcane last) until it was converted.
const RESIST_EMIT_ORDER: [u32; 5] = [6, 2, 3, 4, 5];
for school in RESIST_EMIT_ORDER {
let r = v.resistances[school as usize - 1];
if r == 0 {
continue;
}
let sign = if r > 0 { "+" } else { "-" };
let school = school_key(school).and_then(|k| get(&k)).unwrap_or_default();
keyed(
"ITEM_RESIST_SINGLE",
&[Arg::S(sign), Arg::D(r.abs().into()), Arg::S(&school)],
"DPS_TEMPLATE",
&[Arg::F(f64::from(dps_acc) / secs)],
WHITE,
false,
)?;
}
}
// **Line 17 — the enchant family** (wow-re `tooltip-content-law.md` §1-ENCHANT, byte-carved
// 2026-08-03 on this lane's dispatch; decisions 0915/0920). One contiguous block
// `[0x52c991, 0x52cc69)` between the resistances and the durability precompute, and three arms
// that are mutually exclusive by construction — the per-slot loop falls through to the
// proposed-enchant pair and jumps the block's end, so RANDOM_ENCHANT is reachable only when
// there was no id source at all (§E1).
//
// The **colour is per slot**, and this is the correction the carve landed (§E3): the value is a
// computed local, defaulting to WHITE, overwritten **only for slots 0 and 1** — green
// `0xc0d3ac` for a positive id, pure-red `0xc0d398` for a negative one. Slots 2..6 — the
// random-property suffix enchants — are **always white**, whatever the sign. (Our first cut
// painted every slot green.) The sign never picks a different DBC row; the app already
// resolved that off `abs(id)`.
//
// Two gates sit above the loop. **ITEM_SIGNABLE** (template Flags bit `0x2000`, a petition or
// guild charter) forces every id to 0 with no fallback (`0x52c9e0: test ah,0x20`) — such an
// item shows no enchant line even if its instance carries ids. And with **no id source at all**
// the block instead prints the template-only `ITEM_RANDOM_ENCHANT` placeholder (§E5).
let signable = v.flags & 0x2000 != 0;
let enchant_slots = match signable {
true => &[][..],
false => inst.map(|i| i.enchants.as_slice()).unwrap_or_default(),
};
// "No id source" is the reference's own three-way fork (§E1): a wrapped gift, or no item
// object AND no caller-supplied instance block (`+0x440 == 0`). Ours reads the same: a hover
// that passes NO [`ItemInstance`] is a p6=0 leg — the template sources (merchant, quest,
// craft, buyback, send-mail, the compare legs, `BenillaSetItemById`) — plus the wrapped-gift bit.
//
// **A block-supplying source never prints the placeholder, even carrying no ids at all.** The
// fork tests the block's presence, not its contents, so `SetLootItem`/`SetHyperlink`/
// `SetInboxItem`/`SetAuctionItem`/`SetLootRollItem`/the trade legs fall into the slot loop and
// print whatever their slots hold — nothing, when the roll is absent. Decision 0920's prose
// put a hyperlink hover on the placeholder arm; §E1's `0x52c9a3` fork says otherwise, and
// that is the drift 1547 corrects (a linked or looted "of the Monkey" showed the placeholder
// where the reference shows the rolled lines).
let no_id_source = inst.is_none_or(|i| i.flags & 0x8 != 0);
if no_id_source && !signable && v.random_property != 0 {
keyed("ITEM_RANDOM_ENCHANT", &[], GREEN, false)?;
}
for e in enchant_slots {
let color = match (e.slot < 2, e.negative) {
(true, false) => GREEN,
(true, true) => ENCHANT_RED,
(false, _) => WHITE,
if v.armor > 0 {
keyed("ARMOR_TEMPLATE", &[Arg::D(v.armor.into())], WHITE, false)?;
}
if v.block > 0 {
keyed(
"SHIELD_BLOCK_TEMPLATE",
&[Arg::D(v.block.into())],
WHITE,
false,
)?;
}
// Stat mods (+N Stamina …) in the client's DISPLAY order — the `0x808e88` table (byte-read:
// 4,3,7,5,6,1,0 then 8,9,2,10 + zero padding; the builder's outer loop walks the table,
// the inner loop scans the item's raw slots — `0x52c6b0..0x52c801`). So Strength, Agility,
// Stamina, Intellect, Spirit, Health, Mana — never the wire order. (The table's trailing
// ZERO entries would re-match a mana slot once per pass — a dormant client quirk nothing
// shipped can reach: the only mana-stat item in the whole 1.12 DB is the internal "Test MP
// Ring" 6674. Not emulated.)
const STAT_DISPLAY_ORDER: [u32; 7] = [4, 3, 7, 5, 6, 1, 0];
for &want in &STAT_DISPLAY_ORDER {
for &(t, val) in &v.stats {
if t != want || val == 0 {
continue;
}
// The `ITEM_MOD_*` template is the whole line, sign hole included
// (`"%c%d Agility"`) — the sign is an argument, not a prefix we glue on.
let Some(key) = stat_key(t) else { continue };
let sign = if val > 0 { "+" } else { "-" };
keyed(key, &[Arg::S(sign), Arg::D(val.abs().into())], WHITE, false)?;
}
}
// Resistances: six equal nonzero values collapse to the ALL line; otherwise one line per
// nonzero school with HOLY excluded from the singles loop (both byte-verified). The six
// fields are schools 1..6, so slot `i` names `SPELL_SCHOOL{i+1}_CAP` — the same `%d`-composed
// key the damage line uses one block up.
let first_res = v.resistances[0];
if first_res != 0 && v.resistances.iter().all(|&r| r == first_res) {
let sign = if first_res > 0 { "+" } else { "-" };
keyed(
"ITEM_RESIST_ALL",
&[Arg::S(sign), Arg::D(first_res.abs().into())],
WHITE,
false,
)?;
} else {
// **The singles come out in the BUILDER's order, not the field order.** `0x52c8ad–
// 0x52c93e` runs `edi` 1..5 and reads school `esi = (edi == 1) ? 6 : edi`, so the lines
// are Arcane, Fire, Nature, Frost, Shadow — and that 1→6 remap is *how* Holy is excluded:
// it is displaced by Arcane rather than skipped by a test. Read while converting the
// block's keys and named in decision 2080; we emitted plain field order (Fire first,
// Arcane last) until it was converted.
const RESIST_EMIT_ORDER: [u32; 5] = [6, 2, 3, 4, 5];
for school in RESIST_EMIT_ORDER {
let r = v.resistances[school as usize - 1];
if r == 0 {
continue;
}
let sign = if r > 0 { "+" } else { "-" };
let school = school_key(school).and_then(|k| get(&k)).unwrap_or_default();
keyed(
"ITEM_RESIST_SINGLE",
&[Arg::S(sign), Arg::D(r.abs().into()), Arg::S(&school)],
WHITE,
false,
)?;
}
}
// **Line 17 — the enchant family** (wow-re `tooltip-content-law.md` §1-ENCHANT, byte-carved
// 2026-08-03 on this lane's dispatch; decisions 0915/0920). One contiguous block
// `[0x52c991, 0x52cc69)` between the resistances and the durability precompute, and three arms
// that are mutually exclusive by construction — the per-slot loop falls through to the
// proposed-enchant pair and jumps the block's end, so RANDOM_ENCHANT is reachable only when
// there was no id source at all (§E1).
//
// The **colour is per slot**, and this is the correction the carve landed (§E3): the value is a
// computed local, defaulting to WHITE, overwritten **only for slots 0 and 1** — green
// `0xc0d3ac` for a positive id, pure-red `0xc0d398` for a negative one. Slots 2..6 — the
// random-property suffix enchants — are **always white**, whatever the sign. (Our first cut
// painted every slot green.) The sign never picks a different DBC row; the app already
// resolved that off `abs(id)`.
//
// Two gates sit above the loop. **ITEM_SIGNABLE** (template Flags bit `0x2000`, a petition or
// guild charter) forces every id to 0 with no fallback (`0x52c9e0: test ah,0x20`) — such an
// item shows no enchant line even if its instance carries ids. And with **no id source at all**
// the block instead prints the template-only `ITEM_RANDOM_ENCHANT` placeholder (§E5).
let signable = v.flags & 0x2000 != 0;
let enchant_slots = match signable {
true => &[][..],
false => inst.map(|i| i.enchants.as_slice()).unwrap_or_default(),
};
// A TEMPORARY enchant's countdown REPLACES the plain name in the same line and keeps that
// colour — it is never a second line (§E3). The bucket ladder (day/hour/min/sec) and its
// ceil-vs-truncate split are [`enchant_time_left`]'s; its source is
// `SMSG_ITEM_ENCHANT_TIME_UPDATE`, never the item's own duration field.
let mut text = match e.remaining_ms {
// The countdown IS the line — `ITEM_ENCHANT_TIME_LEFT_MIN = "%s (%d min)"` carries
// the enchant's own name in its first hole. Without that template there is no line
// to compose: the reference printf's an empty format into a zeroed buffer and
// `AddLine` drops the empty row, so a missing key skips the slot rather than falling
// back to the bare name.
Some(ms) => match enchant_time_left(&e.name, ms, &get) {
Some(t) => t,
None => continue,
},
None => e.name.clone(),
};
// " (N Charges)" — the slot's own charges dword through ITEM_SPELL_CHARGES, then the
// literal `" (%s)" 0x854820` (`0x52caa6–0x52cb38`), which is the engine's own format and
// not a string table entry. Only an owned item object carries charges; the session/inspect
// legs ship ids alone, so this is naturally absent there.
if let Some(charges) = charges_phrase(e.charges, &get) {
text.push_str(&format!(" ({charges})"));
// "No id source" is the reference's own three-way fork (§E1): a wrapped gift, or no item
// object AND no caller-supplied instance block (`+0x440 == 0`). Ours reads the same: a hover
// that passes NO [`ItemInstance`] is a p6=0 leg — the template sources (merchant, quest,
// craft, buyback, send-mail, the compare legs, `BenillaSetItemById`) — plus the wrapped-gift bit.
//
// **A block-supplying source never prints the placeholder, even carrying no ids at all.** The
// fork tests the block's presence, not its contents, so `SetLootItem`/`SetHyperlink`/
// `SetInboxItem`/`SetAuctionItem`/`SetLootRollItem`/the trade legs fall into the slot loop and
// print whatever their slots hold — nothing, when the roll is absent. Decision 0920's prose
// put a hyperlink hover on the placeholder arm; §E1's `0x52c9a3` fork says otherwise, and
// that is the drift 1547 corrects (a linked or looted "of the Monkey" showed the placeholder
// where the reference shows the rolled lines).
let no_id_source = inst.is_none_or(|i| i.flags & 0x8 != 0);
if no_id_source && !signable && v.random_property != 0 {
keyed("ITEM_RANDOM_ENCHANT", &[], GREEN, false)?;
}
for e in enchant_slots {
let color = match (e.slot < 2, e.negative) {
(true, false) => GREEN,
(true, true) => ENCHANT_RED,
(false, _) => WHITE,
};
// A TEMPORARY enchant's countdown REPLACES the plain name in the same line and keeps that
// colour — it is never a second line (§E3). The bucket ladder (day/hour/min/sec) and its
// ceil-vs-truncate split are [`enchant_time_left`]'s; its source is
// `SMSG_ITEM_ENCHANT_TIME_UPDATE`, never the item's own duration field.
let mut text = match e.remaining_ms {
// The countdown IS the line — `ITEM_ENCHANT_TIME_LEFT_MIN = "%s (%d min)"` carries
// the enchant's own name in its first hole. Without that template there is no line
// to compose: the reference printf's an empty format into a zeroed buffer and
// `AddLine` drops the empty row, so a missing key skips the slot rather than falling
// back to the bare name.
Some(ms) => match enchant_time_left(&e.name, ms, &get) {
Some(t) => t,
None => continue,
},
None => e.name.clone(),
};
// " (N Charges)" — the slot's own charges dword through ITEM_SPELL_CHARGES, then the
// literal `" (%s)" 0x854820` (`0x52caa6–0x52cb38`), which is the engine's own format and
// not a string table entry. Only an owned item object carries charges; the session/inspect
// legs ship ids alone, so this is naturally absent there.
if let Some(charges) = charges_phrase(e.charges, &get) {
text.push_str(&format!(" ({charges})"));
}
add((text, color))?;
}
add((text, color))?;
}
if let Some((cur, max)) = inst.and_then(|i| i.durability).filter(|&(_, max)| max > 0) {
// Red iff BROKEN (durability 0) — the byte law (wow-re ui.md tooltip content law:
@@ -793,6 +833,14 @@ pub(super) fn render_view(
}
}
}
// **p4's early return** — `0x52e147` reads the flag and `0x52e14c je 0x52e170` takes the
// NON-compact leg, so compact is the fall-through `0x52e14e`: it stamps `[esi+0xd0]=1`, lays
// out, and returns `[ebp-0x38]` (the `hasCooldown` answer is produced on this path too).
// Everything from here down — flavor text, creator/gift, OPENABLE/READABLE, money — is below
// `0x52e170` and never runs.
if flags.name_only {
return Ok(());
}
// The quoted flavor text — gold, wrapped, literal quotes (all three byte-verified).
if !v.description.is_empty() {
addw((format!("\"{}\"", v.description), GOLD))?;
@@ -5,7 +5,11 @@
//! Empty pools push nothing, so an idle sky costs zero here — the structural replacement for
//! the old fixed-capacity meshes' `WriteGate` (the 0353 fps hunt).
use bevy::prelude::*;
// Explicit, not `bevy::prelude::*`: the parent module's own prelude glob reaches this file
// through `use super::*` below, and two globs supplying the same names had rustc crediting the
// use to one or the other by platform — the "unused import" every non-macOS build warned on
// (2205's next move 4).
use bevy::math::{Quat, Vec3};
use crate::particles::buffer::EffectVertex;