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>
191 lines
9.8 KiB
Python
Executable File
191 lines
9.8 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Regenerate `reference/1.12-events.tsv` — every FrameScript event the 1.12.1 client can
|
|
dispatch, with the ARGUMENTS its producers push.
|
|
|
|
scripts/gen-reference-events.py [--catalog FILE] [--firesites FILE] [--out FILE]
|
|
|
|
**Why this exists** (decision 2140). benilla already gates two halves of the event seam: a stock
|
|
chain file listening for an event nothing fires
|
|
(`reference_ui::every_event_a_chain_file_registers_has_a_producer`, 1889) and an event we fire that
|
|
1.12 does not have (`every_event_we_fire_is_an_event_the_reference_has`, 1883). Both compare
|
|
NAMES. Nothing compared the *arguments*, and an event name is a plain string at both ends — so a
|
|
fire with the wrong argument count is silent on both sides, exactly as a binding with the wrong
|
|
return arity was before 1842. It is the same class of bug one API over, and it had three live
|
|
instances: `PLAYERBANKSLOTS_CHANGED`, `UNIT_PET_EXPERIENCE` and `UNIT_PET_TRAINING_POINTS`, the
|
|
last of which routes through `PetPaperDollFrame_OnEvent`'s `elseif ( arg1 == "pet" )` catch-all and
|
|
so reached nobody at all.
|
|
|
|
**The oracle is wow-5875-re's own two censuses**, vendored under `reference/` the way
|
|
`1.12-shapes.tsv` is:
|
|
|
|
- `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
|
|
|
|
**Three producer families, because a literal fire site is not the whole picture.** The firesites
|
|
census is over the two signal helpers' own call sites, and its own header says in capitals that an
|
|
event with no row there is not an event with no producer:
|
|
|
|
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 event id below `0xb6`
|
|
that HAS a name is produced there and appears in no firesites row.
|
|
Cross-check: the 54 catalog ids under `0xb6` are exactly the id set
|
|
wow-re read out of the registration loop by hand
|
|
(`system/ui/scratch/unit-field-event-bridge.md` §2.1) — two independent
|
|
derivations, no difference.
|
|
token-fanout the same `0x515e50` fan-out reached with a LITERAL id in `edx` rather than a
|
|
computed one, so also `%s` and also absent from the firesites census. The
|
|
fourteen ids are wow-re's own list, in that file's header.
|
|
|
|
**Confidence, and what a consumer may gate on:**
|
|
|
|
exact every contributing producer's shape is known. Gate on this.
|
|
advisory a contributing site is one of the three whose pushed format DECLARES more varargs than
|
|
its caller pushes (`re/audit/signalevent2-fmt-args.py` audits all 149 and finds
|
|
exactly these): `0x496230 TRADE_REPLACE_ENCHANT`, `0x5e4527`/`0x5e7960 UPDATE_TICKET`.
|
|
Faithful clients must not copy a bug that hands Lua undefined values.
|
|
none the name is in the catalog and NO producer family reaches it here. **This is not a
|
|
claim that it has none** — see the census caveat above. Never gate on it.
|
|
|
|
Glue-space rows are dropped: `0x703d90` is called twice with different name populations, so the
|
|
same integer names a different event on the glue screen than in the world. wow-re blanks those
|
|
rows' names; this drops them by fire-site address as well, from the four TU ranges its header
|
|
names, so a future re-derivation that fills a name in cannot leak one through.
|
|
|
|
Both inputs are in the repo, so this runs from any clone and reproduces the committed table byte
|
|
for byte; only the two inputs themselves are regenerated in the RE repo.
|
|
"""
|
|
import argparse
|
|
import os
|
|
import sys
|
|
|
|
# The `Source\Glue\` TU ranges whose event ids belong to the GLUE name space, from
|
|
# `re/events/event-firesites.tsv`'s own header.
|
|
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 carrying 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, so the table is the whole surface and a consumer can tell
|
|
# "no producer found" 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 — scripts/gen-reference-events.py. Source: wow-5875-re's\n"
|
|
"# re/events/event-catalog.tsv + event-firesites.tsv. Decision 2140.\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, and which one a given\n"
|
|
"# transition takes is a question about 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 is not a claim that 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()
|