diff --git a/Cargo.lock b/Cargo.lock index fd91f862..057db491 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -543,6 +543,7 @@ dependencies = [ "symphonia", "toml", "wgpu", + "windows-sys 0.59.0", ] [[package]] diff --git a/crates/benilla-app/Cargo.toml b/crates/benilla-app/Cargo.toml index b8f530f9..26b841c3 100644 --- a/crates/benilla-app/Cargo.toml +++ b/crates/benilla-app/Cargo.toml @@ -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"] } diff --git a/crates/benilla-app/src/name_persist.rs b/crates/benilla-app/src/name_persist.rs index fb881241..3dedd158 100644 --- a/crates/benilla-app/src/name_persist.rs +++ b/crates/benilla-app/src/name_persist.rs @@ -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!( diff --git a/crates/benilla-app/src/names.rs b/crates/benilla-app/src/names.rs index 14958393..b0a8c7ef 100644 --- a/crates/benilla-app/src/names.rs +++ b/crates/benilla-app/src/names.rs @@ -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>, /// `(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, /// 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` 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::() 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::() { - cache.players.insert(guid, None); - } - } Some("C") if f.len() >= 11 => { let Ok(entry) = f[1].parse::() 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::() { - 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 diff --git a/crates/benilla-app/src/net.rs b/crates/benilla-app/src/net.rs index 42f5d9e2..141b9193 100644 --- a/crates/benilla-app/src/net.rs +++ b/crates/benilla-app/src/net.rs @@ -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. diff --git a/crates/benilla-app/src/net/apply/session.rs b/crates/benilla-app/src/net/apply/session.rs index 04c0d2f8..32f3a523 100644 --- a/crates/benilla-app/src/net/apply/session.rs +++ b/crates/benilla-app/src/net/apply/session.rs @@ -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 diff --git a/crates/benilla-app/src/net/io.rs b/crates/benilla-app/src/net/io.rs index 09f1fdc6..f65f2f0e 100644 --- a/crates/benilla-app/src/net/io.rs +++ b/crates/benilla-app/src/net/io.rs @@ -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) diff --git a/crates/benilla-app/src/perf/clock.rs b/crates/benilla-app/src/perf/clock.rs index ebbb3876..40502d7a 100644 --- a/crates/benilla-app/src/perf/clock.rs +++ b/crates/benilla-app/src/perf/clock.rs @@ -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 { #[cfg(unix)] { @@ -31,12 +32,49 @@ pub(crate) fn process_cpu_secs() -> Option { 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 { #[cfg(unix)] { @@ -87,7 +127,30 @@ pub(crate) fn main_thread_cpu_secs() -> Option { 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 { /// 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 } diff --git a/crates/benilla-app/src/sound/cinematic.rs b/crates/benilla-app/src/sound/cinematic.rs index ae74563a..a11e72a7 100644 --- a/crates/benilla-app/src/sound/cinematic.rs +++ b/crates/benilla-app/src/sound/cinematic.rs @@ -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` 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::), + ); } /// The narration channel, plus its line in the voice budget. diff --git a/crates/benilla-app/src/ui_action/cast_send.rs b/crates/benilla-app/src/ui_action/cast_send.rs index b2385e13..04cef4d5 100644 --- a/crates/benilla-app/src/ui_action/cast_send.rs +++ b/crates/benilla-app/src/ui_action/cast_send.rs @@ -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), }, diff --git a/crates/benilla-app/src/ui_action/cast_target.rs b/crates/benilla-app/src/ui_action/cast_target.rs index 6e22dbd4..b6f744e4 100644 --- a/crates/benilla-app/src/ui_action/cast_target.rs +++ b/crates/benilla-app/src/ui_action/cast_target.rs @@ -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()), diff --git a/crates/benilla-app/src/ui_action/targeting/mod.rs b/crates/benilla-app/src/ui_action/targeting/mod.rs index e53fb1fc..9914b4f7 100644 --- a/crates/benilla-app/src/ui_action/targeting/mod.rs +++ b/crates/benilla-app/src/ui_action/targeting/mod.rs @@ -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 { + 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; } diff --git a/crates/benilla-app/src/ui_action/targeting/world.rs b/crates/benilla-app/src/ui_action/targeting/world.rs index e237e296..190941a2 100644 --- a/crates/benilla-app/src/ui_action/targeting/world.rs +++ b/crates/benilla-app/src/ui_action/targeting/world.rs @@ -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::().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::>() + .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::() + .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::() + .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::().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::() + .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] diff --git a/crates/benilla-app/src/ui_chat.rs b/crates/benilla-app/src/ui_chat.rs index ebbaad8f..3f77f471 100644 --- a/crates/benilla-app/src/ui_chat.rs +++ b/crates/benilla-app/src/ui_chat.rs @@ -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 diff --git a/crates/benilla-app/src/ui_guild/mod.rs b/crates/benilla-app/src/ui_guild/mod.rs index 7b7231a7..cc2fa0f6 100644 --- a/crates/benilla-app/src/ui_guild/mod.rs +++ b/crates/benilla-app/src/ui_guild/mod.rs @@ -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 diff --git a/crates/benilla-app/src/ui_script/reputation_frame_tests.rs b/crates/benilla-app/src/ui_script/reputation_frame_tests.rs index 00b99092..0610f669 100644 --- a/crates/benilla-app/src/ui_script/reputation_frame_tests.rs +++ b/crates/benilla-app/src/ui_script/reputation_frame_tests.rs @@ -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 `` +/// (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::("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::("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::("return ReputationDetailMainScreenCheckBox:GetChecked() and true or false") + .unwrap()); + assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); +} diff --git a/crates/benilla-protocol/src/messages/items.rs b/crates/benilla-protocol/src/messages/items.rs index 4769d986..2fe9f9bc 100644 --- a/crates/benilla-protocol/src/messages/items.rs +++ b/crates/benilla-protocol/src/messages/items.rs @@ -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 diff --git a/crates/benilla-protocol/src/messages/mod.rs b/crates/benilla-protocol/src/messages/mod.rs index fc6336e7..8f00e15d 100644 --- a/crates/benilla-protocol/src/messages/mod.rs +++ b/crates/benilla-protocol/src/messages/mod.rs @@ -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, diff --git a/crates/benilla-protocol/src/messages/spells.rs b/crates/benilla-protocol/src/messages/spells.rs index 2b72fd63..eef7f96b 100644 --- a/crates/benilla-protocol/src/messages/spells.rs +++ b/crates/benilla-protocol/src/messages/spells.rs @@ -402,6 +402,26 @@ pub fn cast_spell_at_dest(spell_id: u32, dest: [f32; 3]) -> Vec { 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 { + 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 diff --git a/crates/benilla-protocol/src/world/writer/spells.rs b/crates/benilla-protocol/src/world/writer/spells.rs index 913a70b0..14452bf4 100644 --- a/crates/benilla-protocol/src/world/writer/spells.rs +++ b/crates/benilla-protocol/src/world/writer/spells.rs @@ -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`). diff --git a/crates/benilla-protocol/tests/items.rs b/crates/benilla-protocol/tests/items.rs index 646aa0f3..1c9168b2 100644 --- a/crates/benilla-protocol/tests/items.rs +++ b/crates/benilla-protocol/tests/items.rs @@ -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 diff --git a/crates/benilla-ui/src/script/binding_abi.rs b/crates/benilla-ui/src/script/binding_abi.rs index 87913baf..68b9c801 100644 --- a/crates/benilla-ui/src/script/binding_abi.rs +++ b/crates/benilla-ui/src/script/binding_abi.rs @@ -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 { + 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. diff --git a/crates/benilla-ui/src/script/tests/tooltip_item/bindings.rs b/crates/benilla-ui/src/script/tests/tooltip_item/bindings.rs index ee16e94c..c73eb0b8 100644 --- a/crates/benilla-ui/src/script/tests/tooltip_item/bindings.rs +++ b/crates/benilla-ui/src/script/tests/tooltip_item/bindings.rs @@ -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] diff --git a/crates/benilla-ui/src/script/tooltip_item/mod.rs b/crates/benilla-ui/src/script/tooltip_item/mod.rs index c08298a1..20b3d60f 100644 --- a/crates/benilla-ui/src/script/tooltip_item/mod.rs +++ b/crates/benilla-ui/src/script/tooltip_item/mod.rs @@ -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::().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::().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::().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::().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::().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(()) diff --git a/crates/benilla-ui/src/script/tooltip_item/render.rs b/crates/benilla-ui/src/script/tooltip_item/render.rs index 62e29a6d..d22b6e86 100644 --- a/crates/benilla-ui/src/script/tooltip_item/render.rs +++ b/crates/benilla-ui/src/script/tooltip_item/render.rs @@ -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>) = 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>) = 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))?; diff --git a/crates/benilla-world/src/weather/precip/render.rs b/crates/benilla-world/src/weather/precip/render.rs index cd9ec75c..5aa32e41 100644 --- a/crates/benilla-world/src/weather/precip/render.rs +++ b/crates/benilla-world/src/weather/precip/render.rs @@ -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;