Files
benilla-octo/scripts/gen-reference-globals.py
T
SamandClaude Fable 5.1 b05d5d18d5 a fresh clone gets the whole project, and the maintainer's tooling moves out
What a clone lacked: the gates went green with a thousand skipped tests and said nothing, every
login script assumed a worktree-pool slot and its probe account, the client defaulted to the
maintainer's account, a docker path and a home directory were in the code, the temp files broke
on GNU coreutils, and two generators could only run against a sibling repo.

Now the test gates print how many data-gated tests skipped and why; a scripted run logs in as
the account its checkout declares in `.probe-identity` or as the three variables name, never a
default, and the client refuses a scripted login on any other account; the event and message
tables regenerate from vendored inputs, byte for byte; the docs state the real prerequisites.

Out of the tree, into the maintainer's own tooling: the worktree pool, its hooks, the perf leg
runner, the cross-platform compile and the Vulkan container leg — a way of working, and setups
not every machine has. One hook stays: no writes into the install.

Two tests that could never run are gone with their subject: the quest-timer geometry diff
compared a copy of the window that no longer exists (and joined a chain path onto a folder, so
it always skipped), and the FrameXML flag gate now reads the stock files off the player's own
patch chain instead of an extracted folder only one machine had.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-23 17:27:14 +07:00

331 lines
16 KiB
Python
Executable File

#!/usr/bin/env python3
"""Regenerate `reference/1.12-globals.tsv` — the 1.12.1 client's global namespace, with each name
attributed to whoever provides it.
WOW_RE=<the RE repo> scripts/gen-reference-globals.py [--wow-re DIR] [--out FILE]
**Why this exists** (decision 1189): every wrong number in the addon arc came from remembering a
list of WoW functions instead of asking the client. wow-5875-re already ships an addon
(`W5875Capture`) that dumps the in-world `_G` of the *running* reference client, and its output is
committed there as a fixture. This vendors that capture, attributes every name, and writes the
result as the one artifact benilla measures its API surface against.
**Three origins**, because "is this in 1.12?" and "whose job is it to provide?" are different
questions:
lua Lua 5.0's own runtime. We get these from mlua; neither we nor FrameXML write them.
engine The C client provides it. **This is the list benilla implements in Rust.**
framexml Defined by the shipped UI itself — its Lua, its XML, or a `$parent`-composed child of
one. Runs off the player's own patch chain at runtime (1751), never hardcoded in Rust.
Attribution is by *definition site*, computed from a complete 1.12 shipped-UI corpus:
- a name assigned or `function`-declared in shipped Lua (including a `<Script>` body inside a
FrameXML document) is FrameXML's. Indented assignments count: vanilla FrameXML leaks globals
out of function bodies (`button = getglobal(...)`) and those names really are in `_G`;
- a `name="..."` on any shipped XML element is FrameXML's — including `virtual="true"`, because
a virtual `<Font>` is a real font object and 1.12 registers named virtual frames too;
- a name formed as `<a shipped name><a $parent suffix>` is a composed child object
(`ContainerFrame1Item16IconTexture`), so also FrameXML's;
- LUA_5_0 below wins over all of the above: FrameXML *clobbers* `string` in three files
(`string = getglobal(...)`), which overwrites the stdlib table rather than defining it.
**Inputs live outside the repo** — a 1.12 install and the RE repo's runtime capture (`--wow-re`,
or `$WOW_RE`) — so this is a manual regeneration, like `genmap.sh`; the committed table is the
surface benilla tracks. It needs:
- `<wow-re>/WoW/_w5875_fixtures/item13/W5875Capture.lua` the captured in-world `_G`
- `<wow-re>/WoW/_extracted_framexml/` FrameXML, already extracted there
- `<wow-re>/WoW/Data/*.MPQ` for the twelve `Blizzard_*` addons
**The corpus is the part that is easy to get wrong** (decision 1188 §4 — three attempts were
needed, and the first two produce confidently wrong answers). FrameXML alone is not the shipped
UI: the twelve `Blizzard_*` addons live in the MPQs, and their `.lua` is *not* listed in their
`.toc` — it is pulled in by `<Script file=>` inside their XML. A corpus that misses either half
misattributes real FrameXML functions as the engine's. A complete one is 233 files, and this
script prints the count so a short one is visible rather than silent.
"""
import argparse
import os
import re
import subprocess
import sys
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# The twelve addons that ship inside the MPQs. Their folders exist in a real install carrying only
# a `.pub` signature file, so the install cannot be the source — the archives are.
BLIZZARD_ADDONS = [
"Blizzard_AuctionUI", "Blizzard_BattlefieldMinimap", "Blizzard_BindingUI",
"Blizzard_CombatText", "Blizzard_CraftUI", "Blizzard_GMSurveyUI",
"Blizzard_InspectUI", "Blizzard_MacroUI", "Blizzard_RaidUI",
"Blizzard_TalentUI", "Blizzard_TradeSkillUI", "Blizzard_TrainerUI",
]
# Lua 5.0's own globals, as the 1.12 client actually exposes them. Note what is NOT here and is
# absent from the capture: `_G`, `print`, `require`, `dofile`, `loadfile`, and the `io`/`os`/
# `debug`/`coroutine` tables — the client sandboxes them away, which is a fidelity fact about the
# VM we present to addons, not an accident of the capture.
LUA_5_0 = {
"assert", "collectgarbage", "error", "gcinfo", "getfenv", "getmetatable", "ipairs",
"loadstring", "math", "next", "pairs", "pcall", "rawequal", "rawget", "rawset",
"setfenv", "setmetatable", "string", "table", "tonumber", "tostring", "type",
"unpack", "xpcall",
}
# The capture addon's own footprint, each verified against its source rather than guessed at.
# `W5875CaptureHolder` is its `CreateFrame(..., "W5875CaptureHolder", ...)`, `W5875CaptureDB` its
# `## SavedVariables`, `SLASH_W5875CAP1` its slash token. It ran inside the client to produce the
# fixture, so its globals are in the table it dumped; they are the instrument, not the client.
#
# `__framescript_meta` is deliberately NOT here despite looking like tooling: it is the *client's*
# own shared frame metatable, published to `_G` (wow-5875-re RF-0023, `system/ui/ui.md`), and
# benilla mirrors it at `script/object.rs`. Excluding it on the strength of its underscores made
# our own faithful global read as a superset.
CAPTURE_OWN = {"W5875CaptureDB", "W5875CaptureHolder", "SLASH_W5875CAP1"}
LUA_ASSIGN = re.compile(r"^[ \t]*([A-Za-z_][A-Za-z0-9_]*)\s*=[^=]", re.M)
LUA_FUNC = re.compile(r"^[ \t]*function\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(", re.M)
LUA_MULTI = re.compile(
r"^[ \t]*([A-Za-z_][A-Za-z0-9_]*(?:\s*,\s*[A-Za-z_][A-Za-z0-9_]*)+)\s*=[^=]", re.M
)
XML_ELEM = re.compile(r"<(\w+)([^>]*?)/?>", re.S)
XML_NAME = re.compile(r'\bname\s*=\s*"([^"]*)"')
PARENT_SUFFIX = re.compile(r'\bname\s*=\s*"\$parent([A-Za-z0-9_]*)"')
# Lua embedded in a FrameXML document, which only this pass will ever see. Two shapes, and
# missing either misattributes real FrameXML names to the engine: an inline `<Script>…</Script>`
# body (Fonts.xml defines CHAT_FONT_HEIGHTS that way), and every handler element under
# `<Scripts>` — `<OnLoad>`, `<OnEvent>`, `<PreClick>` and kin, which is where MainMenuBar.xml
# assigns SHOW_KEYRING and PERFORMANCEBAR_LOW_LATENCY. Matched by shape rather than by a list of
# handler names so a rarely-used one cannot be forgotten. `<Script file=>` is skipped: it
# references a file already in the corpus.
XML_SCRIPT = re.compile(
r"<((?:On|Pre|Post)\w+|Script)\b(?![^>]*\bfile\s*=)[^>]*>(.*?)</\1>", re.S
)
XML_REF = re.compile(rb'<(?:Script|Include)\s+file\s*=\s*"([^"]+)"', re.I)
def captured_globals(fixture):
"""`["globals"]["keys"]` out of the capture: the in-world `_G`, as (name, lua type)."""
recs, cur_t, inside = [], None, False
with open(fixture, encoding="utf-8", errors="replace") as f:
for line in f:
if not inside:
inside = line.strip() == '["globals"] = {'
continue
if '["count"]' in line:
break
m = re.match(r'\s*\["t"\] = "([^"]*)",', line)
if m:
cur_t = m.group(1)
continue
m = re.match(r'\s*\["name"\] = "(.*)",\s*$', line)
if m:
recs.append((m.group(1), cur_t))
cur_t = None
return recs
def join_ref(base, ref):
"""Resolve a reference against the including file's directory — `loader::join_ref`'s rule."""
ref = ref.replace("\\", "/").strip()
parts = ref[1:].split("/") if ref.startswith("/") else (base.split("/") if base else []) + ref.split("/")
out = []
for p in parts:
if p in ("", "."):
continue
if p == ".." and out and out[-1] != "..":
out.pop()
else:
out.append(p)
return "/".join(out)
def build_corpus(wowre):
"""Every file of the 1.12 shipped UI, as {corpus-relative path: text}."""
archives = [f"{wowre}/WoW/Data/{a}.MPQ" for a in ("patch-2", "patch", "interface", "base")]
archives = [a for a in archives if os.path.exists(a)]
mpqcat = os.path.join(REPO, "target", "debug", "examples", "mpqcat")
if not os.path.exists(mpqcat):
subprocess.run(
["cargo", "build", "-q", "-p", "benilla-mpq", "--example", "mpqcat"],
cwd=REPO, check=True,
)
def mpq_read(archive_path):
for arc in archives:
r = subprocess.run([mpqcat, arc, archive_path], capture_output=True)
if r.returncode == 0:
return r.stdout
return None
files = {}
fx = os.path.join(wowre, "WoW", "_extracted_framexml")
for fn in sorted(os.listdir(fx)):
p = os.path.join(fx, fn)
if os.path.isfile(p):
files[f"FrameXML/{fn}"] = open(p, "rb").read()
print(f" FrameXML: {len(files)} files")
for addon in BLIZZARD_ADDONS:
toc_rel = f"{addon}/{addon}.toc"
toc = mpq_read("Interface\\AddOns\\" + toc_rel.replace("/", "\\"))
if toc is None:
print(f" !! {addon}: no .toc in any archive", file=sys.stderr)
continue
files[toc_rel] = toc
queue, seen = [], set()
for line in toc.decode("utf-8", "replace").splitlines():
line = line.strip()
if line and not line.startswith("#"):
queue.append(join_ref(addon, line))
while queue:
rel = queue.pop(0)
if rel in seen:
continue
seen.add(rel)
data = mpq_read("Interface\\AddOns\\" + rel.replace("/", "\\"))
if data is None:
print(f" !! {addon}: missing {rel}", file=sys.stderr)
continue
files[rel] = data
# The `.lua` is reached from inside the XML, never from the `.toc` — follow it, or
# every Lua-side API in these twelve addons misattributes to the engine.
if rel.lower().endswith(".xml"):
base = rel.rsplit("/", 1)[0] if "/" in rel else ""
for m in XML_REF.finditer(data):
queue.append(join_ref(base, m.group(1).decode("utf-8", "replace")))
print(f" {addon}: {1 + len(seen)} files")
return {k: v.decode("utf-8", "replace") for k, v in files.items()}
def lua_defs(text, into):
into.update(LUA_ASSIGN.findall(text))
into.update(LUA_FUNC.findall(text))
for grp in LUA_MULTI.findall(text):
into.update(n.strip() for n in grp.split(","))
def shipped_names(corpus):
"""Every global the shipped UI defines, plus the `$parent` suffixes children compose with."""
names, suffixes = set(), set()
for rel, text in corpus.items():
if rel.lower().endswith(".lua"):
lua_defs(text, names)
elif rel.lower().endswith(".xml"):
for _tag, body in XML_SCRIPT.findall(text):
lua_defs(body, names)
suffixes.update(s for s in PARENT_SUFFIX.findall(text) if s)
for tag, attrs in XML_ELEM.findall(text):
# `<Binding name="ACTIONBUTTON1">` is a KEY-BINDING command name, not a global —
# it never reaches `_G`. It shares the `name=` attribute with every frame element,
# so a shape-blind harvest pulls all 228 of `Bindings.xml`'s commands in. Harmless
# while this set was only used for *attribution* (none of them is in the capture,
# so none could be misattributed); wrong now that the set also contributes rows
# (decision 1200).
if tag.lower() == "binding":
continue
m = XML_NAME.search(attrs)
if m and "$parent" not in m.group(1):
names.add(m.group(1))
return names, suffixes
def composed(name, roots, suffixes, depth=0):
"""Is `name` `<a shipped name><a $parent suffix>`, recursively? A numeric tail on the parent
side is allowed: `ContainerFrame1` and `Item16` come from Lua's `name..i`."""
if depth > 8:
return False
for i in range(1, len(name)):
head, tail = name[:i], name[i:]
if tail not in suffixes:
continue
if head in roots or head.rstrip("0123456789") in roots:
return True
if composed(head, roots, suffixes, depth + 1):
return True
return False
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--wow-re", default=os.environ.get("WOW_RE"))
ap.add_argument("--out", default=os.path.join(REPO, "reference", "1.12-globals.tsv"))
args = ap.parse_args()
if not args.wow_re:
sys.exit(
"this table is derived from the RE repo's runtime capture of the reference client's "
"_G: pass --wow-re or set WOW_RE. The committed reference/1.12-globals.tsv is the "
"surface benilla tracks."
)
fixture = os.path.join(args.wow_re, "WoW", "_w5875_fixtures", "item13", "W5875Capture.lua")
if not os.path.exists(fixture):
sys.exit(f"no capture fixture at {fixture} — is --wow-re right?")
print("building the 1.12 shipped-UI corpus...")
corpus = build_corpus(args.wow_re)
print(f" TOTAL: {len(corpus)} files" + ("" if len(corpus) == 233 else " ** expected 233 **"))
recs = [(n, t) for n, t in captured_globals(fixture) if n not in CAPTURE_OWN]
defined, suffixes = shipped_names(corpus)
print(f"captured globals: {len(recs)} shipped definitions: {len(defined)} "
f"$parent suffixes: {len(suffixes)}")
rows, tally = [], {}
captured = set()
for name, t in recs:
captured.add(name)
if name in LUA_5_0:
origin = "lua"
elif name in defined or composed(name, defined, suffixes):
origin = "framexml"
else:
origin = "engine"
rows.append((name, t, origin))
tally[(origin, t)] = tally.get((origin, t), 0) + 1
# ── The LoadOnDemand half the capture could not see (decision 1200) ──────────────────────
#
# The fixture is the in-world `_G` of a *running* client, and the twelve `Blizzard_*` addons
# are LoadOnDemand: unless the player had opened the talent window, the trade-skill window and
# the rest before the dump, their globals are not in it. `TalentFrame`, `MacroFrame`,
# `TradeSkillFrame`, `CraftFrame`, `TrainerFrame`, `InspectFrame` and ~350 of their children
# are real 1.12 names that the capture alone reports as absent — which made benilla's own
# transcriptions of those windows look like they were inventing names.
#
# So the table is the capture UNION every global the shipped UI *defines*. That is the honest
# membership test for the question we actually ask it — "is this a 1.12 name?" — and it is
# strictly better than the capture alone, which answers "was this name live at one moment in
# one session". The two halves stay distinguishable: a name only the corpus knows gets type
# `lod`, so a consumer that cares can tell.
#
# Type inference is deliberately coarse: a `function X(` declaration is a `function`, an XML
# `name=` is a frame (`table`), and a bare assignment could be anything, so it says `lod`
# rather than guessing. Nothing downstream keys on the type of these rows.
lod = 0
for name in sorted(defined - captured):
rows.append((name, "lod", "framexml"))
tally[("framexml", "lod")] = tally.get(("framexml", "lod"), 0) + 1
lod += 1
print(f"shipped-UI definitions the capture did not contain: {lod} (LoadOnDemand + unrealized)")
os.makedirs(os.path.dirname(args.out), exist_ok=True)
with open(args.out, "w") as f:
f.write("# The 1.12.1 client's global namespace. GENERATED — scripts/gen-reference-globals.py.\n")
f.write("# Source: wow-5875-re's W5875Capture fixture (the running client's in-world _G),\n")
f.write("# attributed against the complete 1.12 shipped-UI corpus. Decision 1189.\n")
f.write("# UNION every name that corpus defines but the capture did not contain (type\n")
f.write("# `lod`): the twelve Blizzard_* addons are LoadOnDemand, so a live dump misses\n")
f.write("# them unless the player had opened those windows. Decision 1200.\n")
f.write("# name\ttype\torigin(lua|engine|framexml)\n")
for row in sorted(rows):
f.write("\t".join(row) + "\n")
print(f"\nwrote {args.out} ({len(rows)} names)")
for k in sorted(tally):
print(f" {k[0]:9s} {k[1]:9s} {tally[k]}")
main()