Files
benilla-octo/scripts/gen-reference-verb-events.py
T
SamandClaude Opus 5.5 73427e2068 comments: the generators, the tools and the reference table headers
Docstrings keep their usage and exit codes; table headers keep the
column contract and the client addresses and lose the dates, the
correction history and every path into another repository. A
generated header changes in its generator's template, so each of the
four generators still reproduces its output byte for byte, and no data
row changed in any of the seven tables.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 19:32:13 +07:00

229 lines
9.6 KiB
Python
Executable File

#!/usr/bin/env python3
"""Regenerate `reference/1.12-verb-events.tsv`: the events 1.12 Lua verbs fire, keyed by verb.
WOW_RE=DIR scripts/gen-reference-verb-events.py [--wow-re DIR] [--out FILE]
A row says the reference fires `event` on the call path of the registered Lua binding `verb`:
from the verb's body (`shape=body`), or from a helper that only registered verbs call
(`shape=helper`, `via` = the helper's address; `0x4d8c90` is the trainer's). A helper with any
non-verb caller (a packet handler, the cursor, the tutorial signal) fires a state event and is
left out, and so is a hub (`ClearCursor` -> CURSOR_UPDATE, 49 callers).
A site's function is the greatest start at or below it, over the union of: every function the
analysis sizes, every named function start, every `call` target in the client's disassembly,
every registered verb, and every address after a padding run (an `int3` run anywhere, or a `nop`
run after a `ret`; a `nop` run after a `jmp` is a loop head, not a boundary). A site at or past
`start + size` of a sized function is left unattributed rather than handed to it.
Two families are dropped: glue-space verbs and sites (the GlueXML registrar, 0x46a000..0x476000)
and the unit-field bridge and token fan-out ids below 0xb6, which are state events.
A floor, not a census: a verb with no row may still fire an event, because the fire sites are
only the two signal helpers' literal call sites and `shape=helper` stops one call deep.
The fire sites and the binding shapes are the vendored `reference/` tables; the function names,
extents and disassembly are the maintainer's analysis (`--wow-re` or `$WOW_RE`), not in this
repo, so this is a manual regeneration.
"""
import argparse
import bisect
import collections
import glob
import os
import re
import sys
# The GlueXML registrar's address range: an id fired there names a glue-screen event.
GLUE = (0x46A000, 0x476000)
# `0x51bbb0`'s walk bound: every named unit-window field below it fires through the generic bridge.
UNIT_WINDOW_FIELDS = 0xB6
# A helper "only verbs call" is trusted up to this many callers; past it, it is a hub.
MAX_HELPER_CALLERS = 8
LINE = re.compile(r"^\s*([0-9a-f]+):\t(\S+)(?:\s+(.*))?$")
BRANCH = re.compile(r"^(call|jmp)\s+0x([0-9a-f]+)\s*$")
SIZE = re.compile(r"\bsize=(\d+)")
REFERENCE = os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "reference"))
def rows(path):
if not os.path.exists(path):
sys.exit(f"no table at {path}")
for line in open(path, encoding="utf-8"):
if line.startswith("#"):
continue
f = line.rstrip("\n").split("\t")
if f and f[0] in ("name", "addr", "eventId", "fire_site_va"):
continue
yield f
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--wow-re", default=os.environ.get("WOW_RE"))
ap.add_argument("--out", default=os.path.join(REFERENCE, "1.12-verb-events.tsv"))
a = ap.parse_args()
if not a.wow_re:
sys.exit(
"this table is derived from the RE repo's function names, ledgers and disassembly: "
"pass --wow-re or set WOW_RE. The committed reference/1.12-verb-events.tsv is the "
"surface benilla tracks."
)
starts = set()
for f in rows(os.path.join(a.wow_re, "re/names/out/names-5875.tsv")):
if len(f) >= 2 and f[1] == "func":
starts.add(int(f[0], 16))
if len(starts) < 1000:
sys.exit(f"names-5875.tsv gave only {len(starts)} functions — wrong path?")
# Function extents: `size=` on the `fn` rows of the analysis's ledgers.
extent = {} # fn start -> size in bytes
for path in glob.glob(os.path.join(a.wow_re, "system/*/ledger.tsv")):
for line in open(path, encoding="utf-8", errors="replace"):
if not line.startswith("fn\t"):
continue
f = line.rstrip("\n").split("\t")
m = SIZE.search(line)
if m and len(f) > 1 and f[1].startswith("0x"):
fn = int(f[1], 16)
extent[fn] = max(extent.get(fn, 0), int(m.group(1)))
if len(extent) < 5000:
sys.exit(f"the ledgers gave only {len(extent)} sized functions — wrong path?")
starts |= set(extent)
verbs = {} # fn address -> name
for f in rows(os.path.join(REFERENCE, "1.12-shapes.tsv")):
if len(f) < 2:
continue
fn = int(f[1], 16)
if GLUE[0] <= fn < GLUE[1] or f[0].startswith("Get"):
# A getter's fire is a cache-miss re-query announced when the answer lands
# (`GetInboxText`, `GetAuctionItemInfo`): a state event, fired by benilla's feeds.
continue
verbs.setdefault(fn, f[0])
if len(verbs) < 1000:
sys.exit(f"binding-shapes.tsv gave only {len(verbs)} verbs — wrong path?")
starts |= set(verbs)
sites = [] # (site va, event name)
for f in rows(os.path.join(REFERENCE, "1.12-event-firesites.tsv")):
if len(f) < 4 or not f[0].startswith("0x") or not f[3] or f[2] == "dyn":
continue
va, eid = int(f[0], 16), int(f[2])
if GLUE[0] <= va < GLUE[1] or eid < UNIT_WINDOW_FIELDS:
continue
sites.append((va, f[3]))
if len(sites) < 300:
sys.exit(f"event-firesites.tsv gave only {len(sites)} sites — wrong path?")
# One pass over the disassembly: every call/jmp edge, and every padding boundary (an `int3`
# run, or a `nop` run right after a `ret`). A `nop` run after a `jmp` is a loop head's
# alignment inside a function, not a start.
edges = []
after_pad = False
ended = False # the previous real instruction was a `ret`
n_lines = 0
disasm = os.path.join(a.wow_re, "system/ui/scratch/disasm-full.txt")
if not os.path.exists(disasm):
sys.exit(f"no disassembly at {disasm} — is --wow-re right?")
for line in open(disasm, encoding="utf-8", errors="replace"):
m = LINE.match(line)
if not m:
continue
n_lines += 1
va, mnem, ops = int(m.group(1), 16), m.group(2), m.group(3) or ""
if mnem == "int3" or (mnem == "nop" and (ended or after_pad)):
after_pad = True
continue
if after_pad:
starts.add(va)
after_pad = False
ended = mnem == "ret"
b = BRANCH.match(f"{mnem} {ops}".strip())
if b:
edges.append((va, b.group(1), int(b.group(2), 16)))
if n_lines < 1_000_000:
sys.exit(f"disasm-full.txt gave only {n_lines} lines — wrong path?")
starts |= {t for _, k, t in edges if k == "call"}
ordered = sorted(starts)
def enclosing(va):
"""The function holding `va`; None when `va` lies past its candidate's extent."""
fn = ordered[bisect.bisect_right(ordered, va) - 1]
if fn in extent and va >= fn + extent[fn]:
return None
return fn
callers = collections.defaultdict(set)
for va, kind, target in edges:
if kind == "call" or target in starts: # a jmp to a function start is a tail call
src = enclosing(va)
if src is not None and src != target:
callers[target].add(src)
# (verb, event) -> {"shape", "via", "sites"}
table = {}
def add(verb, event, shape, via, site):
row = table.setdefault((verb, event), {"shape": shape, "via": via, "sites": set()})
if shape == "body": # a body fire outranks a helper fire for the same pair
row["shape"], row["via"] = "body", "-"
row["sites"].add(site)
unattributed = 0
for va, event in sites:
fn = enclosing(va)
if fn is None:
unattributed += 1
continue
if fn in verbs:
add(verbs[fn], event, "body", "-", va)
continue
cs = callers.get(fn, set())
if cs and len(cs) <= MAX_HELPER_CALLERS and all(c in verbs for c in cs):
for c in cs:
add(verbs[c], event, "helper", f"0x{fn:x}", va)
out = os.path.normpath(a.out)
with open(out, "w", encoding="utf-8") as fh:
fh.write(
"# The 1.12.1 client's FrameScript events fired from inside a Lua verb, keyed by the verb.\n"
"# Generated by scripts/gen-reference-verb-events.py from reference/1.12-shapes.tsv,\n"
"# reference/1.12-event-firesites.tsv and the maintainer's analysis (`WOW_RE`).\n"
"#\n"
"# shape body = a fire site inside the verb's own extent.\n"
"# helper = a fire site inside a function that only registered verbs call\n"
"# (at most 8 of them); `via` is that helper. A helper with any\n"
"# non-verb caller produces a state event and is not here.\n"
"# sites the fire sites themselves.\n"
"# A floor, not a census: a verb with no row may still fire an event.\n"
"# A site past its candidate function's extent is left out rather than mis-filed.\n"
f"# {len(table)} pairs over {len({v for v, _ in table})} verbs and"
f" {len({e for _, e in table})} events; {unattributed} site(s) past a verified extent.\n"
"verb\tevent\tshape\tvia\tsites\n"
)
for (verb, event), row in sorted(table.items()):
fh.write(
"\t".join(
(
verb,
event,
row["shape"],
row["via"],
"|".join(f"0x{s:x}" for s in sorted(row["sites"])),
)
)
+ "\n"
)
print(
f"wrote {out}: {len(table)} pairs, {len({v for v, _ in table})} verbs,"
f" {len({e for _, e in table})} events"
)
if __name__ == "__main__":
main()