Bring the nampower, UnitXP_SP3 and ClassicAPI ports up to the installed builds

The three DLLs beside the client were updated; re-measured each against the
thing with evidence behind it rather than against a wiki.

nampower's own string pool is exact — `^NP_[A-Za-z0-9]+$` is the CVar list and
`Usage:` the arity of every function — and both came back identical to this
tree: 49/49 CVars, 63/63 functions. The gap was events. Four built:

  BUFF_ADDED_OTHER / BUFF_REMOVED_OTHER   ui_aura.rs, AuraEdge::OtherBuff
  KEY_DOWN / KEY_UP                       bindings.rs, key_event_code

The buff pair was argued against here before, on the cost of diffing the
helpful half of every streamed unit for events nothing registered. Answered
rather than overruled: both are gated on `has_listener`, which is the test
upstream now recommends over its own deprecated NP_Enable*Events CVars. The key
pair uses the client's own code table, which `chord::key_code` already held with
a comment saying it was the fact these events were blocked on.

UnitXP_SP3 is packed, so its contract comes from the wiki plus the call sites in
the installed addons — and those call sites exposed a live bug. Every
`UnitXP("target", …)` queued a SelectionRequest, but the mod selects INSIDE the
call and SuperCleveRoidMacros steps the cycle and reads `"target"` back on the
next line, fifty times in one Lua frame. Queued, every pass read the same unit
and the scan reported one enemy however many were there — truthy return, no
error. The pick now resolves in-call from a pool the app publishes
(`feed_unitxp_scan`), exactly the bargain TargetByName already strikes. All nine
target modes are built where four were; `behindThreshold` is real (the reach
feed publishes the angle now, not a verdict); `timer` rides the one timer queue;
`notify` reaches winit's user-attention request; `version`,
`performanceProfile` and a non-raising `debug` answer.

ClassicAPI carries its whole Lua addon gzipped in `.rsrc` — 173 files, a length
header then the bodies, keyed by the paths in its string pool — so the spec for
the installed build is on disk. `scripts/dll-surface.py --unpack` writes it out.
Read against a LIVE VM (not `UiScript::new()`, which is engine-only), the gap is
898 globals, and almost all of it is either a locale string or a subsystem this
client models differently. What is between those is now in
`compat/classic_api/shared_xml.rs`: the pure Blizzard_SharedXML helpers, in
1.12's grammar, from the shipped addon's own source. `tostringall` stays absent
on `tests/stdlib.rs`'s own rule.

The native half is mostly widget methods; intersected with what the corpus
actually calls it is four, of which three are built (`SetResizeBounds` over
1.12's own resize pair, `GetNormalFontObject` — which needed 1.12's missing
`GetTextFontObject` implemented first — and a bare `IsEquippedItem`) and
`SetMotionScriptsWhileDisabled` deliberately is not, because both its call sites
guard on it and the behaviour behind it does not exist here.

Also measured and recorded: every addon-load warning in grom's own logs is a
third-party optional dependency he has not installed or an addon's own packaging
bug, not a defect on this side. docs/ADDON_COMPAT.md has the table.

Ambiguity ceiling 4,956 -> 4,966, measured: nine for `feed_unitxp_scan` against
transform writers on non-networked lanes, one for the attention drain against a
system that also reads WinitWindows and neither writes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
This commit is contained in:
2026-11-06 09:28:56 -07:00
co-authored by Claude Opus 5
parent b39651d9b4
commit 4974326381
29 changed files with 2101 additions and 112 deletions
+99
View File
@@ -4,6 +4,7 @@
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
scripts/dll-surface.py --unpack DLL DIR # write out an addon the DLL carries in .rsrc
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
@@ -24,6 +25,14 @@ 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.
**`--unpack` is the better question for ClassicAPI**, and the answer the registration scan cannot
give: that DLL carries its whole `!!!ClassicAPI` addon — 173 Lua/XML files — gzipped in `.rsrc`,
and extracts it into `Interface/AddOns` at load. The decompressed blob is a plain container (decimal
byte lengths, one per line, a blank line, then the files concatenated) and the paths are the
`!!!ClassicAPI\…` strings in the pool, in the same order. So the authoritative spec for the build
sitting beside the client is on disk already; see `docs/ADDON_COMPAT.md`. Nothing else in this
bundle ships resources this way, so `--unpack` on another DLL simply finds none.
"""
import argparse
@@ -131,12 +140,102 @@ def addon_calls(root: Path) -> set[str]:
return called
def unpack(dll: Path, out: Path) -> None:
"""Write out the addon a DLL carries in `.rsrc`, one file per path in its string pool."""
import gzip
import os
image = dll.read_bytes()
pe = struct.unpack_from("<I", image, 0x3C)[0]
nsec = struct.unpack_from("<H", image, pe + 6)[0]
opt = struct.unpack_from("<H", image, pe + 20)[0]
base = pe + 24 + opt
rsrc = None
for i in range(nsec):
off = base + 40 * i
name = image[off : off + 8].rstrip(b"\0").decode("latin1")
vsize, va, rsize, raw = struct.unpack_from("<IIII", image, off + 8)
if name == ".rsrc":
rsrc = (va, raw, rsize)
if rsrc is None:
sys.exit(f"{dll.name}: no .rsrc section")
rva, raw, rsize = rsrc
blob = image[raw : raw + rsize]
entries: list[bytes] = []
def walk(off: int) -> None:
nnamed, nid = struct.unpack_from("<HH", blob, off + 12)
for i in range(nnamed + nid):
_, dataoff = struct.unpack_from("<II", blob, off + 16 + 8 * i)
if dataoff & 0x80000000:
walk(dataoff & 0x7FFFFFFF)
else:
drva, dsize = struct.unpack_from("<II", blob, dataoff)
start = raw + (drva - rva)
entries.append(image[start : start + dsize])
walk(0)
# The paths, in the order the pool holds them — which is the order the files inside one blob
# are concatenated. Grouped by top-level folder, because each blob is one addon and the pool
# also holds paths that belong to none of them (`Interface\\FrameXML\\Bindings.xml`, the
# SavedVariables templates). A blob is matched to the group whose file count it equals, which
# is unambiguous here and refuses rather than guesses when it is not.
groups: dict[str, list[str]] = {}
for m in re.finditer(rb"[A-Za-z!][\w!.-]*\\[^\x00\n]*?\.(?:lua|xml|toc|tga|blp)", image):
path = m.group(0).decode("latin1").replace("\\", "/")
if "%" in path or path.startswith(("Interface/", "WTF/")):
continue
groups.setdefault(path.split("/", 1)[0], []).append(path)
written = 0
for data in entries:
if data[:2] != b"\x1f\x8b":
continue
body = gzip.decompress(data)
nl = body.find(b"\n\n")
if nl < 0:
continue
try:
sizes = [int(x) for x in body[:nl].split(b"\n")]
except ValueError:
continue
files = body[nl + 2 :]
if sum(sizes) != len(files):
continue
hits = [k for k, v in groups.items() if len(v) == len(sizes)]
if len(hits) != 1:
print(f" skipped a {len(sizes)}-file blob: {len(hits)} folders match that count")
continue
paths, pos = groups.pop(hits[0]), 0
for path, size in zip(paths, sizes):
dst = out / path
dst.parent.mkdir(parents=True, exist_ok=True)
dst.write_bytes(files[pos : pos + size])
pos += size
written += 1
if written == 0:
sys.exit(f"{dll.name}: no packed addon found in .rsrc")
print(f"{dll.name}: wrote {written} files to {out}")
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")
ap.add_argument(
"--unpack",
nargs=2,
metavar=("DLL", "DIR"),
help="write out the addon a DLL carries in .rsrc (ClassicAPI.dll) and exit",
)
args = ap.parse_args()
if args.unpack:
dll = Path(args.unpack[0])
unpack(dll if dll.exists() else CLIENT / args.unpack[0], Path(args.unpack[1]))
return
vanilla = census(REPO / "reference" / "1.12-globals.tsv")
have = ours()
called = addon_calls(REPO / "benilla-config" / "AddOns")