Integrate upstream through 746ad351 with the ruRU community pack
Keep MoonWell branding and deployment support on the stock glue screens and Rust Lua VM. Preserve locale overlays, Cyrillic names and fonts, localized spell costs and race/class labels through the new APIs. Verified on Windows: formatting and the Russian locale/name policy unit check in both VM kinds. Workspace clippy passed; complete real-data, no-install, player and engine gates are pending before landing.
This commit is contained in:
@@ -5,15 +5,15 @@
|
||||
# scripts/api-coverage.sh --missing # + every engine global we do not have
|
||||
# scripts/api-coverage.sh --beyond # + every global we have that 1.12 does not
|
||||
#
|
||||
# The 1.12 side is `reference/1.12-globals.tsv`, the reference client's in-world `_G`
|
||||
# (scripts/gen-reference-globals.py); ours is a real `UiScript::new()` dumped through `pairs(_G)`.
|
||||
# The 1.12 side is `reference/lua_globals.tsv`, the reference client's in-world `_G`; ours is a
|
||||
# real `UiScript::new()` dumped through `pairs(_G)`.
|
||||
# Never quote the percentage alone: a missing global is an unbuilt feature or a hole in a shipped
|
||||
# one, and a global beyond 1.12 sends a feature-detecting addon down a path we cannot honour.
|
||||
set -u
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
REF="reference/1.12-globals.tsv"
|
||||
[ -f "$REF" ] || { echo "no $REF — run scripts/gen-reference-globals.py" >&2; exit 1; }
|
||||
REF="reference/lua_globals.tsv"
|
||||
[ -f "$REF" ] || { echo "no $REF — run scripts/sync-reference.py" >&2; exit 1; }
|
||||
|
||||
show_missing=0
|
||||
show_beyond=0
|
||||
@@ -36,7 +36,7 @@ fi
|
||||
awk -F'\t' -v ref="$REF" -v show_missing="$show_missing" -v show_beyond="$show_beyond" '
|
||||
# ── the reference side ──────────────────────────────────────────────────────────────────────
|
||||
FNR == NR {
|
||||
if ($0 ~ /^#/) next
|
||||
if (FNR == 1) next # the header row
|
||||
origin[$1] = $3
|
||||
n_ref[$3]++
|
||||
if ($2 == "function") n_ref_fn[$3]++
|
||||
@@ -67,7 +67,7 @@ awk -F'\t' -v ref="$REF" -v show_missing="$show_missing" -v show_beyond="$show_b
|
||||
n_ref_fn["engine"], n_ref["engine"] - n_ref_fn["engine"]
|
||||
printf " framexml %5d functions, %5d other <- the stock FrameXML defines these\n", \
|
||||
n_ref_fn["framexml"], n_ref["framexml"] - n_ref_fn["framexml"]
|
||||
printf " lua %5d functions, %5d other <- mlua provides these\n", \
|
||||
printf " lua %5d functions, %5d other <- benilla-lua provides these\n", \
|
||||
n_ref_fn["lua"], n_ref["lua"] - n_ref_fn["lua"]
|
||||
|
||||
printf "\nbenilla'\''s VM — %d globals (UiScript::new(), asked at runtime)\n\n", n_ours
|
||||
|
||||
@@ -102,6 +102,8 @@ run() {
|
||||
local name="$1"; shift
|
||||
if ! "$@" >"$log" 2>&1; then
|
||||
echo "CHECK FAILED: $name"
|
||||
# The failing test or error first: a test gate's tail is the doc-test noise after it.
|
||||
grep -E 'panicked at|^---- .* ----$|^test .* FAILED$|^error(\[E[0-9]+\])?:' "$log" | head -40
|
||||
tail -30 "$log"
|
||||
exit 1
|
||||
fi
|
||||
@@ -110,6 +112,10 @@ run() {
|
||||
|
||||
run fmt cargo fmt --all -- --check
|
||||
run clippy cargo clippy $pflags --all-targets -- -D warnings
|
||||
# The player build (no `dev` feature) is linted on its own: the run above sees only the dev set.
|
||||
case " $pkgs " in *" benilla-app "* | *" benilla "*)
|
||||
run player-clippy cargo clippy -p benilla -p benilla-app --no-default-features -- -D warnings ;;
|
||||
esac
|
||||
# --no-fail-fast: one red test binary does not hide the failures in the ones after it.
|
||||
run test env BENILLA_SKIP_LOG="$skips" cargo test --no-fail-fast $pflags
|
||||
# Data-gated tests skip without the install or the addon corpus and libtest swallows their line,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fail on a doc link whose target is declared nowhere in the workspace.
|
||||
|
||||
It reads rustdoc's unresolved-link warnings and fails on a `crate::`, `super::`, `self::` or
|
||||
It runs `cargo doc` over the whole workspace (a full doc build, minutes on a cold tree) and reads
|
||||
rustdoc's unresolved-link warnings and fails on a `crate::`, `super::`, `self::` or
|
||||
`Self::` path whose leaf name is declared nowhere: the item is gone. Every other warning is a
|
||||
convention here, not rot (a private module named through its parent, another crate's module
|
||||
named with `crate::`, a bare name for an unimported bevy type).
|
||||
|
||||
+44
-7
@@ -1,12 +1,13 @@
|
||||
#!/usr/bin/env bash
|
||||
# The land gate chain: every gate in order, stopping at the first failure.
|
||||
#
|
||||
# fmt, clippy, test, test-no-install, doc-links, pass-span-lint, player-tests, enforcer,
|
||||
# enforcer-no-install. A failure prints the gate's log tail and exits nonzero; a green
|
||||
# run stamps the tree, which then re-greens without running. GATES_FORCE=1 runs the chain anyway,
|
||||
# fmt, clippy, reference, test, test-no-install, vm-parity, doc-links, message-catalog,
|
||||
# pass-span-lint, player-clippy, player-tests, enforcer, enforcer-no-install. A failure prints the
|
||||
# gate's log tail and exits nonzero; a green run stamps the tree, which then re-greens without
|
||||
# running. GATES_FORCE=1 runs the chain anyway,
|
||||
# WOW_DATA picks the install (set and empty: none) and TMPDIR holds the scratch files; the script
|
||||
# sets BENILLA_REQUIRE_DATA, BENILLA_ADDON_CORPUS, BENILLA_SKIP_LOG and WOW_WORLDVIEW_CHECK for
|
||||
# its gates.
|
||||
# its gates. BENILLA_RE names the benilla-re checkout the reference gate compares `reference/` with.
|
||||
# `.github/workflows/ci.yml` runs the gates that need neither the install nor a display on a fork's
|
||||
# pull request: a gate added here goes there too, if it can run on a bare Linux runner.
|
||||
#
|
||||
@@ -41,11 +42,12 @@ tree_key() {
|
||||
"$(shasum "$root/scripts/gates.sh" 2>/dev/null | cut -d' ' -f1)"
|
||||
}
|
||||
resolve_wow() { cargo run -q -p benilla-formats --example where 2>/dev/null || true; }
|
||||
# A delta only under docs/ or in top-level *.md files keeps the stamped verdict, since no gate
|
||||
# reads those paths; a gate that starts to read one must narrow this pattern.
|
||||
# A delta only under docs/, in top-level *.md files or in the issue and PR templates keeps the
|
||||
# stamped verdict, since no gate reads those paths; a gate that starts to read one must narrow
|
||||
# this pattern.
|
||||
docs_only_delta() { # $1 = stamped tree, $2 = current tree
|
||||
git cat-file -e "$1^{tree}" 2>/dev/null && git cat-file -e "$2^{tree}" 2>/dev/null || return 1
|
||||
[ -z "$(git diff-tree -r --name-only "$1" "$2" 2>/dev/null | grep -vE '^(docs/|[^/]*\.md$)' || true)" ]
|
||||
[ -z "$(git diff-tree -r --name-only "$1" "$2" 2>/dev/null | grep -vE '^(docs/|[^/]*\.md$|\.github/(ISSUE_TEMPLATE/|pull_request_template\.md$))' || true)" ]
|
||||
}
|
||||
key_start="$(tree_key || true)"
|
||||
if [ "${GATES_FORCE:-}" != "1" ] && [ -n "$key_start" ] && [ -f "$stamp" ]; then
|
||||
@@ -98,6 +100,8 @@ run() {
|
||||
if ! "$@" >"$log" 2>&1; then
|
||||
echo "GATE FAILED: $name ($((SECONDS - t0)) s)"
|
||||
note_timing "$name" "$((SECONDS - t0))" "FAILED"
|
||||
# The failing test or error first: a test gate's tail is the doc-test noise after it.
|
||||
grep -E 'panicked at|^---- .* ----$|^test .* FAILED$|^error(\[E[0-9]+\])?:' "$log" | head -40
|
||||
tail -30 "$log"
|
||||
exit 1
|
||||
fi
|
||||
@@ -120,6 +124,29 @@ else
|
||||
"corpus=$([ -d "$root/wow-addons-vanilla" ] && echo yes || echo no)):" \
|
||||
"data-gated tests skip silently on this machine"
|
||||
fi
|
||||
# reference: `reference/` is byte for byte benilla-re's tables at `reference/PIN`. It needs a
|
||||
# benilla-re checkout ($BENILLA_RE, else `../benilla-re` beside the repo, else beside the main
|
||||
# checkout when this is a linked worktree) and skips with a line where there is none, unless data
|
||||
# is required (BENILLA_REQUIRE_DATA, set above where the install and corpus are, or by the caller).
|
||||
re_checkout=""
|
||||
primary="$(cd "$(git rev-parse --git-common-dir 2>/dev/null)/.." 2>/dev/null && pwd || true)"
|
||||
if [ -n "${BENILLA_RE:-}" ] && [ ! -d "$BENILLA_RE/tables" ]; then
|
||||
echo "GATE FAILED: reference (BENILLA_RE=$BENILLA_RE holds no benilla-re checkout)"
|
||||
exit 1
|
||||
fi
|
||||
for c in "${BENILLA_RE:-}" "$root/../benilla-re" "${primary:+$primary/../benilla-re}"; do
|
||||
if [ -n "$c" ] && [ -d "$c/tables" ]; then re_checkout="$c"; break; fi
|
||||
done
|
||||
if [ -n "$re_checkout" ]; then
|
||||
run reference scripts/sync-reference.py "$re_checkout" --check
|
||||
elif [ -n "$require_data" ] || [ "${BENILLA_REQUIRE_DATA:-}" = "1" ]; then
|
||||
echo "GATE FAILED: reference (no benilla-re checkout: set BENILLA_RE, or clone it beside the repo)"
|
||||
exit 1
|
||||
else
|
||||
echo "gate SKIPPED: reference (no benilla-re checkout found: BENILLA_RE, ../benilla-re)"
|
||||
note_timing reference 0 SKIPPED
|
||||
fi
|
||||
|
||||
run test env ${require_data:+BENILLA_REQUIRE_DATA=1} BENILLA_SKIP_LOG="$skips" cargo test --no-fail-fast --workspace
|
||||
report_skips test
|
||||
|
||||
@@ -131,10 +158,20 @@ run test-no-install env -u BENILLA_REQUIRE_DATA WOW_DATA= cargo test --no-fail-f
|
||||
# declared nowhere in the workspace. Narrow on purpose: every other unresolved link is a convention.
|
||||
run doc-links scripts/doc-links.py
|
||||
|
||||
# message-catalog: `crates/benilla-ui/src/messages/catalog.rs` is what the vendored messages table
|
||||
# generates, byte for byte.
|
||||
run message-catalog scripts/gen-message-catalog.py --check
|
||||
|
||||
# pass-span-lint: refuses a second `pass_span` on a render pass whose span is still open, a fatal
|
||||
# wgpu validation error on Vulkan and a silent no-op on Metal and DX12, which no macOS run sees.
|
||||
run pass-span-lint scripts/pass-span-lint.py
|
||||
|
||||
# player-clippy: the player build's own lints, which no other gate sees. `clippy` runs the dev
|
||||
# feature set and player-tests compiles under `cfg(test)`, where test code uses imports and items
|
||||
# the shipped library does not; this lints the shipped library and the launcher, with no
|
||||
# `--all-targets`, so a warning confined to the player graph fails here.
|
||||
run player-clippy cargo clippy -p benilla -p benilla-app --no-default-features -- -D warnings
|
||||
|
||||
# player-tests: the player build, `benilla-app` without `dev`, which compiles out the debug panel,
|
||||
# perf HUD, inspector, capture harness and probes, so it fails when any other code names one of
|
||||
# them. Its test build compiles every line of the player graph but the `benilla` launcher, a shim
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
#!/usr/bin/env python3
|
||||
r"""Generate `crates/benilla-ui/src/messages/catalog.rs` from `reference/1.12-message-catalog.tsv`.
|
||||
r"""Generate `crates/benilla-ui/src/messages/catalog.rs` from `reference/messages.tsv`.
|
||||
|
||||
The table is the reference's message registry at `0xb4b498`, which `CGGameUI::DisplayError`
|
||||
`0x496720` indexes by message id; this re-shapes it into a Rust static that ships with benilla.
|
||||
|
||||
python3 scripts/gen-message-catalog.py # the vendored table -> the .rs, in place
|
||||
python3 scripts/gen-message-catalog.py --check # fail when the .rs is not what it generates
|
||||
python3 scripts/gen-message-catalog.py <tsv> # another table
|
||||
|
||||
It refuses rather than emits on a non-dense id range, a duplicate key or an unknown kind.
|
||||
@@ -13,7 +14,7 @@ import os
|
||||
import sys
|
||||
|
||||
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
TABLE = os.path.join(REPO, "reference", "1.12-message-catalog.tsv")
|
||||
TABLE = os.path.join(REPO, "reference", "messages.tsv")
|
||||
OUT = os.path.join(REPO, "crates", "benilla-ui", "src", "messages", "catalog.rs")
|
||||
|
||||
KINDS = {0: "Chat", 1: "Info", 2: "Error"}
|
||||
@@ -21,7 +22,7 @@ KINDS = {0: "Chat", 1: "Info", 2: "Error"}
|
||||
HEADER = """\
|
||||
//! The 1.12 client's message catalog, row for row.
|
||||
//!
|
||||
//! @generated by `scripts/gen-message-catalog.py` from `reference/1.12-message-catalog.tsv`.
|
||||
//! @generated by `scripts/gen-message-catalog.py` from `reference/messages.tsv`.
|
||||
//! Do not edit by hand; regenerate.
|
||||
|
||||
use super::{MessageRecord, MsgKind};
|
||||
@@ -35,24 +36,24 @@ pub static CATALOG: [MessageRecord; {count}] = [
|
||||
"""
|
||||
|
||||
|
||||
def main(path: str) -> int:
|
||||
def main(path: str, check: bool = False) -> int:
|
||||
if not os.path.exists(path):
|
||||
raise SystemExit(f"no table at {path}")
|
||||
rows = []
|
||||
for line in open(path):
|
||||
if line.startswith("#") or line.startswith("id_hex"):
|
||||
if line.startswith("id\t"):
|
||||
continue
|
||||
f = line.rstrip("\n").split("\t")
|
||||
if len(f) != 8:
|
||||
raise SystemExit(f"malformed row (expected 8 fields, got {len(f)}): {line!r}")
|
||||
if len(f) != 7:
|
||||
raise SystemExit(f"malformed row (expected 7 fields, got {len(f)}): {line!r}")
|
||||
rows.append(
|
||||
{
|
||||
"id": int(f[1]),
|
||||
"key": f[3],
|
||||
"kind": int(f[4]),
|
||||
"sound": f[5],
|
||||
"type_tag": int(f[6], 16),
|
||||
"chat_type": int(f[7]),
|
||||
"id": int(f[0]),
|
||||
"key": f[2],
|
||||
"kind": int(f[3]),
|
||||
"sound": f[4],
|
||||
"type_tag": int(f[5], 16),
|
||||
"chat_type": int(f[6]),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -77,13 +78,26 @@ def main(path: str) -> int:
|
||||
f'type_tag: {r["type_tag"]:#04x}, chat_type: {r["chat_type"]} }},\n'
|
||||
)
|
||||
out.append("];\n")
|
||||
text = "".join(out)
|
||||
if check:
|
||||
have = open(OUT).read() if os.path.exists(OUT) else None
|
||||
if have != text:
|
||||
raise SystemExit(
|
||||
f"{os.path.relpath(OUT, REPO)} is not what {os.path.relpath(path, REPO)} "
|
||||
"generates; run scripts/gen-message-catalog.py"
|
||||
)
|
||||
print(f"{os.path.relpath(OUT, REPO)} matches its table ({len(rows)} rows)")
|
||||
return 0
|
||||
with open(OUT, "w") as f:
|
||||
f.write("".join(out))
|
||||
f.write(text)
|
||||
print(f"wrote {os.path.relpath(OUT, REPO)} ({len(rows)} rows)")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) > 2:
|
||||
args = sys.argv[1:]
|
||||
check = "--check" in args
|
||||
args = [a for a in args if a != "--check"]
|
||||
if len(args) > 1 or (check and args):
|
||||
raise SystemExit(__doc__)
|
||||
sys.exit(main(sys.argv[1] if len(sys.argv) == 2 else TABLE))
|
||||
sys.exit(main(args[0] if args else TABLE, check))
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
#!/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()
|
||||
@@ -1,281 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Regenerate `reference/1.12-globals.tsv`: the 1.12.1 client's global namespace, by origin.
|
||||
|
||||
scripts/gen-reference-globals.py --capture FILE --framexml DIR [--data DIR] [--drop NAME]...
|
||||
[--out FILE]
|
||||
|
||||
Each name in a capture of the running reference client's in-world `_G` gets one origin:
|
||||
|
||||
lua Lua 5.0's own runtime, which mlua provides.
|
||||
engine the C client provides it; benilla implements these in Rust.
|
||||
framexml the shipped UI defines it (its Lua, its XML, or a `$parent`-composed child of one);
|
||||
it runs off the player's own patch chain, never hardcoded in Rust.
|
||||
|
||||
Attribution is by definition site in the complete 1.12 shipped-UI corpus:
|
||||
|
||||
- a name assigned or `function`-declared in shipped Lua, including Lua inside a FrameXML
|
||||
document, is FrameXML's; indented assignments count, because FrameXML leaks globals out of
|
||||
function bodies (`button = getglobal(...)`);
|
||||
- a `name="..."` on any shipped XML element is FrameXML's, `virtual="true"` included: a virtual
|
||||
`<Font>` is a real font object, and 1.12 registers named virtual frames too;
|
||||
- `<a shipped name><a $parent suffix>` is a composed child (`ContainerFrame1Item16IconTexture`);
|
||||
- LUA_5_0 wins over all of these: FrameXML's `string = getglobal(...)` would otherwise claim
|
||||
the stdlib table.
|
||||
|
||||
The capture (`--capture`, with `--drop` naming the capturing addon's own globals) and the
|
||||
extracted FrameXML (`--framexml`) are the maintainer's, not in this repo; the MPQs are the
|
||||
install's (`--data`, else `$WOW_DATA`, else `WoW/Data`). FrameXML alone is not the shipped UI:
|
||||
the twelve `Blizzard_*` addons live in the MPQs, and their `.lua` is reached through
|
||||
`<Script file=>` in their XML, not their `.toc`. A complete corpus is 233 files; the count is
|
||||
printed.
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
# The twelve addons shipped inside the MPQs; an install's folder for each holds only a `.pub`.
|
||||
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 globals as the 1.12 client exposes them: it removes `_G`, `print`, `require`,
|
||||
# `dofile`, `loadfile` and the `io`/`os`/`debug`/`coroutine` tables.
|
||||
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",
|
||||
}
|
||||
|
||||
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 inside a FrameXML document: an inline `<Script>` body (Fonts.xml's CHAT_FONT_HEIGHTS) and
|
||||
# every `<Scripts>` handler element (`<OnLoad>`, `<PreClick>`, ...), matched by shape so no handler
|
||||
# name is missed. `<Script file=>` is skipped: its file is 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, as `loader::join_ref` does."""
|
||||
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(data, framexml):
|
||||
"""Every file of the 1.12 shipped UI, as {corpus-relative path: text}."""
|
||||
archives = [os.path.join(data, f"{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 = 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 addons' `.lua` is reached only from their XML, never from the `.toc`.
|
||||
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, not a global.
|
||||
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("--capture", help="a capture of the running client's in-world _G")
|
||||
ap.add_argument("--framexml", help="the shipped FrameXML, extracted to a folder")
|
||||
ap.add_argument("--data", help="the install's Data folder, which holds the MPQs",
|
||||
default=os.environ.get("WOW_DATA") or os.path.join(REPO, "WoW", "Data"))
|
||||
# The capturing addon's own globals (its holder frame, SavedVariables and slash token) are the
|
||||
# instrument, not the client. `__framescript_meta` stays in: it is the client's own shared
|
||||
# frame metatable.
|
||||
ap.add_argument("--drop", action="append", default=[], help="a global of the capturing addon")
|
||||
ap.add_argument("--out", default=os.path.join(REPO, "reference", "1.12-globals.tsv"))
|
||||
args = ap.parse_args()
|
||||
if not args.capture or not args.framexml:
|
||||
sys.exit(
|
||||
"this table is derived from a runtime capture of the reference client's _G and the "
|
||||
"extracted FrameXML, which are not in this repo: pass --capture and --framexml. The "
|
||||
"committed reference/1.12-globals.tsv is the surface benilla tracks."
|
||||
)
|
||||
if not os.path.exists(args.capture):
|
||||
sys.exit(f"no capture at {args.capture}")
|
||||
|
||||
print("building the 1.12 shipped-UI corpus...")
|
||||
corpus = build_corpus(args.data, args.framexml)
|
||||
print(f" TOTAL: {len(corpus)} files" + ("" if len(corpus) == 233 else " ** expected 233 **"))
|
||||
|
||||
recs = [(n, t) for n, t in captured_globals(args.capture) if n not in set(args.drop)]
|
||||
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 names ─────────────────────────────────────────────────────────────────
|
||||
# The twelve `Blizzard_*` addons are LoadOnDemand, so a live capture holds their globals only
|
||||
# if their windows were opened first. Every name the shipped UI defines that the capture lacks
|
||||
# joins with type `lod`: the table answers "is this a 1.12 name?", and the halves stay apart.
|
||||
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 by scripts/gen-reference-globals.py.\n")
|
||||
f.write("# Source: a capture of the running client's in-world _G (the maintainer's),\n")
|
||||
f.write("# attributed against the complete 1.12 shipped-UI corpus.\n")
|
||||
f.write("# Also every name that corpus defines but the capture lacks, as type `lod`: the\n")
|
||||
f.write("# twelve Blizzard_* addons are LoadOnDemand, so a live dump misses them unless\n")
|
||||
f.write("# their windows were opened.\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()
|
||||
@@ -1,229 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Regenerate `reference/1.12-verb-events.tsv`: the events 1.12 Lua verbs fire, keyed by verb.
|
||||
|
||||
scripts/gen-reference-verb-events.py --names FILE --extents FILE... --disasm FILE [--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
|
||||
(`--names`), extents (`--extents`, the `size=` on each `fn` row) and disassembly (`--disasm`) are
|
||||
the maintainer's analysis, not in this repo, so this is a manual regeneration.
|
||||
"""
|
||||
import argparse
|
||||
import bisect
|
||||
import collections
|
||||
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("--names", help="the function names: `addr<TAB>kind` rows, `func` a start")
|
||||
ap.add_argument("--extents", nargs="+", default=[], help="tables whose `fn` rows carry size=")
|
||||
ap.add_argument("--disasm", help="the client's disassembly, one objdump line per instruction")
|
||||
ap.add_argument("--out", default=os.path.join(REFERENCE, "1.12-verb-events.tsv"))
|
||||
a = ap.parse_args()
|
||||
if not (a.names and a.extents and a.disasm):
|
||||
sys.exit(
|
||||
"this table is derived from the maintainer's function names, extents and disassembly, "
|
||||
"which are not in this repo: pass --names, --extents and --disasm. The committed "
|
||||
"reference/1.12-verb-events.tsv is the surface benilla tracks."
|
||||
)
|
||||
|
||||
starts = set()
|
||||
for f in rows(a.names):
|
||||
if len(f) >= 2 and f[1] == "func":
|
||||
starts.add(int(f[0], 16))
|
||||
if len(starts) < 1000:
|
||||
sys.exit(f"{a.names} gave only {len(starts)} functions — wrong file?")
|
||||
|
||||
# Function extents: `size=` on the `fn` rows of the extent tables.
|
||||
extent = {} # fn start -> size in bytes
|
||||
for path in a.extents:
|
||||
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 extent tables gave only {len(extent)} sized functions — wrong files?")
|
||||
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"1.12-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"1.12-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 = a.disasm
|
||||
if not os.path.exists(disasm):
|
||||
sys.exit(f"no disassembly at {disasm}")
|
||||
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} gave only {n_lines} lines — wrong file?")
|
||||
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.\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()
|
||||
+305
-3
@@ -1,9 +1,11 @@
|
||||
#!/usr/bin/env bash
|
||||
# Generate docs/MAP.md, the map of what is built, from what is on disk.
|
||||
# Generate docs/MAP.md, the map of what is built, and docs/DEVIATIONS.md, where benilla differs from
|
||||
# 1.12.1, both from what is on disk.
|
||||
#
|
||||
# Regenerated and committed at every land; run it by hand only to look, never edit the output. No
|
||||
# timestamps, so an identical tree gives an identical map. No `pipefail`/`-e`: a grep that finds
|
||||
# nothing (a single-file lib, a crate with no bins) must not abort the generator.
|
||||
# timestamps, so an identical tree gives an identical file. No `pipefail`/`-e`: a grep that finds
|
||||
# nothing (a single-file lib, a crate with no bins) must not abort the generator. Bash and POSIX
|
||||
# awk only (macOS's awk is the floor): no gawk extensions, no new dependencies.
|
||||
set -u
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
@@ -123,3 +125,303 @@ field() { grep -m1 "^$2" "$1" 2>/dev/null | sed -E "s/^$2[[:space:]]*=[[:space:]
|
||||
} > docs/MAP.md
|
||||
|
||||
echo "genmap: wrote docs/MAP.md"
|
||||
|
||||
# ── docs/DEVIATIONS.md ─────────────────────────────────────────────────────────────────────────────
|
||||
# Every place benilla writes down that it differs from 1.12.1: the `Deviates` rows of the CVar table,
|
||||
# and each `Deviation:` comment in the crates (Rust and WGSL `//`, Lua `--`, XML `<!-- -->`). A
|
||||
# deviation's wording is read from where it lives, so the list cannot drift from the code.
|
||||
tmp=$(mktemp -d "${TMPDIR:-/tmp}/genmap.XXXXXX") || exit 1
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
|
||||
# Test code is left out: a file under `tests/`, named `tests.rs` or `*_tests.rs`, or declared by a
|
||||
# `#[cfg(test)] mod x;` (listed here, resolved the way rustc does, `#[path]` included), and a
|
||||
# `#[cfg(test)] mod x { … }` body (skipped in the extractor below, which closes it at the first `}`
|
||||
# in the module's own column; rustfmt keeps it there).
|
||||
grep -rlE --include='*.rs' 'cfg\(test\)' crates 2>/dev/null | LC_ALL=C sort | xargs awk '
|
||||
FNR == 1 { pend = 0; path = ""; base = FILENAME; sub(/[^\/]*$/, "", base)
|
||||
stem = FILENAME; sub(/.*\//, "", stem); sub(/\.rs$/, "", stem)
|
||||
own = (stem == "mod" || stem == "lib" || stem == "main") ? base : base stem "/" }
|
||||
{ t = $0; sub(/^[ \t]+/, "", t) }
|
||||
t == "#[cfg(test)]" { pend = 1; path = ""; next }
|
||||
pend && t ~ /^#\[path = "/ { path = t; sub(/^#\[path = "/, "", path); sub(/".*/, "", path); next }
|
||||
pend && t ~ /^#\[/ { next }
|
||||
pend {
|
||||
pend = 0
|
||||
if (t ~ /^(pub(\([a-z]+\))? )?mod [A-Za-z_0-9]+;/) {
|
||||
name = t; sub(/^(pub(\([a-z]+\))? )?mod /, "", name); sub(/;.*/, "", name)
|
||||
if (path != "") print base path
|
||||
else { print own name ".rs"; print own name "/mod.rs" }
|
||||
}
|
||||
}' > "$tmp/testmods" 2>/dev/null
|
||||
|
||||
# Stage 1: one TSV row per `Deviation:` comment: crate, module, path in the crate, line, sentence.
|
||||
LC_ALL=C grep -rIlE 'Deviation:' crates 2>/dev/null | LC_ALL=C sort | grep -vE '/tests/|(^|/)tests\.rs$|_tests\.rs$' \
|
||||
| xargs env LC_ALL=C awk -v list="$tmp/testmods" '
|
||||
function indent_of(s, t) { t = s; sub(/[^ \t].*$/, "", t); return length(t) }
|
||||
function trim(s) { sub(/^[ \t]+/, "", s); sub(/[ \t]+$/, "", s); return s }
|
||||
# The first sentence of a joined comment: to the first `.`/`?`/`!` that is followed by an
|
||||
# uppercase word or the end, outside backticks and parentheses, and not an abbreviation.
|
||||
function first_sentence(t, len, k, ch, tick, par, nx, rest, pre) {
|
||||
len = length(t); tick = 0; par = 0
|
||||
for (k = 1; k <= len; k++) {
|
||||
ch = substr(t, k, 1)
|
||||
if (ch == "`") { tick = !tick; continue }
|
||||
if (tick) continue
|
||||
if (ch == "(") par++
|
||||
else if (ch == ")") { if (par > 0) par-- }
|
||||
else if ((ch == "." || ch == "?" || ch == "!") && par == 0) {
|
||||
nx = substr(t, k + 1, 1)
|
||||
if (nx == "") return substr(t, 1, k)
|
||||
if (nx != " ") continue
|
||||
rest = substr(t, k + 2); sub(/^ +/, "", rest)
|
||||
if (rest != "" && substr(rest, 1, 1) !~ /[A-Z]/) continue
|
||||
pre = tolower(substr(t, 1, k - 1))
|
||||
if (pre ~ /(^|[^a-z])(vs|cf|ca)$/ || pre ~ /(e\.g|i\.e)$/) continue
|
||||
return substr(t, 1, k)
|
||||
}
|
||||
}
|
||||
return t
|
||||
}
|
||||
# The sentence, and the next one too while it is a fragment (`min(I, 1)`, `returns nothing`).
|
||||
function lead(t, s, rest) {
|
||||
s = first_sentence(t)
|
||||
while (length(s) < 70 && length(s) < length(t)) {
|
||||
rest = substr(t, length(s) + 1); sub(/^ +/, "", rest)
|
||||
s = s " " first_sentence(rest)
|
||||
}
|
||||
return s
|
||||
}
|
||||
# Where a `Deviation:` label starts in a comment, 0 if none: one inside backticks is a mention.
|
||||
function label_at(c, at, off, rest) {
|
||||
off = 0; rest = c
|
||||
while ((at = index(rest, "Deviation:")) > 0) {
|
||||
if (substr(rest, at - 1, 1) != "`" || at == 1) return off + at
|
||||
off += at + 9; rest = substr(rest, at + 10)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
function module_of(r, rest, m) {
|
||||
if (r ~ /^src\//) {
|
||||
rest = substr(r, 5)
|
||||
if (index(rest, "/")) { sub(/\/.*/, "", rest); return rest }
|
||||
sub(/\.[^.]*$/, "", rest)
|
||||
return (rest == "lib" || rest == "main") ? "(crate root)" : rest
|
||||
}
|
||||
if (r ~ /^assets\//) { rest = substr(r, 8); m = "assets"; if (index(rest, "/")) { sub(/\/.*/, "", rest); m = m "/" rest } return m }
|
||||
sub(/\/.*/, "", r); return r
|
||||
}
|
||||
function process(f, n, i, j, s, t, ext, xin, skipping, tindent, pend, p, q, c, kind, code, marker, text, lab, cr, rel, first, cont) {
|
||||
n = 0; delete L; delete K; delete C; delete Q; delete XI; delete T
|
||||
while ((getline s < f) > 0) L[++n] = s
|
||||
close(f)
|
||||
ext = f; sub(/.*\./, "", ext)
|
||||
if (ext != "rs" && ext != "wgsl" && ext != "xml" && ext != "lua") return
|
||||
cr = f; sub(/^crates\//, "", cr); sub(/\/.*/, "", cr)
|
||||
rel = f; sub(/^crates\/[^\/]*\//, "", rel)
|
||||
xin = 0; skipping = 0; pend = 0
|
||||
for (i = 1; i <= n; i++) {
|
||||
s = L[i]; t = s; sub(/^[ \t]+/, "", t)
|
||||
T[i] = 0
|
||||
if (ext == "rs") {
|
||||
if (skipping) { T[i] = 1; if (indent_of(s) == tindent && t ~ /^}/) skipping = 0 }
|
||||
else if (t == "#[cfg(test)]") pend = 1
|
||||
else if (pend) {
|
||||
if (t !~ /^#\[/) {
|
||||
pend = 0
|
||||
if (t ~ /^(pub(\([a-z]+\))? )?mod [A-Za-z_0-9]+ *\{[ \t]*$/) { skipping = 1; tindent = indent_of(s); T[i] = 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
XI[i] = xin; K[i] = ""; C[i] = ""; Q[i] = 0
|
||||
if (xin) {
|
||||
K[i] = "x"; c = s
|
||||
if ((q = index(c, "-->")) > 0) { c = substr(c, 1, q - 1); xin = 0 }
|
||||
C[i] = trim(c)
|
||||
} else if (ext == "xml" || ext == "lua") {
|
||||
p = index(s, "<!--")
|
||||
if (p > 0 && ext == "xml") {
|
||||
K[i] = "x"; c = substr(s, p + 4); Q[i] = (trim(substr(s, 1, p - 1)) != "")
|
||||
if ((q = index(c, "-->")) > 0) c = substr(c, 1, q - 1); else xin = 1
|
||||
C[i] = trim(c)
|
||||
} else if ((p = index(s, "--")) > 0) {
|
||||
K[i] = "--"; c = substr(s, p + 2); sub(/^-+/, "", c); Q[i] = (trim(substr(s, 1, p - 1)) != "")
|
||||
C[i] = trim(c)
|
||||
}
|
||||
} else {
|
||||
p = index(s, "//")
|
||||
if (p > 0) {
|
||||
c = substr(s, p + 2); marker = "//"
|
||||
if (substr(c, 1, 1) == "/") { c = substr(c, 2); marker = "///" }
|
||||
else if (substr(c, 1, 1) == "!") { c = substr(c, 2); marker = "//!" }
|
||||
K[i] = marker; Q[i] = (trim(substr(s, 1, p - 1)) != "")
|
||||
C[i] = trim(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
for (i = 1; i <= n; i++) {
|
||||
if (K[i] == "" || (lab = label_at(C[i])) == 0) continue
|
||||
text = trim(substr(C[i], lab + 10))
|
||||
# Continue over the comment lines that follow, to its paragraph end: a blank comment line,
|
||||
# a list item, a code fence or the next `Deviation:` ends it.
|
||||
for (j = i + 1; j <= n; j++) {
|
||||
if (K[i] == "x") { if (!XI[j] || K[j] != "x") break }
|
||||
else if (K[j] != K[i] || Q[j]) break
|
||||
cont = C[j]
|
||||
if (cont == "" || cont ~ /^([-*] |```)/ || label_at(cont)) break
|
||||
text = text " " cont
|
||||
}
|
||||
text = trim(lead(trim(text)))
|
||||
gsub(/\t/, " ", text)
|
||||
# A rustdoc link [`x`] or [`x`](path) reads as `x`.
|
||||
while (match(text, /\[`[^`]*`\]\([^)]*\)/)) { q = index(substr(text, RSTART), "]("); text = substr(text, 1, RSTART - 1) substr(text, RSTART + 1, q - 2) substr(text, RSTART + RLENGTH) }
|
||||
gsub(/\[`/, "`", text); gsub(/`\]/, "`", text)
|
||||
# A plain lowercase first word starts a sentence; an identifier (`pfUI`, `a.b`) is left alone.
|
||||
if (match(text, /^[a-z]+/) && (substr(text, RLENGTH + 1, 1) == "" || substr(text, RLENGTH + 1, 1) ~ /^[ ;,-]$/)) text = toupper(substr(text, 1, 1)) substr(text, 2)
|
||||
sub(/[.;,:]+$/, "", text)
|
||||
if (text == "") text = "(see the comment)"
|
||||
if (T[i] || skipfile) { skipped++; continue }
|
||||
printf "%s\t%s\t%s\t%d\t%s\n", cr, module_of(rel), rel, i, text
|
||||
}
|
||||
}
|
||||
BEGIN {
|
||||
while ((getline s < list) > 0) tests[s] = 1
|
||||
close(list)
|
||||
for (a = 1; a < ARGC; a++) { skipfile = (ARGV[a] in tests); process(ARGV[a]) }
|
||||
if (skipped) print "genmap: " skipped " deviation comment(s) in test code left out" > "/dev/stderr"
|
||||
exit
|
||||
}' > "$tmp/code.tsv"
|
||||
LC_ALL=C sort -t "$(printf '\t')" -k1,1 -k2,2 -k3,3 -k4,4n "$tmp/code.tsv" > "$tmp/code.sorted"
|
||||
|
||||
# Stage 2: the `Deviates` rows of the CVar table. The whole file is scanned as a token stream
|
||||
# (comments, strings and char literals skipped), so a call may sit on one line or twelve. A literal
|
||||
# is read with Rust's `\`-newline continuation and `\"`/`\\` escapes; a constant argument
|
||||
# (`crate::realmlist::CVAR_REALMLIST`) is resolved to its `const NAME: &str = "…";` in the crates.
|
||||
# `cvars::tests::the_deviates_rows_are_what_the_generator_reads` pins this reading to the table.
|
||||
LC_ALL=C awk '
|
||||
{ src = src $0 "\n" }
|
||||
function resolve(e, id, cmd, line) {
|
||||
id = e; sub(/.*::/, "", id)
|
||||
if (id !~ /^[A-Za-z_][A-Za-z_0-9]*$/) return "`" e "`"
|
||||
cmd = "grep -rhE --include=\"*.rs\" \"const " id ": &str = \\\"\" crates 2>/dev/null"
|
||||
cmd | getline line; close(cmd)
|
||||
if (line ~ /= "/) { sub(/^[^"]*"/, "", line); sub(/";.*$/, "", line); return line }
|
||||
return "`" e "`"
|
||||
}
|
||||
function str_at(i, out, c, d) { # i is on the opening quote; returns the decoded text, sets endi
|
||||
out = ""; i++
|
||||
while (i <= N) {
|
||||
c = substr(src, i, 1)
|
||||
if (c == "\"") { endi = i; return out }
|
||||
if (c == "\\") {
|
||||
d = substr(src, i + 1, 1)
|
||||
if (d == "\n") { i += 2; while (substr(src, i, 1) ~ /[ \t\n]/) i++; continue }
|
||||
if (d == "\"" || d == "\\" || d == "'"'"'") { out = out d; i += 2; continue }
|
||||
if (d == "n") { out = out " "; i += 2; continue }
|
||||
}
|
||||
out = out c; i++
|
||||
}
|
||||
endi = N; return out
|
||||
}
|
||||
function skip_noise(i, c, d) { # returns the index past a comment, or i itself
|
||||
c = substr(src, i, 1); d = substr(src, i + 1, 1)
|
||||
if (c == "/" && d == "/") { while (i <= N && substr(src, i, 1) != "\n") i++; return i }
|
||||
if (c == "/" && d == "*") { i += 2; while (i <= N && substr(src, i, 2) != "*/") i++; return i + 2 }
|
||||
return i
|
||||
}
|
||||
function call(i, depth, c, k, arg, a, isstr, lit, expr, j) {
|
||||
# i is just past the opening paren
|
||||
depth = 1; k = 1; expr = ""; lit = 0; delete A
|
||||
while (i <= N && depth > 0) {
|
||||
j = skip_noise(i); if (j != i) { i = j; continue }
|
||||
c = substr(src, i, 1)
|
||||
if (c == "\"") { A[k] = str_at(i); lit = 1; i = endi + 1; isl[k] = 1; continue }
|
||||
if (c == "(" || c == "[" || c == "{") depth++
|
||||
if (c == ")" || c == "]" || c == "}") { depth--; if (depth == 0) break }
|
||||
if (c == "," && depth == 1) { k++; i++; continue }
|
||||
if (c !~ /[ \t\n]/) { if (!isl[k]) A[k] = A[k] c }
|
||||
i++
|
||||
}
|
||||
for (a = 1; a <= 4; a++) {
|
||||
arg = A[a]
|
||||
if (!isl[a]) arg = resolve(arg)
|
||||
gsub(/[\t\n]/, " ", arg)
|
||||
printf "%s%s", arg, (a < 4 ? "\t" : "\n")
|
||||
}
|
||||
delete isl
|
||||
return i
|
||||
}
|
||||
END {
|
||||
N = length(src); i = 1; prev = ""
|
||||
while (i <= N) {
|
||||
j = skip_noise(i); if (j != i) { i = j; continue }
|
||||
c = substr(src, i, 1)
|
||||
if (c == "\"") { str_at(i); i = endi + 1; continue }
|
||||
if (c == "'"'"'") {
|
||||
if (substr(src, i + 2, 1) == "'"'"'") i += 3
|
||||
else if (substr(src, i + 1, 1) == "\\") { i += 2; while (i <= N && substr(src, i, 1) != "'"'"'") i++; i++ }
|
||||
else i++
|
||||
continue
|
||||
}
|
||||
if (c ~ /[A-Za-z_]/) {
|
||||
id = c; i++
|
||||
while (i <= N && substr(src, i, 1) ~ /[A-Za-z_0-9]/) { id = id substr(src, i, 1); i++ }
|
||||
if (id == "deviates" && prev != "fn" && substr(src, i, 1) == "(") i = call(i + 1)
|
||||
prev = id; continue
|
||||
}
|
||||
i++
|
||||
}
|
||||
}' crates/benilla-app/src/cvars/table.rs 2>/dev/null \
|
||||
| LC_ALL=C sort -f -t "$(printf '\t')" -k1,1 > "$tmp/cvars.tsv"
|
||||
|
||||
# Markdown-escape outside code spans (an odd backtick count means no spans: every backtick escaped).
|
||||
MDESC='
|
||||
function mdesc(t, cell, out, k, ch, tick, n) {
|
||||
n = gsub(/`/, "`", t); out = ""; tick = 0
|
||||
for (k = 1; k <= length(t); k++) {
|
||||
ch = substr(t, k, 1)
|
||||
if (ch == "`") { if (n % 2) out = out "\\`"; else { tick = !tick; out = out ch } continue }
|
||||
if (tick) { out = out ((cell && ch == "|") ? "\\|" : ch); continue }
|
||||
if (ch ~ /[\\*_<\[\]&]/ || (cell && ch == "|")) out = out "\\"
|
||||
out = out ch
|
||||
}
|
||||
return out
|
||||
}'
|
||||
|
||||
{
|
||||
echo "# benilla — generated deviations"
|
||||
echo
|
||||
echo "> **GENERATED** by \`scripts/genmap.sh\` from the code — do not edit by hand; rerun the script"
|
||||
echo "> when a deviation is added or reworded. No timestamp — identical code yields an identical file."
|
||||
echo
|
||||
echo "benilla is the 1.12.1 client reimplemented: what the reference does is what benilla does. Where it"
|
||||
echo "differs on purpose, that is a deviation, the maintainer's call alone (\`docs/METHOD.md\`), and it is"
|
||||
echo "written down where it lives: a setting's \`Deviates\` row in the CVar table, or a \`Deviation:\` comment"
|
||||
echo "at the code, the Lua or the XML that differs. This file gathers them all, regenerated at every land,"
|
||||
echo "so the list is read from the code and cannot drift from it. Test code is left out, and so are the"
|
||||
echo "CVars that are benilla's own knobs with no 1.12.1 counterpart (\`ours\` rows)."
|
||||
echo
|
||||
echo "## Settings that ship off the reference default"
|
||||
echo
|
||||
echo "A setting boots at the reference's default unless it is listed here (\`docs/METHOD.md\`)."
|
||||
echo
|
||||
echo "| CVar | 1.12.1 | benilla | Why |"
|
||||
echo "| --- | --- | --- | --- |"
|
||||
LC_ALL=C awk -F '\t' "$MDESC"'
|
||||
function code(v) { return "`" (v == "" ? "\"\"" : v) "`" }
|
||||
{ printf "| %s | %s | %s | %s |\n", code($1), code($3), code($2), mdesc($4, 1) }' "$tmp/cvars.tsv"
|
||||
echo
|
||||
echo "## Deviations in the code"
|
||||
echo
|
||||
echo "Each entry is the first sentence of a \`Deviation:\` comment, linking to where it lives."
|
||||
echo
|
||||
LC_ALL=C awk -F '\t' '{ c[$1]++; if (!($1 in seen)) { seen[$1] = 1; order[++n] = $1 } }
|
||||
END { for (i = 1; i <= n; i++) printf "- [%s](#%s): %d\n", order[i], order[i], c[order[i]] }' "$tmp/code.sorted"
|
||||
echo
|
||||
LC_ALL=C awk -F '\t' "$MDESC"'
|
||||
$1 != crate { crate = $1; module = ""; printf "### %s\n\n", crate }
|
||||
$2 != module { if (module != "") print ""; module = $2; printf "#### %s\n\n", module }
|
||||
{ printf "- %s. ([`%s:%d`](../crates/%s/%s#L%d))\n", mdesc($5), $3, $4, $1, $3, $4 }
|
||||
END { print "" }' "$tmp/code.sorted"
|
||||
} > docs/DEVIATIONS.md
|
||||
|
||||
echo "genmap: wrote docs/DEVIATIONS.md ($(wc -l < "$tmp/code.sorted" | tr -d ' ') code entries, $(wc -l < "$tmp/cvars.tsv" | tr -d ' ') CVar rows)"
|
||||
|
||||
+21
-8
@@ -101,6 +101,15 @@ printf '%s' "$plain" | grep -q "logout-smoke: empty roster" &&
|
||||
fail "$user has no characters — the re-entry leg cannot run (make one, or rig it: WOW_RIG=…)"
|
||||
printf '%s' "$plain" | grep -q "logout-smoke: done" ||
|
||||
fail "the round trip never completed (no 'logout-smoke: done')"
|
||||
# The logout returns to character select on the link the world was entered by and re-lists there
|
||||
# (`0x5aaeb0` → `0x46b880`): a roster on that world session, and one realmd logon for the walk.
|
||||
printf '%s' "$plain" | grep -q "logout-smoke: FAILED" &&
|
||||
fail "$(printf '%s\n' "$plain" | sed -n 's/.*logout-smoke: FAILED — //p' | tail -1)"
|
||||
printf '%s' "$plain" | grep -q "logout-smoke: roster after the logout on the same link" ||
|
||||
fail "no roster came on the same link after the logout"
|
||||
logons="$(printf '%s\n' "$plain" | grep -cF "net: parked at the realm list")"
|
||||
[ "$logons" -eq 1 ] ||
|
||||
fail "$logons realmd logons in the round trip, expected 1 — the logout logged on again"
|
||||
|
||||
# The re-entered character must be drivable: the leg lists what would block movement (a /logout
|
||||
# root must end with its session), and anything but `none` fails.
|
||||
@@ -127,20 +136,24 @@ panics="$(printf '%s\n' "$plain" | grep -cE 'panicked at')"
|
||||
|
||||
# A marker counts once per edge it fires on. Each world entry builds a world VM beside the
|
||||
# character screen's glue VM (the reference's `0x490bd0`/`0x48fbf0` pair), so the VM marker counts
|
||||
# twice per login; half that means an entry adopted the glue VM and its spent `VmMemo`s.
|
||||
sessions=2 # world entries in this walk
|
||||
vms=$((sessions * 2)) # …and the Lua states they cost: a glue VM and a world VM each
|
||||
# twice per login; half that means an entry adopted the glue VM and its spent `VmMemo`s. One more
|
||||
# is built once at startup: the boot strings beside the glue VM, read for the slash-command table
|
||||
# (`ui_chat::commands::build_slash_commands`).
|
||||
sessions=2 # world entries in this walk
|
||||
vms=$((sessions * 2 + 1)) # …and the Lua states they cost: a glue VM and a world VM each
|
||||
for marker in "new VM, session"; do
|
||||
n="$(printf '%s\n' "$plain" | grep -cF "$marker")"
|
||||
printf ' %-24s %s\n' "$marker" "$n"
|
||||
[ "$n" -eq "$vms" ] ||
|
||||
fail "'$marker' happened $n time(s), expected $vms — one per VM built, two per login; \
|
||||
$sessions would mean the entry adopted the character screen's VM and inherited its spent VmMemos"
|
||||
fail "'$marker' happened $n time(s), expected $vms — one per VM built, two per login and \
|
||||
the startup strings; $((sessions + 1)) would mean the entry adopted the character screen's VM and \
|
||||
inherited its spent VmMemos"
|
||||
done
|
||||
# The in-game UI, `FrameXML.toc`'s `Fonts.xml` included, and the key-binding commands (the core's
|
||||
# `Bindings.xml`, loaded after the toc) build once per login; one count for two logins means the
|
||||
# second reused the first's frame tree.
|
||||
for marker in "Fonts.xml loaded" "UIParent.xml loaded" "commands registered"; do
|
||||
# second reused the first's frame tree. The `Fonts.xml` marker names its folder: the glue's
|
||||
# `GlueFonts.xml` ends the same way.
|
||||
for marker in 'FrameXML\Fonts.xml loaded' "UIParent.xml loaded" "commands registered"; do
|
||||
n="$(printf '%s\n' "$plain" | grep -cF "$marker")"
|
||||
printf ' %-24s %s\n' "$marker" "$n"
|
||||
[ "$n" -eq "$sessions" ] ||
|
||||
@@ -191,7 +204,7 @@ fi
|
||||
# fails anything but a completed walk.
|
||||
echo "smoke: running the realm-list boundary walk (~12 s, opens a window)…"
|
||||
rlog="$(mktemp "${TMPDIR:-/tmp}/benilla-smoke-realm.XXXXXX")"
|
||||
# No WOW_CHAR: the walk drives character select, and `realm_select::smoke` refuses a seated body.
|
||||
# No WOW_CHAR: the walk drives character select, and `glue_session::realm_smoke` refuses a seated body.
|
||||
WOW_UNATTENDED=1 WOW_NOSOUND=1 WOW_USER="$user" WOW_PASS="$pass" WOW_REALM_SMOKE=1 \
|
||||
timeout 120 cargo run -q -p benilla >"$rlog" 2>&1
|
||||
rcode=$?
|
||||
|
||||
Executable
+86
@@ -0,0 +1,86 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Vendor benilla-re's tables into `reference/` at the commit `reference/PIN` names.
|
||||
|
||||
scripts/sync-reference.py <benilla-re checkout> copy the tables at the PIN
|
||||
scripts/sync-reference.py <benilla-re checkout> --check fail when a copy differs
|
||||
|
||||
Each file is `git show <PIN>:tables/<name>` byte for byte. To update, put a new 40-hex commit of
|
||||
benilla-re in `reference/PIN` and run the script without `--check`.
|
||||
"""
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
REFERENCE = os.path.join(REPO, "reference")
|
||||
|
||||
# The benilla-re tables benilla reads.
|
||||
TABLES = [
|
||||
"bindings.tsv",
|
||||
"chat_groups.tsv",
|
||||
"chat_types.tsv",
|
||||
"cvars.tsv",
|
||||
"event_args.tsv",
|
||||
"events.tsv",
|
||||
"glue_events.tsv",
|
||||
"glue_surface.tsv",
|
||||
"lua_globals.tsv",
|
||||
"messages.tsv",
|
||||
"verb_events.tsv",
|
||||
]
|
||||
|
||||
|
||||
def git_show(checkout, pin, name):
|
||||
r = subprocess.run(
|
||||
["git", "-C", checkout, "show", f"{pin}:tables/{name}"], capture_output=True
|
||||
)
|
||||
if r.returncode != 0:
|
||||
sys.exit(f"sync-reference: {pin[:12]}:tables/{name} not in {checkout}: {r.stderr.decode().strip()}")
|
||||
return r.stdout
|
||||
|
||||
|
||||
def main():
|
||||
args = [a for a in sys.argv[1:] if a != "--check"]
|
||||
check = "--check" in sys.argv[1:]
|
||||
if len(args) != 1:
|
||||
sys.exit("usage: scripts/sync-reference.py <benilla-re checkout> [--check]")
|
||||
checkout = args[0]
|
||||
with open(os.path.join(REFERENCE, "PIN")) as f:
|
||||
pin = f.read().strip()
|
||||
if len(pin) != 40 or any(c not in "0123456789abcdef" for c in pin):
|
||||
sys.exit("sync-reference: reference/PIN must hold a full 40-hex commit")
|
||||
for ref in ("origin/main", "main"):
|
||||
r = subprocess.run(["git", "-C", checkout, "merge-base", "--is-ancestor", pin, ref],
|
||||
capture_output=True)
|
||||
if r.returncode in (0, 1):
|
||||
if r.returncode == 1:
|
||||
sys.exit(f"sync-reference: the PIN {pin[:12]} is not on benilla-re's {ref}")
|
||||
break
|
||||
stray = sorted(n for n in os.listdir(REFERENCE) if n.endswith(".tsv") and n not in TABLES)
|
||||
if stray:
|
||||
sys.exit("sync-reference: reference/ holds tables it does not vendor: " + ", ".join(stray))
|
||||
bad = []
|
||||
for name in TABLES:
|
||||
want = git_show(checkout, pin, name)
|
||||
path = os.path.join(REFERENCE, name)
|
||||
have = open(path, "rb").read() if os.path.exists(path) else None
|
||||
if have == want:
|
||||
continue
|
||||
if check:
|
||||
bad.append(name)
|
||||
else:
|
||||
with open(path, "wb") as f:
|
||||
f.write(want)
|
||||
print(f"sync-reference: wrote reference/{name}")
|
||||
if bad:
|
||||
sys.exit(
|
||||
"sync-reference: differs from benilla-re at the PIN: "
|
||||
+ ", ".join(bad)
|
||||
+ " (run scripts/sync-reference.py <benilla-re checkout>)"
|
||||
)
|
||||
if check:
|
||||
print(f"sync-reference: {len(TABLES)} tables match benilla-re at {pin[:12]}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user