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

165 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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. `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**:
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.
## 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 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.