Decode SCINIT scene dispatch registry
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Extract a *INIT data table to JSON. Auto-detects the table's shape.
|
||||
|
||||
*INIT scripts populate global arrays and work buffers with static game data. Five shapes seen:
|
||||
*INIT scripts populate global arrays and work buffers with static game data. Six shapes seen:
|
||||
|
||||
name — records keyed by a name string. Each record: set-string(name), static field writes,
|
||||
set-string(desc). Arrays indexed by record id in lockstep (+1/record).
|
||||
@@ -14,12 +14,14 @@
|
||||
footer arrays for one runtime record. (STINIT stages)
|
||||
rules — conditional blocks select a unit promotion and add effects to shared output
|
||||
buffers. (CCINIT class changes)
|
||||
dispatch—paired parallel arrays map a sparse decision id to a packed script resource id
|
||||
and authored chapter metadata. (SCINIT scene dispatch)
|
||||
|
||||
Records are {id, name?, desc?, fields:{"0x<col_base>": value}} or, for footer tables,
|
||||
{id, global_addr, footer_off, values:[...]}. Column addresses are raw engine globals;
|
||||
confirmed names come from the generated engine global registry while raw keys remain provenance.
|
||||
|
||||
Usage: py -3.11 -X utf8 tools/extract_init.py <TABLE> [OUTNAME] [--mode name|numeric|footer|mixed|rules]
|
||||
Usage: py -3.11 -X utf8 tools/extract_init.py <TABLE> [OUTNAME] [--mode name|numeric|footer|mixed|rules|dispatch]
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import json
|
||||
@@ -165,6 +167,27 @@ def _class_change_guards(scr) -> list[dict]:
|
||||
return guards
|
||||
|
||||
|
||||
def _paired_parallel_writes(scr) -> tuple[list[tuple], int] | None:
|
||||
"""Recognize alternating writes to two equally indexed parallel arrays."""
|
||||
writes = []
|
||||
for ins in scr.instructions:
|
||||
write = _static_global_write(ins)
|
||||
if write is not None and isinstance(write[1], int):
|
||||
writes.append((ins.offset, *write))
|
||||
elif sys4load.display_label(ins.opcode) != "exit":
|
||||
return None
|
||||
if len(writes) < 200 or len(writes) % 2:
|
||||
return None
|
||||
span = writes[1][1] - writes[0][1]
|
||||
if span <= 0:
|
||||
return None
|
||||
for index in range(0, len(writes), 2):
|
||||
primary, secondary = writes[index:index + 2]
|
||||
if secondary[1] - primary[1] != span:
|
||||
return None
|
||||
return writes, span
|
||||
|
||||
|
||||
def detect_mode(scr):
|
||||
ops = [ins.opcode for ins in scr.instructions]
|
||||
has_str = any(ins.opcode == SET_STRING and ins.args and ins.args[0][0] == T_GLOBAL_STRING
|
||||
@@ -181,6 +204,8 @@ def detect_mode(scr):
|
||||
return "mixed"
|
||||
if has_str:
|
||||
return "name"
|
||||
if _paired_parallel_writes(scr):
|
||||
return "dispatch"
|
||||
n_footer = ops.count(COPY_LOCAL_ARRAY)
|
||||
n_int = ops.count(MOV) + ops.count(COPY_TO_GLOBAL)
|
||||
return "footer" if n_footer >= max(4, n_int) else "numeric"
|
||||
@@ -676,6 +701,128 @@ def extract_numeric(scr):
|
||||
return records, {"primary_index_base": f"0x{base:x}", "record_span": n}
|
||||
|
||||
|
||||
@cache
|
||||
def callscript_names() -> dict[int, str]:
|
||||
"""Load the generated packed script-resource id join."""
|
||||
try:
|
||||
data = json.loads(
|
||||
(paths.BUILD / "callscript-names.json").read_text(encoding="utf8")
|
||||
)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return {}
|
||||
return {int(key): value for key, value in data.items()}
|
||||
|
||||
|
||||
@cache
|
||||
def scjump_decision_chapters() -> tuple[dict[int, set[int]], int]:
|
||||
"""Load SCJUMP's generated decision sites as independent correlation evidence."""
|
||||
try:
|
||||
data = json.loads(
|
||||
(paths.BUILD / "scjump-decisions.json").read_text(encoding="utf8")
|
||||
)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return {}, 0
|
||||
chapters: dict[int, set[int]] = {}
|
||||
for decision in data.get("decisions", []):
|
||||
chapter = decision.get("chapter")
|
||||
if isinstance(chapter, int):
|
||||
chapters.setdefault(decision["decision"], set()).add(chapter)
|
||||
return chapters, len(data.get("decisions", []))
|
||||
|
||||
|
||||
def extract_dispatch(scr):
|
||||
"""Extract SCINIT's decision -> scene-script registry without losing overwrites."""
|
||||
paired = _paired_parallel_writes(scr)
|
||||
if paired is None:
|
||||
return [], {}
|
||||
writes, span = paired
|
||||
primary_base = writes[0][1]
|
||||
chapter_base = primary_base + span
|
||||
names = callscript_names()
|
||||
scjump_chapters, decision_site_count = scjump_decision_chapters()
|
||||
records_by_id: dict[int, dict] = {}
|
||||
assignment_count = 0
|
||||
|
||||
for index in range(0, len(writes), 2):
|
||||
primary, chapter = writes[index:index + 2]
|
||||
decision_id = primary[1] - primary_base
|
||||
script_resource_id = primary[2]
|
||||
assignment = {
|
||||
"offset": f"0x{primary[0]:x}",
|
||||
"script_resource_id": script_resource_id,
|
||||
"script_name": names.get(script_resource_id, ""),
|
||||
"authored_chapter": chapter[2],
|
||||
}
|
||||
record = records_by_id.setdefault(decision_id, {
|
||||
"id": decision_id,
|
||||
"assignments": [],
|
||||
})
|
||||
record["assignments"].append(assignment)
|
||||
assignment_count += 1
|
||||
|
||||
chapter_match_count = 0
|
||||
chapter_mismatches = []
|
||||
resolved_script_count = 0
|
||||
overwritten_record_count = 0
|
||||
conflicting_chapter_record_count = 0
|
||||
for decision_id, record in records_by_id.items():
|
||||
assignments = record["assignments"]
|
||||
final = assignments[-1]
|
||||
script_resource_id = final["script_resource_id"]
|
||||
authored_chapter = final["authored_chapter"]
|
||||
record.update({
|
||||
"name": final["script_name"],
|
||||
"script_resource_id": script_resource_id,
|
||||
"script_name": final["script_name"],
|
||||
"authored_chapter": authored_chapter,
|
||||
"assignment_count": len(assignments),
|
||||
"fields": {
|
||||
f"0x{primary_base:x}": script_resource_id,
|
||||
f"0x{chapter_base:x}": authored_chapter,
|
||||
},
|
||||
})
|
||||
if final["script_name"]:
|
||||
resolved_script_count += 1
|
||||
if len(assignments) > 1:
|
||||
overwritten_record_count += 1
|
||||
if len({assignment["authored_chapter"] for assignment in assignments}) > 1:
|
||||
conflicting_chapter_record_count += 1
|
||||
if decision_id in scjump_chapters:
|
||||
expected = sorted(scjump_chapters[decision_id])
|
||||
record["scjump_chapters"] = expected
|
||||
matches = authored_chapter in scjump_chapters[decision_id]
|
||||
record["authored_chapter_matches_scjump"] = matches
|
||||
if matches:
|
||||
chapter_match_count += 1
|
||||
else:
|
||||
chapter_mismatches.append({
|
||||
"decision_id": decision_id,
|
||||
"authored_chapter": authored_chapter,
|
||||
"scjump_chapters": expected,
|
||||
})
|
||||
|
||||
records = [records_by_id[key] for key in sorted(records_by_id)]
|
||||
return records, {
|
||||
"selector_global": "0x62ccf",
|
||||
"script_resource_array_base": f"0x{primary_base:x}",
|
||||
"authored_chapter_array_base": f"0x{chapter_base:x}",
|
||||
"reserved_array_span": span,
|
||||
"assignment_count": assignment_count,
|
||||
"overwritten_record_count": overwritten_record_count,
|
||||
"conflicting_chapter_record_count": conflicting_chapter_record_count,
|
||||
"resolved_script_count": resolved_script_count,
|
||||
"scjump_decision_site_count": decision_site_count,
|
||||
"scjump_distinct_decision_count": len(scjump_chapters),
|
||||
"scjump_joined_record_count": sum(
|
||||
record["id"] in scjump_chapters for record in records
|
||||
),
|
||||
"scjump_chapter_match_count": chapter_match_count,
|
||||
"scjump_chapter_mismatches": sorted(
|
||||
chapter_mismatches, key=lambda row: row["decision_id"]
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def extract_footer(scr):
|
||||
records = []
|
||||
for i, ins in enumerate(scr.instructions):
|
||||
@@ -1020,6 +1167,11 @@ def write_data_index(data_dir: Path) -> None:
|
||||
"eligibility, titles, deployment-cost and named stat deltas, awarded SKINIT skills, and",
|
||||
"the persistent state slot set by each class change. Raw output addresses remain beside",
|
||||
"the joined EBINIT unit and SKINIT skill names.",
|
||||
"",
|
||||
"Dispatch-mode tables preserve SCINIT's complete source-ordered assignment history",
|
||||
"while exposing the final sparse decision-id registry. Packed resource ids join to",
|
||||
"SYS4INI script names, authored chapter tags correlate with SCJUMP's decoded decision",
|
||||
"sites, and legacy/stale chapter mismatches remain explicit.",
|
||||
"Use `tools/init_table_profile.py <TABLE> --build` to generate value/population and",
|
||||
"direct-consumer evidence.",
|
||||
"",
|
||||
@@ -1059,6 +1211,7 @@ def main() -> int:
|
||||
"footer": extract_footer,
|
||||
"mixed": extract_mixed,
|
||||
"rules": extract_class_change_rules,
|
||||
"dispatch": extract_dispatch,
|
||||
}[mode]
|
||||
recs, meta = extractor(scr)
|
||||
if mode == "name" and name in MESSAGE_TABLES:
|
||||
|
||||
@@ -37,7 +37,7 @@ def load_table(name: str) -> dict:
|
||||
if not path.exists():
|
||||
raise SystemExit(f"missing extracted table: {path}")
|
||||
data = json.loads(path.read_text(encoding="utf8"))
|
||||
if data.get("mode") not in {"name", "numeric", "mixed", "rules"}:
|
||||
if data.get("mode") not in {"name", "numeric", "mixed", "rules", "dispatch"}:
|
||||
raise SystemExit(f"{name}: unsupported field-profiling mode {data.get('mode')!r}")
|
||||
return data
|
||||
|
||||
@@ -86,6 +86,7 @@ def profile_columns(data: dict) -> list[dict]:
|
||||
"kind": (
|
||||
"scalar-field" if data.get("mode") == "mixed"
|
||||
else "rule-output" if data.get("mode") == "rules"
|
||||
else "dispatch-field" if data.get("mode") == "dispatch"
|
||||
else "parallel-array"
|
||||
),
|
||||
"base": key,
|
||||
@@ -185,6 +186,25 @@ def profile_rules(data: dict) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def profile_dispatch(data: dict) -> dict:
|
||||
"""Summarize SCINIT's final registry and preserved assignment history."""
|
||||
if data.get("mode") != "dispatch":
|
||||
return {}
|
||||
return {
|
||||
"assignment_count": data.get("assignment_count", 0),
|
||||
"overwritten_record_count": data.get("overwritten_record_count", 0),
|
||||
"conflicting_chapter_record_count": data.get(
|
||||
"conflicting_chapter_record_count", 0
|
||||
),
|
||||
"resolved_script_count": data.get("resolved_script_count", 0),
|
||||
"scjump_joined_record_count": data.get("scjump_joined_record_count", 0),
|
||||
"scjump_chapter_match_count": data.get("scjump_chapter_match_count", 0),
|
||||
"scjump_chapter_mismatch_count": len(
|
||||
data.get("scjump_chapter_mismatches", [])
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def profile_messages(data: dict) -> dict:
|
||||
"""Summarize the joined player-facing message evidence."""
|
||||
records = data["records"]
|
||||
@@ -307,6 +327,21 @@ def render_markdown(data: dict, rows: list[dict], limit: int) -> str:
|
||||
f"- level-independent rules: {rule_profile['level_independent_rule_count']}",
|
||||
f"- awarded skills: {rule_profile['skill_award_count']}",
|
||||
])
|
||||
elif dispatch_profile := profile_dispatch(data):
|
||||
lines.extend([
|
||||
f"- source assignments: {dispatch_profile['assignment_count']}",
|
||||
f"- overwritten decision ids: {dispatch_profile['overwritten_record_count']}",
|
||||
f"- ids whose assignment history crosses chapter tags: "
|
||||
f"{dispatch_profile['conflicting_chapter_record_count']}",
|
||||
f"- packed script ids resolved: {dispatch_profile['resolved_script_count']}/"
|
||||
f"{data['record_count']}",
|
||||
f"- SCJUMP decisions joined: {dispatch_profile['scjump_joined_record_count']}",
|
||||
f"- final authored chapters matching SCJUMP: "
|
||||
f"{dispatch_profile['scjump_chapter_match_count']}/"
|
||||
f"{dispatch_profile['scjump_joined_record_count']}",
|
||||
f"- explicit chapter mismatches: "
|
||||
f"{dispatch_profile['scjump_chapter_mismatch_count']}",
|
||||
])
|
||||
else:
|
||||
lines.extend([
|
||||
f"- player-facing messages: {message_profile['population']}/{data['record_count']} "
|
||||
@@ -367,8 +402,10 @@ def main() -> int:
|
||||
"array_cell_count": sum(row["kind"] == "array-cell" for row in rows),
|
||||
"footer_array_count": sum(row["kind"] == "footer-array" for row in rows),
|
||||
"rule_output_count": sum(row["kind"] == "rule-output" for row in rows),
|
||||
"dispatch_field_count": sum(row["kind"] == "dispatch-field" for row in rows),
|
||||
"message_profile": messages,
|
||||
"rule_profile": profile_rules(data),
|
||||
"dispatch_profile": profile_dispatch(data),
|
||||
"columns": sorted(rows, key=lambda row: (
|
||||
int(row["base"], 16), row["stride"] or 0, row["column"] or 0
|
||||
)),
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Static decoder for SCJUMP.BIN's progression decision logic. Guarded DFS over its acyclic CFG
|
||||
extracts, per decision site, the (chapter_mode, path-condition guards) -> decision value rule.
|
||||
Decision->scene resolution is native (u00428010) and out of scope. See
|
||||
docs/superpowers/specs/2026-07-07-scjump-decision-decode-design.md.
|
||||
Decision->scene resolution is the SCINIT registry at G[0x87a57], consumed by
|
||||
SYSTEM4 and related scripts. See docs/scjump-progression.md.
|
||||
(no flag) -> build/scjump-decisions.json + build/scjump-decisions.md
|
||||
--verify synthesize a witness per decision, run SCJUMP in vm0, assert emitted 0x62ccf matches"""
|
||||
from __future__ import annotations
|
||||
@@ -187,7 +187,7 @@ def emit_json(decs, names) -> str:
|
||||
by_chapter = collections.defaultdict(list)
|
||||
for d in out_decs:
|
||||
by_chapter[d["chapter"]].append(d["decision"])
|
||||
meta = {"note": "GENERATED by tools/scjump_decode.py — do not edit. Decision->scene is native (u00428010), not resolved here.",
|
||||
meta = {"note": "GENERATED by tools/scjump_decode.py; do not edit. Join decision ids to build/data/SCINIT.json for scene script resources and authored chapter metadata.",
|
||||
"decision_sites": len({d["site_offset"] for d in decs}),
|
||||
"distinct_decisions": len({d["decision"] for d in decs})}
|
||||
return json.dumps({"meta": meta, "decisions": out_decs,
|
||||
@@ -199,8 +199,8 @@ def emit_md(decs, names) -> str:
|
||||
L = ["<!-- GENERATED by tools/scjump_decode.py — do not edit -->",
|
||||
"# SCJUMP progression decisions (generated)", "",
|
||||
f"{len({d['site_offset'] for d in decs})} decision sites. Each rule: guards (all true along the "
|
||||
"path) -> decision value written to `0x62ccf`. Decision->scene is native (`u00428010`), see "
|
||||
"`docs/scjump-progression.md`.", ""]
|
||||
"path) -> decision value written to `0x62ccf`. `build/data/SCINIT.json` joins decisions "
|
||||
"to scene scripts and authored chapters; see `docs/scjump-progression.md`.", ""]
|
||||
by_ch = collections.defaultdict(list)
|
||||
for d in decs:
|
||||
by_ch[d["chapter"]].append(d)
|
||||
|
||||
@@ -309,6 +309,46 @@ def test_real_class_change_rules() -> None:
|
||||
"CCINIT accounts for all 30 awarded skills")
|
||||
|
||||
|
||||
def test_real_scene_dispatch() -> None:
|
||||
script = sys4load.load(extract_init.resolve("SCINIT"))
|
||||
check(extract_init.detect_mode(script) == "dispatch",
|
||||
"SCINIT auto-detects as paired scene dispatch arrays")
|
||||
check(
|
||||
extract_init.detect_mode(
|
||||
sys4load.load(extract_init.resolve("RTINIT"))
|
||||
) == "numeric",
|
||||
"RTINIT's multi-table writes do not false-positive as paired dispatch",
|
||||
)
|
||||
records, meta = extract_init.extract_dispatch(script)
|
||||
by_id = {record["id"]: record for record in records}
|
||||
check(len(records) == 1209 and meta["assignment_count"] == 2179,
|
||||
"SCINIT preserves all assignments and 1,209 final decision rows")
|
||||
check(meta["script_resource_array_base"] == "0x87a57"
|
||||
and meta["authored_chapter_array_base"] == "0x8a167"
|
||||
and meta["reserved_array_span"] == 10000,
|
||||
"SCINIT exposes its paired 10,000-cell array layout")
|
||||
check(by_id[0]["script_resource_id"] == 34
|
||||
and by_id[0]["script_name"] == "SC0000.BIN"
|
||||
and by_id[0]["authored_chapter"] == 1,
|
||||
"SCINIT joins packed resource ids to scene names and chapter metadata")
|
||||
check(by_id[1]["assignment_count"] == 3
|
||||
and [entry["authored_chapter"] for entry in by_id[1]["assignments"]]
|
||||
== [1, 5, 5],
|
||||
"SCINIT retains source-ordered overwrites rather than only the final cell")
|
||||
check(meta["resolved_script_count"] == 1209
|
||||
and len({record["script_resource_id"] for record in records}) == 135,
|
||||
"every final SCINIT row resolves to one of 135 numbered scene scripts")
|
||||
check(meta["scjump_joined_record_count"] == 847
|
||||
and meta["scjump_chapter_match_count"] == 844
|
||||
and [row["decision_id"] for row in meta["scjump_chapter_mismatches"]]
|
||||
== [250, 1001, 1005],
|
||||
"SCINIT chapter tags cross-check against every live SCJUMP decision")
|
||||
semantics = extract_init.field_semantics(records)
|
||||
check(semantics["0x87a57"] == "scjump_scene_script_resource_ids"
|
||||
and semantics["0x8a167"] == "scjump_authored_chapters",
|
||||
"SCINIT's paired columns join to canonical semantic names")
|
||||
|
||||
|
||||
def test_real_message_tables() -> None:
|
||||
scripts = paths.scripts()
|
||||
expected = {
|
||||
@@ -483,6 +523,7 @@ if __name__ == "__main__":
|
||||
test_output_name_validation()
|
||||
test_real_mixed_table()
|
||||
test_real_class_change_rules()
|
||||
test_real_scene_dispatch()
|
||||
test_real_message_tables()
|
||||
test_message_join()
|
||||
test_field_semantics()
|
||||
|
||||
@@ -95,6 +95,31 @@ def main() -> int:
|
||||
assert rule_summary["class_change_slot_indices"] == {"0": 1, "1": 1}
|
||||
assert rule_summary["skill_award_count"] == 1
|
||||
|
||||
dispatch_fixture = {
|
||||
"table": "DISPATCH",
|
||||
"mode": "dispatch",
|
||||
"assignment_count": 4,
|
||||
"overwritten_record_count": 1,
|
||||
"conflicting_chapter_record_count": 1,
|
||||
"resolved_script_count": 2,
|
||||
"scjump_joined_record_count": 2,
|
||||
"scjump_chapter_match_count": 1,
|
||||
"scjump_chapter_mismatches": [{"decision_id": 2}],
|
||||
"records": [
|
||||
{"id": 1, "name": "SC0000.BIN", "fields": {"0x100": 34, "0x200": 1}},
|
||||
{"id": 2, "name": "SC0010.BIN", "fields": {"0x100": 286, "0x200": 2}},
|
||||
],
|
||||
}
|
||||
dispatch_rows = {
|
||||
row["key"]: row for row in profile.profile_columns(dispatch_fixture)
|
||||
}
|
||||
assert dispatch_rows["0x100"]["kind"] == "dispatch-field"
|
||||
dispatch_summary = profile.profile_dispatch(dispatch_fixture)
|
||||
assert dispatch_summary["assignment_count"] == 4
|
||||
assert dispatch_summary["overwritten_record_count"] == 1
|
||||
assert dispatch_summary["scjump_chapter_match_count"] == 1
|
||||
assert dispatch_summary["scjump_chapter_mismatch_count"] == 1
|
||||
|
||||
messages = profile.profile_messages(fixture)
|
||||
assert messages["population"] == 1
|
||||
assert messages["coverage"] == 1 / 3
|
||||
|
||||
Reference in New Issue
Block a user