ui+docs: the GUID token's real spelling, 40-yard interact, and the whole patch map
Working down the 29 `.tdata` hooks from the realm's patched `WoW.exe`.
Most turned out to be already ported; two were wrong here, and one of
those was wrong in a way that mattered.
**The GUID token was the wrong string.** `unit::guid_token` wrote 16
lowercase hex digits and no prefix. The realm writes `0x` + 16 upper-case
digits: its formatter at `0xd06500` — which `UnitExists`'s second return
goes through — lays down `0x30 0x78` then sixteen nibbles each `+0x37`
above 9, and its parser at `0xd06650` demands the `0x` and takes upper
case only. grom's own addons agree from the other end: BigWigs decides a
string is a GUID with `string.sub(guidOrName, 1, 2) == "0x"`
(`MageTools.lua:963`), and AI_VoiceOver reads fields from position 3 and
mints its own with `format("0x%04X%06X%06X", …)`.
Same number, wrong string, and wrong where it mattered most:
`UNIT_CASTEVENT` and the aura events carry these tokens, so every such
test read our GUIDs as unit *names* and every `sub(3, …)` field read came
out two characters off. Now written the realm's way; both spellings and
either case are accepted on the way in, since refusing one buys nothing.
Pinned literally rather than by round trip — a round trip held fine
through the whole bug.
**`CheckInteractDistance` type 4 is 40 yards, not 30.** `0x48bafd` jumps
to `0xd0686c`, which tests the type against 4 and compares against the
float the section carries at `0xd06868`: `1600.0`, squared thresholds.
Stock 1.12's `900.0` is still what the other three types use.
`docs/ADDON_COMPAT.md` now carries all 39 hook sites of both sections
with a status each: 10 in `.octoql` (one feature, the quest log, ported),
and 29 in `.tdata` — the aura ids, the master looter, `UnitExists`'s
GUID, the char-create mask and `CHAT_MSG_HARDCORE` already done; the two
challenge-mode functions needing nothing, because the realm's own
`ChallengeList.lua` defines them in Lua for exactly our case; four read
but unported with the reason (unnamed struct fields, no consumer in the
corpus); and three that cannot apply to a client that loads no DLLs.
Also `scripts/dll-surface.py`, which asks the injected DLLs the same
question. Their export tables are one `DllMain` each, but a DLL that
registers a Lua function must point at the name: collecting NUL-delimited
identifier strings the image holds a pointer to cuts `ClassicAPI.dll`
from 2872 CamelCase candidates to 706, and subtracting the 1.12 census,
Lua's own names and benilla's real surface leaves a candidate list the
addon corpus can rank. It is a list to read, not a specification —
statically linked libraries contribute their own strings, and
`UnitXP_SP3.dll` yields nothing. It found 38 names in `nampower.dll` that
grom's addons call and this file did not know about; they are written
down rather than guessed at.
269 data-gated failures, identical to the baseline, all nine gates green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
This commit is contained in:
@@ -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::<CastEventFeed>().0.is_empty(),
|
||||
|
||||
@@ -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::<i64>(&format!(
|
||||
r#"return C_CreatureInfo.GetCreatureID("{creature:016x}")"#
|
||||
r#"return C_CreatureInfo.GetCreatureID("{token}")"#
|
||||
))
|
||||
.unwrap(),
|
||||
1234
|
||||
|
||||
@@ -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:?}"
|
||||
);
|
||||
|
||||
|
||||
@@ -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::<String>("local _, g = tip:GetUnitGUID() return g")
|
||||
.unwrap(),
|
||||
"00000000deadbeef",
|
||||
"0x00000000DEADBEEF",
|
||||
"still Hogger's guid: the tooltip is still showing Hogger"
|
||||
);
|
||||
assert_eq!(
|
||||
|
||||
@@ -99,7 +99,9 @@ mod tests {
|
||||
);
|
||||
|
||||
let guid = s.eval::<String>(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::<String>(&format!(r#"return UnitTokenFromGUID("{guid}")"#))
|
||||
.unwrap(),
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -234,7 +234,7 @@ fn get_name_true_answers_the_plates_guid() {
|
||||
assert_eq!(
|
||||
s.eval::<String>("return WorldFrame:GetChildren():GetName(1)")
|
||||
.unwrap(),
|
||||
format!("{key:016x}"),
|
||||
crate::script::guid_token(key),
|
||||
"GetName(1) is the SuperWoW GUID token"
|
||||
);
|
||||
assert!(s
|
||||
|
||||
@@ -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<String> {
|
||||
// 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::<Option<String>>(&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::<bool>(r#"return UnitGUID("nameplate1target") == "1111000000000004""#)
|
||||
s.eval::<bool>(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::<bool>(r#"return UnitGUID("0000000000000000") == nil"#)
|
||||
s.eval::<bool>(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::<bool>(r#"return UnitGUID("nameplate2") == "1111000000000002""#)
|
||||
.eval::<bool>(r#"return UnitGUID("nameplate2") == "0x1111000000000002""#)
|
||||
.unwrap());
|
||||
|
||||
let mut fresh = vm();
|
||||
drive(&mut fresh, &[feed(BEAR, "Bear")]);
|
||||
assert!(
|
||||
fresh
|
||||
.eval::<bool>(r#"return UnitGUID("nameplate1") == "1111000000000002""#)
|
||||
.eval::<bool>(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::<Option<String>>("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"
|
||||
);
|
||||
|
||||
@@ -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<u64> {
|
||||
(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()
|
||||
}
|
||||
|
||||
|
||||
@@ -1923,9 +1923,10 @@ fn superwow_guid_owner_and_mark_tokens_resolve() {
|
||||
.eval::<String>(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::<String>(&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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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_msgFrame<Name>ScrollingMessageFrame`) — 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.
|
||||
|
||||
Executable
+172
@@ -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("<I", image, 0x3C)[0]
|
||||
if image[pe : pe + 4] != b"PE\0\0":
|
||||
raise ValueError("not a PE image")
|
||||
count = struct.unpack_from("<H", image, pe + 6)[0]
|
||||
optional_size = struct.unpack_from("<H", image, pe + 20)[0]
|
||||
base = struct.unpack_from("<I", image, pe + 24 + 28)[0]
|
||||
table = pe + 24 + optional_size
|
||||
out = []
|
||||
for i in range(count):
|
||||
_, vsize, vaddr, rsize, raddr = struct.unpack_from("<8sIIII", image, table + i * 40)
|
||||
out.append((vaddr, vsize, raddr, rsize))
|
||||
return base, out
|
||||
|
||||
|
||||
def pointed_at(path: Path) -> 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("<I", image, i)[0] for i in range(0, max(len(image) - 4, 0))
|
||||
}
|
||||
return {name for address, name in by_address.items() if address in pointers}
|
||||
|
||||
|
||||
def census(tsv: Path) -> 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()
|
||||
Reference in New Issue
Block a user