Files
benilla-octo/scripts/gen-reference-events.py
T
Sam SungandClaude Opus 5.5 7b66dac9db comments cite the reference by address, not by private note (#30)
Comments that pointed into the maintainer's private reverse-engineering notes (a note by name, a
path, a section or finding id, the analysis's own name for a client function, a process word) now
carry the fact itself, cited by the reference client's address, by vmangos file and line, or as
"the reference". Nobody outside could follow those pointers.

Comment text only, plus the test-assert and log strings that carried the same pointers. No claim
changes: a comment the notes have since corrected keeps its wording here, and correcting it is
separate work.

The two reference generators, `scripts/gen-reference-globals.py` and
`scripts/gen-reference-verb-events.py`, named the private notes' layout to find their inputs. They
now take the input files as arguments (`--capture`, `--framexml`, `--names`, `--extents`,
`--disasm`). Run on the same inputs, both write the committed tables row for row; only the two
header lines that named the old environment variable change.

Checked per file:
- the code is identical: each file with its comments stripped compares equal before and after; a
  changed string literal is compared under a mask that proves only literal text moved and every
  literal kept its line count;
- no client address the old text cited was dropped;
- a second reviewer checked every added address against its derivation;
- a detector for the notes' names, paths, ids, function names and process words finds none left;
- `scripts/gates.sh` is green.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 12:14:08 +07:00

165 lines
7.7 KiB
Python
Executable File

#!/usr/bin/env python3
"""Regenerate `reference/1.12-events.tsv`: each 1.12 FrameScript event and the arguments it carries.
scripts/gen-reference-events.py [--catalog FILE] [--firesites FILE] [--out FILE]
The inputs are vendored under `reference/`, so this runs from any clone and reproduces the
committed table byte for byte:
- `1.12-event-catalog.tsv` eventId -> name, off the name-pointer array at `.data 0xbe1198`
- `1.12-event-firesites.tsv` every `call` and tail-`jmp` to `FrameScript_SignalEvent`
(`0x703e50`, no varargs) and `SignalEvent2` (`0x703f50`,
printf-style), with the format string each site pushes
A literal fire site is not every producer; the families are:
signal a `0x703e50` site: `__fastcall(ecx = id)`, a plain `ret`, zero Lua values.
signal2 a `0x703f50` site: the pushed format string is the argument shape.
unit-field-bridge the generic UpdateField -> event bridge (`0x51bbb0` registers one watch per
named unit-window field; `0x51bd50` -> `0x515e50` fans out over the unit
tokens and calls `0x703f50(id, "%s", token)`). Every named event id below
`0xb6` is produced there and has no fire-site row.
token-fanout the same `0x515e50` fan-out reached with a literal id in `edx`: also `%s`,
also absent from the fire sites.
Confidence, and what a consumer may gate on:
exact every contributing producer's shape is known. Gate on this.
advisory a contributing site's format declares more varargs than its caller pushes
(`0x496230` TRADE_REPLACE_ENCHANT, `0x5e4527`/`0x5e7960` UPDATE_TICKET), which hands
Lua undefined values; do not copy it.
none no producer family reaches the name here, which does not mean it has none. Never
gate on it.
Glue-space sites are dropped by address: `0x703d90` loads a separate name table for the glue
screen, so the same id names a different event there. Their names are blank in the input, and
dropping by address keeps a name filled in later from leaking through.
"""
import argparse
import os
import sys
# The `Source\Glue\` fire-site ranges (`1.12-event-firesites.tsv`'s header): glue-space ids.
GLUE_SITES = [(0x46AA34, 0x46AA34), (0x46BC9D, 0x46C52E), (0x46E73D, 0x46E73D), (0x47461E, 0x47461E)]
# The three sites whose format string declares more varargs than the caller pushes.
DECLARES_MORE_THAN_IT_PUSHES = {0x496230: "TRADE_REPLACE_ENCHANT", 0x5E4527: "UPDATE_TICKET", 0x5E7960: "UPDATE_TICKET"}
# `0x515e50` callers with a literal id: the token fan-out reached outside the generic bridge.
TOKEN_FANOUT_IDS = [0x10, 0x16, 0x1C, 0x1D, 0x1E, 0x29, 0xB7, 0xB8, 0xB9, 0xBA, 0xBB, 0x159, 0x197, 0x20A]
# `0x51bbb0`'s walk bound (`cmp esi,0xb6`): every named unit-window field below it is watched.
UNIT_WINDOW_FIELDS = 0xB6
# The zero-argument format, spelled so the column is never empty.
NONE = "()"
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 ("eventId", "fire_site_va"):
continue
yield f
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--catalog", default=os.path.join(REFERENCE, "1.12-event-catalog.tsv"))
ap.add_argument("--firesites", default=os.path.join(REFERENCE, "1.12-event-firesites.tsv"))
ap.add_argument("--out", default=os.path.join(REFERENCE, "1.12-events.tsv"))
a = ap.parse_args()
catalog = {}
for f in rows(a.catalog):
if len(f) >= 2 and f[1]:
catalog.setdefault(int(f[0]), f[1])
if len(catalog) < 300:
sys.exit(f"{a.catalog} gave only {len(catalog)} names — wrong path?")
# name -> {ids, producers, formats, notes}
ev = {}
def add(name, eid, producer, fmt, note=None):
e = ev.setdefault(name, {"ids": set(), "producers": set(), "formats": set(), "notes": []})
e["ids"].add(eid)
e["producers"].add(producer)
e["formats"].add(fmt)
if note and note not in e["notes"]:
e["notes"].append(note)
dropped_glue = 0
for f in rows(a.firesites):
if len(f) < 4:
continue
va = int(f[0], 16)
dispatcher, eid, name = f[1], f[2], f[3]
if any(lo <= va <= hi for lo, hi in GLUE_SITES):
dropped_glue += 1
continue
if not name or eid == "dyn":
continue
fmt = NONE if dispatcher == "SignalEvent" else (f[4] if len(f) > 4 and f[4] else NONE)
note = None
if va in DECLARES_MORE_THAN_IT_PUSHES:
note = f"0x{va:x} declares more varargs than it pushes — do not copy"
add(name, int(eid), "signal" if dispatcher == "SignalEvent" else "signal2", fmt, note)
for eid, name in catalog.items():
if eid < UNIT_WINDOW_FIELDS:
add(name, eid, "unit-field-bridge", "%s")
for eid in TOKEN_FANOUT_IDS:
if eid in catalog:
add(catalog[eid], eid, "token-fanout", "%s")
# Every remaining catalog name too, so "no producer found" differs from "not an event".
for eid, name in catalog.items():
if name not in ev:
ev.setdefault(name, {"ids": set(), "producers": set(), "formats": set(), "notes": []})["ids"].add(eid)
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 and the arguments their producers push.\n"
"# Generated by scripts/gen-reference-events.py from reference/1.12-event-catalog.tsv\n"
"# and reference/1.12-event-firesites.tsv.\n"
"#\n"
"# arg_formats `|`-separated, one per distinct producer shape. `()` = zero Lua values.\n"
"# Directives: %s string, %d/%u number, %f number. An event with two\n"
"# producers of different shapes carries both; which one fires depends\n"
"# on the transition, not the name.\n"
"# conf exact = gate on it. advisory = a contributing site declares more\n"
"# varargs than it pushes. none = no producer family reaches it here,\n"
"# which does not mean it has none; never gate on it.\n"
"# producers signal (0x703e50, no varargs) | signal2 (0x703f50, format-driven) |\n"
"# unit-field-bridge (0x51bbb0/0x51bd50 -> 0x515e50, always %s) |\n"
"# token-fanout (0x515e50 with a literal id, also %s).\n"
f"# {len(ev)} events, {sum(1 for e in ev.values() if e['producers'])} with a known producer;"
f" {dropped_glue} glue-space fire sites dropped.\n"
"name\tids\tproducers\targ_formats\tconf\tnote\n"
)
for name in sorted(ev):
e = ev[name]
conf = "none" if not e["producers"] else ("advisory" if e["notes"] else "exact")
fh.write(
"\t".join(
(
name,
"|".join(str(i) for i in sorted(e["ids"])),
"|".join(sorted(e["producers"])),
"|".join(sorted(e["formats"])) or "?",
conf,
"; ".join(e["notes"]),
)
)
+ "\n"
)
print(f"wrote {out}: {len(ev)} events, {sum(1 for e in ev.values() if e['producers'])} produced")
if __name__ == "__main__":
main()