Files
benilla-octo/scripts/dll-surface.py
T
gromandClaude Opus 5 ada08720bb 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
2026-10-23 19:57:26 -06:00

173 lines
7.6 KiB
Python
Executable File

#!/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()