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>
165 lines
7.7 KiB
Python
Executable File
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"event-catalog.tsv 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()
|