Decode SCINIT scene dispatch registry

This commit is contained in:
gamer147
2026-07-23 13:08:48 -04:00
parent f5b986e9f1
commit 9948f23a97
14 changed files with 359 additions and 40 deletions

View File

@@ -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:

View File

@@ -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
)),

View File

@@ -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)

View File

@@ -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()

View File

@@ -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