Files
OpenMaidEngine/docs/superpowers/specs/2026-07-07-scjump-decision-decode-design.md
gamer147 82fad44bbe docs: design spec for SCJUMP static decision-logic decode
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>
2026-07-07 09:00:45 -04:00

10 KiB
Raw Blame History

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 0x62ccf becomes an actual SCxxxx.BIN via the native op u00428010 + array 0x5f0ed — the resolution is compiled into AGE.EXE, not present in any script. We document this as the engine boundary (same bucket as the unsolved call-script dispatch, see docs/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.py lacks 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) and mov 0x62ccf, <value> (the decision). 1755 decision sites, 847 distinct values, range 0..1693. 0x62ccf is our curated scjump_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 + 1764 jmp, 0 back-edges (1763 forward edges + 1765 edges to the END jump 0x2f78d). No loops → guarded DFS is bounded and safe.
  • jcc semantics (0xa0, argc 3): jcc(cond, A, B)cond≠0 → target A; cond==0 → target B; an operand 0xffffffff = fall through to the next instruction. cond is 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 when 0x6d3 != 1 (the fall-through branch, i.e. L1 true).
  • 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 0x55 writes arg0.
  • The consumer is native: FIELD/CAMP/scenes do lookup-array(ptr, 0x5f0ed, 0x62ccf) then u00428010(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) — from 0x56/0x57 combining 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 if A==0xffffffff) with guard expr asserted true;
  • recurse to target B (or next instr if B==0xffffffff) with guard Not(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} for Cmp, or a nested {and:[…]}/{or:[…]}/{not:…}/{opaque:"desc"}. Also a by_chapter index and summary counts (sites, distinct decisions, opaque-guard count).
  • build/scjump-decisions.md (generated, disposable): human table grouped by chapter — each rule as chapter_mode==7 AND 0x6d3!=1 → decision 0, globals rendered with their globals.toml registry names (loaded from build/globals.json; e.g. 0x3234chapter_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:

  1. 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 is Opaque or the per-global constraints are unsatisfiable by the simple solver, mark the decision uncovered and skip (reported, not failed).
  2. 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 emitted 0x62ccf.
  3. 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.

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 read build/scjump-decisions.{json,md} and regenerate them, the --verify coverage/agreement summary, and cross-links to name-resolution.md §1 (call-script) and globals.toml.
  • CLAUDE.md canonical-documents map — add a row: Progression / scene-sequencing logic (SCJUMP) → docs/scjump-progression.md.
  • docs/tools-reference.md — add scjump_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).
  • --verify sound — witness cross-check runs on a bounded sample and reports 100% agreement on covered decisions (0 mismatches).

10. Implementation order (for the plan)

  1. scjump_decode.py: load + CFG build + acyclic assert; test_scjump.py CFG/chapter tests.
  2. Guarded DFS + Expr model + negation → in-memory decisions; known-decision + totals tests.
  3. Emit build/scjump-decisions.json + build/scjump-decisions.md (registry-named); JSON shape test.
  4. --verify: witness synthesis + headless SCJUMP run + agreement report; verify test.
  5. Curate progression counters into globals.toml; rebuild registry.
  6. Docs: docs/scjump-progression.md, CLAUDE.md map row, tools-reference, name-resolution back-ref; status memory.