diff --git a/crates/benilla-app/src/ui_cast_events.rs b/crates/benilla-app/src/ui_cast_events.rs index 5f651dc5..9fd898bf 100644 --- a/crates/benilla-app/src/ui_cast_events.rs +++ b/crates/benilla-app/src/ui_cast_events.rs @@ -790,10 +790,10 @@ mod tests { assert_eq!( seen, vec![ - "000000000000002a/0000000000000063/START/133/1500".to_string(), - "000000000000002a/0000000000000000/FAIL/133/0".to_string(), + "0x000000000000002A/0x0000000000000063/START/133/1500".to_string(), + "0x000000000000002A/0x0000000000000000/FAIL/133/0".to_string(), ], - "packet order, 16-hex-digit GUIDs matching every other compat/ GUID surface" + "packet order, and `0x` + 16 uppercase digits as `guid_token` writes them" ); assert!( app.world().resource::().0.is_empty(), diff --git a/crates/benilla-ui/src/script/compat/classic_api/creature_info.rs b/crates/benilla-ui/src/script/compat/classic_api/creature_info.rs index 370cc632..90035c4b 100644 --- a/crates/benilla-ui/src/script/compat/classic_api/creature_info.rs +++ b/crates/benilla-ui/src/script/compat/classic_api/creature_info.rs @@ -405,9 +405,10 @@ mod tests { let s = seeded(); // HIGH_UNIT (0xF130) in the top 16 bits, entry 1234 at bits 24-47. let creature: u64 = (0xF130u64 << 48) | (1234u64 << 24) | 7; + let token = crate::script::guid_token(creature); assert_eq!( s.eval::(&format!( - r#"return C_CreatureInfo.GetCreatureID("{creature:016x}")"# + r#"return C_CreatureInfo.GetCreatureID("{token}")"# )) .unwrap(), 1234 diff --git a/crates/benilla-ui/src/script/compat/classic_api/nameplate.rs b/crates/benilla-ui/src/script/compat/classic_api/nameplate.rs index 35a1931b..5acf1b4b 100644 --- a/crates/benilla-ui/src/script/compat/classic_api/nameplate.rs +++ b/crates/benilla-ui/src/script/compat/classic_api/nameplate.rs @@ -214,7 +214,7 @@ mod tests { .unwrap(); assert_eq!(guids.len(), 2); assert!( - guids.iter().all(|g| g.len() == 16), + guids.iter().all(|g| g.len() == 18 && g.starts_with("0x")), "16-hex-digit tokens, the same shape UnitGUID hands out: {guids:?}" ); diff --git a/crates/benilla-ui/src/script/compat/classic_api/tooltip.rs b/crates/benilla-ui/src/script/compat/classic_api/tooltip.rs index ec9556d6..ba027931 100644 --- a/crates/benilla-ui/src/script/compat/classic_api/tooltip.rs +++ b/crates/benilla-ui/src/script/compat/classic_api/tooltip.rs @@ -875,7 +875,7 @@ mod tests { assert_eq!( s.eval::<(String, String)>("return tip:GetUnitGUID()") .unwrap(), - ("Hogger".to_string(), "00000000deadbeef".to_string()), + ("Hogger".to_string(), "0x00000000DEADBEEF".to_string()), "the name and the 16-hex-digit guid token, in GetUnit()'s two-value shape" ); // The second value is what pfUI feeds `UnitTokenFromGUID`, so it has to round-trip. @@ -890,7 +890,7 @@ mod tests { assert_eq!( s.eval::("local _, g = tip:GetUnitGUID() return g") .unwrap(), - "00000000deadbeef", + "0x00000000DEADBEEF", "still Hogger's guid: the tooltip is still showing Hogger" ); assert_eq!( diff --git a/crates/benilla-ui/src/script/compat/classic_api/unit.rs b/crates/benilla-ui/src/script/compat/classic_api/unit.rs index ff86e1cc..43d1c87a 100644 --- a/crates/benilla-ui/src/script/compat/classic_api/unit.rs +++ b/crates/benilla-ui/src/script/compat/classic_api/unit.rs @@ -99,7 +99,9 @@ mod tests { ); let guid = s.eval::(r#"return UnitGUID("target")"#).unwrap(); - assert_eq!(guid.len(), 16); + // "0x" + 16 digits: the realm writes `0x` + 16 uppercase digits (`unit::guid_token`). + assert_eq!(guid.len(), 18); + assert!(guid.starts_with("0x")); assert_eq!( s.eval::(&format!(r#"return UnitTokenFromGUID("{guid}")"#)) .unwrap(), diff --git a/crates/benilla-ui/src/script/inspect.rs b/crates/benilla-ui/src/script/inspect.rs index 8bdce4ec..b97f2ca5 100644 --- a/crates/benilla-ui/src/script/inspect.rs +++ b/crates/benilla-ui/src/script/inspect.rs @@ -17,9 +17,16 @@ use mlua::{Lua, Value}; use super::char_stats::InventorySlots; use super::Model; -/// `CheckInteractDistance`'s squared thresholds for types 1..=4, `{10², 11.1111², 10², 30²}` -/// (`0x48ba00`, from the `.rdata` at `0x804498`, `0x804490`, `0x80448c`, `0x8044a4`). -pub const INTERACT_DIST_SQ: [f64; 4] = [100.0, 123.45678, 100.0, 900.0]; +/// `CheckInteractDistance`'s squared thresholds for types 1..=4. Stock 1.12 is +/// `{10², 11.1111², 10², 30²}` (`0x48ba00`, from the `.rdata` at `0x804498`, `0x804490`, +/// `0x80448c`, `0x8044a4`). +/// +/// Deviation from stock, and faithful to what grom runs: type 4 is **40 yards**, not 30. His +/// client's `WoW.exe` patches it, in the `.tdata` section — `CheckInteractDistance+0xfd` +/// (`0x48bafd`) jumps to `0xd0686c`, which is `cmp $0x4,%esi` on the distance type and, on a +/// match, `fcomps 0xd06868`, a float the section carries as **1600.0**. Squared thresholds, so +/// 40 yards. The other three types fall through to the stock compare untouched. +pub const INTERACT_DIST_SQ: [f64; 4] = [100.0, 123.45678, 100.0, 1600.0]; /// `CanInspect`'s threshold squared (`0xb4d918`, the `.rdata` 10.0 at `0x804498` squared by /// `0x48a1b0`); vmangos checks the same 10 yards (`INSPECT_DISTANCE`, `ObjectDefines.h:26`). diff --git a/crates/benilla-ui/src/script/tests/nameplate.rs b/crates/benilla-ui/src/script/tests/nameplate.rs index d6f0cba5..f5b48c10 100644 --- a/crates/benilla-ui/src/script/tests/nameplate.rs +++ b/crates/benilla-ui/src/script/tests/nameplate.rs @@ -234,7 +234,7 @@ fn get_name_true_answers_the_plates_guid() { assert_eq!( s.eval::("return WorldFrame:GetChildren():GetName(1)") .unwrap(), - format!("{key:016x}"), + crate::script::guid_token(key), "GetName(1) is the SuperWoW GUID token" ); assert!(s diff --git a/crates/benilla-ui/src/script/tests/nameplate_tokens.rs b/crates/benilla-ui/src/script/tests/nameplate_tokens.rs index e38e8c41..8c2164af 100644 --- a/crates/benilla-ui/src/script/tests/nameplate_tokens.rs +++ b/crates/benilla-ui/src/script/tests/nameplate_tokens.rs @@ -143,10 +143,11 @@ fn a_unit_keeps_its_nameplate_token_for_the_whole_life_of_its_plate() { let token_of = |s: &mut UiScript, guid: u64, name: &str| -> Option { // Which token names this unit, asked the way an addon would: by its name, over the 1..40 // the token space can reach. + let token = crate::script::guid_token(guid); s.eval::>(&format!( r#"for i = 1, 40 do local t = "nameplate" .. i - if UnitExists(t) and UnitGUID(t) == "{guid:016x}" then + if UnitExists(t) and UnitGUID(t) == "{token}" then if UnitName(t) ~= "{name}" then return "WRONG-NAME:" .. t end return t end @@ -233,7 +234,7 @@ fn the_three_plate_events_fire_once_each_on_their_own_edge() { // Created first: pfUI's added-handler is a no-op until `plate.nameplate` exists, and // that field is what its created-handler installs (`nameplates.lua:589`). "CREATED Button".to_string(), - "NAME_PLATE_UNIT_ADDED nameplate1 plate=yes name=Wolf guid=1111000000000001" + "NAME_PLATE_UNIT_ADDED nameplate1 plate=yes name=Wolf guid=0x1111000000000001" .to_string(), ], "arg1 is the plate frame on created and the token on added, and the token already \ @@ -254,7 +255,7 @@ fn the_three_plate_events_fire_once_each_on_their_own_edge() { log(&mut s), vec![ "CREATED Button".to_string(), - "NAME_PLATE_UNIT_ADDED nameplate2 plate=yes name=Bear guid=1111000000000002" + "NAME_PLATE_UNIT_ADDED nameplate2 plate=yes name=Bear guid=0x1111000000000002" .to_string(), ] ); @@ -266,9 +267,9 @@ fn the_three_plate_events_fire_once_each_on_their_own_edge() { assert_eq!( log(&mut s), vec![ - "NAME_PLATE_UNIT_REMOVED nameplate2 plate=yes name=Bear guid=1111000000000002" + "NAME_PLATE_UNIT_REMOVED nameplate2 plate=yes name=Bear guid=0x1111000000000002" .to_string(), - "NAME_PLATE_UNIT_ADDED nameplate2 plate=yes name=Boar guid=1111000000000003" + "NAME_PLATE_UNIT_ADDED nameplate2 plate=yes name=Boar guid=0x1111000000000003" .to_string(), ], "the departure is dispatched before the arrival that reuses its slot, and no plate frame \ @@ -290,7 +291,7 @@ fn the_removal_event_still_finds_both_the_departing_unit_and_its_plate() { assert_eq!( log(&mut s), vec![ - "NAME_PLATE_UNIT_REMOVED nameplate1 plate=yes name=Wolf guid=1111000000000001" + "NAME_PLATE_UNIT_REMOVED nameplate1 plate=yes name=Wolf guid=0x1111000000000001" .to_string() ] ); @@ -365,7 +366,7 @@ fn a_plate_tokens_target_names_the_unit_that_plate_unit_is_targeting() { ) .unwrap()); assert!( - s.eval::(r#"return UnitGUID("nameplate1target") == "1111000000000004""#) + s.eval::(r#"return UnitGUID("nameplate1target") == "0x1111000000000004""#) .unwrap(), "it names the targeted unit, not the plate unit" ); @@ -384,7 +385,7 @@ fn a_plate_tokens_target_names_the_unit_that_plate_unit_is_targeting() { /// Prefer absent over wrong, the rule the whole token space is built under: a plate whose unit the /// app could not snapshot names *nothing*. `UnitExists` answering true for a token with no GUID, no /// name and no health is a wrong answer no caller can detect, and a zero GUID would additionally -/// make `Model::unit`'s raw-GUID fallback resolve `"0000000000000000"` to it. +/// make `Model::unit`'s raw-GUID fallback resolve `"0x0000000000000000"` to it. #[test] fn a_plate_with_no_unit_snapshot_names_nothing_rather_than_a_blank_unit() { let mut s = vm(); @@ -396,7 +397,7 @@ fn a_plate_with_no_unit_snapshot_names_nothing_rather_than_a_blank_unit() { "the plate is up, but its token names no unit" ); assert!( - s.eval::(r#"return UnitGUID("0000000000000000") == nil"#) + s.eval::(r#"return UnitGUID("0x0000000000000000") == nil"#) .unwrap(), "and the zero GUID resolves to nothing, so the raw-GUID grammar cannot reach it" ); @@ -417,14 +418,14 @@ fn a_rebuilt_vm_re_announces_every_plate_from_slot_one() { drive(&mut s, &[feed(WOLF, "Wolf"), feed(BEAR, "Bear")]); drive(&mut s, &[feed(BEAR, "Bear")]); assert!(s - .eval::(r#"return UnitGUID("nameplate2") == "1111000000000002""#) + .eval::(r#"return UnitGUID("nameplate2") == "0x1111000000000002""#) .unwrap()); let mut fresh = vm(); drive(&mut fresh, &[feed(BEAR, "Bear")]); assert!( fresh - .eval::(r#"return UnitGUID("nameplate1") == "1111000000000002""#) + .eval::(r#"return UnitGUID("nameplate1") == "0x1111000000000002""#) .unwrap(), "the surviving unit is slot 1 in the new VM: slot identity does not outlive the VM that \ announced it" @@ -433,7 +434,7 @@ fn a_rebuilt_vm_re_announces_every_plate_from_slot_one() { log(&mut fresh), vec![ "CREATED Button".to_string(), - "NAME_PLATE_UNIT_ADDED nameplate1 plate=yes name=Bear guid=1111000000000002" + "NAME_PLATE_UNIT_ADDED nameplate1 plate=yes name=Bear guid=0x1111000000000002" .to_string(), ] ); @@ -609,7 +610,7 @@ fn pfuis_own_oncreate_body_runs_on_the_frame_the_created_event_hands_it() { -- property worth pinning is that the engine art is gone, not which falsy it is. local border = np.original.border:GetTexture() return np.unit == "nameplate1" - and np.cachedGuid == "1111000000000001" + and np.cachedGuid == "0x1111000000000001" and (border == nil or border == "") and np.raidicon:GetParent() == np.raidiconframe"# ) @@ -644,7 +645,7 @@ fn pfuis_own_oncreate_body_runs_on_the_frame_the_created_event_hands_it() { s.eval::>("return cleared") .unwrap() .as_deref(), - Some("1111000000000001"), + Some("0x1111000000000001"), "pfUI's removal handler reached the departing plate's own cached GUID and dropped it — \ with a nil plate there, that cache would leak for the session" ); diff --git a/crates/benilla-ui/src/script/unit/mod.rs b/crates/benilla-ui/src/script/unit/mod.rs index 34bc6fd9..322681a1 100644 --- a/crates/benilla-ui/src/script/unit/mod.rs +++ b/crates/benilla-ui/src/script/unit/mod.rs @@ -308,14 +308,33 @@ pub struct UnitCastState { /// existing `String`-typed unit-token parameter here, which a raw 8-byte binary string would not /// reliably do. pub fn guid_token(guid: u64) -> String { - format!("{guid:016x}") + format!("0x{guid:016X}") } -/// The inverse of [`guid_token`]: `None` for anything that isn't exactly 16 hex digits, so a real -/// token name (`"player"`, `"party1"`, …) never misparses as a GUID. +/// The inverse of [`guid_token`]: `None` for anything that isn't 16 hex digits, with the `0x` +/// optional, so a real token name (`"player"`, `"party1"`, …) never misparses as a GUID. +/// +/// The prefix is optional on the way **in** and always written on the way **out**, because the +/// realm's own client writes it and addons test for it. Its formatter — `0xd06500` in the `.tdata` +/// section of the patched `WoW.exe`, which `UnitExists`'s second return goes through — lays down +/// `0x30 0x78` ("0x"), then sixteen nibbles each `+0x37` above `9`, so uppercase. Its parser +/// (`0xd06650`, reached from the unit-token resolver at `0x515c14`) demands the `0x` and accepts +/// `0`-`9` and `A`-`F` only. grom's own addons agree from the other side: BigWigs decides a string +/// is a GUID with `string.sub(guidOrName, 1, 2) == "0x"` (`MageTools.lua:963`), and AI_VoiceOver +/// both reads fields from position 3 and mints its own with `format("0x%04X%06X%06X", …)`. +/// +/// This client used to write sixteen lowercase digits and no prefix, which is the same number and +/// the wrong string: every such test read our GUIDs as unit *names*, and every `sub(3, …)` field +/// read came out shifted by two. `UNIT_CASTEVENT` and the aura events carry these +/// (`benilla-app`'s `ui_cast_events`, `ui_aura`), so it was wrong wherever it mattered most. +/// Lower case is still accepted in, since nothing is gained by refusing it. pub(crate) fn token_guid(token: &str) -> Option { - (token.len() == 16 && token.bytes().all(|b| b.is_ascii_hexdigit())) - .then(|| u64::from_str_radix(token, 16).ok()) + let body = token + .strip_prefix("0x") + .or_else(|| token.strip_prefix("0X")) + .unwrap_or(token); + (body.len() == 16 && body.bytes().all(|b| b.is_ascii_hexdigit())) + .then(|| u64::from_str_radix(body, 16).ok()) .flatten() } diff --git a/crates/benilla-ui/src/script/unit/tests.rs b/crates/benilla-ui/src/script/unit/tests.rs index c6183ab7..736fc8ca 100644 --- a/crates/benilla-ui/src/script/unit/tests.rs +++ b/crates/benilla-ui/src/script/unit/tests.rs @@ -1923,9 +1923,10 @@ fn superwow_guid_owner_and_mark_tokens_resolve() { .eval::(r#"local _, guid = UnitExists("target") return guid"#) .unwrap(); assert_eq!( - guid.len(), - 16, - "a 16-hex-digit opaque id, guid_token's shape" + guid, + super::guid_token(42), + "`0x` + 16 uppercase digits, guid_token's shape — the form the realm's own client writes \ + and addons test for with `sub(guid, 1, 2) == \"0x\"`" ); assert_eq!( s.eval::(&format!(r#"return UnitName("{guid}")"#)) @@ -2206,3 +2207,38 @@ fn pfuis_aggro_scan_walks_past_the_nameplate_tokens_to_reach_the_raid_ones() { "only the seated `player` exists; the point is that the walk reached `raid1` at all" ); } + +/// The GUID token's exact spelling, against the three independent sources that fix it — the +/// realm's own formatter, and two of grom's addons reading the other end. +/// +/// Literal rather than round-tripped through [`super::guid_token`], which would assert nothing: a +/// round trip held while this client wrote sixteen lowercase digits and no prefix, and every addon +/// that tests `sub(guid, 1, 2) == "0x"` read those as unit *names*. +#[test] +fn a_guid_token_is_0x_and_sixteen_upper_case_digits() { + assert_eq!( + super::guid_token(0xF130_0000_04D2_0007), + "0xF130000004D20007" + ); + assert_eq!(super::guid_token(42), "0x000000000000002A"); + assert_eq!(super::guid_token(0).len(), 18); + + // Accepted back with the prefix, without it, and in either case: nothing is gained by + // refusing a spelling, and the realm's own parser (`0xd06650`) takes upper case only. + for token in [ + "0xF130000004D20007", + "0XF130000004D20007", + "F130000004D20007", + "f130000004d20007", + ] { + assert_eq!( + super::token_guid(token), + Some(0xF130_0000_04D2_0007), + "{token}" + ); + } + // And a real token name is still never a GUID. + for name in ["player", "target", "party1", "nameplate1", "", "0x", "0xZZ"] { + assert_eq!(super::token_guid(name), None, "{name}"); + } +} diff --git a/docs/ADDON_COMPAT.md b/docs/ADDON_COMPAT.md index c6bb61ba..b5b7875d 100644 --- a/docs/ADDON_COMPAT.md +++ b/docs/ADDON_COMPAT.md @@ -419,3 +419,70 @@ with the load checked after each. | A frame left in saved variables came back as a bag of its fields | **fixed** | `attempt to call method 'AddMessage' (a nil value)` at `Decursive.lua:1393`, every login. Decursive keeps its output window in its own saved table (`Dcr_OutputWindow = DEFAULT_CHAT_FRAME`) and guards the reload with `if Dcr_Saved.Dcr_OutputWindow == nil or not ...` (`:1245`) — which a non-nil **data** table passes. Our writer walked the frame like any other table and wrote ChatFrame1's own fields (`buttonSide`, `channelList`, `editBox`, …), doubling the file (2217 bytes against his real client's 1108) and losing every method. The reference tests the value's `T[0]` identity first — `lua_rawgeti(v, 0)` then `lua_touserdata` at `0x704613`-`0x704628`, the lightuserdata `0x701bd0` wrote — and never walks a widget: a **named** one writes as its bare global name, a Lua expression, so the reloaded chunk's own lookup hands the live object back, and an anonymous one as `nil --[[ unnamed object ]]` (`0x872e0c`). His own real-client file is the proof: `["Dcr_OutputWindow"] = ChatFrame1,`. Deviation: a name that is no Lua identifier writes as an anonymous object, where the reference writes it raw (`%s`) and can leave a file that will not parse. Tests: `a_saved_frame_comes_back_as_the_frame_not_a_copy_of_its_fields`, `an_anonymous_frame_saves_as_the_references_unnamed_object` | | `SetScript("OnMessageScrollChanged")` raised, aborting the builder around it | **fixed** | WIM sets it on every window it builds — the history frame at load and then one per correspondent (6 whisper windows in one session of his log, each `WIM_msgFrameScrollingMessageFrame`) — so refusing the name by raise killed the rest of that builder function every time, not just the handler. It was on the deliberate "real 1.12 kinds nothing fires, so raise rather than silently accept" list (`object/events_regions.rs`), which is the right rule and the wrong side of it here: the slot now **fires**, from the ScrollingMessageFrame's own scroll verbs when a scroll actually moves the view (`messageframe::scrolling::scroll`), which is what a chat addon hangs its scroll-down arrow on. A scroll refused at either end moves nothing and says nothing; whether the reference also fires one there is untraced. Test: `a_scroll_that_moves_the_view_fires_the_frames_scroll_script`, and the raise list's own test keeps its rule (`the_unfired_script_kinds_still_raise_rather_than_silently_accepting`) | | nampower's equipment and item-cache getters (`GetEquippedItem`, `GetEquippedItems`, `GetItemLevel`, `GetItemStats`, `GetItemStatsField`) | **built** | *"then we gotta fix bettercharacterpanel, that one is driving me nuts"*. BetterCharacterPanel is built on all five: `GetEquippedItem` was 8 raises in one session (`BetterCharacterPanel-lib.lua:93`, `:193`, `-paperdoll.lua:179`) and the other four failed inside the addon's own `pcall`s, so the panel's item-level column, its quality colours, its missing-enchant warning and its two-hand test were all dead at once. DoiteAuras reads the same getter in six places. **The spec is the installed `nampower.dll`'s own string pool, not inference**: its usage strings give the signatures (`GetEquippedItem(unitStr, slot) or GetEquippedItem(guid, slot)`), its validation message **"Slot must be a number between 1 and 19"** gives the band — 1.12's own `GetInventoryItemLink` numbering, so `NampowerAPI.lua`'s `slot - 1` in `IsItemInSlot` is that shim's bug and not the contract — and the key names are its pooled strings in order: `itemId`, `permanentEnchantId`, `tempEnchantId`, `randomPropertiesId`, `stackCount`, `duration`, `spellChargesRemaining`, `flags`, `durability`, `maxDurability`, `tempEnchantmentTimeLeftMs`, `tempEnchantmentCharges` for an item, and `class`/`subclass`/`displayInfoID`/`quality`/`buyPrice`/`sellPrice`/`itemLevel`/… for a cache row. Its miss, **"Item not found in DBCache"**, is a raise, which is why the addon `pcall`s exactly these three and calls `GetEquippedItem` bare — ours raises the same string and records the template ask, so the call after the query's answer succeeds. `itemLevel` is the field 1.12 cannot reach at all: `GetItemInfo`'s fourth value is the *required* level (`0x48e070` pushes nine values; later clients are the ones that insert `itemLevel` at 4), so the item-level column had no possible source before this. Left absent rather than guessed: `duration` (the descriptor's seconds, where this client holds a display countdown in ms), the `resistances` array (six scalars here, index base in nampower's table untraced), the five-slot `spell*` block, and `ammoType`/`rangedModRange`/`languageID`/`pageMaterial`/`material`/`sheatheType`/`area`/`bagFamily`/`bonusStat`/`bonusAmount` — nothing installed reads any of them. An inspected unit answers from `PLAYER_VISIBLE_ITEM_*`, which carries the item and its two enchant slots and nothing else; nampower's own limit there is the same ("Cannot inspect unit"). Tests: `the_character_panels_four_item_reads_all_answer`, `an_uncached_item_raises_the_dlls_own_miss_and_asks_for_the_row`, `the_slot_band_is_the_dlls_own_one_to_nineteen` | + +## The client's own patches, read off `WoW.exe` — all 39 hook sites accounted for + +The realm patches its client, and it leaves the patches **in PE sections it names after them**. The +launcher keeps that file current (it replaced it on 2026-10-23), so this is a first-class source +rather than a last resort: scan `.text` for a `jmp`/`call` whose target lands in one of those +sections and you have every entry point. There were 39, in two sections. Recipe and worked example: +`benilla-octo-exe-patches-are-readable` (the project memory) and +`FIELD_PLAYER_QUEST_LOG_21_1`'s doc comment. + +`.octoql` (10 sites, VA `0xd26000`) is **one feature**: the extended quest log. Nine of the ten are +register shims calling one eight-instruction routine; the tenth detours +`GetNumQuestLogEntries`. Ported — `PLAYER_QUEST_LOG_SLOTS` is 25 and +`player_quest_log_field` holds both blocks. + +`.tdata` (29 sites, VA `0xd06000`) is TurtleWoW's loader patch set. Its strings name it: +`twloader.dll`, `HARDCORE`, `GetSelectedChallengeModes`. + +| Hook(s) | What the patch does | Status | +|---|---|---| +| `0x515c14` | a unit token spelled `0x` + 16 hex digits resolves as a raw GUID (parser at `0xd06650`, uppercase only; the `"Unknown unit name: %s"` arm is what it jumps ahead of) | **had the grammar, had the spelling wrong — fixed.** `unit::guid_token` wrote 16 lowercase digits and no prefix. Same number, wrong string: every addon that decides "is this a GUID" with `string.sub(g, 1, 2) == "0x"` (BigWigs `MageTools.lua:963`) read ours as a unit *name*, and every `sub(3, …)` field read came out two characters off. It now writes `0x` + 16 upper-case digits, the form the formatter at `0xd06500` lays down and AI_VoiceOver's own `format("0x%04X%06X%06X", …)` mints. Carried by `UNIT_CASTEVENT` and the aura events, so it was wrong where it mattered most; both spellings and either case are accepted on the way in | +| `0x51601b` | `UnitExists(unit)` returns the GUID as a second value | done | +| `0x51962e`, `0x519703` / `0x5199cd`, `0x519aec`, `0x519b14` | `UnitBuff` / `UnitDebuff` append the aura's spell id | done (`script/aura/mod.rs`) | +| `0x4e9223`, `0x4e924e`, `0x4e926e` | `GetLootMethod` appends the master looter's roster index (helper `0xd06720` scans the party/raid table at `0xb712a8` for the GUID at `0xbc7608`) | done (`PartyState::master_looter`) | +| `0x48bafd` | `CheckInteractDistance` type **4** compares against the float at `0xd06868`, which is `1600.0` | **fixed**: `INTERACT_DIST_SQ[3]` was stock 1.12's `900.0` (30 yd), now 40 yd | +| `0x49f7aa`, `0x49b193` | chat type `0x5E` is `CHAT_MSG_HARDCORE` | done | +| `0x5aad29` | appends the challenge mask to `CMSG_CHAR_CREATE` | done (`messages::client::char_create`) | +| `0x46abc4`, `0x46acf4` | registers / unregisters `GetSelectedChallengeModes` and `SetSelectedChallengeModes` as Lua globals (table at `0xd062a8`, registrar `0x704120`) | **nothing to do, and worth knowing why**: the realm's own `GlueXML/ChallengeList.lua:15-23` defines both in pure Lua `if type(…) ~= "function"`, for exactly our case. Its header says so. We already read the mask it sets | +| `0x5176d0`, `0x517726` / `0x517840`, `0x517886` | `UnitMana` / `UnitManaMax` append a second value read from `[unit+0x110]+0x44` / `+0x5c`, gated on a byte global being `0x0B` — on the evidence, a druid's real mana pool while shapeshifted. The `+0x60` hook of each pair exists only to preserve the unit pointer for the `+0xb6`/`+0xa6` one, which checks the first is installed by reading its own `jmp` opcode back | read, not ported: **nothing in the corpus reads a second return from either**, and the gating byte is a runtime value this static read cannot name. Port it when an addon asks | +| `0x48e2fe` | `GetItemInfo` appends one number, `[item+0x28]` | read, not ported: the field is unnamed and no addon reads an 11th return | +| `0x4ce715`, `0x4ce75d` | `GetAuctionSellItemInfo` appends a number (`[rec+0x60]`) and a string (`0x52ae00`), with the empty-slot arm padding to the same arity | read, not ported: same — unnamed fields, no consumer | +| `0x4f161a`, `0x4f1986` | `CreateMacro` arg 2 / `EditMacro` arg 3 take a branch when a type test passes, bypassing the stock handling | read, not ported: our `CreateMacro` already takes a numeric icon index (its own usage string's shape), and which *other* type this admits is not decidable from the branch alone | +| `0x505e19` | `Model:SetUnit` accepts a number (`lua_type == 3`) and clears three fields at `[frame+0x3e0/0x3e4/0x3f0]` | read, not ported: a GUID-as-model-unit, which nothing installed calls | +| `0x404100` | `LoadLibraryA("twloader.dll")` + `GetProcAddress("func1")` (helper `0xd06228`) | not applicable: benilla loads no DLLs, and that one is not even in the folder | +| `0x42a341` | raises an internal buffer bound (a global set to the section's own last byte) | not applicable | +| `0x5e8a71` | wraps an assert in `Player_C.cpp` with a GUID format | not applicable | + +## Asking the DLLs what they publish — `scripts/dll-surface.py` + +The same question for the six injected DLLs, which is harder: every one of them exports a single +`DllMain`/`Load` and registers with the client at runtime, so the export table says nothing. + +**They do point at every name they register.** A DLL publishing a Lua function hands the registrar +a `const char*`, so the name is a NUL-delimited string in its data and the image holds a +little-endian pointer to it. Collecting identifier-shaped NUL-delimited strings, converting each to +its virtual address through the section headers, and keeping the ones the file points at is what +makes this usable: on `ClassicAPI.dll` it cuts 2,872 CamelCase candidates to 706. The script then +subtracts `reference/1.12-globals.tsv` (the client's own), Lua 5.0's keywords and standard library, +and benilla's real surface via `dump_globals` — and flags what the addon corpus actually calls. + +It is a **candidate list, not a specification**: any pointed-at string survives, so statically +linked libraries contribute theirs (freetype's `BlueValues`, the CRT's `FlsAlloc`). Treat a name as +real when something else agrees — an addon calling it, or the mod's own docs. `UnitXP_SP3.dll` and +`fmod.dll` yield nothing at all, so the technique has a floor as well as a ceiling. + +What it found that this file did not already have: **38 names in `nampower.dll` that grom's addons +call**, on top of the families the nampower section above already tracks. Unported, and listed +rather than guessed at — `CancelPlayerAuraSlot`, `CancelPlayerAuraSpellId`, +`CastSpellByNameNoQueue`, `CastSpellNoQueue`, `ChannelStopCastingNextTick`, `CombatLogFlush`, +`CustomFileExists`, `EncryptPassword`, `ExecuteCustomLuaFile`, `FindPlayerItemSlot`, `GetBagItem`, +`GetCastInfo`, `GetCurrentCastingInfo`, `GetItemIconTexture`, `GetItemIdCooldown`, +`GetSpellModifiers`, `GetSpellRangeData`, `GetTrinketCooldown`, `GetTrinkets`, `GetUnitData`, +`IsAuraHidden`, `LearnTalentRank`, `PlayerIsRooted`, `PlayerIsSwimming`, `QueueScript`, +`QueueSpellByName`, `UseItemIdOrName`, `WriteCustomFile`, the six `NP_EnableUnitEvents*` CVars, and +the events `SPELL_FAILED_SELF`, `SPELL_QUEUE_EVENT`, `UNIT_DIED`, `UNIT_HEALTH_GUID`. A name being +called is not proof an addon needs it — most sit behind an `if GetTrinkets then` — so the next step +is reading each call site, not implementing the list. diff --git a/scripts/dll-surface.py b/scripts/dll-surface.py new file mode 100755 index 00000000..984cc946 --- /dev/null +++ b/scripts/dll-surface.py @@ -0,0 +1,172 @@ +#!/usr/bin/env python3 +"""What Lua surface the injected DLLs beside the client publish, and how much of it benilla has. + + scripts/dll-surface.py # the report + scripts/dll-surface.py --names # + every name per DLL + scripts/dll-surface.py --wanted # + only the names grom's addon corpus calls + +benilla loads no DLLs: it reimplements what they add (`docs/ADDON_COMPAT.md`). The problem that +creates is knowing *what* they add, and the answer is not their export tables — every one of these +exports a single `DllMain`/`Load` and registers with the client at runtime. + +**They do point at every name they register, though.** A DLL that publishes a Lua function hands +the registrar a `const char*`, so the name sits in its data as a NUL-delimited string and the +image holds a little-endian pointer to it, in the registration table or at the push. So: collect +identifier-shaped strings that are NUL-delimited on both sides, convert each to its virtual +address through the section headers, and keep the ones the file contains a pointer to. The pointer +test is what makes this usable — on `ClassicAPI.dll` it cuts 2872 CamelCase candidates to 706. + +What it is NOT: proof of a Lua API. Any pointed-at string survives, so a statically linked library +contributes its own (freetype's `BlueValues`, the CRT's `FlsAlloc`, month names). The filters below +remove the classes we can name, and the 1.12 census removes the client's own; what is left is a +*candidate* list to read, not a specification. Treat a name as real when something else agrees — +the addon corpus calling it, or the DLL's own docs. + +Reading the DLL's code for one name is `benilla-octo-exe-patches-are-readable`'s recipe, which is +how the `.octoql` and `.tdata` patches in `WoW.exe` were read. +""" + +import argparse +import re +import struct +import subprocess +import sys +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +# The client's folder: the DLLs sit beside `WoW.exe`, one level above this checkout. +CLIENT = REPO.parent + +# Lua 5.0's own keywords and standard library, which a DLL links rather than publishes. +LUA_OWN = { + "and", "break", "do", "else", "elseif", "end", "false", "for", "function", "if", "in", + "local", "nil", "not", "or", "repeat", "return", "then", "true", "until", "while", + "assert", "collectgarbage", "dofile", "error", "gcinfo", "getfenv", "getmetatable", + "ipairs", "loadfile", "loadstring", "newproxy", "next", "pairs", "pcall", "print", + "rawequal", "rawget", "rawset", "require", "setfenv", "setmetatable", "tonumber", + "tostring", "type", "unpack", "xpcall", "abs", "acos", "asin", "atan", "atan2", "ceil", + "cos", "deg", "exp", "floor", "fmod", "frexp", "ldexp", "log", "log10", "max", "min", + "mod", "modf", "pow", "rad", "random", "randomseed", "sin", "sqrt", "tan", "byte", "char", + "find", "format", "gfind", "gmatch", "gsub", "len", "lower", "rep", "reverse", "sub", + "upper", "concat", "foreach", "foreachi", "getn", "insert", "remove", "setn", "sort", + "clock", "date", "difftime", "time", "close", "flush", "lines", "open", "read", "seek", + "write", "operator", +} + + +def sections(image: bytes): + """`(image_base, [(virtual_address, virtual_size, raw_offset, raw_size)])` from the PE headers.""" + pe = struct.unpack_from(" set[str]: + """Identifier strings the image holds a pointer to; see the module docstring.""" + image = path.read_bytes() + base, secs = sections(image) + + def virtual(offset: int): + for vaddr, _vsize, raddr, rsize in secs: + if rsize and raddr <= offset < raddr + rsize: + return base + vaddr + (offset - raddr) + return None + + by_address: dict[int, str] = {} + for match in re.finditer(rb"\x00([A-Za-z_][A-Za-z0-9_]{3,48})\x00", image): + address = virtual(match.start() + 1) + if address is not None: + by_address.setdefault(address, match.group(1).decode("latin1")) + pointers = { + struct.unpack_from(" set[str]: + """The 1.12 client's own globals, which a DLL points at without adding.""" + names = set() + for line in tsv.read_text(errors="replace").splitlines(): + if line.startswith("#") or not line.strip(): + continue + names.add(line.split("\t")[0]) + return names + + +def ours() -> set[str]: + """benilla's real global surface, the same way `api-coverage.sh` asks for it.""" + run = subprocess.run( + ["cargo", "run", "-q", "-p", "benilla-ui", "--example", "dump_globals"], + cwd=REPO, + capture_output=True, + text=True, + ) + if run.returncode != 0: + sys.exit("dump_globals failed — build the workspace first") + return {line.split("\t")[0] for line in run.stdout.splitlines() if line.strip()} + + +def addon_calls(root: Path) -> set[str]: + """Every identifier the addon corpus calls, as a crude `name(` scan — enough to rank.""" + if not root.is_dir(): + return set() + called = set() + pattern = re.compile(rb"\b([A-Za-z_][A-Za-z0-9_]{2,48})\s*\(") + for lua in root.rglob("*.lua"): + try: + body = lua.read_bytes() + except OSError: + continue + for match in pattern.finditer(body): + called.add(match.group(1).decode("latin1")) + return called + + +def main() -> None: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--names", action="store_true", help="list every candidate per DLL") + ap.add_argument("--wanted", action="store_true", help="list only what the addon corpus calls") + args = ap.parse_args() + + vanilla = census(REPO / "reference" / "1.12-globals.tsv") + have = ours() + called = addon_calls(REPO / "benilla-config" / "AddOns") + print(f"1.12 census {len(vanilla)} · benilla exposes {len(have)} · addon corpus calls {len(called)}\n") + + dlls = sorted(p for p in CLIENT.glob("*.dll")) + if not dlls: + sys.exit(f"no DLLs beside the client at {CLIENT}") + for path in dlls: + try: + names = pointed_at(path) + except Exception as err: # a packed or malformed image: say so, do not guess + print(f"{path.name}: unreadable ({err})") + continue + # A Lua global these DLLs add is CapitalCase or SCREAMING_CASE by convention; the + # lower-case tail is overwhelmingly struct fields and C symbols. + candidates = { + n + for n in names + if n not in vanilla and n not in LUA_OWN and n[:1].isupper() + } + missing = sorted(n for n in candidates if n not in have) + wanted = sorted(n for n in missing if n in called) + print( + f"{path.name}: {len(names)} pointed-at · {len(candidates)} beyond 1.12 · " + f"{len(missing)} not in benilla · {len(wanted)} of those the addons call" + ) + for name in wanted if args.wanted else (missing if args.names else []): + print(f" {name}{'' if name in called else ' (no addon calls it)'}") + + +if __name__ == "__main__": + main()