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:
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