Guarded-DFS decode of SCJUMP's DAG into (chapter, guards)->decision table; VM witness cross-check; native decision->scene boundary documented/deferred. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
10 KiB
SCJUMP static decision-logic decode — design
Date: 2026-07-07 Status: approved (brainstorming) → ready for implementation plan Slice: Phase B story-state layer, progression sub-slice (#2, following the globals registry).
1. Problem & goal
SCJUMP.BIN (29,790 instructions) is the game's progression state machine — it decides "what
comes next" in the story. Today it is an opaque blob. This slice decodes its decision logic
faithfully into a readable, VM-verified progression graph: for every decision site, the exact
(chapter_mode, guarding conditions) → decision value rule, extracted from the bytecode.
This is decode, not stub: every rule traces to specific instructions; nothing is invented. The VM already executes SCJUMP correctly — this slice makes that logic legible and verified, it does not change execution.
2. Non-goals (this slice)
- Decision→scene resolution is deferred. The decision value
0x62ccfbecomes an actualSCxxxx.BINvia the native opu00428010+ array0x5f0ed— the resolution is compiled intoAGE.EXE, not present in any script. We document this as the engine boundary (same bucket as the unsolvedcall-scriptdispatch, seedocs/name-resolution.md §1) and stop there. No Frida / engine dump in this slice. - No VM changes beyond, at most, a minimal headless-seed hook if
vm0.pylacks one (used only by--verify; must not alter existing trace/sweep parity). - Not decoding other flow scripts (FIELD/CAMP dispatch loops) — only SCJUMP's decision production.
3. Findings (verified against the bytecode, 2026-07-07)
- Outputs: SCJUMP writes exactly two globals, paired at every decision:
mov 0x0, 1(decision-valid flag, always 1) andmov 0x62ccf, <value>(the decision). 1755 decision sites, 847 distinct values, range 0..1693.0x62ccfis our curatedscjump_decision_out. - Top dispatch switches on
0x3234(chapter_mode):eq(0x3234==N) ; jcc. Chapter → block:1→0x81, 2→0x9f, 3→0x1ded, 4→0x1e6d, 5→0x54c1, 6→0x93c4, 7→0x1e868, 8→0x2aa42, 9→0x2b8a3. - Dominant decision inputs (comparison-read):
0x4dfbc(1609×),0x2052e(1223×),0x152618(530×) — the progression counters — plus story flags (0xa63,0xa99,0x71b, …). - Control flow is a pure DAG: 1764
jcc+ 1764jmp, 0 back-edges (1763 forward edges + 1765 edges to the END jump0x2f78d). No loops → guarded DFS is bounded and safe. - jcc semantics (
0xa0, argc 3):jcc(cond, A, B)—cond≠0 → targetA;cond==0 → targetB; an operand0xffffffff= fall through to the next instruction.condis a local holding a prior comparison result. Example (chapter-1 block entry):ne(L1, 0x6d3, 1) ; jcc(L1, 0xffffffff, 0x9c) ; mov 0x0,1 ; mov 0x62ccf,0 ; jmp END— decision 0 is emitted exactly when0x6d3 != 1(the fall-through branch, i.e.L1true). - Comparison ops (result→arg0 local, operands arg1/arg2):
eq 0x5a, ne 0x5b, lt 0x5c, lte 0x5d, gr 0x5e, gre 0x5f. Logical combiners:and 0x56, or 0x57(arg0=dest local, arg1/arg2=source locals).mov 0x55writes arg0. - The consumer is native: FIELD/CAMP/scenes do
lookup-array(ptr, 0x5f0ed, 0x62ccf)thenu00428010(ptr)— decision→scene is a native handler. (lookup-array=0x61.)
4. Architecture — tools/scjump_decode.py
Static analyzer, reusing sys4load for decode and paths for location. Three stages.
4a. CFG build
Load SCJUMP; collect all jump/jcc code-target offsets (via is_label_argument) as block leaders;
split the instruction stream into basic blocks; build the successor graph. Assert acyclic (finding
§3). The END jump target (0x2f78d) is a terminal sink.
4b. Guarded DFS (the core)
Walk from entry (offset 0) carrying two path-local structures:
- a guard stack — the list of asserted condition-expressions along the current path;
- a local→expression map — updated whenever a compute op writes a local.
Expression model (Expr):
Cmp(global_addr, op, value)— from a comparison op where one operand is a global (atype ∈ {3,4,5,6,8}) and the other an immediate (atype 0).op ∈ {==,!=,<,<=,>,>=}from the opcode.And(l, r),Or(l, r)— from0x56/0x57combining two locals (resolved via the local map).Opaque(desc)— any condition we cannot model faithfully (global-vs-global compare, arithmetic feeding a condition, an unresolved local). Represented, never silently dropped.
Traversal: at cmp Ln, … set localmap[Ln] = Cmp(...) (or Opaque); at and/or Ln, La, Lb
set localmap[Ln] = And/Or(localmap[La], localmap[Lb]). At jcc(Lc, A, B):
- expr =
localmap.get(Lc, Opaque(Lc)) - recurse to target
A(or next instr ifA==0xffffffff) with guardexprasserted true; - recurse to target
B(or next instr ifB==0xffffffff) with guardNot(expr)asserted.
At mov 0x62ccf, V emit a decision {guards: copy of stack, decision: V, site_offset}. At the END
jump, return. DAG + no revisiting within a path → bounded (~1755 leaves).
Guard negation normalizes for readability: Not(Cmp(g,==,v)) → Cmp(g,!=,v), < ↔ >=,
<= ↔ >, > ↔ <=, >= ↔ <; Not(And/Or) via De Morgan; Not(Opaque) kept as a negated-opaque
marker.
4c. Chapter attribution
The first guard on every path is chapter_mode == N (top dispatch). Tag each decision with that N.
Decisions whose path never asserts a chapter_mode equality (shouldn't occur) are tagged null.
5. Output
build/scjump-decisions.json(generated, disposable —build/is gitignored):meta+decisions[], each{site_offset, chapter, decision_value, guards:[…]}. Each guard is{global:"0xADDR", name:<from registry or null>, op:"==", value:N}forCmp, or a nested{and:[…]}/{or:[…]}/{not:…}/{opaque:"desc"}. Also aby_chapterindex and summary counts (sites, distinct decisions, opaque-guard count).build/scjump-decisions.md(generated, disposable): human table grouped by chapter — each rule aschapter_mode==7 AND 0x6d3!=1 → decision 0, globals rendered with their globals.toml registry names (loaded frombuild/globals.json; e.g.0x3234→chapter_mode).
The generated artifacts live in build/; the stable narrative lives in the canonical doc (§8).
6. Validation — VM cross-check (--verify)
Because the table is a decision function over a deterministic DAG, exactly one path is realized per concrete state. Validate the decode by witness synthesis + VM ground truth:
- For each decision, synthesize a witness state from its guards: collect the per-global
constraints (equalities and
</<=/>/>=/!=bounds) and pick a satisfying value per global (solve the conjunction; equality wins; inequalities → pick an in-range integer;!=→ avoid the value). If a guard isOpaqueor the per-global constraints are unsatisfiable by the simple solver, mark the decision uncovered and skip (reported, not failed). - Run SCJUMP headlessly with the witness as the initial global bank (via
vm0.py; add a minimal seed hook if absent — must not change existing modes) and read the emitted0x62ccf. - Assert the emitted value equals the decision's value. Any mismatch = a mis-modeled condition → hard failure with the offending site.
--verify reports: covered/total decisions, and agreement (must be 100% on covered). Optionally also
seed a batch of random (chapter, counters, flags) states and check that the single table-predicted
decision (all guards concrete-true) matches the VM — a second, independent check. Coverage < 100% is
expected (opaque guards) and is a documented measure, not a failure.
7. Cross-link — name the progression counters
SCJUMP's dominant inputs are the story-progress state. As part of this slice, curate them into
vm-map/globals.toml (then globals_build.py --build): 0x4dfbc, 0x2052e, 0x152618 (and
0xe6c5d) as progress_* counters (category = "counter" or story-flag as fits, source = inference, evidence = "top SCJUMP switch input"). This makes both the SCJUMP table and the wider
corpus more readable, and closes the loop with the registry built in the previous slice.
8. Docs & canonical map
- New canonical doc
docs/scjump-progression.md(hand-authored, stable narrative): what SCJUMP is, the decode mechanism, the native decision→scene boundary (u00428010+0x5f0ed), how to readbuild/scjump-decisions.{json,md}and regenerate them, the--verifycoverage/agreement summary, and cross-links toname-resolution.md §1(call-script) andglobals.toml. - CLAUDE.md canonical-documents map — add a row:
Progression / scene-sequencing logic (SCJUMP) → docs/scjump-progression.md. docs/tools-reference.md— addscjump_decode.py(+test_scjump.py).docs/name-resolution.md §1— add a back-reference: SCJUMP's decision logic is now decoded (scjump-progression.md); only the native decision→scene hop remains engine-level.- Update the status memory (
himegari-port-status.md+MEMORY.md).
9. Testing — tools/test_scjump.py
Standalone script (check(cond,msg) pattern, like test_globals.py):
- CFG acyclic — the built graph has no back-edge (regression-guards the DFS assumption).
- Chapter dispatch — extracted chapter→block map equals the known offsets (
1→0x81 … 9→0x2b8a3). - Known decision — the chapter-1 entry decision decodes to guards
{chapter_mode==1, 0x6d3!=1} → decision 0(hand-traced anchor). - Totals — decode yields 1755 decision sites (regression on the site count).
--verifysound — witness cross-check runs on a bounded sample and reports 100% agreement on covered decisions (0 mismatches).
10. Implementation order (for the plan)
scjump_decode.py: load + CFG build + acyclic assert;test_scjump.pyCFG/chapter tests.- Guarded DFS + Expr model + negation → in-memory decisions; known-decision + totals tests.
- Emit
build/scjump-decisions.json+build/scjump-decisions.md(registry-named); JSON shape test. --verify: witness synthesis + headless SCJUMP run + agreement report; verify test.- Curate progression counters into
globals.toml; rebuild registry. - Docs:
docs/scjump-progression.md, CLAUDE.md map row, tools-reference, name-resolution back-ref; status memory.