3707 lines
295 KiB
Markdown
3707 lines
295 KiB
Markdown
# Native-engine reverse engineering (Ghidra + MCP)
|
||
|
||
Static RE of the **unpacked** `AGE.EXE` engine image, driving Ghidra 12.1.2 via the
|
||
bethington/ghidra-mcp bridge. This is the home for decompiled native-op findings — the class of logic
|
||
the scripts call but that lives compiled in the engine (decision→scene, call-script dispatch, op 0x60,
|
||
the gfx command-buffer). Opcode semantics recovered here also flow into `vm-map/opcodes.toml`.
|
||
|
||
Related: `docs/scjump-progression.md` (the SCJUMP decoder that hit this wall), `name-resolution.md §1`
|
||
(call-script), `vm-mapping-plan.md` appendix (why the exe is packed + the runtime-dump route).
|
||
|
||
---
|
||
|
||
## Runbook — the Ghidra + MCP loop
|
||
|
||
**One-time setup (done 2026-07-07):**
|
||
- **MCP server:** bethington/ghidra-mcp, cloned to `S:\Game Hacking\ghidra-mcp`. We used the **prebuilt
|
||
extension** `GhidraMCP-5.14.2.zip` (installed in Ghidra via File > Install Extensions) — this skips
|
||
the Maven/Java-21 build. The Python **bridge** runs from a venv (`.venv`, Python 3.11, `pip install .`);
|
||
no `uv` needed. Registered in Claude Code via `.mcp.json` at the workspace root:
|
||
`{"mcpServers":{"ghidra":{"command":"S:\\Game Hacking\\ghidra-mcp\\.venv\\Scripts\\bridge-mcp-ghidra.exe","args":["--transport","stdio"]}}}`.
|
||
- **In Ghidra:** enable the GhidraMCP plugin (File > Configure) and **Tools > GhidraMCP > Start MCP
|
||
Server** (serves `http://127.0.0.1:8089/`). The bridge talks to that; Claude reaches the bridge over stdio.
|
||
|
||
**Loading the engine image (IMPORTANT — the language gotcha):**
|
||
- Import `age-reimpl/build/engine-dump/range_00400000.bin` (the module dump: 2,490,368 bytes, the full
|
||
0x400000 module image; VA→file offset = `VA − 0x400000`).
|
||
- **Format = Raw Binary, Language = `x86:LE:32:default`, Image Base = `0x400000`.** Ghidra's language
|
||
picker offers `x86:LE:32:System Management Mode` as the "closest" match — **do NOT use it.** SMM is a
|
||
16-bit *segmented* (segment:offset) variant for BIOS/SMRAM; it mis-decodes flat 32-bit code (it loaded
|
||
with addresses like `0000:0000`/`0025:ffff` and produced **0 functions**). The plain `default` variant
|
||
is correct and yielded **2,721 functions**.
|
||
- We drove the (re)import over MCP: `import_file(language="x86:LE:32:default", compiler_spec="windows",
|
||
auto_analyze=false)` → `set_image_base(0x400000)` **before** analysis (so absolute-address refs resolve)
|
||
→ `run_analysis`.
|
||
- **Load sanity check (AGF-decoder landmark):** at VA `0x474f23`, `CMP word ptr [ESI + 0x4], 0x4d42`
|
||
(the `BM`/BMP-magic check) confirms the image is correctly based + decoded.
|
||
- IAT reconstruction — **tried, DOESN'T WORK on this binary (2026-07-09):** `bin/pe-sieve32.exe /pid
|
||
<PID> /imp 3 /dmode 3 /dir build/pe-sieve` (run from **PowerShell**, not Git Bash — it mangles
|
||
`/flags`) ran fine but the game is packed with a **zeroed IAT** resolved via `GetProcAddress` at load,
|
||
so there is no conventional import table to rebuild. Of 363 "imports" it emitted, only ~17 are genuine
|
||
(`in_main:1`): the packer bootstrap (`LoadLibraryA`/`GetProcAddress`/`GetModuleHandleA`/`VirtualAlloc`/
|
||
`VirtualFree`) + a one-per-DLL seed block at RVA `0x202bfc` (`d3d9.Direct3DCreate9`,
|
||
`user32.RegisterClassExA`, `gdi32.GetStockObject`, `winmm.timeSetEvent`, `advapi32.RegOpenKeyA`,
|
||
`shell32.SHGetSpecialFolderPathA`, `oleaut32.Variant*`, `kernel32.RaiseException`, …). The other 300+
|
||
are stray pointer-shaped DWORDs mis-resolved to "first export at module base" (e.g. `msvcrt._wstrtime_s`
|
||
30×, `in_main:0`, non-terminated). ⇒ **do not graft pe-sieve output** — grafting the noise would inject
|
||
wrong import names. The game's hot APIs (`ReadFile`/`CreateFileA`/`timeGetTime`/d3d9 device methods) are
|
||
`GetProcAddress`-resolved into private pointer tables, invisible to a static IAT scan. Report/dump left
|
||
at `build/pe-sieve/process_<pid>/` (disposable).
|
||
|
||
**→ The Frida import-map approach — ✅ DONE 2026-07-09 (replaced pe-sieve).** Named the
|
||
dynamically-resolved APIs at their call sites via the LIVE process. `tools/frida/map_imports.py`
|
||
(read-only, plain-JS): (1) Frida-reads all loaded modules' export tables → `{runtime_addr → dll!Func}`
|
||
(23,342 exports); (2) scans the `0x400000` module for aligned DWORDs holding those addresses → `RVA →
|
||
name` (ASLR-stable: RVAs into the fixed main module transfer to the dump even though the DLL targets
|
||
relocate); (3) a `run_script_inline` pass labels the `/v2` image `imp_<dll>_<func>`. **Result: the packer's
|
||
rebuilt core IAT lives at RVA `0x16f000` (VA `0x56f000`) — 248 imports labeled** (kernel32 129, user32 57,
|
||
winmm 20, gdi32 17, advapi32/ole/oleaut/version/ntdll), 0 clobbers. **Validated:** `FUN_0044f390` now reads
|
||
`(*imp_kernel32_CreateFileA)` / `(*imp_kernel32_SetFilePointer)` at its resolver I/O; `sleep_timer_arm` reads
|
||
`(*imp_winmm_timeGetTime)()` — pinning the long-standing `DAT_0056f3d4` = **timeGetTime**. Tool:
|
||
`tools/frida/map_imports.py [--recon]` → `build/import-map.json`; plan
|
||
`docs/superpowers/plans/2026-07-09-frida-import-map.md`.
|
||
**Known limit (by design):** only the module-resident IAT is labelable. `d3d9`/`shell32`/`dsound`/CRT are
|
||
`GetProcAddress`-resolved into HEAP (not in the `0x400000` dump), so they aren't labeled — and D3D9 is used
|
||
via COM vtables (`Present` = device vtable slot 17, see `probe_present.py`), not an import thunk, so this
|
||
costs us nothing on the render path. 29 isolated singleton matches were set aside (`build/import-map-singletons.json`),
|
||
not auto-applied.
|
||
|
||
---
|
||
|
||
## Master key — the opcode→handler dispatch table (2026-07-07, anchored)
|
||
|
||
The interpreter dispatches each op via a per-context handler table, **fully anchored**:
|
||
|
||
> **`handler(op) = ctx[0x26c93 + op]`** (word index) **= `*(ctx + 0x9b24c + op*4)`**
|
||
> — `ctx` = the engine context (`esi` in handlers, thiscall; `param_1` in the decompile of the
|
||
> registration routine).
|
||
|
||
The registration routine **`FUN_00413860`** first fills `0x400` (1024) slots starting at
|
||
`ctx[0x26c93]` with a **default handler `FUN_004162b0`** (op 0's slot), then overrides specific
|
||
opcodes: `ctx[0x26c93 + op] = <handler_va>`. So **opcode = (word_index − 0x26c93)**. Cross-check:
|
||
`ctx[0x26e3f] = 0x427fb0` (byte offset `0x9b8fc`) → op `0x26e3f − 0x26c93 = 0x1ac`.
|
||
|
||
**Why this matters:** the Kelebek `u00XXXXXX` opcode names encode handler VAs from *Kelebek's* build,
|
||
which **drift** in ours. This table resolves the *real* handler for any opcode in our image — the
|
||
general fix for VA drift project-wide. To find op `N`'s handler: read `ctx[0x26c93 + N]` from the
|
||
`FUN_00413860` decompile (or `*(ctx + 0x9b24c + N*4)` at runtime).
|
||
|
||
### Materialized + applied image-wide (2026-07-09)
|
||
|
||
The table is no longer resolved op-by-op by hand — it is **extracted once and applied to the whole
|
||
image**. `tools/ghidra_handler_map.py` parses the override stores in `FUN_00413860` (dump at
|
||
`build/engine-dump/FUN_00413860.disasm.txt`) → **`build/op-handler-map.json`** (`{op → handler VA}`,
|
||
**420 overrides**). Regenerate: `py -3.11 -X utf8 tools/ghidra_handler_map.py
|
||
build/engine-dump/FUN_00413860.disasm.txt --check`. The `--check` diffs the derived handlers against the
|
||
handler VAs mentioned in `vm-map/opcodes.toml` prose and found **0 real drift** — the only 7 flags are
|
||
ops whose toml text records the *worker* VA, not the handler (`0x20c→0x4174a0`, and the `0x21c–0x243`
|
||
cluster entries), each already matching the recon tables below.
|
||
|
||
A one-shot Ghidra script (via `run_script_inline`; needs `GHIDRA_MCP_ALLOW_SCRIPTS=1`) then labeled the
|
||
image from that map: **281 raw `FUN_`/`LAB_` handlers renamed `op_0xNN_handler`, 107 bare handler VAs
|
||
turned into functions, 31 hand-named handlers preserved** (source `USER_DEFINED` is never renamed), and
|
||
a plate comment `opcode 0xNN dispatch handler; ctx[0x26c93+op] in FUN_00413860` set on every one
|
||
(appended to existing decode comments, never clobbering). The one shared handler `0x416650` (ops
|
||
`0xaf`/`0x1a8`) is `op_0xaf_0x1a8_handler`. ⇒ every dispatch handler in the image now self-identifies its
|
||
opcode; a bare `op_0xNN_handler` is a handler not yet role-RE'd. Enrich with a descriptive name +
|
||
decode when you reverse one (the generic name is a floor, not a final).
|
||
|
||
> **⚠ Two-program gotcha (cost time 2026-07-09).** The Ghidra project holds **two** imports named
|
||
> `range_00400000.bin`: the GOOD one at project path **`/v2/range_00400000.bin`** (`x86:LE:32:default`,
|
||
> image base `0x400000`, 4308 functions — all our annotations live here) and a BROKEN early import at
|
||
> **`/range_00400000.bin`** (the `x86:LE:32:System Management Mode` mis-import: base `0000:0000`, **0
|
||
> functions**; see the language gotcha in the runbook). After a Ghidra restart the broken one can become
|
||
> active. **Always confirm `get_current_program_info` shows base `0x400000` / 4308 functions (or
|
||
> `switch_program /v2/range_00400000.bin`) before doing anything** — `run_script_inline` runs against the
|
||
> GUI's active program, so a wrong-program script would mutate/measure garbage.
|
||
|
||
**Other confirmed engine-context offsets** (`ctx`/`esi`): `+0x53d14` = current script-context index;
|
||
`+0x53d88 + index*0x78` = current decoded instruction length in dwords (the interpreter advances PC by
|
||
that value times four); operand-fetch helper =
|
||
**`vm_operand_fetch`@`0x41b940`** (thiscall, `ecx=ctx`, arg = operand index → returns the operand value);
|
||
**`vm_operand_write`@`0x425fb0`** = the counterpart store; **`vm_operand_lvalue`@`0x415f30`** = the
|
||
companion index/pointer accessor.
|
||
|
||
**Hot-helper naming pass (2026-07-09, lever #2).** Ghidra's Function ID analyzer names **0** functions on
|
||
this image (the bundled FidDbs don't cover the VC9/VS2008 static runtime; ~3,660 of 4,428 funcs stay
|
||
`FUN_`), and the library workers we actually touch were already hand-named (`gfx_object_query_source_slot`,
|
||
etc.). So "STL/CRT auto-naming" had little to add — but the recon (rank unnamed funcs by call-count)
|
||
surfaced the real win: ubiquitous **documented-but-unnamed helpers**. Named the top 5 (~2,400 call
|
||
sites): `vm_operand_fetch`@`0x41b940` (1021 refs), `vm_operand_write`@`0x425fb0` (188),
|
||
`vm_operand_lvalue`@`0x415f30`, plus two CRT primitives identified by behavior —
|
||
`__security_check_cookie`@`0x54f981` (692; compares `__security_cookie`=`DAT_005c28c0`) and
|
||
`operator_new`@`0x5502be` (533; `_malloc`+`__callnewh`+throw `bad_alloc`). ⇒ every handler now reads
|
||
e.g. `vm_operand_fetch(2)` not `FUN_0041b940(2)`. No FidDb generation (out of scope, low ROI). Rename
|
||
hot unnamed funcs by call-count when de-noising further; there is no registry file for these — the
|
||
Ghidra name is the record.
|
||
|
||
**These `ctx` offsets are now a typed struct (2026-07-09).** The canonical field map is
|
||
`vm-map/engine-ctx.toml` → generated `docs/engine-ctx-reference.md`; a `run_script_inline` pass created
|
||
an `EngineCtx` Ghidra struct and retyped **all 419 dispatch handlers' `this` to `EngineCtx *`**, so they
|
||
decompile `ctx->cur_ctx_index` / `ctx->frame_instruction_word_count` / `ctx->run_state_flags` instead of `param_1 + 0x…`
|
||
(verified: `sleep_op_0xc8`, `gfx_op_0x215_query_source_slot`). Add a field: edit `engine-ctx.toml`, run
|
||
`engine_ctx_build.py --build`, re-apply the struct. (The VM global bank `G[…]` is separate — `globals.toml`.)
|
||
|
||
---
|
||
|
||
## Findings
|
||
|
||
### Logical screen dimensions come from the SYS4INI settings trailer (resolved 2026-07-24)
|
||
|
||
AGE has a compiled fallback, but the shipped game's logical resolution is data-driven. Engine construction
|
||
at `0x413860` initializes `EngineCtx.screen_w/screen_h` to `640x480`, and
|
||
`engine_settings_register_defaults@0x46be30` independently registers `set:WinX=640` and
|
||
`set:WinY=480`. During `engine_load_sys4ini_and_mount_append_catalogs@0x4099f0`, the loader advances past
|
||
the archive/file directory and VM-bank metadata into the remaining SYS4INI data, then calls
|
||
`engine_apply_sys4ini_settings@0x4056d0`. That routine loads the serialized settings through the engine
|
||
settings service and copies `set:WinX`/`set:WinY` into the two context fields before surfaces and the main
|
||
window are created.
|
||
|
||
The decompressed Himegari SYS4INI trailer explicitly contains `SCREENX=800` and `SCREENY=600`. As an
|
||
independent cross-game check, Kamidori's trailer contains `1024x576`. Thus the answer for the logical game
|
||
canvas is **SYS4INI per-game data overriding a generic EXE fallback**, not a separately compiled AGE
|
||
binary for every resolution. `display:ScreenMode`, `display:FullScreenWidth/Height`, aspect mode, and
|
||
related registered settings govern presentation/fullscreen selection separately; they do not redefine the
|
||
authored logical canvas. The serialized trailer location is recorded in `sys4-format-notes.md`.
|
||
|
||
### SYS4INI startup-settings catalog (resolved 2026-07-28)
|
||
|
||
The settings trailer is a real per-game startup profile, not a miscellaneous string appendix. The complete
|
||
36-pair serialized inventory and physical offsets are canonical in `sys4-format-notes.md`; this section owns
|
||
their native behavior and current port relevance.
|
||
|
||
The startup order is:
|
||
|
||
1. `engine_settings_register_defaults@0x46be30` creates AGE's generic registry.
|
||
2. `engine_load_sys4ini_and_mount_append_catalogs@0x4099f0` parses the asset directory and VM-bank metadata,
|
||
then passes the remaining length-delimited settings record to
|
||
`EngineCtx::engine_apply_sys4ini_settings@0x4056d0`.
|
||
3. `engine_settings_import_sys4ini_pairs@0x46da80` reads the pair count followed by NUL-terminated CP932
|
||
key/value strings, matches external names case-insensitively, and inserts their canonical typed keys.
|
||
Unknown external names take the loader error path.
|
||
4. `engine_apply_sys4ini_settings` copies `set:WinX`, `set:WinY`, and `set:AntiFontVersion` into context
|
||
fields. `engine_initialize_subsystems_from_settings@0x415390` then initializes text, graphics, and audio
|
||
from the completed registry before script execution.
|
||
|
||
Most entries are direct integer/string mappings, but three transforms matter:
|
||
|
||
- `SAVEVERSION=310` becomes `set:SaveVersion1=3` and `set:SaveVersion2=10` by quotient/remainder division
|
||
by 100. It is the source of Himegari's persistence layout and append-catalog compatibility version.
|
||
- `NOSETMUSIC=3` stores `set:NoSetMusic=3` and also seeds `sound:Music=2`
|
||
(`NOSETMUSIC - 1`). `sound_route_is_enabled@0x405420` treats nonnegative music states as enabled;
|
||
`sound_set_music_route_enabled@0x407f80` moves the state between enabled/disabled bands separated by
|
||
three. The `set:NoSetMusic` copy itself has no later direct reader in this image.
|
||
- `WHEELKEYUP`/`WHEELKEYDOWN` each populate both `set:WheelKey*` and
|
||
`message:WheelKey*OnTW`. A nonempty `VERREGPOS` also invokes
|
||
`registration_read_class00_version@0x46ae80`; its Windows `CLASS00` registry result replaces
|
||
`set:GameVersion`.
|
||
|
||
The meaningful consumer catalog is:
|
||
|
||
| Setting group | Native Himegari effect | Classification |
|
||
|---|---|---|
|
||
| `CREATEOBJECT`, `DRAWMODE` | `CreateObject=2` selects only the retained D3D/object backend; `DrawMode=1` selects its matching draw/text path. Startup verifies that bit `1 << DrawMode` exists in the backend mask. | Active renderer selection |
|
||
| `SCREENX`, `SCREENY` | Replaces the generic `640x480` fallback with the `800x600` logical canvas before window/surface creation. The port parses and applies these dimensions to its logical canvas and default windowed client size; optional host boot arguments can vary only the physical client. | Active core profile; implemented |
|
||
| `FONT`, `ENABLEANTIFONT`, `ANTIFONTVERSION` | Seeds MS Mincho, enables `message:UseAntiFont`, selects the grayscale glyph-outline path, and records antialias version 3. | Active text profile |
|
||
| `DEPENDMOVIESOUND` | Supplies the default movie-audio dependency/routing policy when movie opcode flags do not force another route. | Active media policy |
|
||
| `FULLSCREENBIT`, `ALWAYSBACKUPSURFACE` | These are read only inside the legacy `CreateObject & 1` DirectDraw branch of `engine_initialize_graphics_from_settings@0x406480`. Himegari selects only bit 2, so its 32-bit/zero values do not drive the active renderer. | Valid compatibility settings, inert for this profile |
|
||
| `SCREENWARNING` | Imported as `set:ScreenWarning`; no reader beyond default registration/import was found in this EXE image. | Loaded, no observed consumer |
|
||
| `NOSETMUSIC` | Seeds the live music-route state as described above; the route starts enabled. | Active audio initialization plus otherwise-unused policy copy |
|
||
| all seven `MENU_*` keys | Imported as `set:Menu_*`. No direct non-loader reader of these exact registry keys was found; they appear to be shared AGE native-menu capability metadata rather than Himegari script-menu state. `MENU_USEANTIFONT` does **not** disable the independently enabled `message:UseAntiFont`. | Loaded compatibility/menu metadata |
|
||
| `ENABLEMEMFLIP` | Gates `op_0x6d`, which swaps VM-bank pointer pairs only when enabled. Himegari disables it and its corpus does not use that opcode. | Compatibility capability gate |
|
||
| `CLICKONUP` | Imported as `set:ClickOnUp`, but no direct runtime reader was found in this image. | Loaded, no observed consumer |
|
||
| `CANCELMESSKIPONCLICK` | Himegari overrides the zero fallback with 2, enabling `adv_interpreter_tick`'s press/release click-cancel state machine for persistent message Skip. | Active ADV input behavior; currently missing from port |
|
||
| `CONTROLDISIBLECURSOR` | The spelling is native. Value 1 suppresses AGE's ordinary cursor-restore call when a bound ADV hotspot action or hover callback is selected. | Active ADV cursor behavior; currently missing from port |
|
||
| `COEXISTMESSKIP` | Value 1 lets Auto and all-message Skip remain simultaneously enabled. With zero, `adv_toggle_auto_mode@0x406b70` clears Skip and `adv_toggle_skip_mode@0x406c20` clears Auto. | Active ADV toggle behavior; currently missing from port |
|
||
| `REDRAWTEXTONKEY` | Value 0 disables the wheel/key path that traverses and republishes the current text-history view through `CALLBACK_TEXT.BIN`. | Active ADV history-input policy; port uses its script callback path instead |
|
||
| `WHEELKEYUP`, `WHEELKEYDOWN` | Rebinds the registry action bits from generic defaults 3/1 to 8/9. `adv_input_service_poll@0x411230` uses them for ADV wheel-key/history handling; raw `WM_MOUSEWHEEL` accumulation for op `0x10d` remains a separate channel. | Active ADV input binding; port does not source these values |
|
||
| `USEAPPDATAFOLDER`, `SAVEPATH` | Selects `%LOCALAPPDATA%\Eushully\姫狩りダンジョンマイスター\SAVE` as the native save root. | Active native path policy; the port redirects it through the shared profile-root policy |
|
||
| `REGFILEPATH` | Supplies the Eushully/product-relative directory for native `SYS4REG.INI`. With Himegari's `USEAPPDATAFOLDER=1`, AGE resolves `%LOCALAPPDATA%\Eushully\姫狩りダンジョンマイスター\SYS4REG.INI`. | Active native engine-options path; redirected with saves while retaining the native format |
|
||
| `SAVEVERSION` | Selects numbered-save layout 3.10 and enables the matching append-catalog persistence structures. | Active persistence ABI |
|
||
| `GAMEVERSION`, `VERREGPOS` | Seeds display/registration version `1.00`, then permits the GUID-selected Windows registry `CLASS00` value to replace it. | Windows version/registration metadata |
|
||
| `REGKEY` | A nonzero value activates `registration_validate_key_file@0x46fc80`, which opens `SYS4RK.BIN`, validates its header/transformed payload and both CRC variants, and publishes the registration result. | Native registration/key validation |
|
||
| `COPYRIGHT`, `RCVERSION` | Both are imported; no later direct consumer of the exact registry keys was found in this image. | Loaded product metadata |
|
||
|
||
This is intentionally a native-behavior catalog, not a mandate to reproduce every Win32-era switch.
|
||
`Sys4AssetCatalog` now parses the ordered trailer once, retaining unknown pairs for diagnostics, and projects
|
||
`SCREENX`/`SCREENY` into one validated engine-owned logical canvas. Godot uses that value for its content-scale
|
||
base, default windowed client, backbuffer/compositor bounds, primary surface, movie/layout fallbacks, and input
|
||
coordinates. `--window-width`/`--window-height` may vary that client independently while the logical canvas
|
||
and all AGE coordinates remain unchanged. AGE's independent `640x480` defaults apply for missing or invalid
|
||
dimensions. The port still
|
||
preserves requested Mincho/Gothic faces, save version 3.10, and movie/audio behavior through their existing
|
||
paths; those keys have not yet been migrated to the parsed registry.
|
||
|
||
The remaining generic-profile gap is semantic application of further portable settings. In particular,
|
||
click-cancel, Auto/Skip coexistence, cursor policy, redraw policy, and wheel action ids must be decided from
|
||
this evidence instead of AGE's compiled defaults. The port deliberately redirects the native profile to
|
||
Godot `user://`: save payloads remain under `user://SAVE`, while engine options retain AGE's native
|
||
`SYS4REG.INI` filename and format at `user://SYS4REG.INI`.
|
||
It should not reproduce legacy DirectDraw selection or native registration/key validation.
|
||
|
||
### ops `0x1a2`/`0x1a3` store and restore shared `SAVE.DAT` integer cells (resolved 2026-07-20)
|
||
|
||
The SCJUMP slice assumed `u00428010` resolved a decision value to a scene. **That premise is wrong**,
|
||
and pinning the real handler plus its paired reader resolves the service:
|
||
|
||
- **VA-drift trap:** Kelebek's `u00428010` = op `0x1a2`. But Kelebek's raw VA `0x428010`, in *our*
|
||
build, sits inside a *different* handler `0x427fb0`, which is **op `0x1ac`** (per the table:
|
||
`ctx[0x26e3f]=0x427fb0`). Op `0x1ac` is a **save-path** op — its handler formats
|
||
`%s\SAVE%2.2d.DAT` (format string `0x571e70`) and is multi-operand. Reading the raw VA gave the
|
||
wrong opcode.
|
||
- **`0x1a2` = store.** `op_0x1a2_store_shared_profile_int@0x42d360` records the generic three-dword
|
||
instruction length, reads operand 1's current raw 32-bit value, and resolves its actual cell index with
|
||
`vm_operand_lvalue`. That accessor accepts a direct global integer (type 3), global pointer (type 6), or
|
||
local pointer (type `0xc`); pointer forms resolve to an index relative to the global integer bank. The
|
||
handler formats the key `3%08x` and insert-or-assigns the value in the table at `ctx+0x5190`.
|
||
- **`0x1a3` = load.** `op_0x1a3_load_shared_profile_int@0x427e90` resolves the identical cell index,
|
||
calls `shared_profile_int_lookup@0x4199d0`, and writes the result back through `vm_operand_write`.
|
||
A missing key returns **zero**. The old `string-lookup-set` label described neither its type nor effect.
|
||
- **The consumer is shared `SAVE.DAT`.** `shared_profile_payload_write@0x430a20` enumerates this table
|
||
and writes its entry count followed by one 12-byte key and one 32-bit value per entry. It is invoked by
|
||
`shared_profile_save@0x40c950`. `shared_profile_payload_read@0x431070`, called by
|
||
`shared_profile_load@0x40ccd0`, reconstructs the same table with insert-or-assign. Numbered
|
||
`SAVE##.DAT` paths use the surrounding state object only for container metadata/timing; they do not
|
||
enumerate this table. `RT.DAT` independently stores `ReadTextDB`.
|
||
- **The generic hash helper caused the earlier conflation.** `hash_table_insert_or_assign@0x42cf70` and
|
||
`hash_table_find_value_ptr@0x419290` operate on whichever table ECX selects. `0x1a2` selects
|
||
`ctx+0x5190`; value-switch ops `0xa2`/`0xa3` select the distinct temporary table at `ctx+0x5f6c0`;
|
||
engine settings and text caches use still other instances.
|
||
- **Corpus shape matches profile persistence.** The corpus has 17,585 `0x1a2` calls in 315 scripts:
|
||
17,539 operate on a local pointer immediately resolved by `lookup-array`, while 46 name a global cell
|
||
directly. The paired `0x1a3` appears 73 times in 12 scripts. `LOADCONFIG.BIN` restores configuration
|
||
globals with consecutive loads; `SYSTEM4.BIN` stores its initialized-config flag; gameplay and ADV
|
||
scripts store selected array cells rather than the whole VM global bank.
|
||
|
||
**Port verdict (implemented 2026-07-24):** `SharedProfile` now owns the selected
|
||
`global-cell-index → raw-int32` map across VM/script lifetimes. Opcode `0x1a2` upserts and `0x1a3`
|
||
loads-or-zero for direct globals and local pointers resolved into the global bank. The profile is shared by
|
||
fresh scene VMs through `GameSession`, while `GameSession` JSON remains intentionally unchanged and does not
|
||
silently alias this native profile domain. The paired string service and native `SAVE.DAT` payload/store
|
||
lifecycle are implemented with it; see the persistence-family section below.
|
||
|
||
The FIELD snippet `lookup(0x5f0ed, 0x62ccf); mov(ptr,1); lookup(...); 0x1a2(ptr)` therefore persists that
|
||
selected global array cell to the shared profile. It does not resolve decision→scene; scene dispatch remains
|
||
the separate call-script/progression path documented in `name-resolution.md` and `scjump-progression.md`.
|
||
|
||
**Lesson:** never analyze a native op by its Kelebek `u00XXXXXX` VA directly — always resolve the real
|
||
handler through the dispatch table (`ctx[0x26c93 + op]`). The raw VA is off by whole functions.
|
||
|
||
---
|
||
|
||
### op `0x03` (`call-script`) is a raw index into the SYS4INI file table — SOLVED (2026-07-07)
|
||
|
||
The long-deferred `call-script <id>` registry (`name-resolution.md §1`) is cracked. Resolved through
|
||
the dispatch table (op `0x03` → `ctx[0x26c93+3]` = **`FUN_0041bc90`**), then the loader/resolver chain:
|
||
|
||
- **`FUN_0041bc90`** (handler): fetches operand 1 (the id), bounds-checks call depth (≤ 0x26), pushes
|
||
a script frame, and calls the loader.
|
||
- **`FUN_0040e980`** (loader): opens the resource by id, reads the **0x20-byte SYS4 header**, checks
|
||
magic, allocates per-frame code/local buffers from the header var-counts, reads the bytecode body,
|
||
and pushes a script frame (**stride 0x1e = 30 dwords**, indexed by `ctx[0x14f45]`). Returns to the
|
||
caller when the callee ends.
|
||
- **`asset_open_indexed_entry@0x44f390`** (resolver — the key): its `this` is the embedded FileDB at
|
||
`EngineCtx+0x9c24c`, not EngineCtx itself. A base `record = [FileDB+0x414] + id*0x50`. The record is exactly the
|
||
**SYS4INI 80-byte layout** `{name[64], arc_id@0x40, file_number@0x44, offset@0x48, size@0x4c}`
|
||
(count = `[FileDB+0x40c]`, archive-name table = `[FileDB+0x410]`; absolute EngineCtx fields
|
||
`+0x9c658/+0x9c65c/+0x9c660`). It tries a **loose override first**
|
||
(`CreateFileA` on `record.name` → the mod/patch hook point), else opens archive
|
||
`[record.arc_id*0x100 + FileDB+0x410]`, `SetFilePointer` to `record.offset`, size = `record.size`.
|
||
High-byte-tagged ids select `[FileDB+0x3028 + signed_selector*4]` and use the low 24 bits as the
|
||
selected AAI record. The base corpus has no explicit high-byte call-script operand; INIT2 op `0x143`
|
||
supplies mounted record-zero ids dynamically.
|
||
|
||
**So `call-script <id>` = a direct RAW index into the SYS4INI global file table** — the same table
|
||
`parse_sys4ini.py` reads, but indexed *without* skipping `@` placeholders (13208 records, 2
|
||
placeholders). There is **no separate on-disk id→code registry**; SYS4INI *is* the registry, and we
|
||
already had it. **Statically confirmed:** all **297/297** distinct corpus `call-script` ids resolve to
|
||
a `.BIN` script with a semantically-exact name (`0x1ab→ADDITEM`, `0x2ae7→MES`, `0x143→BUNKI`,
|
||
`0x329d→CALCREVISE`, `0x2add→CALCBTPARAM`), 0 out-of-range, 0 pack-branch. Tooling:
|
||
`parse_sys4ini.py` emits `build/callscript-names.json` (id→name); `sys4load` annotates
|
||
`call-script 0x1ab =ADDITEM.BIN`; the whole `build/disasm/*.asm` call graph now reads by name. See
|
||
`name-resolution.md §1`.
|
||
|
||
**Companion — op `0x8f` (`call`) is INTRA-script, not cross-script.** Its handler **`FUN_0041fba0`**
|
||
sets `[frame PC @+0x53d2c] = [frame codebase @+0x53d28] + operand*4` and pushes a return address on
|
||
the per-frame return stack (`[ctx+0x552e8]`/`[ctx+0x55248]`). The operand is a **code offset within
|
||
the current script** (matches header table **T3, tag 0x8F** = local call targets). So `0x8f` is a
|
||
local JSR; only `0x03` loads another script.
|
||
|
||
**Port status:** the C# VM resolves both immediate and computed resource ids through `IScriptProvider`,
|
||
pushes an `ExecFrame`, runs the child, and resumes its caller. This is the same mechanism needed for the
|
||
natural root described below; an out-of-band scene-name registry is not required.
|
||
|
||
#### Natural boot and New Game control spine (B0, 2026-07-20)
|
||
|
||
SYSTEM4 is the long-lived script root and the game's actual scene coordinator. Its initial path establishes
|
||
the nine ADV layouts and system surfaces, chooses `LOADCONFIG.BIN` or `INITCONFIG.BIN`, calls `INIT2.BIN`,
|
||
optionally calls `LOGO.BIN` and `OP.BIN`, calls the one-op `INIT.BIN`, and enters `TITLE.BIN`. `INIT2` is
|
||
not a thin handle seed: it calls 23 data initializers in order (`EBINIT`, `CNINIT`, `ITINIT`, `SKINIT`,
|
||
`ILINIT`, `AFINIT`, `TRINIT`, `MAINIT`, `ALINIT`, `CDINIT2`, `MPINIT`, `LAINIT`, `OBINIT`, `STINIT2`,
|
||
`RTINIT`, `CGINIT`, `SPINIT`, `CTINIT`, `CVINIT`, `CIINIT`, `VIINIT`, `SCINIT`, `BTANINIT2`) and then
|
||
`TUNE.BIN`. The CLI `play --boot` nine-script list is therefore only a partial diagnostic approximation.
|
||
Godot now runs SYSTEM4 itself by default, so this complete sequence and its host-visible side effects execute
|
||
in one VM; Godot `--boot` remains only for an explicit direct-scene diagnostic such as `--scene SC0000`.
|
||
|
||
**Startup-video gate and modal player (2026-07-20).** The apparently optional `LOGO`/`OP` calls are
|
||
deterministic first-process-run behavior, not a profile/save decision. `SYSTEM4@0x29a` calls op `0x130` and
|
||
executes `LOGO.BIN@0x2a4` then `OP.BIN@0x2a7` when its result is nonzero. Native
|
||
`op_0x130_get_initial_root_run@0x4295b0` returns `EngineCtx+0x54ff0`; context construction initializes that
|
||
field to one, while `op_0x9_reset_scene_and_reload_root` is its only later writer and clears it before
|
||
resetting engine state and reloading root script id zero. The port now owns the same flag in the persistent
|
||
VM: it begins at one,
|
||
`0x130` writes it, and op `0x9` clears it as part of the whole-stack root reload described below. It is not
|
||
a boot seed or profile value.
|
||
|
||
**Whole-stack root reload (`0x9`, 2026-07-20).** Native
|
||
`op_0x9_reset_scene_and_reload_root@0x418f50` is not an ordinary child-script return. It clears the
|
||
initial-root flag, calls `script_frame_dispose@0x40e610` across all 40 interpreter slots, cancels timed and
|
||
input-callback state, invokes `scene_context_init_reset@0x40b3b0`, resets hotspot/input services, and finally
|
||
loads raw script resource zero with `script_frame_load_resource(..., 0)`. Raw SYS4INI index zero is
|
||
`SYSTEM4.BIN` in Himegari.
|
||
|
||
The scene reset owns interpreter run state, ADV input/skip/auto state, retained gfx objects and command
|
||
queues, text/render buffers, and—on the normal fresh-session path—the 1000 ordinary surface/movie slots.
|
||
It does not clear the global VM bank or engine configuration. The port now mirrors that boundary: an `0x9`
|
||
request propagates through every nested `call-script` frame without executing any caller continuation,
|
||
clears scene-owned VM/Godot presentation and input state, cancels deferred SFX starts without unloading or
|
||
stopping active channels/BGM, preserves global/external-global banks and process-owned configuration/caches,
|
||
then begins raw script zero at offset zero in the same VM session.
|
||
The retained ADV history backlog remains intact for now because its lifetime across this reset has not yet
|
||
been proven; only recording suppression is reset. Focused tests cover a three-frame unwind and raw-zero
|
||
resolution to `SYSTEM4.BIN`.
|
||
|
||
**Frontend exit request is not a root reload (`0x1`, 2026-07-20).** Native
|
||
`op_0x1_throw_exit_request@0x4162e0` constructs a four-byte value-one payload and throws it with
|
||
`ThrowInfo@0x5a9710`. The catchable-type metadata resolves that object to the named RTTI type
|
||
`Command_Exit_Exception` (`TypeDescriptor@0x5b13e8`), rather than an undifferentiated integer exception.
|
||
Its only two corpus sites establish the intent: TITLE executes it after the fifth main-menu action's sound
|
||
and delay, while SYSTEM4 executes it after reporting an invalid execution mode.
|
||
|
||
The catcher is the outer native message/scheduler loop now recovered as
|
||
`engine_main_tick_with_exception_policy@0x411840`. Its MSVC `FuncInfo@0x5a9750` has a typed catch entry for
|
||
`Command_Exit_Exception` at `catch_CommandExitException_set_exit_result@0x412648`. That funclet forces the
|
||
enclosing result to one and returns continuation `0x412961`, which performs loop teardown and returns to the
|
||
frontend. It never changes `frame_pc`. This matters because the dispatcher itself advances `frame_pc` only
|
||
after an opcode handler returns; `0x1` throws instead, so neither the dispatcher nor the catcher advances
|
||
past it. A distinct generic error-dialog policy at `0x412689` proves that fall-through is explicit: result
|
||
two retries the same instruction, result four adds the decoded instruction length before restarting the
|
||
loop, and other results exit. `Command_Exit_Exception` bypasses that policy entirely.
|
||
|
||
The Windows/frontend policy that follows the returned exit request—full exit versus returning to
|
||
title—is outside this opcode handler and is not implemented in the Godot frontend yet.
|
||
|
||
TITLE happens to contain a developer menu immediately after its `0x1`, including a `call-script` to
|
||
`DEBUG.BIN`; that code is unreachable in the native flow because the handler never returns. The port's
|
||
former unknown-op fallback did expose that menu when selecting TITLE's fifth action, providing a useful
|
||
visual confirmation of the static mapping but not a legitimate retail route. Opcode `0x1` now propagates a
|
||
process-exit request through hotspot callbacks and nested script frames and ends the VM session without
|
||
executing the following bytecode. An explicit Godot `--native-debug-menu` diagnostic can deliberately
|
||
restore the old fall-through for developer archaeology; it maps to `VmOptions.IgnoreExitRequests`, defaults
|
||
off, and is not part of the native compatibility path. Because it changes the opcode globally, it also
|
||
suppresses SYSTEM4's invalid-execution-mode exit site during that run. End-to-end visual validation of the native
|
||
`SYSTEM4 -> TITLE -> child -> 0x9 -> SYSTEM4 -> TITLE` history remains deferred until the frontend
|
||
exit/return-to-title boundary or a natural game-over/completion route exists.
|
||
|
||
The unreachable developer menu nevertheless records the game's intended debug-scene handoff. Its two ADV
|
||
viewer choices write `G[0]=1`, `G[0xaba5c]=-1`, `G[0x62ccf]=0`, and a raw script id into `G[0x699]`, then
|
||
return TITLE with local result one. TITLE's outer loop performs its normal ADV input/skip exit pair and
|
||
returns to SYSTEM4. SYSTEM4 resumes at `0x2b0`; the nonzero `G[0xaba5c]` suppresses SCJUMP remapping, so the
|
||
coordinator keeps the requested `G[0x699]`, performs its normal scene-entry setup, and calls that script at
|
||
`0x477`. Other developer choices directly call utility scripts such as `DEBUG.BIN` from TITLE instead.
|
||
|
||
The complete post-`0x1` menu accounts for all eight base-catalog `DEBUG*.BIN` records; no other fixed-id
|
||
caller was found in the corpus. `ADVデバッグ` writes packed id `0x325f` (`DEBUGADV.BIN`) to `G[0x699]`, and
|
||
`ADVデバッグ(PG用)` similarly writes `0x3294` (`DEBUGADV2.BIN`); these are the two coordinator-return paths.
|
||
The remaining choices are direct nested TITLE calls: `迷宮` calls `0x338c`/`DEBUGMAP.BIN`; both the base and
|
||
PG-oriented dungeon/base choices use `0x338d`/`DEBUGMAP2.BIN` with different surrounding mode writes;
|
||
`迷宮(敵確認用)` calls `0x3390`/`DEBUGMAP3.BIN`; `戦闘` calls `0x338e`/`DEBUGBTL.BIN` then `BTL.BIN`;
|
||
`戦闘エフェクト` calls `DEBUGBTL.BIN`, `0x338f`/`DEBUGANIME.BIN`, then `BTL.BIN`; and `システムテスト`
|
||
calls `0x3391`/`DEBUG.BIN`. Thus every authored path is behind the same non-returning exit request in this
|
||
retail executable. The generic SYSTEM4 computed call can technically accept any packed id, but no separate
|
||
normal-game writer of these eight fixed ids appears in the static corpus.
|
||
|
||
This also confirms three distinct cleanup owners around a debug launch: the selected script's own terminal
|
||
subroutines, SYSTEM4's ordinary post-child cleanup (including all ten SFX channels and retained scene
|
||
objects), and op `0x9`'s whole-stack scene reset when that opcode is actually executed. An arbitrary VM
|
||
script replacement would bypass the first two and is not equivalent to native scene dispatch.
|
||
|
||
Both child scripts create/draw 800x600 surface 42 and call op `0x20f`; their resource ids are universal raw
|
||
SYS4INI indexes `0x335f`/`LOGO.AGF` and `0x3364`/`OP.AGF`. `ED.BIN` is the only other corpus user, with
|
||
`0x3324`/`ED.AGF`. All three payloads begin with MPEG program-stream pack code `00 00 01 BA`.
|
||
`op_0x20f_play_modal_movie_to_surface@0x422e50` shares the movie allocation/open/audio setup used by
|
||
non-modal scene-movie op `0x236`, then starts playback and sets run-state bit `0x2000`; the engine main loop
|
||
and window procedure treat that state as the modal whole-movie service. This parks the script at the opcode
|
||
until EOF or input cancellation, after which the script's following instructions release the object/surface.
|
||
The port implements `0x20f` through a typed packed-movie resolver and a distinct modal host call. The existing
|
||
asynchronous decoder publishes frames through the retained surface while only the VM thread is parked;
|
||
EOF, mouse click, Accept, or Cancel resumes the wrapper so its scripted cleanup releases surface 42.
|
||
`0x236` retains its non-modal contract; native passes its packed resource operand through the same universal
|
||
catalog opener as `0x20f`. Both paths now decode the MPEG audio pin through FFmpeg into timestamped stereo
|
||
float PCM. Godot owns one `AudioStreamGenerator` per playback instance, applies the native flag-selected
|
||
mute/music/SE/voice or default movie route, and uses the sound-hardware position as the presentation clock.
|
||
|
||
**Native first-frame preroll and stream origin (2026-07-28):** AGE does not seek or manually decode an
|
||
opening frame. `movie_to_texture_open_asset_graph@0x463e20` finishes with the DirectShow graph stopped.
|
||
`movie_start_modal_playback@0x463280` then calls `IMediaControl::Run` at vtable slot `+0x1c`; there is no
|
||
preceding `Pause` or `IMediaPosition` mutation in `0x20f`. DirectShow's stopped-to-running transition
|
||
necessarily passes through paused/preroll. For a file source, the graph manager waits for each renderer to
|
||
receive a sample; a video renderer displays that held first sample as a poster image before the graph enters
|
||
running state. See Microsoft's [Filter States](https://learn.microsoft.com/en-us/windows/win32/directshow/filter-states)
|
||
contract.
|
||
|
||
The custom `movie_texture_renderer_receive_sample@0x4628d0` copies that preroll sample into the D3D texture
|
||
and sets renderer `+0x5b4`. `movie_consume_renderer_new_frame_flag@0x405120` clears the flag and makes the
|
||
outer engine tick render the retained surface. Audio is cued during the same graph transition but begins only
|
||
with the running reference clock. Native observation adds the decisive cadence constraint: OP's image advances
|
||
immediately with its audio; it does not hold the preroll image for roughly 600 ms. AGE contains no later
|
||
timestamp correction, so the DirectShow splitter/graph is necessarily presenting the video stream relative to
|
||
its first sample rather than exposing FFmpeg's shared program-stream timestamp origin. This last mapping is an
|
||
inference about DirectShow internals, but the visible native behavior is the parity oracle.
|
||
|
||
The installed FFmpeg probe rules out dropped or transparent source frames. LOGO and OP first report video PTS
|
||
600/601 ms on the shim's shared mux timeline, then deliver every tested opening frame at consecutive 33/34 ms
|
||
steps; pixels change within the first 20 frames. Every decoded alpha byte in those frames is 255. MPEG-1 has no
|
||
alpha plane. Native `movie_texture_renderer_set_media_type@0x463750` accepts RGB24/RGB32, and the RGB24 copy
|
||
path promotes pixels to alpha `0xff` before comparing the complete packed ARGB value with its renderer key.
|
||
Opcode `0x20f` attaches that renderer to the existing D3D target and never reloads the retained surface or
|
||
assigns it an RGB color key. The former port did both `Gfx.SetSurface(..., colorKey: 0)` and RGB-only keying,
|
||
which incorrectly made exact-black movie texels transparent and explained grey-background MPEG speckles.
|
||
|
||
The corrected port publishes frame zero before starting audio, then schedules all video frames from
|
||
`source_video_pts - first_video_pts`; audio retains its own complete stream from timestamp zero. No video or
|
||
audio samples are discarded. Signal-bearing PCM in OP's first 600 ms proves only that the audio data is real,
|
||
not that the MPEG mux's cross-stream start offset is an intended 600 ms visible lead.
|
||
|
||
An existing native operand trace identifies every observed heap codebase by a 100% match against its static
|
||
instruction-offset set. The captured New Game route is:
|
||
|
||
`TITLE → GAMESTART → UNITECH → CALCARR → GAMESTART → TUNE → GAMESTART → TITLE → SYSTEM4 → SC0000`.
|
||
|
||
The transition sites make the ownership explicit. `TITLE@0x31c` calls `GAMESTART`. The selected New Game
|
||
path calls `UNITECH@0xd47` (which calls `CALCARR@0x4ca`), later calls `TUNE@0x1338`, writes flow result
|
||
`G[0]=1` and SCJUMP decision `G[0x62ccf]=0`, and returns. SYSTEM4 resumes at `0x2b0`, prepares the ADV
|
||
scene boundary, resolves `G[0x87a57][G[0x62ccf]]` into next-script resource `G[0x699]`, falls back to raw
|
||
SYS4INI id `0x22` (`SC0000.BIN`) when the mapping is zero, and executes computed `call-script@0x477`.
|
||
Thus normal scenes remain nested script frames under SYSTEM4 and return to it; the port should keep one
|
||
VM/host session rooted at SYSTEM4 rather than replace top-level VMs based on a host-invented scene result.
|
||
|
||
**Port landing (2026-07-20).** The no-argument Godot path is rooted at SYSTEM4 and renders TITLE without
|
||
manual inherited-layout or surface injection. A real-script integration test drives the same input callback
|
||
lifecycle through TITLE and GAMESTART and observes SYSTEM4 enter SC0000 with `G[0]=1`, `G[0x699]=0x22`,
|
||
and script-produced `G[0x6c1]=1`. Direct `--scene` launches retain the old bootstrap strictly as a diagnostic.
|
||
|
||
`tools/frida/capture_script_loads.py` hooks `script_frame_load_resource@0x40e980` and reads its third stack
|
||
argument (the raw packed resource id) for direct name resolution. It is attach-only: attempting to gate the
|
||
installed executable at process start with this loader hook produced Protection Error 45 and no records.
|
||
No protection bypass or executable patch is part of the investigation.
|
||
|
||
---
|
||
|
||
### op `0x215` (`query-gfx-object?`) is a retained gfx-object query — settles the render drift as (b) (corrected 2026-07-20)
|
||
|
||
**This is the canonical account of the background/sprite "drift" bug** (background pinned off-centre /
|
||
bottom-right, rest grey — `Screenshot 2026-07-06 211353.png`). It supersedes the earlier "drift =
|
||
state-divergence, seed state and it's fixed" conclusion in `docs/phase-a-slice-plan.md` and the status
|
||
memory, which are corrected to point here.
|
||
|
||
Resolved via the dispatch table (`ctx[0x26c93 + 0x215]`): the registration routine `FUN_00413860` stores
|
||
`[ESI + 0x9baa0] = 0x42a0b0`, so op `0x215`'s **real handler is `FUN_0042a0b0`**. (Kelebek's `0x421160` is
|
||
VA-drift — it lands inside the unrelated `FUN_00421090`. Same lesson as `0x1a2`: never trust a Kelebek raw VA.)
|
||
|
||
`FUN_0042a0b0(ctx)` does exactly two things:
|
||
1. **`*(ctx + 0x53d88 + ctx[0x53d14]*0x78) = 5`** — records this opcode's generic encoded length
|
||
(one opcode dword plus two dwords per operand). This is interpreter bookkeeping, not a gfx side effect.
|
||
2. **`out = FUN_0047f280(FUN_0041b940(2))`** — `FUN_0041b940(2)` fetches operand 2 (the bytecode handle
|
||
key); `FUN_0047f280` is a **`std::map::find`** over an engine-internal associative registry, returning
|
||
the mapped value or **`0xffffffff` (not-found)**; `FUN_00425fb0(1, out)` writes it to operand 1. That
|
||
registry is populated by the retained-object draw/geometry workers. Op `0x1a2`'s shared-profile integer
|
||
table is separate and does not populate this map.
|
||
|
||
**(a) vs (b) — the verdict is (b).** The value `0x215` returns is **native retained-object state**: "has a
|
||
gfx object already been created under this handle, and what is its source slot?" (`≥0` = existing → use its slot;
|
||
`-1` = absent). That
|
||
state lives in the engine's own registry, maintained by the gfx ops, **not in the VM global bank**. So
|
||
**seeding story-state globals cannot reproduce it** — the drift is *not* the Phase-B state-divergence
|
||
problem. Stubbing `0x215` returns a constant → `label_12649`'s slot-select always takes one branch → every
|
||
draw collapses onto slot 0 → the anchor-preserve math measures foreign-sized textures → cumulative drift.
|
||
|
||
**Why the prior "state-divergence" conclusion was wrong.** It was grounded in `capture_gfx_objects.py`,
|
||
which polled `[esi+0x53d64]` at ~2/s and mistook the engine's `0x78`-byte **script-context records** for gfx
|
||
objects. The branch is driven by the retained-object map at `ctx+0x46614+0x408`, a different structure the
|
||
probe never observed. Absence in that capture therefore says nothing about the native gfx-object path.
|
||
|
||
**The fix is tractable and Frida-free.** (b) does *not* mean an opaque native state machine. The subsystem
|
||
is a **modelable data structure**: retained object records (slot / geometry / draw state) behind a
|
||
handle→object registry (a `std::map`). Geometry and draw workers lazily populate that retained-object map;
|
||
query and erase workers read/remove the same entries. Op `0x1a2` also maintains a shared-profile integer table,
|
||
but that is a separate structure and is not what op `0x215` queries. The opcode-level source of truth is
|
||
`vm-map/opcodes.toml`.
|
||
|
||
#### Op `0x215` queries the retained gfx-object's source slot (corrected 2026-07-09)
|
||
|
||
The decisive caller/callee detail is the owner pointer. Op `0x215`'s handler passes
|
||
`ECX = ctx+0x46614` to `gfx_object_query_source_slot` (`0x47f280`); that worker searches
|
||
`ECX+0x408`. `draw-texture` passes the same owner to `gfx_object_bind_draw` (`0x47e870`), whose
|
||
`gfx_object_get_or_create` uses the same `owner+0x408` map and writes the source surface slot to
|
||
`obj+4`. Therefore:
|
||
|
||
- absent handle → `0x215` returns `0xffffffff` (-1);
|
||
- geometry-only/unbound object → its default source slot is -1;
|
||
- draw-bound object → `0x215` returns the live source slot from `obj+4`.
|
||
|
||
The earlier “op `0x215` reads a registry populated only by `0x1a2`” conclusion was wrong: it conflated
|
||
the retained-object `std::map` with `0x1a2`'s open-addressing shared-profile integer table. The useful part of
|
||
the earlier fix remains: `GetOrCreate` must not fabricate a slot. A fresh object stays unbound (-1) until
|
||
`draw-texture` supplies its real source slot.
|
||
|
||
This also explains the reported magic-circle retention end-to-end. `AE001H.AGF` (resource `0x37`) is
|
||
bound to the ritual object's surface slot. At the post-effect cleanup (SC0000 `0x3321`), the script queries that object with
|
||
`0x215`, sign-tests the returned slot, then executes `0x1f7(handle, 10)` followed by
|
||
`0x1fa(returned_slot)`. Native `0x1f7` removes the retained object group from this same map; `0x1fa`
|
||
releases the surface slot. The port's old separate-registry model returned -1, skipped the guarded cleanup,
|
||
and left the circle compositing. `GfxState.QuerySlot` now returns `GfxObject.SourceSlot`; VM op `0x1fa`
|
||
clears that surface slot. The booted SC0000 regression ends with no visible resource `0x37`; live
|
||
clicked-path validation confirmed the corrected disappearance on 2026-07-10.
|
||
|
||
Note a **second, still-latent** gap this uncovered: `label_125bd` (which fills `rec[s3]`/`G[0x3239]` with
|
||
the eight per-object slots 4..11, called at `SC0000` `0x50f`) does **not** execute in a cold single-scene run —
|
||
the scene coroutine framework (ops `0x7b`/`0x140` + the `G[0xaba5c]==1` re-entry gate) routes cold flow
|
||
past it, so every fresh CG is assigned slot `0`. It doesn't break the *opening* (one full-screen CG shown
|
||
at a time, so sharing slot 0 is harmless and the fresh-branch geometry is correct regardless), but a scene
|
||
with several simultaneous distinct-slot objects would need the setup to run. Tracked as the scene-coroutine
|
||
work, separate from this fix.
|
||
|
||
#### ADV layer surface-slot registry (`G[0x3239]`, closed 2026-07-23)
|
||
|
||
The former auto label `rec[s3]` is the eight-row `adv_layer_surface_slots` registry. Each scene's
|
||
setup helper writes the same three banks: column zero `primary_surface_slot` = 4..11, column one
|
||
`alternate_surface_slot` = 43..50, and column two `transition_surface_slot` = 51..58. The shape is
|
||
pervasive rather than SC0000-specific: 309 scripts reference the base, with 2,657 table-base
|
||
accesses; 143 scripts contain the initializer.
|
||
|
||
The common CG loader proves the column roles. `adv_gfx_layer_index` (`G[0x62450]`, always assigned
|
||
0..7) selects both this row and `adv_gfx_object_handles` (`G[0x62455]`). For a fresh retained object,
|
||
the loader uses the primary slot. For an already-bound object, it compares the live slot returned
|
||
by op `0x215` in `adv_gfx_surface_slot_work` (`G[0x62452]`) and chooses the other member of the
|
||
primary/alternate pair before rebinding. Column two is released and reused by the transition path.
|
||
The resource passed to `set-texture` is the transient `adv_gfx_resource_id` (`G[0x62424]`).
|
||
|
||
INIT2 seeds the first nine cells of the 20-cell retained-object handle array with `0xcb20`,
|
||
`0xcb2a`, `0xcb8e`..`0xcbc0`, and `0xcf08`. The eight-row layer table governs indices zero through
|
||
seven; the ninth handle is a fixed extra object outside that row-indexed layer set. These names now
|
||
live canonically in `vm-map/globals.toml`.
|
||
|
||
**⇒ Scene-coroutine framework — INVESTIGATION COMPLETE (2026-07-09).** The mechanism behind the slot-0
|
||
collapse is fully understood; the native finding and the implemented host-model disposition follow:
|
||
|
||
**The gate `G[0xaba5c]` is NATIVE scene-entry state — no script sets it to 1.** Across the whole corpus
|
||
(429 references in 150 files) *every* `aba5c` reference is a read or a write of `0`; nothing anywhere writes
|
||
`1`. So `aba5c==1` is set by the engine's scene loader/scheduler on entry — the **same class as the INIT2
|
||
handle array** (native entry-state a cold single-scene harness skips), NOT a story flag. Cold, it reads `0`.
|
||
|
||
**Corrected roles of the two branches** (the earlier head-start had them inverted). At SC0000 `0x450`
|
||
`eq local0 = (aba5c==1)`; `0x457 jcc local0 label_462 <fallthrough>`:
|
||
- **`aba5c==1` → `label_462` "ループ開始" (loop start)** = the scene's **intro/setup LOOP**. Its body
|
||
`label_491` runs `call label_125bd` (@`0x50f`, the slot-table fill `G[0x3239..0x324e]=4..11`) plus ADV
|
||
state init, UI-slot clears (a loop over `G[0x3239]`), intro draw — then `jmp label_462` (@`0x711`). A real
|
||
loop, exited only when its iterator makes `G[0x6be]==exit-PC` (→ `mov aba5c 0`, `jmp label_45e`).
|
||
- **`aba5c!=1` → `label_45e → call label_71b`** = the **scene CONTENT**: `label_71b` is a `switch(G[0x62ccf])`
|
||
on the SCJUMP decision → "序章 / プロローグ", `play-bgm`, `call label_12649` (CG loads that *read* the slot
|
||
table). So the intended lifecycle is **enter `aba5c=1` → intro loop fills slots → `aba5c→0` → content uses
|
||
slots.** Cold we skip straight to content with an empty slot table → all layers collapse to slot 0 → grey.
|
||
|
||
**The loop iterator `op 0x140` is a native video-service call — not statically reproducible.** Handler =
|
||
**`0x4299c0`** (dispatch `ctx[0x9b74c]=0x4299c0`; created+typed `EngineCtx*`+annotated; Kelebek `u0041F9C0` is
|
||
VA-drift). It records the generic 9-dword instruction length, copies operand-2/3 strings (`"LABEL"`, `"J"`) + operand-4 int, calls
|
||
**`(*DAT_005c6018)(8, ctx[0x54fe8], &{str,str,int})`**, and writes the returned PC-like value back to operand 1
|
||
(SC0000: `G[0x6be]`). `DAT_005c6018` is a **runtime-resolved function pointer** (all 6 xrefs are READs, no
|
||
static writer) — the engine's **native video / transition / timing service**: `FUN_00405740` (a screen-
|
||
transition/fade routine full of DirectDraw-layer calls) calls the *same* pointer with `cmd_id=3` and branches
|
||
on its return (`1`/`2` = transition progress). It is the **same class as the DirectDraw workers this project
|
||
deliberately does not model.** ⇒ faithfully emulating `0x140` = emulating the native video service = out of
|
||
static scope, permanently.
|
||
|
||
**The two companion ops (confirmed):**
|
||
- **`0x7b` (`FUN_0041ebf0`)** — *save yield handlers*: writes op1→`ctx[0x6da88+idx*4]`, op2→`ctx[0x6db28+idx*4]`
|
||
(idx=`ctx[0x53d14]`). SC0000 `0x79`: `op 0x7b label_3c9 label_41e` — registers the per-frame ADV handlers.
|
||
- **`0x7c` (`FUN_00417cb0`)** — *resume*: requires run-state bit `0x2000000` (`ctx[0x6dbc8]`) — **throws
|
||
(`__CxxThrowException`) if unset**, so it is only ever reached on a scheduler-driven re-entry, never cold;
|
||
restores PC=`ctx[0x53d28]+ctx[0x6dbcc]*4`, clears the bit, resets input/line state.
|
||
- `label_3c9`/`label_41e` (the `0x7b`-saved handlers) are per-frame *render → poll (`call label_8c`) → yield
|
||
(`0x7c`)* routines — i.e. **this coroutine machinery IS the ADV per-frame loop**, not just intro setup.
|
||
|
||
**⇒ DESIGN (host-model, not emulate).** To make cold single-scene runs correct: (1) supply `aba5c=1` as
|
||
scene-entry state (native, seedable, like INIT2); (2) give `0x140` a **host-modeled bounded "labeled yield"**
|
||
that runs the intro body at least once (so `label_125bd` fills the slot table + ADV init runs) then reports
|
||
completion so content plays — we reproduce the *observable effect*, not the video service. The idiom is
|
||
byte-identical across all ~136 ADV scenes, so it generalizes with zero per-scene work. **Payoff beyond the
|
||
slot fix:** the same `0x7b`/`0x7c` + handler machinery is the ADV frame loop, so a clean host model becomes
|
||
the seam for the interactive-ADV backlog (`0x90` hotspots / EMPTY scenes) and likely fixes the stuck magic
|
||
circle (scene-phase cleanup). **Permanently out of static scope:** the real intro-transition *timing/pacing*
|
||
inside the loop (it lives in the native video service) — we approximate it host-side, as everywhere else.
|
||
**Host model implemented (2026-07-09).** `VirtualMachine` recognizes only the ADV `"LABEL","J"` form
|
||
(138 corpus scripts; all have the same terminal-check shape), synthesizes `G[0xaba5c]=1` on top-level
|
||
scene entry, forces one setup-body pass even if `G[0x6be]` is stale, then returns the terminal immediate
|
||
discovered from the following `mov`/`eq` pair. Thus no SC0000 offset is hardcoded. Op `0x7b` retains the
|
||
saved handler PCs as frame metadata; op `0x7c` is a host-scheduler marker because the host already owns
|
||
service-boundary suspension and retained presentation. `TITLE.BIN`'s unrelated `"BIN","SC????.BIN"` service remains
|
||
stubbed. The real video-service timing remains intentionally unmodeled.
|
||
|
||
**Magic-circle retention fixed in the host model (2026-07-09).** The effect is `AE001H.AGF`
|
||
(resource `0x37`). SC0000 already contains the correct teardown, but the port's wrong `0x215` query
|
||
returned -1 and skipped it. The corrected source-slot query now reaches `0x1f7(handle,10)` object erasure
|
||
and `0x1fa(slot)` surface release; the booted regression ends with no visible `0x37` object.
|
||
|
||
#### gfx opcode contract table (corrected 2026-07-20; full family reversed)
|
||
|
||
Every handler first writes its encoded instruction length in dwords to the current **script-frame** record
|
||
(`*(ctx + 0x53d88 + ctx[0x53d14]*0x78) = 1 + 2*argc`), then fetches operands via
|
||
`FUN_0041b940(i)` (1-based). Gfx handlers then either **SET** retained-object fields (call a native worker
|
||
`FUN_0047xxxx`) or **QUERY** them (write results back via `FUN_00425fb0(i, val)`). The length write is generic
|
||
interpreter bookkeeping and is not part of the gfx contract. Handlers resolved through
|
||
the dispatch table (`ctx[0x26c93+op]`). The later ADV-text correction below removes `0x212/0x213`
|
||
from this family: their shared pointer table was initially mistaken for retained gfx objects.
|
||
|
||
| op | handler | words | dir | argc | contract |
|
||
|---|---|---|---|---|---|
|
||
| `0x1f7` | `0x422270` | 5 | erase | 2 | retained-object erase: `op2>1` → `gfx_object_erase_range(op1,op2)` erases `[op1,op1+op2)`, else `gfx_object_erase(op1)` |
|
||
| `0x1fa` | `0x4224a0` | 3 | set | 1 | release **surface slot** `ctx+0x52bd4[op1]` (vtbl free) + `FUN_00474e40(op1)` |
|
||
| `0x1ff` | `0x4227b0` | 9 | set | 4 | 3 int→float params on obj op1 → `FUN_0047e800(op1,f2,f3,f4)` |
|
||
| `0x202` | `0x4228d0` | 0xb | set | 5 | blit obj op1 with (op2,op3) + **packed ARGB** from op4(alpha)/op5(color) → `FUN_0047ea00` |
|
||
| `0x203` | `0x4229a0` | 9 | set | 4 | draw obj op1 with op2 + packed color(op3/op4) → `FUN_0047e9b0` |
|
||
| `0x212` | `0x4230c0` | 5 | ADV | 2 | layout `op1` retained wait-indicator handle `+0x64 = op2` |
|
||
| `0x213` | `0x423110` | 7 | ADV | 3 | layout `op1` retained glyph interval `+0x68 = op2` (first handle), `+0x6c = op3` (capacity) |
|
||
| `0x215` | `0x42a0b0` | 5 | **query** | 2 | retained-object **find**(op2 handle) → op1 = obj+4 source slot / `0xffffffff`. **Drives setup and teardown.** |
|
||
| `0x216` | `0x42a0f0` | 5 | **query** | 2 | read `[ctx+0x46d14 + op2*0x14]` → op1 |
|
||
| `0x217` | `0x4231b0` | 9 | set | 4 | 3 int→float on obj op1 → `FUN_0047e960` (SETS a geom 3-vector) |
|
||
| `0x218` | `0x42a130` | 9 | **query** | 4 | `FUN_0047f360(obj op1)` → op2,op3,op4 (GETS a geom 3-vector) |
|
||
| `0x219` | `0x423240` | 9 | set | 4 | 3 int→float on obj op1 → `FUN_0047e910` (SETS a geom 3-vector) |
|
||
| `0x21a` | `0x42a1b0` | 9 | **query** | 4 | `FUN_0047f2e0(obj op1)` → op2,op3,op4 (GETS a geom 3-vector) |
|
||
|
||
**`label_12649` correlation (the drift chain, confirmed).** The recurring idiom is:
|
||
```
|
||
query-gfx-object? (G 0x62452) (G 0x6245X) ; 0x215: handle G[0x6245X] -> working slot G[0x62452]
|
||
ui-elem? (G 0x6245X) 0xa ; 0x1f7: select that element
|
||
ui-clear? (G 0x62452) ; 0x1fa: clear the slot
|
||
```
|
||
`G[0x62452]` is the **working slot**; `G[0x6245X]` are per-object **handles** (the `0x62455[idx]` family:
|
||
`0x62456/7/8/a/b/c`). The geometry ops move two per-object 3-vectors between object records and globals:
|
||
- **`0x217` SET** anchor-vector `G[0x6249b/c/d]` **into** the object; **`0x218` GET** it back **out**.
|
||
- **`0x21a` GET** position-vector into `G[0x62498/9/a]`.
|
||
These get-vectors are exactly the inputs to the anchor-preserve math (`docs/superpowers/specs/2026-07-06-a2b-graphics-geometry-design.md`:
|
||
`G[0x62498] = G[0x6249b] − w/2`, foot-anchor at `G[0x6249c]`). **So the drift has two stubbed drivers, not
|
||
one:** `0x215` (wrong slot → collapse to slot 0) **and** `0x218`/`0x21a` (stale geometry vectors → the
|
||
anchor math reads garbage). Both read object state the SET ops (`0x217`/`0x219`/`0x1ff`) wrote — all
|
||
bytecode-driven, all host-modelable.
|
||
|
||
**Model implication for the host-side reimplementation (Phase 2 input).** The subsystem is a set of
|
||
per-object records keyed by handle, carrying: a live source **slot** (written by draw-texture, queried by `0x215`), a **position 3-vector**
|
||
(`0x21a` get / a matching set), and an **anchor 3-vector** (`0x218` get / `0x217` set), plus color/blit
|
||
params (`0x202`/`0x203`). The native workers (`FUN_0047xxxx` = the DirectDraw/surface layer) need **not** be
|
||
modelled — only the object-record data model, so the QUERY ops return what the SET ops stored. That makes
|
||
`0x215`/`0x216`/`0x218`/`0x21a` return correct values and the existing bytecode geometry math produces
|
||
correct `dst`/`w`/`h`. Ancillary per-object tables observed: `ctx+0x46d14` (stride `0x14`),
|
||
`ctx+0x52bd4` (element pointers), plus the `0x408` registry.
|
||
|
||
**ADV pointer-table correction (2026-07-27).** `ctx+0x14d54` is not a gfx-object table. It is
|
||
text-manager `ctx+0x14940` plus the ADV layout pointer table at manager `+0x414`. Opcode `0x212` writes
|
||
layout `+0x64`, consumed by `adv_text_publish_wait_indicator_frame@0x453120` as the retained indicator
|
||
handle. Opcode `0x213` writes layout `+0x68/+0x6c`, consumed by
|
||
`adv_text_publish_next_glyph@0x451220` as first glyph handle plus capacity and erased by layout reset/
|
||
publication. SYSTEM4 is the complete corpus: one `0x212(1,0xd674)` and nine `0x213` layout ranges, with
|
||
layout 1 assigned `0xd6d8..0xd8cb`.
|
||
|
||
Worker functions decoded + annotated in the Ghidra project (updated 2026-07-09): `gfx_object_erase`(`0x47d850`),
|
||
`gfx_object_erase_range`(`0x47d8b0`), `gfx_object_query_source_slot`(`0x47f280`),
|
||
`gfx_object_get_or_create`(`0x47ddb0`, inserts a zeroed default via
|
||
`gfx_object_init_default`@`0x472810`; critically, source slot `obj+4` defaults to **0**, while only an absent
|
||
map entry queries as `-1`), the setters `gfx_set_vec18/24/16c`(`0x47e960/e910/e800`), the getters
|
||
`gfx_get_vec18/24`(`0x47f360/f2e0`).
|
||
|
||
**Page-58 lifecycle correction (2026-07-11).** Locator `SC0000 P058` resolves to `wait@0x6545`; the loader at
|
||
`0x6470..0x6478` resolves resource `0x7a` to `EV050EA.AGF`. The port loaded and bound it correctly, but handle
|
||
`0xcb2a` inherited an earlier translation target `(-100,0)`, rotation `-90 degrees`, and alpha endpoint zero.
|
||
The preceding query-gated cleanup had skipped this created-but-unbound object because the port initialized
|
||
`SourceSlot=-1`. Native `gfx_object_init_default` zeroes `obj+4`, so op `0x215` returns slot 0, the cleanup
|
||
erases the object, and the later bind recreates identity state. Matching that default leaves EV050EA centered,
|
||
unrotated, and opaque in the synchronized page-58 compositor trace. The adjacent op `0x242(handle,0)` is not
|
||
a reset: it clears the object's detached-animation control word after binding. Its consumer and complete
|
||
lifecycle are documented under “Detached finite object animation” below.
|
||
|
||
**Page-89 surface-lifetime correction (2026-07-11).** Locator `SC0000 P089` resolves to
|
||
`wait@0x89e9`; the loader at `0x8976..0x898a` resolves resource `0x6a` to `BG004D.AGF`. The background
|
||
was bound but composited at `(400,650)`. The remaining 550 pixels were intentional retained motion from
|
||
BG001A; the incorrect `(400,600)` component came from the host keeping slot 0's boot-time 800x600 dimensions
|
||
after op `0x1fa` released that surface. Native op `0x1fa` frees and nulls `ctx+0x52bd4[slot]`, so a subsequent
|
||
op `0x208` size query of the released slot yields no surface dimensions. Clearing the host slot resource and
|
||
dimensions on release makes the script's existing-object geometry path compute base `(0,-500)`; after the
|
||
retained `(0,550)` translation, BG004D lands at `(0,50)`. A synchronized run at the exact wait confirms the
|
||
background and character layers together. This path also established that op `0x1ff` directly replaces the
|
||
current translation matrix at `obj+0x16c`; it is now modeled rather than stored in an inert side vector.
|
||
|
||
#### The `0x21c–0x243` sprite transform / ANIMATION cluster (2026-07-10, partial implementation)
|
||
|
||
The scene-completeness tracker (`tools/scene_opcode_coverage.py`) flagged a dense band of GAP ops in
|
||
`0x21c–0x243` (+ `0x2bd/0x2bf`) — at that point the **largest remaining rendering unknown** in SC0000
|
||
(e.g. `0x220`×66,
|
||
`0x22f`×34, `0x228`×33, `0x21e`×25 static sites). Resolving every one through the dispatch table
|
||
(`ctx[0x26c93+op]`, read from `FUN_00413860`) shows it is **one coherent subsystem: sprite transform +
|
||
animation/tween** — and two members were already named in prior RE (`0x234 gfx_op_0x234_anim_start`,
|
||
`0x238 gfx_op_0x238_set_anim_clock`). Kelebek VAs drift here as everywhere (op `0x220` real handler is
|
||
`0x4234e0`, not Kelebek's `0x4215D0`). **Op → real handler map:**
|
||
|
||
| op | handler | op | handler | op | handler |
|
||
|---|---|---|---|---|---|
|
||
| `0x21c` | `0x417520` (417xxx trivial) | `0x229` | `0x423700` | `0x236` | `0x423ee0` |
|
||
| `0x21d` | `0x423310` | `0x22a` | `0x4237b0` | `0x237` | `0x4240a0` |
|
||
| `0x21e` | `0x423350` **✎ set_transform3_norm** | `0x22b` | `0x423850` | `0x238` | **anim_start's clock ✎** |
|
||
| `0x21f` | `0x423410` | `0x22c` | `0x423900` | `0x239` | `0x424120` |
|
||
| `0x220` | `0x4234e0` **✎ set_transform3_abs** | `0x22d` | `0x423990` | `0x23a` | `0x42a440` |
|
||
| `0x221` | `0x423590` | `0x22e` | `0x423a40` | `0x23b` | `0x424190` |
|
||
| `0x222` | `0x4235e0` | `0x22f` | `0x423b00` | `0x23c` | `0x417580` (417xxx) |
|
||
| `0x223` | `0x423620` | `0x230` | `0x423ba0` | `0x23d` | `0x4175c0` (417xxx) |
|
||
| `0x224` | `0x417550` (417xxx) | `0x231` | `0x423be0` | `0x23e` | `0x42a4a0` |
|
||
| `0x225` | `0x4236a0` | `0x232` | `0x423c30` | `0x23f` | `0x42a520` |
|
||
| `0x226` | `0x42a230` | `0x233` | `0x423cf0` | `0x240` | `0x4245f0` |
|
||
| `0x227` | `0x42a2e0` | `0x234` | **set_rotation_cycle ✎** | `0x241` | `0x4247e0` |
|
||
| `0x228` | `0x42a3a0` | `0x235` | `0x423e40` | `0x242` | `0x4249d0` |
|
||
| | | | | `0x243` | `0x4182d0` (417xxx) |
|
||
|
||
(`0x2bd`→`0x4251c0`, `0x2bf`→`0x425240`. The handful of `0x417xxx` handlers are trivial/marker-shaped — the
|
||
default-handler neighbourhood — and are almost certainly no-ops or arg-poppers; triage before modelling.)
|
||
|
||
**Contract (completed 2026-07-10, representative ops `0x21e`/`0x220`, both `argc 6`):** these are
|
||
independent matrix channels, not two encodings of one vec3 property.
|
||
|
||
- `0x21e` normalizes operands 4–6, then `gfx_object_set_scale_channel` (`0x47eaa0`) stores timing at
|
||
`obj+0x3c/+0x50` and calls `0x48af1d`, which writes the three values onto a 4×4 matrix diagonal at
|
||
`obj+0xac`: a **scale matrix**.
|
||
- `0x220` passes raw operands 4–6 to `gfx_object_set_translation_channel` (`0x47ecc0`), stores timing at
|
||
`obj+0x44/+0x58`, and calls `0x48afb1`, which writes them into matrix entries 12–14 at
|
||
`obj+0x1ac`: a **translation matrix**.
|
||
- `0x1fe(handle, axis_x, axis_y, axis_z, angle_degrees)` is the immediate-current rotation setter.
|
||
`op_0x1fe_set_rotation_current` (`0x422700`) converts operands 2–5 to floats and calls
|
||
`gfx_object_set_rotation_current` (`0x47e720`). The worker stores current axis at
|
||
`obj+0x1ec..0x1f4`, current angle in degrees at `obj+0x204`, converts the angle to radians, writes the
|
||
current axis-angle matrix at `obj+0xec`, marks transform state at `obj+0x68`, and raises retained-gfx
|
||
redraw dirty at manager `+0xb558` (`EngineCtx+0x51b6c`). It is the direct-current companion to
|
||
`0x21f`, not another timed channel. Of 187 corpus calls, 186 use Z axis `(0,0,1)`; the lone DEBUG call
|
||
uses Y axis `(0,1,0)`. SC0010 uses immediate `5`, `-5`, and `0` degree Z rotations in a wobble setup.
|
||
- `0x21f` converts operands 4–7 to floats and calls `gfx_object_set_rotation_channel` (`0x47eb70`). It
|
||
stores delay/duration at `obj+0x40/+0x54`, target axis at `obj+0x1f8..0x200`, target angle (degrees)
|
||
at `obj+0x208`, and the target axis-angle matrix at `obj+0x12c`. Current axis/angle are
|
||
`obj+0x1ec..0x1f4/+0x204`, with current matrix `obj+0xec`.
|
||
- `gfx_object_apply_transform_channels` (`0x472f00`) supplies the timing contract. All three channels use
|
||
shared start timestamp `obj+0x34` and retained-gfx frame-time `owner+0xb550`
|
||
(`EngineCtx+0x51b64`), but have independent delay/duration:
|
||
scale `obj+0x3c/+0x50`, rotation `obj+0x40/+0x54`, translation `obj+0x44/+0x58`. Each holds current through the
|
||
delay, linearly interpolates current→target for its duration, then commits the target and clears its own timing.
|
||
Neither third component is opacity.
|
||
|
||
**Exact composition and 2D reduction (live-validated 2026-07-10).** The one-shot consumer starts from identity and
|
||
right-multiplies `T(-V18) → scale-current → rotation-current → translation-current → T(+V18)`;
|
||
`matrix4_multiply` at `0x4ee2a4` computes `out = left * right`. AGE uses row vectors. With no
|
||
rotation/perspective, the screen projection is therefore exactly
|
||
`V18.xy + (point.xy - V18.xy) * scale.xy + translation.xy`. The captured SC0000 handle `0xcbc0`
|
||
has base `(0,600)`, anchor `(400,1000)`, and final scale `(5,5)`; native matrix translation
|
||
terms are `(-1600,-4000)`, projecting the base point to `(-1600,-1000)`. The port's focused
|
||
projection test and transform-aware gfx log reproduce those values.
|
||
|
||
`gfx_object_composite` then right-multiplies `gfx_object_anim_interpolate`'s separately anchored product,
|
||
which contains op `0x234`'s cyclic rotation. With the other oscillating matrices at identity, adjacent anchors
|
||
cancel and the full order is
|
||
`T(-V18) * scale * one-shot-rotation * translation * cyclic-rotation * T(+V18)`. Thus cyclic rotation
|
||
also rotates the translation vector. The cyclic angle is integer degrees
|
||
`floor(((frameTime-start) % period) * 360 / period)`; it wraps to zero without ping-pong. Positive Z produces
|
||
`m01=+sin, m10=-sin`, clockwise on the Y-down screen.
|
||
|
||
Native matrix oracle: handle `0xcb8e`, anchor `(700,600)`, scale current `0.9`, op `0x21f` target axis
|
||
`(0,0,1)`/30° after 500 ms for 390 ms, sampled 11 ms into the ramp as
|
||
`[0.9055,0.0134;-0.0134,0.9055]` with translation `(74.1449,47.3127)`. The port focused test matches
|
||
those terms. In the windowed port capture, the two SC0000 `0x234` sites (periods 9000/13000 ms, Z axes
|
||
`+1/-1`) advanced after 563 ms to integer angles `22/15`, exactly the native formula, and produced distinct
|
||
affine PNG frames. Nearest-neighbour inverse mapping is the deliberate software raster sampling policy;
|
||
native D3D9 subpixel filtering remains a possible pixel-level difference, not an uncertain matrix approximation.
|
||
|
||
**Port result (2026-07-10):** `GfxState` retains scale, one-shot rotation, translation, and cyclic rotation
|
||
with their native clocks/order. `Transform2DMath` composes the full row-vector 4×4 transform before 2D
|
||
projection. Godot uses an inverse-mapped affine RGBA8 rasterizer for textured objects and solid fills,
|
||
preserving colorkey/tint/opacity behavior and never deriving opacity from transform Z.
|
||
|
||
##### `anim_start`/`set_anim_clock` decoded + opening confirmed (2026-07-07, animation-slice Task 1)
|
||
|
||
Decoding the two already-named clock/start ops (dispatch table → `0x234`@`0x00423da0`, `0x238`@`0x004240e0`;
|
||
both annotated) and grepping the SC0000 opening settles the animation model and confirms the opening exercises it:
|
||
|
||
- **`0x238 set_anim_clock` (argc 1, 3-dword instruction):** `ctx+0x51b78 = 0` (elapsed), `ctx+0x51b7c = operand1`
|
||
(total duration). **A GLOBAL, NON-BLOCKING clock** — not per-object. The op only *configures* the clock; it
|
||
does **not** loop/wait. The native render loop advances this clock each frame and interpolates *all* animating
|
||
objects. Its own plate comment states the payoff: "our port can drive animation in the host's per-frame loop
|
||
while the VM is parked at wait-for-input; no VM/host frame-lockstep." → **validates the wall-clock-tween
|
||
architecture directly.** SC0000: `set-anim-clock(G[0x624bb])` @`0x123bd`, `set-anim-clock(0x190=400)` @`0x13858`.
|
||
- **`0x234 anim_start` (legacy mnemonic; argc 5, 11-dword instruction):** following worker
|
||
`gfx_object_set_rotation_cycle` (`0x47f060`) into `gfx_object_anim_interpolate` (`0x473ed0`)
|
||
corrects its ABI to `(handle)(period_ms)(axis_x)(axis_y)(axis_z)`. Period is `obj+0x228`, axis is
|
||
`obj+0x244..0x24c`, and the frame-clock consumer applies
|
||
`360*((now-start)%period)/period` degrees. This is a cyclic **rotation** channel, not a target vec3
|
||
for scale/translation and not opacity.
|
||
- **The opening path uses the whole subsystem, early.** `0x21e`/`0x220` transform-sets fire from `0x00f73`
|
||
onward (`0x21e (G[0x6245b]) 0 0x12c l0 l1 0x64`, `0x220 (G[0x62457]) 0x96 0x3e8 l1 l3 0`), on the same INIT2 CG
|
||
handles (`G[0x62457]`,`0x6245b`,`0x6245c`) — this is the opening, **not** battle/debug. So the slice's ops are
|
||
real and verifiable on screen.
|
||
|
||
**Corrected host model:** `0x21e` scale and `0x220` translation run directly from frame-time
|
||
`retained-gfx owner+0xb550` (`EngineCtx+0x51b64`) using their own delay/duration; they do not use op
|
||
`0x238` as their duration.
|
||
Op `0x234` is the independent rotation cycle above. Op `0x238` still configures the separate
|
||
`ctx+0x51b78/+0x51b7c` animation service used by its own family.
|
||
|
||
##### Shared frame clock and cyclic source-cell cadence (2026-07-22)
|
||
|
||
The native scheduler confirms the shared-clock recollection, with one important distinction between a
|
||
shared **timebase** and universal phase. `engine_main_tick_with_exception_policy@0x411840` samples
|
||
`timeGetTime()` once per active outer presentation tick, first shifting retained-gfx manager current time
|
||
`+0xb550` (`EngineCtx+0x51b64`) into previous time `+0xb554` (`EngineCtx+0x51b68`). Opcode `0x23c`
|
||
explicitly performs the same shift/sample; it does not create another timer or impose a fixed frame rate.
|
||
Every cyclic object channel reads that shared pair, but each channel owns a start timestamp and period in
|
||
its retained object record. `gfx_animation_service_poll@0x407640` and op `0x238`'s
|
||
manager `+0xb564/+0xb568` start/duration window are separate finite-animation service state, not the cyclic
|
||
spritesheet clock.
|
||
|
||
Op `0x231` is already natively cell-change-driven. `gfx_worker_anim_srcrect@0x47eec0` resets object start
|
||
`+0x21c` and stores period `+0x230`, frame count `+0x238`, and columns `+0x23c`.
|
||
`gfx_object_anim_interpolate@0x473ed0` seeds that start from the shared current timestamp on the first
|
||
sample and selects `floor((current-start)/period) % frame_count`. It performs the same calculation with the
|
||
shared previous timestamp and raises manager redraw-dirty `+0xb558` only when the selected cells differ.
|
||
Other retained mutations raise dirty directly; continuously varying cyclic channels keep it raised. The
|
||
outer loop renders only for retained dirty state or a new movie frame. Merely completing an opcode `0xc8`
|
||
sleep/poll interval is not itself a native graphics-dirty request.
|
||
|
||
FIELD's DEBUGMAP unit family is more tightly synchronized than the general model permits. At
|
||
`FIELD@0x924a`, the script configures prototype handle `0x9c40` as `(period=200 ms, frames=4, columns=2)`.
|
||
The following loop calls `DRAWCH`, whose `0x21d` clones that complete retained record from `0x9c40` to
|
||
`0x9c40+entity_index`. Ordinary bytecode executes burst-fast and there is no render between prototype setup
|
||
and those clones, so every copied start remains zero and their first render seeds the same shared timestamp.
|
||
Those unit sprites are therefore phase-locked at one cell change every 200 ms (5 Hz), even though the
|
||
engine generally permits different object-local starts and periods.
|
||
|
||
FIELD's per-tile movement animation uses two retained objects. At `0x45c9`/`0x4686` and again at
|
||
`0x51fd`, it clones the bound idle handle `0x9c40+entity_index` into temporary moving handle
|
||
`0x9c40+0x76c`. The following mode-0 `0x203` write sets the idle handle's alpha to zero, while translation
|
||
`0x220` and source-cell endpoint `0x239` animate only the temporary handle. After the presentation boundary,
|
||
`DRAWCH@0x51ec` rebinds the idle handle at the new tile; a multi-tile route immediately repeats the
|
||
clone/suppress cycle. Treating every static mode-0 alpha byte on a loaded texture as inert therefore leaves
|
||
the old idle sprite composited beneath the moving clone until each segment ends. The port records the
|
||
post-bind static-alpha consumption as a transient opacity latch, cleared by the next `draw-texture` bind.
|
||
This preserves ADV's distinct pre-bind/static alpha-zero CG initialization.
|
||
|
||
The same ordering rule applies when an ADV callback rebuilds the message window after a modal menu.
|
||
`CALLBACK_SETTING` erases `0xd2f0..0xd2f6`, binds a fresh `SO001` crop to backing handle `0xd2f0`, reads
|
||
`message:MesWinAlpha`, and only then applies `(16-value)<<4` through mode-0 `0x203`. The erase discards the
|
||
backing's earlier one-shot-color provenance, so classifying only animated or looping objects made every
|
||
rebuilt message window opaque. Post-bind static alpha now controls this fresh backing as well; repeated
|
||
CONFIG changes therefore take effect immediately while the enclosing ADV wait remains active.
|
||
|
||
The same route exposed an independent publication-atomicity requirement. Each tile step reaches
|
||
`FIELD@0x4eb2 -> 0x9225`, which calls `DRAWMAP`, `DRAWOBJ`, and `DRAWMINIMAP`. `DRAWMAP` first erases its
|
||
old terrain handle ranges and then rebuilds them; `DRAWMINIMAP` recreates mutable surfaces `0x42..0x44`
|
||
from transparent pixels before filling the terrain and copying object/unit markers. Those are ordinary
|
||
VM-side construction bursts, not a series of front-buffer presents. Letting Godot's render thread sample
|
||
between their individual erase/fill/copy instructions displayed a partially blank minimap at every tile
|
||
and could display the erased terrain state for one frame when a complete route returned. The interactive
|
||
host now holds one writer-side presentation barrier for the whole script burst. Explicit presentation,
|
||
transition, sleep, text, movie, and input services temporarily expose the completed state; callbacks
|
||
serviced inside a parked input wait reacquire the writer side for their own atomic burst.
|
||
|
||
**Port implication:** replace the current “any spritesheet is active” redraw predicate with native-style
|
||
shared current/previous sampling and request a composition only when at least one visible sampled cell
|
||
changes. The FIELD `sleep(1)` input-poll loop must also stop requesting a redraw when no retained mutation
|
||
occurred. This is a fidelity correction as well as an optimization; it preserves phase-locking for cloned
|
||
records and does not invent 51 independent timers.
|
||
|
||
##### `label_1235a` animation-section boundary (2026-07-10)
|
||
|
||
The section helper computes the maximum configured duration and arms it with `0x238`, then reads
|
||
message-skip through `0x1c7` and ADV read/click-skip state through `0x1cc`. The raw `jcc` order matters:
|
||
normal playback (both zero) branches to `0x21c` (set run-state bit `0x400`), while a nonzero skip/read
|
||
state executes `0x243` (reset the separate global animation-service clock) followed by `0x20c` present.
|
||
Both branches finish with
|
||
`0x224`, which clears the native gfx command queue at `ctx+0x418`. These handlers are now named,
|
||
commented, and saved in the Ghidra image.
|
||
|
||
The matching native presentation trace corrects the earlier cadence model. Ordinary opcode execution is
|
||
**burst-fast between presentation services**, while `0x21c` is the render/wait boundary: it parks the
|
||
interpreter and `gfx_render_frame` repeatedly samples visible finite one-shot channels and queued surface
|
||
commands until dirty presentation state clears; `0x224` then clears the command queue. `0x20c` is a single
|
||
explicit publication on the skip branch.
|
||
|
||
At the opening AE001D passage, native `0x125a6` rendered the preceding state, then both object binds plus
|
||
mode-1 `0x203` and target `0x202` writes (`0xd5a/0xd63/0xd73/0xd8a`) completed in about 5 ms with **no render
|
||
between them**. Their first composition was the following repeated `gfx_render_frame` loop at `0x21c`.
|
||
Likewise, the explicitly presented mode-0 white CG at `0x125a6` survived only about 10 ms before that next
|
||
boundary. The port's former 200-completed-op/s throttle stretched the same burst across many display frames;
|
||
that average had folded service waits into execution time and was not an opcode scheduler rate.
|
||
|
||
The Godot host therefore leaves ordinary `FrameYield` non-blocking and publishes retained mutations only at
|
||
`0x20c`, `0x21c`, sleep, and stable input waits. `0x21c` waits for visible finite color/matrix channels plus
|
||
`0x223`; ambient cyclic/spritesheet pulses do not block, and click forcing remains restricted to `0x223`.
|
||
This is a native-evidenced scheduler correction, not a guessed duration sleep.
|
||
|
||
##### The opening render path is RETAINED, not immediate-mode (2026-07-08, ground-truth correction)
|
||
|
||
A working note in the animation slice mis-called the SC0000 opening a set of "immediate-mode slot-0 blits." That
|
||
was **wrong**, and it came from trusting our own `Age.Cli gfx` oracle (which executes our VM and mis-labeled the
|
||
CG draws as "slot 0"). Verified against native code + the raw bytecode:
|
||
|
||
- **`draw-texture` (op `0x1fb`, handler `gfx_op_0x1fb_draw_bind`@`0x422510`) is a RETAINED bind, not a blit.** It
|
||
records its 17-dword instruction length and calls **`gfx_object_bind_draw`@`0x47e870`**, which on the object keyed by `handle`
|
||
(operand 1) sets: `flag|=1` (visible), `obj+4 = source SLOT index`, `obj+8..0x14 = source rect`,
|
||
`obj+0x24/28/2c = position`. Its plate comment (prior RE) already states the key fact: the object stores the
|
||
**slot INDEX — a live ref to `surface[slot]`, resolved each frame at render — NOT a texture snapshot.** Objects
|
||
persist and are composited each frame; this is exactly the surfaces+objects model in "The full gfx render
|
||
model" above.
|
||
- **The SC0000 opening is a retained scene of distinct objects, `sleep`-paced.** Raw bytecode: fixed-handle UI
|
||
objects (`0xcf08` slot 3 full-screen, `0xc350` slot 0xe, `0xe678` slot 0xd — a 400×30 element re-bound 20+
|
||
times), an animated sprite (`draw-texture (G[0x62457]) (G[0x62452]) … (G[0x62498]) (G[0x62499])`, computed
|
||
position), and the CG loader (`SC0000` @`0x126e1`/`0x12970`): `set-texture G[0x62424] → slot G[0x62452]`,
|
||
`get-texture-size`, centre it, then `draw-texture (handle = CG_array[G[0x62450]] = INIT2 array G[0x62455..])
|
||
slot G[0x62452] …`. `sleep 0x64/0x3e8/0x2ee` sits between steps. So different draws use **different handles and
|
||
per-object working slots** — not one slot-0 canvas.
|
||
- **Why our port still doesn't animate the opening (conclusion unchanged, mechanism corrected):** we execute the
|
||
whole load/draw/`sleep` sequence **instantly** — no `sleep` timing, no per-frame present — so we only ever see
|
||
the *final* retained state; the intermediate `AE*` frames (`AE001D→AE002B→AE003B`, surface swaps on the working
|
||
slot between paced frames) never get a frame to display. The fix is **frame-pacing** (scene-coroutine / `sleep
|
||
0xc8`), a separate subsystem from the transform/alpha channel. **Lesson: never characterise the engine's render
|
||
mechanism from our own VM's oracle output — use native code + raw bytecode.**
|
||
|
||
### `sleep` (op `0xc8`) — the frame-pacing primitive (2026-07-08, decoded)
|
||
|
||
Handler resolved via the dispatch table (`ctx[0x26c93+0xc8]` = `param_1[0x26d5b]` in `FUN_00413860`) →
|
||
**`sleep_op_0xc8`@`0x420ec0`** (was `LAB_00420ec0`; created + annotated). It is **NON-BLOCKING**:
|
||
|
||
- It **arms a timer** — `sleep_timer_arm`@`0x44cff0` on the object at `ctx+0x5f304`: `+8 = 1` (active),
|
||
`+0x14 = (*DAT_0056f3d4)()` (start tick — an **ms** source, `timeGetTime`/`GetTickCount` class, same
|
||
`DAT_0056f3d4` the boot uses to seed `srand` via `time/100`), `+0x18 = duration` (operand, min 1). The engine's
|
||
main loop polls `elapsed ≥ duration` and resumes the script — rendering continues in the meantime. This is the
|
||
native confirmation that the engine paces animation in its per-frame loop, not by blocking.
|
||
- **Operand unit = MILLISECONDS.** `duration < 10` fast-paths through import `[0x56f0b8]`; every real scene sleep
|
||
(`100`/`750`/`1000` in SC0000) is `≥ 10` → the timer-arm path.
|
||
- The handler also records its generic **3-dword instruction length** in the current script-frame record
|
||
(`ctx+0x53d88+curidx*0x78`) and runs
|
||
two **anti-tamper** checks (call `[ctx+0x5512c]`; a rotate-checksum compare of `ctx+0x55120/0x55124`;
|
||
`__CxxThrowException` on mismatch — integrity work piggybacked on a hot op). Neither is needed by our model.
|
||
|
||
**Port equivalent (implemented):** our VM runs on a background thread (like `wait-for-input`), so blocking that
|
||
thread for `duration` ms while the main-thread compositor (`Main.Recomposite` in `_Process`) keeps presenting is
|
||
behaviorally equivalent to the native non-blocking timer. This correctly reproduces the **explicit one-shot
|
||
sleeps** (the dramatic 1000/750/200 ms holds). Headless/CLI hosts no-op `Sleep` (parity). `IHost.Sleep(long)` +
|
||
VM `case "sleep"`; see `vm-map/opcodes.toml` 0xc8. **It does not pace ordinary opcode bursts.** The later
|
||
native presentation trace proved that back-to-back retained mutations execute within milliseconds and are
|
||
first published at the following `0x20c`/`0x21c`, sleep, or stable input boundary. An earlier claim that the
|
||
opening was generally sleep-paced was inherited without execution evidence and is superseded.
|
||
|
||
The native timer arm clamps every duration to at least one millisecond. This is semantically important for
|
||
menu scripts: ROOM's steady input loop uses `sleep 0` as a one-engine-tick yield. The Godot host formerly
|
||
allowed a zero deadline, turning that loop into a free-running burst that exhausted the VM step guard and
|
||
showed `-end-`; it now applies the native minimum after the debug speed multiplier.
|
||
|
||
**Related — `present-frame` (op `0x20c`):** dispatch `param_1[0x26e9f] = gfx_op_0x20c_present_frame` →
|
||
`gfx_render_frame`@`0x4820b0` (buffer flip). The port treats `0x20c` as an explicit retained-state publication
|
||
boundary in the interactive host while keeping it `noop_headless=true`; the Kelebek label `u00416200` was
|
||
VA-drift. This supersedes the earlier host-implicit/no-op presentation model.
|
||
|
||
### Frame cadence — the interpreter tick, and why our port "speeds through" (2026-07-08)
|
||
|
||
**Historical reconstruction, corrected by the synchronized presentation trace below.** This pass correctly
|
||
identified the one-op interpreter tick and ADV Ctrl state, but incorrectly inferred a constant engine-level
|
||
opcode cadence. The outer native service invokes that tick in bursts and publishes retained state only at
|
||
specific service boundaries.
|
||
|
||
**Confirmed from the engine image (annotated in Ghidra):**
|
||
|
||
- **The interpreter is a cooperative one-op-per-tick step, not a run-to-completion loop.**
|
||
`adv_interpreter_tick`@`0x410fb0` (renamed from `FUN_00410fb0`) executes **exactly one opcode** per call:
|
||
`op = **(ctx+0x53d2c + curCtx*0x78)`; if `0 ≤ op ≤ 0x3ff` it dispatches `(*(ctx+0x9b24c+op*4))()` (the
|
||
handler table = `ctx[0x26c93+op]`) then advances `PC += *(ctx+0x53d88+curCtx*0x78) * 4` (decoded cmd size),
|
||
else the default handler `FUN_004162b0`. It also runs the **message-skip / click / auto-advance** logic each
|
||
tick (`s_set_CancelMesSkipOnClick`, `s_message_ReadTextSkip`, skip bit `ctx+0xa0ce4 & 0x8000000`) — i.e. the
|
||
**Ctrl fast-forward governor lives at the per-op level**, and a click can reposition the PC (skip-to-next).
|
||
- **Script contexts are coroutine records.** `curCtx = *(ctx+0x53d14)` indexes `0x78`-byte records at
|
||
`ctx+0x53d60`/`ctx+0x53d2c` (PC, codebase, cmd-size). The engine multiplexes script "threads." Init/reset =
|
||
`scene_context_init_reset`@`0x40b3b0` (zeroes `0x53d14` + `0xa0ce4`, allocs surfaces `ctx+0x52bd4[1000]`).
|
||
- **Advancement is gated by an interpreter run-state flags word `ctx+0xa0ce4`** (bit1 = sleeping, plus wait/
|
||
skip/etc.), read+written by ~40 state functions. `sleep_op_0xc8` sets bit1 + arms the ms timer and returns —
|
||
it does not block. So the outer loop consults `0xa0ce4` to decide whether to step the script this frame.
|
||
- **Effects are frame-stepped.** Screen transitions `FUN_0043cdb0` (12 wipe/slide modes) render **one frame per
|
||
step** and take a step-count parameter (the natural place a speed multiplier applies); `present-frame` (0x20c)
|
||
and the anim clock (0x238) advance per frame. A CG transition therefore spreads over many real frames.
|
||
- **Timing source** = the ms-clock function pointer `*DAT_0056f3d4` (`timeGetTime`-class), used throughout.
|
||
|
||
**Model proposed at this stage (partially superseded):** single-threaded frame loop; each service pass steps
|
||
opcodes until the context **yields**
|
||
(`sleep` armed / `wait-for-input` 0x72 / active frame-stepped transition/anim / present), renders
|
||
(`gfx_render_frame`), waits on the clock, continues. Back-to-back draws inside one page compose into a single
|
||
frame (fine); the opening's CG-to-CG advances are gated by frame-stepped transitions + sleeps, which spread
|
||
them over real time.
|
||
|
||
**⚠ Not statically resolvable (honest boundary):** the **outer frame loop itself** is not readable from this
|
||
dump. `adv_interpreter_tick` is invoked through a **runtime-set mode function pointer** (heap/vtable slot) — it
|
||
has zero static xrefs, and its address bytes (`b0 10 41 00`) appear nowhere in `range_00400000` (0x400000–
|
||
0x65ffff). The functions touching the scheduler state (`0x53d14`, `0xa0ce4`) are init/reset, save
|
||
(`context_state_serialize`@`0x40d320`), and op-handlers — never the loop. The "run-until-yield then render"
|
||
statement above is a **reconstruction** from those pieces, not a line read from the loop; pinning the actual
|
||
loop + its exact per-frame step budget / vsync wait needs a **live-debugger break** (attach + break in the
|
||
frame loop), or a wider memory dump that includes the mode object.
|
||
|
||
**Historical port conclusion (superseded):** this pass prescribed a bounded wall-clock opcode rate and led to
|
||
the frame-stepped implementation documented in the historical spec. The 2026-07-10 native trace disproved that
|
||
rate model: ordinary opcode work must remain burst-fast, while `0x20c`, `0x21c`, sleep, and stable input waits
|
||
own publication/pacing. Commit `85fc07d` implements the corrected boundary model.
|
||
|
||
### Frame cadence — live measurement (2026-07-08, Frida read-only)
|
||
|
||
The static pass couldn't reach the outer loop, so we measured the running game. **Read-only / import-only
|
||
only** (`tools/frida/probe_frame_cadence.py`, `probe_present.py`): a plain-JS hook on the proven operand-fetch
|
||
`0x41b940` (grab ctx + count exec rate) + system-DLL hooks; no engine-code patching. **Lesson learned the hard
|
||
way:** a first attempt with a **CModule** hook on the hyper-hot `adv_interpreter_tick` crashed the game
|
||
instantly (bad native callback into the hottest path — *not* anti-tamper; our other scripts hook engine code
|
||
via plain JS and survive). Use plain-JS hooks on proven addresses + memory polling.
|
||
|
||
Findings, corrected by the later synchronized presentation trace:
|
||
- Normal active capture measured about **1,788 operand fetches/sec** (peak ~5,796). This is neither a
|
||
completed-opcode count nor a scheduler budget; it mixes burst execution with native-service parking.
|
||
- **Fast-forward (Ctrl)** raised operand-fetch activity about 4× (≈7,738/sec avg, peak ~15,572), gated by
|
||
**`ctx+0xa0ce4 & 0x8000000`**. This remains useful evidence that Ctrl is ADV-scoped, but it does not define
|
||
a constant opcode cadence.
|
||
- Rendering is Direct3D 9 and uncapped. The candidate D3D9 `Present` hook fired around 1,908/sec in the
|
||
original probe; there is no fixed display refresh boundary to copy into the port.
|
||
- **Final scheduler implication (2026-07-10):** ordinary opcode work is burst-fast between explicit native
|
||
service boundaries. AE001D bind + mode + color-target writes completed within ~5 ms with no
|
||
`gfx_render_frame`; rendering began only at `0x21c`. The old 200-completed-op/s calibration folded service
|
||
waits into script time and is discarded. Godot `FrameYield` is non-blocking; `0x20c`, `0x21c`, sleep, and
|
||
input own publication/pacing.
|
||
|
||
### The render drift's SECOND half: missing system-boot state (2026-07-07, resolved)
|
||
|
||
Implementing the gfx ops (above) was necessary but not sufficient — a cold single-scene run of SC0000 still
|
||
drifted. Runtime tracing found **the CG handle array `G[0x62455..0x6245c]` was all zeros**, so every CG
|
||
collapsed onto object `0` and its geometry accumulated. Those handles are set by the **boot script `INIT2`**
|
||
(mov `0x62455=0xcb20 … 0x6245c=0xcbc0`), which is call-scripted by the real entrypoint **`SYSTEM4.BIN`**
|
||
(`LOADCONFIG → INITCONFIG → INIT2 → LOGO → OP → INIT → TITLE → …`). Our harness teleports straight into
|
||
SC0000, skipping that boot. **Fix:** run the system-boot state prefix (`INITCONFIG/INIT2/INIT`, skipping the
|
||
UI scripts) before the scene — `Age.Cli gfx --boot` and Godot `--boot` (both via `GameSession`). With boot,
|
||
the CGs **de-collapse and render correctly** (screenshot-confirmed). **This is the synthesis of the old
|
||
(a)-vs-(b) debate: the drift needed BOTH the native gfx ops (b) AND boot state (a) — specifically INIT2's
|
||
handle array, never before identified (it is not a story flag).** Note two distinct boots: our Phase-B
|
||
`--boot` runs the *data* `*INIT` scripts (skills/items/…); this is the *system* boot (`SYSTEM4` prefix) — a
|
||
"full boot" should run both. **Historical residual (subsequently resolved):** this capture still had opaque
|
||
`AE*` fade/flash effects and zero-anchor object-slot CGs. The later blend, geometry, animation, and retained-
|
||
presentation subsections below supersede that state; default object geometry remains confirmed `(0,0)` in
|
||
`gfx_object_init_default`.
|
||
|
||
### The gfx animation/effects subsystem — the `AE*` fades (2026-07-07)
|
||
|
||
The `AE*` flash/glow effects (and sprite motion) are a **native time-animated retained render loop**, not
|
||
per-frame bytecode. Reversed + annotated in Ghidra:
|
||
- **Retained objects carry cyclic rotation state:** flag value `4` at `obj+0`, start timestamp
|
||
`obj+0x214`, period `obj+0x228`, and axis `obj+0x244/248/24c`.
|
||
- **`gfx_object_set_rotation_cycle`** (`0x47f060`, worker for legacy op **`0x234`**)
|
||
configures that channel. `gfx_object_anim_interpolate` consumes it from retained-gfx frame-time
|
||
`owner+0xb550` (`EngineCtx+0x51b64`)
|
||
as a repeating 0..360-degree axis rotation. Op **`0x1fd`**
|
||
(`gfx_op_0x1fd_set_vec_scaled@0x422650` → `gfx_object_set_scale_current@0x47e6b0`) is an immediate
|
||
current-scale setter: it divides integer X/Y/Z percentages by 100, marks scale state at `obj+0x68`, and
|
||
writes `matrix4_make_scale` at `obj+0x6c`.
|
||
- **Op `0x238`** (`gfx_op_0x238_set_anim_clock`) sets a **global animation clock**, **non-blocking**:
|
||
`ctx+0x51b78 = 0` (elapsed), `ctx+0x51b7c = duration` (the max per-object duration; SC0000 `label_1235a`
|
||
maxes a table to compute it). It does **not** loop/wait.
|
||
- **Frame model:** the bytecode does `configure anims (0x234/0x1fd) → set clock (0x238) → show-text →
|
||
wait-for-input` and **continues**; the native render loop advances the clock + per-object progress each
|
||
frame, interpolates, composites, presents. Render/present family nearby: `0x243/0x20c/0x21c/0x224`
|
||
(`u004162xx`, not yet fully RE'd). **⇒ the port can drive animation in the HOST per-frame loop while the
|
||
VM is parked at wait-for-input — no blocking present op, no VM/host frame-lockstep** (the answer to the
|
||
"frame loop" question).
|
||
|
||
Consequence: animation needs a retained per-frame compositor. That architecture is live; scale,
|
||
one-shot rotation/translation, and cyclic rotation now rasterize through the affine software path.
|
||
|
||
### The full gfx render model — surfaces + objects + composite (2026-07-07)
|
||
|
||
Reversed the create/set/draw-texture handlers + the render loop (all annotated in Ghidra). **This is the
|
||
canonical model** (an earlier flat "draw layers to one screen" attempt was WRONG — it had no surface concept
|
||
and snapshotted textures at draw time; symptoms: alternating grey, glow over backgrounds, vanishing sprites).
|
||
|
||
**Two distinct stores:**
|
||
- **Surfaces** — image buffers at `ctx+0x52bd4[slot]`, indexed by slot. `gfx_op_0x1f8_create_surface`
|
||
(`0x4222d0`) allocates a blank one (releasing any old); `gfx_op_0x1f9_load_surface` (`0x422360`, op `0x1f9`
|
||
set-texture) resolves `resId` via the SYS4INI resolver (`FUN_0044f390`) and loads the file into the slot's
|
||
surface **with a colorkey/chromakey** (op arg 3 — never modelled before), also releasing the old surface.
|
||
A surface persists at its slot until the next set-texture overwrites it.
|
||
- **Objects** — the retained-gfx `owner+0x408` registry (`EngineCtx+0x46a1c`), keyed by handle (a
|
||
`std::map`). `gfx_op_0x1fb_draw_bind` (`0x422510`,
|
||
op `0x1fb` draw-texture) → `gfx_object_bind_draw` (`0x47e870`): sets the object's **source slot** (`obj+4`),
|
||
**source rect** (`obj+8..0x14` = left,top,right,bottom), **position** (`obj+0x24/28/2c` = V24), and the
|
||
**visible** flag (bit 0). The object references its surface **by slot index, live** (re-resolved each frame),
|
||
NOT a snapshot. Objects also carry anchor V18 (`obj+0x18`), independent scale and translation
|
||
matrices/timing, cyclic rotation state, and color/alpha (`0x202/0x203`).
|
||
|
||
**Render frame** — `gfx_render_frame` (`0x4820b0`), driven by op `0x20c` present (`gfx_op_0x20c_present_frame`
|
||
`0x4174a0`, which also updates the frame timer `ctx+0x51b64/68`): iterate the object registry **in ascending
|
||
handle order — that IS the z-order** (lower handle behind, higher on top; `std::map` key order). For each
|
||
object with visible bit 0, `gfx_object_composite` (`0x47f650`) computes its transform from geometry, **applies
|
||
the animation interpolation if bit 2 is set**, and blits `surface[obj.slot]` with alpha/colorkey. Then swap
|
||
buffers (present). **Slot 0 is NOT special** — a normal slot; several objects may share one surface.
|
||
|
||
**⇒ Faithful port:** a `SurfaceStore` (`slot → {image, colorkey}`, from create/set-texture) + an `ObjectStore`
|
||
(`handle → {slot, srcRect, position, anchor, scale, anim, alpha, visible}`, from draw-texture + the gfx ops) +
|
||
a host per-frame compositor that draws visible objects **in ascending-handle order** from their live surface,
|
||
interpolating animations by elapsed time. No VM/host lockstep: animations play during the wait-for-input park.
|
||
Separate scale/rotation/translation state and timing are implemented. Anchor semantics, multiplication order,
|
||
cyclic wrapping, 2D projection, and affine raster coverage have focused native-oracle tests. Native D3D9 filtering
|
||
and render-target command execution remain separate fidelity work.
|
||
|
||
#### Raw mode-1 surface load — opcode `0x249` (2026-07-21)
|
||
|
||
`op_0x249_load_raw_texture_surface@0x424b20` has ABI
|
||
`(universal_packed_catalog_id, surface_slot, RGB_colorkey)`. Its release, movie detach, indexed-asset open,
|
||
colorkey conversion, failure exception, and stream cleanup are the same as `0x1f9`. The only native loader
|
||
difference is the last argument to `gfx_surface_load_asset@0x477c40`: `0x1f9` passes mode 0, while `0x249`
|
||
passes mode 1. `gfx_surface_decode_and_create@0x474e90` selects a base 0x450-byte texture object for mode 0
|
||
and a derived 0x460-byte texture object for mode 1. A successful mode-0 load records the resource id in its
|
||
device-reload record; mode 1 records `-1`.
|
||
|
||
The derived class is specifically a **tiled large-image surface**, not an alternate pixel format,
|
||
spritesheet interpretation, or blend mode. `gfx_tiled_surface_create@0x432ff0` divides the logical image into
|
||
`ceil(width / DAT_005b15b0) × ceil(height / DAT_005b15b0)` ordinary mode-0 child surfaces.
|
||
`gfx_tiled_surface_upload_agf@0x431a10` decodes indexed 1/4/8-bpp and 24/32-bpp input and uploads each tile;
|
||
`gfx_tiled_surface_blit@0x4316b0` divides any requested logical source rectangle across the intersecting
|
||
children and adjusts their destinations. A CPU compositor can therefore keep one contiguous decoded RGBA
|
||
image without losing the mode-1 behavior relevant to Himegari.
|
||
|
||
The corpus makes the addressing and first gameplay consequence concrete. FIELD calls `0x249` with raw ids
|
||
`0x32da..0x32dd`, which are SYS4INI entries `SO005`, `SO007`, `SO008A`, and `SO007A`, into slots
|
||
`0x3e..0x41`. `DRAWMAP.BIN` then creates the dungeon tile objects almost entirely from slots `0x3e` and
|
||
`0x3f`. A skipped `0x249` therefore leaves the surrounding UI operational but the central map black. After
|
||
implementing it, the first DEBUGMAP retest exposed a separate host blit error: FIELD intentionally binds a
|
||
zero-width/zero-height SO005 prototype object, while the port expanded zero dimensions to the full texture.
|
||
Native `gfx_object_blit_d3d9@0x4774c0` clips the explicit source rectangle and returns when
|
||
`right<=left || bottom<=top`; mode-1's tiled blit likewise visits no children for an empty rectangle. The
|
||
port now preserves that empty draw rather than leaking the complete SO005 sheet.
|
||
|
||
#### Selected retained-object range transform — opcodes `0x229`/`0x22a`/`0x22c`/`0x22d` (2026-07-21)
|
||
|
||
`0x229` was formerly misclassified as a second per-object position setter. Native
|
||
`op_0x229_set_gfx_range_transform@0x423700` instead resets an embedded gfx-object record at retained-gfx
|
||
owner `+0x428`, writes `(first_handle,count)` to owner `+0x420/+0x424`, and writes operands 3..5 as that
|
||
embedded object's anchor at owner `+0x440..+0x448`. The actual per-object direct-position opcode remains
|
||
`0x22f`.
|
||
|
||
On every render frame, `gfx_range_transform_sample_frame@0x476df0` samples the embedded object's ordinary
|
||
scale/rotation/translation channels into owner matrix `+0xb5b4`. `gfx_object_composite@0x47f650`
|
||
post-multiplies an object's normal matrix by this shared matrix only when its handle is in
|
||
`[first_handle, first_handle+count)`. The sibling setters are:
|
||
|
||
- `0x22a`: current scale, three integer percentages divided by 100;
|
||
- `0x22b`: current axis-angle rotation (present in the native dispatch table, zero Himegari corpus calls);
|
||
- `0x22c`: current translation in pixels;
|
||
- `0x22d`: delayed/duration scale target, using the embedded object's ordinary one-shot scale channel;
|
||
- `0x22e`: delayed/duration axis-angle target (native-dispatch-only, zero Himegari corpus calls).
|
||
|
||
FIELD's camera helper selects handles `[1,50000)`, anchors the transform at the current camera world
|
||
coordinate `(G[0x767e],G[0x767f])`, sets translation to `(400-camera_x,300-camera_y,0)`, and applies the
|
||
zoom percentage from `G[0xccc09]`. Thus the map layer is centered/scaled while handles `>=50000`—the dungeon
|
||
UI—remain screen-fixed. FIELD's sole `0x22d` call animates a zoom over 300 ms. LOOK reuses the immediate
|
||
camera helper. Across the corpus, `0x229` occurs 693 times in 309 scripts: 590 all-zero disables, 101
|
||
identity-range selections, and the two FIELD/LOOK camera selections. Correcting the contract therefore
|
||
removes spurious object-zero mutations without changing established ADV output.
|
||
|
||
### Blend & transparency — colorkey + `0x202`/`0x203` color/alpha (2026-07-08)
|
||
|
||
Reversed for graphics slice A (spec `docs/superpowers/specs/2026-07-08-blend-transparency-design.md`;
|
||
Ghidra functions renamed + plate-commented, saved).
|
||
|
||
- **Colorkey format** (`gfx_op_0x1f9_load_surface` `0x422360`): read op arg 3; if `(int)key < 0` → **no
|
||
colorkey** (opaque); else the operand is **`0xRRGGBB`**, converted to `0xFFRRGGBB` and passed to the
|
||
surface creator `FUN_00477c40` as the transparent key (so operand `0` = key **black**). Colorkey is baked
|
||
**at surface-load** (matching texels → transparent), NOT compared per-blit. *Port:* interpret arg 3 as
|
||
RGB888; `<0` = none; else texels whose `(R,G,B)` equal the key become transparent when the surface image
|
||
is loaded/cached.
|
||
- **`0x202` (`gfx_op_0x202_worker_set_color_anim` `0x47ea00`)**: sets an **animated** color/alpha target
|
||
`obj+0x64 = packedARGB`, the color-anim active bit, and resets shared start `obj+0x34=0`. Operands 2/3
|
||
are delay/duration at `obj+0x38/+0x4c`; sampling uses retained-gfx frame clock `owner+0xb550`
|
||
(`EngineCtx+0x51b64`), not op `0x238`.
|
||
- **`0x203` (`gfx_op_0x203_worker_set_color` `0x47e9b0`)**: sets a **static** color/alpha `obj+0x60`, no anim
|
||
bit. Immediate per-object modulation.
|
||
- **Blit** (`gfx_object_blit_d3d9` `0x4774c0`): selects a **blend mode** from `obj+0x30`, the value written
|
||
by op `0x203`, and passes a modulation color/alpha to the device draw. Correcting the D3D9 constants:
|
||
mode 1 writes `SRCBLEND=SRCALPHA` (5) and `DESTBLEND=ONE` (2), so it is additive glow—not ordinary
|
||
`SRCALPHA/INVSRCALPHA`. Mode 2 conditionally writes `ONE/ZERO` for a selected render target; mode 3 adds
|
||
the subtract blend operation to the mode-1 factors. The port implements mode-1 additive composition,
|
||
where packed alpha scales source contribution and packed RGB multiplicatively modulates it. For a
|
||
**textured mode-0** object, preserved `0xffffffff` is opaque
|
||
identity, not a request to replace the texture with white; the packed alpha byte is therefore not a
|
||
generic tint-strength control. Mode 3 remains separately scoped beyond the completed mode-1 path, and
|
||
surfaceless mode-0 fills remain a distinct consumer case.
|
||
|
||
Created/render-target surfaces are a third mode-0 consumer, distinct from both loaded textures and an object
|
||
with no surface. SYSTEM4 creates and fills surface 3; BUNKI draws it with alpha `0xd0`, while FIELD draws it
|
||
beneath the minimap with alpha `0x40`. Original screenshots show the former as a translucent tinted panel and
|
||
the latter allowing the sidebar paper through. Therefore created surfaces consume packed alpha as object
|
||
opacity and packed RGB as multiplicative modulation. The port retains created-surface identity separately
|
||
even though these surfaces have resource id zero; their no-colorkey sentinel is `-1`, not active key-black
|
||
zero. Loaded mode-0 textures keep the established opaque/alpha-inert behavior above.
|
||
|
||
**TITLE SO022 additive proof (2026-07-20).** TITLE loads type-1 8-bpp `SO022.AGF` with no alpha plane and
|
||
no color key, binds two 140×140 spritesheet objects, and calls `0x203(handle,1,255,0xffffff)` for both.
|
||
Ordinary alpha composition therefore produces opaque black squares around the blue flames. Native mode-1
|
||
`SRCALPHA/ONE` makes black contribute zero; the implemented additive raster path removes the rectangles
|
||
while preserving the animated glow, verified across a windowed SYSTEM4/TITLE capture.
|
||
|
||
**SC0000 third-CG white-screen and missing-glow fix (2026-07-11).** The page containing
|
||
`大役を担ったのは…` reaches the intended EV052DA image and both animated AE001D layers. A synchronized
|
||
Godot capture proved AE001D is correctly alpha-bearing and drawn in mode 1 at only a 6-8% additive source scale;
|
||
it was not the white wall. During the preceding `0x223` EV052CA→EV052DA crossfade, base handle `0xcb2a`
|
||
renders EV052DA in mode 2 with identity modulation. When the transition ends, `0x203@0x12478` restores that
|
||
same textured object to mode 0 with negative color operands, preserving `0xffffffff`. Native keeps EV052DA
|
||
visible; the old port resolved the state as `tintStr=1.00` and lerped every source texel to white. Textured
|
||
mode 0 now resolves as opaque multiplicative RGB, making white identity, while surfaceless mode-0 fill
|
||
strength remains separate.
|
||
|
||
The first corrected capture exposed a second independent gap: AE001D was submitted and rotating but had no
|
||
visible pixel contribution. Its 800×800 circle was based at `(0,550)` around anchor `(400,950)` and remained
|
||
at the port's default 100% scale, almost wholly below the viewport. SC0000 executes `0x1fd` with
|
||
`210/210/100` and `240/240/100`; implementing the native immediate current-scale setter expands the circles
|
||
into the viewport (the first top edge reaches y=110). The user positively confirmed both the restored CG and
|
||
visible glow on the ordinary interactive path. This glow is a retained scaled/rotating texture, not an
|
||
`0x231` spritesheet-cell animation; later missing spritesheet effects remain a separate issue.
|
||
|
||
**Interpolation RE pass (2026-07-08, stalled → both deferrals confirmed).** Attempted to pin how a `0x202`
|
||
fade animates so smooth ramping could join slice A. Findings (Ghidra `gfx_object_anim_interpolate`
|
||
`0x473ed0`, annotated+saved): the bit-2 anim family (op `0x234`) interpolates **5 independent sub-channels**
|
||
(color `obj+0x240`/period `obj+0x220`, two matrices, rotation `0x168`=360°, src-rect scroll), each on the
|
||
**global frame clock `retained-gfx owner+0xb550` (`EngineCtx+0x51b64`)** (sampled by the active outer engine tick, NOT the
|
||
op-`0x238` clock `ctx+0x51b7c`), and each
|
||
**ping-pongs** (triangle wave, folded at period/2) — i.e. these are *oscillating/pulsing* effects, not
|
||
one-shot fades. The `0x202`/`0x203` color (`obj+0x60` static / `obj+0x64` animated, sets
|
||
`owner+0xb558`/`0xb560` = `EngineCtx+0x51b6c`/`0x51b74`) is a **separate channel whose blit consumer was
|
||
not located** in this pass. So a
|
||
one-shot fade's exact source→target→easing is still unresolved and would take a dedicated dig (find the
|
||
`obj+0x64` consumer + the color→`obj+0x240` path + the clock advance). ⇒ **smooth color-anim interpolation
|
||
stays deferred**; slice A ships the static end-state (which reaches the correct final alpha/tint and fixes
|
||
the stuck-opaque bug), with interpolation as a scoped follow-up.
|
||
|
||
**Resolution (2026-07-10 — supersedes the deferral above).** The missing consumer was the bit-1 branch in
|
||
`gfx_object_apply_transform_channels` (`0x472f00`), before its matrix channels. It seeds shared start
|
||
`obj+0x34` from retained-gfx `owner+0xb550` (`EngineCtx+0x51b64`); holds current packed ARGB `obj+0x60`
|
||
through delay `+0x38`; then performs an
|
||
integer, bytewise linear interpolation to target `+0x64` for duration `+0x4c`. At natural completion—or
|
||
when retained-gfx `owner+0xb55c == 1` (`EngineCtx+0x51b70`) requests forced completion—the target commits
|
||
to current, delay/duration clear,
|
||
target becomes `0xffffffff`, and the one-shot active bit clears when no color/matrix/src-rect sibling remains.
|
||
The object-local bit at `+0x2d0` suppresses the global force and keeps that finite group from raising the
|
||
blocking-presentation dirty flag; it remains redraw-active and therefore animates asynchronously. Negative
|
||
alpha/RGB target operands independently preserve their bytes from current `+0x60`.
|
||
|
||
The port now carries current and target separately and samples them from the unified `FrameClock`; an op
|
||
`0x203` static write after `0x202` therefore becomes the ramp's current value rather than overwriting its
|
||
target. Mode 0 retains the established CG/tint/fill behavior; mode 1 uses native additive composition with
|
||
ARGB alpha as the source scale plus RGB modulation. `draw-string 0x204`/`0x7a` remains a separate dependency.
|
||
|
||
**ADV chrome correction (2026-07-11).** Mode 0 cannot be classified from the final packed color alone.
|
||
Static `0x203(mode=0, alpha=0, rgb=white)` remains the established opaque/no-tint CG initializer, but a
|
||
mode-0 object whose current/target channel was armed by `0x202` consumes that sampled ARGB as opacity plus
|
||
multiplicative RGB modulation, including after target commit. SC0000 proves the distinction with SYSTEM4's
|
||
SO001 surface: backing object `0xd2f0` ramps `0x00000000 <-> 0xff000000`, while control-strip object
|
||
`0xd2f1` ramps `0x00ffffff <-> 0xffffffff`. Treating alpha as tint strength made the visible controls solid
|
||
white and the hidden backing expose SO001's raw white crop. Preserving one-shot provenance makes white an
|
||
identity modulation for the yellow controls and alpha zero fully transparent. A matching windowed capture
|
||
and user manual check confirmed both endpoints.
|
||
|
||
### SC0000 anim/transform/spritesheet cluster — op→field map (2026-07-08)
|
||
|
||
Reversed for the animation cluster slice (spec `docs/superpowers/specs/2026-07-08-sc0000-anim-transform-cluster-design.md`).
|
||
Every cluster handler resolved via the dispatch table `handler(op)=ctx[0x26c93+op]` (the `opcodes.toml`
|
||
`u004xxxx` labels are Kelebek VA drift — do not use them). Each op is a thin wrapper (`FUN_0041b940(n)`
|
||
fetches operand n) → a worker that writes object fields; the interpolator `gfx_object_anim_interpolate`
|
||
(`0x473ed0`) is the consumer. **The cluster is heterogeneous** — setters, queries, and a movie op. Renamed +
|
||
annotated in Ghidra, saved.
|
||
|
||
**In scope (built this slice):**
|
||
| op | handler / worker | semantics |
|
||
|---|---|---|
|
||
| `0x22f` | `gfx_op_0x22f_set_position_anim` → `gfx_worker_set_translation` | set object **position** (translation vec `obj+0x5d4`); base transform, not a ping-pong channel. Operand 2 is also retained as channel control state; the port currently ignores that field. |
|
||
| `0x229` | `op_0x229_set_gfx_range_transform` → `gfx_range_transform_reset` / `select_handles` / `set_anchor` | reset/select the shared **retained-object range transform**; not a per-object position setter (superseded finding above) |
|
||
| `0x239` | `gfx_op_0x239_set_srcrect_cell` → `gfx_worker_set_srcrect_cell` | one-shot **spritesheet-cell** channel: delay/duration `obj+0x48/+0x5c`, total frames/columns `obj+0x238/+0x23c`, target frame `obj+0x234` |
|
||
| `0x231` | `gfx_op_0x231_anim_srcrect` → `gfx_worker_anim_srcrect` | looping **spritesheet-cell** channel: milliseconds per frame `obj+0x230`, total frames `obj+0x238`, columns `obj+0x23c`; row-major and wraps, not ping-pong |
|
||
| `0x232` | `gfx_op_0x232_anim_color` → `gfx_worker_anim_color` | **animate color**: bit2 active, period `obj+0x220`, target `obj+0x240` → interpolator COLOR channel (ping-pong). Negative alpha/RGB preserve corresponding bytes from static color `obj+0x60`; alpha >255 clamps. Distinct from one-shot `0x202`/static `0x203` |
|
||
| `0x228` | `gfx_op_0x228_query_position` → `gfx_object_query_translation_target` (`0x47cdd0`) | **query** the decomposed target-translation matrix (x,y,z), `obj+0x1ac/+0x1b0/+0x1b4`, → operand slots 3/4/5; success is 0 and missing is 1 |
|
||
| `0x23f` | `op_0x23f_query_surface_stop_time_ms` (`0x42a520`) | **query loaded-surface DirectShow stop position in integer milliseconds** → operand slot 1, or -1 for an empty movie surface. Implemented with an explicit warning/-1 safety path when host timing metadata is unavailable. |
|
||
|
||
**Opcode-hardening follow-up (2026-07-28):** `0x233` is the missing cyclic-scale sibling between
|
||
`0x232` color and `0x234` rotation. `op_0x233_set_scale_cycle@0x423cf0` accepts
|
||
`(handle, period_ms, scale_x_percent, scale_y_percent, scale_z_percent)`, divides the three scale operands
|
||
by 100, and calls `gfx_object_set_scale_cycle@0x47efd0`. The worker clears start timestamp `obj+0x210`,
|
||
stores period `obj+0x224`, builds the target scale matrix at `obj+0x250`, and raises retained redraw dirty.
|
||
|
||
`gfx_object_anim_interpolate@0x473ed0` samples
|
||
`phase = 2 * min(elapsed % period, period - elapsed % period) / period` and linearly blends the identity
|
||
matrix to the target. The first half-cycle therefore grows/shrinks identity to target and the second returns
|
||
target to identity. This matrix lives in the separately anchored cyclic-animation product, independent of
|
||
the immediate/current and delayed one-shot scale matrices. Himegari has 31 sites in nine scripts: nineteen
|
||
in DEBUGADV and twelve across eight ordinary ADV scenes. The port now models this as its own retained
|
||
channel, samples the exact triangular phase on the shared frame clock, and composes
|
||
`T(-anchor) * one-shot-scale * one-shot-rotation * translation * cyclic-scale * cyclic-rotation *
|
||
T(anchor)`. It remains frame-driven while visible and survives native-style object cloning independently
|
||
of the immediate and delayed one-shot scale fields.
|
||
|
||
**Cyclic-channel reset follow-up (2026-07-29):** opcode `0x230` is the shared reset for this looping
|
||
animation family. `op_0x230_reset_gfx_cyclic_animations@0x423ba0` takes one retained-object handle and
|
||
calls `gfx_object_reset_cyclic_animation_channels@0x47ee60`. The worker gets or creates the object, clears
|
||
active flag bit 2, and zeroes five start/period pairs:
|
||
|
||
| channel field | start | period |
|
||
|---|---:|---:|
|
||
| looping color | `obj+0x20c` | `obj+0x220` |
|
||
| cyclic scale | `obj+0x210` | `obj+0x224` |
|
||
| cyclic rotation | `obj+0x214` | `obj+0x228` |
|
||
| second cyclic matrix | `obj+0x218` | `obj+0x22c` |
|
||
| looping source rectangle | `obj+0x21c` | `obj+0x230` |
|
||
|
||
It does not change the object's current/base transforms. Five DEBUGADV calls reset an effect object before
|
||
demonstrating a new looping asset; FIELD's sole ordinary call resets the temporary moving-unit handle
|
||
between its one-shot translation setup and `0x239` source-cell animation. This is a bounded retained-state
|
||
operation. The port now exposes the same get-or-create reset on `GfxState`: it disables cyclic color,
|
||
scale, rotation, and source-rectangle sampling; clears all ten native timing dwords in the preserved
|
||
numbered-save record, including the unmodeled second cyclic matrix; and leaves base/current transforms,
|
||
cyclic targets, and finite one-shot channels intact.
|
||
|
||
#### Diagnostic accumulator opcodes `0x1b2`–`0x1b4` (2026-07-29)
|
||
|
||
The three remaining diagnostic-looking instructions are one native family. The real dispatch registrations
|
||
are `ctx+0x9b914 = 0x41f4b0`, `ctx+0x9b918 = 0x41a400`, and `ctx+0x9b91c = 0x419240`; the legacy Kelebek
|
||
labels do not identify these handlers correctly.
|
||
|
||
- `op_0x1b2_append_diagnostic_value@0x41f4b0` formats operand 1 through
|
||
`vm_operand_format_as_string@0x41af90`. Integer operand forms become signed decimal text, while string
|
||
forms return their byte strings; the helper also covers AGE's other operand families. The handler
|
||
appends the exact returned bytes to the embedded 28-byte MSVC string at `ctx+0x6f880`, now
|
||
`EngineCtx::diagnostic_text_buffer`.
|
||
- `op_0x1b3_append_diagnostic_newline@0x41a400` appends the two bytes at `0x57082c`, `0d 0a`, to that
|
||
same accumulator. It neither presents nor clears anything.
|
||
- `op_0x1b4_show_and_clear_diagnostic@0x419240` passes owner HWND
|
||
`EngineCtx::owner_window_handle` (`ctx+0x54fe8`), the accumulator's current byte pointer, and flags
|
||
`0x10004` through `engine_host_show_message@0x403820`. The thunk loads
|
||
`EngineCtx::host_ui_interface` from `ctx+4` and dispatches virtual slot `+4`; `ctx+0x54fe8` is an
|
||
explicit argument, not the interface object.
|
||
- The installed default vslot, `default_host_show_message@0x431520`, writes the raw diagnostic between
|
||
`"\r\n致命的エラー[\r\n"` / `"]\r\n"` trace markers, then calls
|
||
`age_show_message_box@0x409370`. Flag `0x10000` appends the current interpreter coordinate using
|
||
`"\n\nデバック情報:\nFILE=%s ADDRESS=%X LINE=%d COMMAND=%s(%d) DEPTH=%d\n"`. Low mode `4`
|
||
formats body `%s%s`, selects caption `エラーが発生しました`, and invokes USER32 `MessageBoxA`
|
||
synchronously with style `0x40` (`MB_OK | MB_ICONINFORMATION`) and the supplied owner HWND. The
|
||
opcode ignores the `IDOK` result and only then erases the whole string with
|
||
`msvc_string_erase(buffer, 0, 0xffffffff)`.
|
||
- The optional command-metadata table at `ctx+0x6ddac` and per-frame source-line maps at
|
||
`ctx+0x6f798` are both zeroed by the constructor. Exhaustive immediate-offset xrefs show reads plus
|
||
that initialization only—no release-image writer—so Himegari deterministically emits
|
||
`LINE=-1 COMMAND=-(436)` for `0x1b4`. `FILE`, dword `ADDRESS`, and zero-based frame `DEPTH` remain
|
||
live values.
|
||
|
||
Himegari contains three `0x1b2` sites. FIELD appends the literal invalid-mutual-destruction-state text and
|
||
then CRLF. SYSTEM4 appends the literal invalid-execution-mode prefix, appends global
|
||
`system_flow_request` in decimal, presents and clears the assembled diagnostic, then appends CRLF to the
|
||
now-empty buffer. The trio therefore owns three effectful gaps totaling six instructions and is a bounded
|
||
next implementation slice; native ordering, including SYSTEM4's apparently odd post-clear newline, should
|
||
be preserved. A compatible host seam therefore needs synchronous modal presentation on the UI thread and
|
||
the current script coordinate; it is not a general shell-command interface.
|
||
|
||
The port implements the trio with a `GameSession`-shared `DiagnosticOutputState`. Godot marshals the
|
||
completed prompt to the main thread through `OS.Alert` and parks the VM until dismissal; headless
|
||
`CaptureHost` records the same effect. The accumulator clears after the synchronous call returns, so
|
||
FIELD's unpresented line and SYSTEM4's unusual post-clear CRLF retain their native lifetime and ordering.
|
||
|
||
**Follow-up resolution (2026-07-10):** `0x21f` is the one-shot axis-angle channel and is implemented with
|
||
affine rasterization. `0x223` is **not affine**: `gfx_queue_surface_alpha_transition` (`0x47f440`) inserts
|
||
a type-0 command-map record keyed by arg 1: start `+4`, delay/duration `+8/+0xc`, target surface slot `+0x10`,
|
||
and two object handle ranges at `+0x14/+0x1c` and `+0x18/+0x20`. `gfx_render_frame` composites those ranges
|
||
into the target and ramps alpha 0→1. Its SC0000 site `0x129e7` passes `(handle+2, transition slot,
|
||
handle+1,1,handle,1,G[0x6249f],G[0x624a0])`. It remains a render-target/transition slice dependency rather
|
||
than being approximated in the affine object compositor.
|
||
|
||
#### ADV foreground surface-transition lifecycle (2026-07-10)
|
||
|
||
The completed native chain is `0x21d` snapshot -> `0x223` queue -> `label_1235a` skip queries ->
|
||
`0x21c` normal yield or `0x20c` skip endpoint-present:
|
||
|
||
- `gfx_object_clone` (`0x47e4f0`, op `0x21d` handler `0x423310`) copies the complete retained-object
|
||
record: exactly `0xb5` dwords / `0x2d4` bytes. SC0000 `0x128fc` clones the current CG handle to
|
||
`handle+1`; the loader then rebinds the source handle to the new CG, and `0x223 @ 0x129e7` uses
|
||
`handle+1` as old range A, the updated handle as new range B, and `handle+2` as the target presenter.
|
||
- `0x1c7` is the `run_state_flags & 0x08000000` message-skip query. `0x1cc` reads
|
||
`ctx+0x6dbd4`, now named `adv_read_skip_state`; `adv_refresh_read_skip_state` (`0x406cd0`) and the
|
||
text/label/wait handlers maintain it from `message_ReadTextSkip` plus per-PC read history. It is not
|
||
surface-transition progress.
|
||
- Zero OR-state is normal playback and reaches `0x21c`; run-state bit `0x400` parks interpreter
|
||
progression while the type-0 surface command advances from the frame clock. Nonzero skip/read state
|
||
reaches `0x243 + 0x20c`, exposing the completed endpoint without the normal wait.
|
||
- Op `0x203` stores operand 2 at object `+0x30`. Transition sources use mode 2; their common
|
||
`0xffffffff` color is opaque identity modulation, not a solid-white tint. Negative alpha/RGB operands
|
||
separately preserve the corresponding byte(s) of current static color via the `FUN_0047f3e0` lookup.
|
||
|
||
The port mirrors this with an explicit pending/start/progress/natural-or-forced-complete state. A click
|
||
while `0x21c` is parked completes only the active foreground surface transition and is consumed; it does
|
||
not pre-arm the following `wait-for-input`, and it does not complete independent retained rotation,
|
||
matrix, spritesheet, or color-animation channels.
|
||
|
||
**Source-rectangle correction (2026-07-11):** `gfx_object_anim_interpolate` preserves the source rectangle
|
||
set by `draw-texture`; `0x231` does not divide that rectangle by its operands. It computes
|
||
`frame = floor(elapsed / frame_period) % frame_count`, then offsets both X bounds by
|
||
`rect_width * (frame % columns)` and both Y bounds by `rect_height * (frame / columns)`. SC0000 passes
|
||
`(100, 8, 4)` for AE001H: eight 200x200 cells in an 800x400, 4x2 sheet. Treating 8 and 4 as grid width
|
||
and height shrank the crop to 25x50 and made the intended cave spirit effectively disappear.
|
||
|
||
**AE001H travel-path correction (2026-07-11):** the three post-movie movement legs exposed a separate
|
||
port bug from the source-rectangle correction. Native `0x228` copies the full retained-object record and
|
||
decomposes the target matrix beginning at `obj+0x17c`; the returned translation comes from
|
||
`obj+0x1ac/+0x1b0/+0x1b4`. It does **not** return the draw/base position at `obj+0x24` (`V24`). SC0000 uses
|
||
that query before each `0x220` to derive the next translation target. AE001H's draw base is `(360,20)` and
|
||
its initial translation target is `(0,0)`, so the first native leg targets `(40,-20)`. The current C#
|
||
`0x228` implementation instead returns `(360,20)`, producing `(400,0)`; the compositor then adds that
|
||
translation to the unchanged draw base, placing the object near `(760,20)` before its cyclic rotation.
|
||
That explained the observed immediate off-screen movement and why later legs never visibly returned. The
|
||
port now queries `TranslationTarget` independently of `V24`, returns the native status convention, and
|
||
leaves output operands untouched when the object is absent. A bytecode-level regression reproduces the
|
||
three SC0000 targets `(40,-20)`, `(50,-80)`, and `(130,-100)` while retaining base `V24=(360,20)`.
|
||
|
||
**AE001H white-pulse verification (2026-07-11):** the eight source frames contain only the expected
|
||
purple artwork; the white wash is introduced by the port's `0x232` path. SC0000 `0x1a0e` explicitly arms
|
||
`0x232(handle,1200,224,-1)`. Native `gfx_op_0x232_anim_color` treats negative RGB as a sentinel and fetches
|
||
the object's current static packed color before constructing the target. The C# dispatch instead calls
|
||
`PackColor(224,-1)`, whose RGB mask becomes `0xffffff`, and `SnapshotVisibleObjects` maps animated alpha
|
||
to mode-0 tint strength. Existing retained-state evidence shows `tintStr` cycling `0 → 0.82 → 0` on
|
||
AE001H, exactly matching the reported white pulse. Follow-up native dataflow closes the remaining question:
|
||
fresh objects initialize static color `obj+0x60` to `0xffffffff`; the interpolator samples that temporary
|
||
color toward target `obj+0x240`, then `gfx_object_composite` passes the sample and the unchanged blend
|
||
selector `obj+0x30` to `gfx_object_blit_d3d9`. Mode 0 enables no alpha blending and uses RGB only as vertex
|
||
modulation, while mode 1 enables SRCALPHA/ONE additive composition. AE001H therefore cycles
|
||
`0xffffffff ↔ 0xe0ffffff`: identity RGB throughout, with alpha intentionally inert in mode 0, so native has
|
||
no visible pulse. The faithful fix is now fully bounded: initialize/resolve static color correctly, sample
|
||
packed ARGB, and consume it through the existing mode-specific blend path instead of converting animated
|
||
alpha into tint strength. The port now initializes static color to native identity `0xffffffff`, resolves
|
||
negative operands in `SetColorAnimResolved`, samples packed ARGB before blend selection, and feeds it through
|
||
the existing mode-specific path. Exact AE001H, mode-0 RGB-modulation, and mode-1 additive regressions cover the
|
||
contract; the white pulse is removed without suppressing the scripted channel.
|
||
|
||
**Resolved 2026-07-20:** `0x236` is the movie-to-retained-surface path described below. `0x242` is the
|
||
detached finite-animation control described below; `0x23d`, `0x20a`, and `0x20e` are also implemented in
|
||
their later lifecycle/presentation slices.
|
||
|
||
### Movie-to-surface opcode `0x236` (2026-07-11)
|
||
|
||
The exact ABI is `play-movie-to-surface(resource_id, surface_slot, movie_flags, start_delay_ms)`. Handler
|
||
`op_0x236_play_movie_to_surface@0x423ee0` records a 9-dword instruction length and requires the destination texture to exist.
|
||
It allocates/reuses a 0x478-byte `CMovieToTexture` object, binds the D3D device/backing texture, opens
|
||
operand 1 through `asset_open_indexed_entry`, constructs a stopped DirectShow FilterGraph, and configures its
|
||
pending start. The graph
|
||
queries `IGraphBuilder`, `IMediaControl`, `IMediaPosition`, `IMediaEvent`, and `IBasicAudio`; its custom
|
||
`CMovieTextureRenderer` accepts RGB samples and copies the bottom-up frame into the retained texture.
|
||
|
||
Operand 3 is retained movie mode plus sound-route policy. Bits `0x10000/0x20000/0x40000/0x80000` force
|
||
mute/music/SE/voice routing; without an override, native setting `set:DependMovieSound` supplies the normal
|
||
movie route. The port applies those categories through Godot audio buses. Operand 4 is stored at movie object
|
||
`+0x42c` as a millisecond start delay: `movie_start_pending_after_sync_delay` records `timeGetTime()` on the
|
||
first service tick and starts the graph once `current - start >= operand4`. It is not a device mask or movie
|
||
duration. Replacing or releasing the owning surface stops the graph and detaches the renderer. The port still
|
||
threads this value through a legacy `syncMask`-named host parameter but does not yet delay FFmpeg start for
|
||
nonzero values. The corpus has 25 sites: 24 pass literal zero, while BTL supplies a dynamic local-pointer value.
|
||
|
||
Graph construction/open is synchronous, but playback and sample delivery are asynchronous. The handler
|
||
returns normally and the interpreter advances one instruction: at SC0000 `0x13c8`, the native operand
|
||
capture evaluates `(0x33, 0, 2, 0)` and the next executed bytecode is `0x13d1`. The movie therefore does
|
||
not itself block the VM. SC0000 prepares additional static layers, then reaches `0x21c` through
|
||
`label_1235a@0x1574`. That opcode sets run-state bit `0x400` and yields the interpreter; the presentation
|
||
service continues sampling the retained movie until DirectShow EOF, after which the following script
|
||
cleanup releases it. The static preparation before `0x21c` is not a movie teardown boundary.
|
||
|
||
Non-modal start is deferred through `movie_play_configure@0x4625e0`: the outer tick's
|
||
`movie_start_pending_after_sync_delay@0x4633b0` records the first service time and calls the same shared
|
||
`IMediaControl::Run` worker once operand 4's sync delay has elapsed. A zero mask starts on the first service
|
||
tick. It therefore receives the same stopped-to-paused preroll and first-frame poster behavior as modal `0x20f`.
|
||
|
||
**Manual-test corrections (2026-07-11):** the initial port incorrectly treated pre-yield static loads as
|
||
surface replacement, producing start/first-frame/stop all in render frame 0. The bounded host now retains
|
||
the movie through `0x21c` until DirectShow completion. A follow-up compositor trace proved the movie object
|
||
was present at the correct z-position, but the static AGF cache reused the first decoded sample forever
|
||
because every sample has the same `(assetId,colorKey)`. An unconditional current-sample backbuffer copy did
|
||
not help because normal retained composition immediately covered it with that cached first sample. Movie
|
||
surfaces now bypass the static cache and publish only through the retained object. A windowed run records
|
||
first frame 101 and stop frame 190; the user manually confirmed visible playback. The separate lower white
|
||
textbox-area object remains outside this finding. Embedded audio was added later through the portable
|
||
FFmpeg/audio-clock path described in `docs/platform-portability.md`.
|
||
|
||
The `/v2` image names/comments the handler; movie ctor/interface/open/play/volume/release workers; sound
|
||
route helpers; renderer media-type/sample workers; and stop/detach/destructor lifecycle. The image was
|
||
saved after annotation.
|
||
|
||
### Movie-surface stop-time query `0x23f` (2026-07-21)
|
||
|
||
The exact ABI is `query-surface-stop-time-ms(out_stop_time_ms, surface_slot)`. Handler
|
||
`op_0x23f_query_surface_stop_time_ms@0x42a520` records a 5-dword instruction length, fetches operand 2,
|
||
and directly indexes `EngineCtx+0x52bd4[surface_slot]`. A null surface writes `-1` to operand 1. A non-null
|
||
surface dereferences the `CMovieToTexture+0x414` interface pointer and calls vtable slot `+0x28` with a
|
||
stack `double` output parameter.
|
||
|
||
That interface is conclusively `IMediaPosition`: graph initialization queries IID
|
||
`{56A868B2-0AD4-11CE-B03A-0020AF0BA770}`, and the inherited `IUnknown` + `IDispatch` layout places
|
||
`get_Duration` at `+0x1c`, `put_CurrentPosition` at `+0x20`, `get_CurrentPosition` at `+0x24`, and
|
||
`get_StopTime` at `+0x28`. Neighboring op `0x23e` calls `+0x24`, while movie op `0x245` calls `+0x20`,
|
||
which independently confirms the slot mapping. Thus `0x23f` does **not** call `get_Duration`; it asks for
|
||
the configured playback stop position. With an ordinary freshly opened graph that position normally
|
||
equals the media duration, explaining why every observed consumer uses it as a lifetime.
|
||
|
||
Native multiplies the returned `REFTIME` seconds by the double `1000.0` at `0x5713e8`, passes the x87
|
||
value to the compiler helper at `0x550850`, and writes the low 32-bit integer to operand 1. The helper's
|
||
SSE2 branch uses `CVTTSD2SI`; its x87 fallback corrects the current rounding result to the same behavior,
|
||
so conversion is truncation toward zero. The COM `HRESULT` is ignored and the local output is not
|
||
preinitialized: the handler assumes any non-null movie surface has a usable `IMediaPosition`. It is a
|
||
pure query—no run-state bit, service boundary, seek, or playback mutation occurs.
|
||
|
||
Himegari has 23 calls in 17 scripts: BTL, DEBUGADV2, FIELD (2), SC0000/10/20/40/50/60/70/80/90,
|
||
SC0100 (2), SC0110 (2), SC0120 (2), SC0130 (3), and USEMAGIC. Every site is associated with a preceding
|
||
`0x236` load of the queried surface; the repeated ADV form has five table writes between load and query.
|
||
FIELD supplies the strongest unit evidence: one path computes `stop_time_ms / 16 + 1` for a 16 ms callback
|
||
schedule, and another clamps it to 600 ms before calling DRAWVOL. This corroborates the native decode and
|
||
rules out the former port behavior, `retained gfx object exists ? 0 : -1`.
|
||
|
||
**Port implementation (2026-07-21):** `DirectShowMovieDecoder` now queries
|
||
`IMediaPosition::get_StopTime` after the graph reaches its running state and before synchronous `0x236`
|
||
initialization returns. It applies native seconds-to-milliseconds truncation and hands the value through
|
||
`IHost.PlayMovieToSurface` into a movie-surface record owned by `GfxState`. Decoder construction moved from
|
||
the deferred Godot callback to the VM-side synchronous open boundary; the ready decoder is staged in a
|
||
thread-safe pending registry and adopted by the main thread before frame sampling, preserving asynchronous
|
||
presentation. `0x23f` silently returns -1 for an empty movie slot. Native ignores the HRESULT for a non-null
|
||
movie's stop-time query and has no meaningful contract for the port-only case where the installed host
|
||
decoder cannot construct a graph for a shipped asset. The port therefore models backend failure as an
|
||
explicitly completed movie with stop time 0. This gives BTL an immediate-effect duration rather than feeding
|
||
its timeline -1. Started decoders also have a duration-based completion watchdog (minimum five seconds,
|
||
reported stop time plus two seconds, maximum five minutes; 30 seconds without timing) so missing EOF cannot
|
||
hold the shared `0x21c` wait indefinitely. A normal Game Start through SC0000 was manually validated with
|
||
ordinary positive timing.
|
||
|
||
**Portable replacement closeout (2026-07-25):** the synchronous metadata, asynchronous presentation,
|
||
failure-as-completed, and watchdog contracts above remain, but `FfmpegMovieDecoder` is now the sole backend.
|
||
After the 213/213 video/audio corpus gate and clean audible LOGO/OP/CHAPTER acceptance, the port deleted
|
||
`DirectShowMovieDecoder`, its COM/temp-file adapter, compatibility test, and managed Windows annotations.
|
||
Native AGE's DirectShow behavior remains relevant evidence for opcode semantics; it is no longer port code.
|
||
|
||
### Grey-background root cause — slot collision + tint-strength (2026-07-08, gfx-log)
|
||
|
||
Diagnosed with the new `--gfx-log` compositor/op trace (docs/tools-reference.md). The grey background has
|
||
**two distinct causes**, both now proven:
|
||
|
||
1. **CORRECTION to the blend section above — op `0x202`/`0x203` "alpha" is a TINT STRENGTH, not object
|
||
opacity.** Evidence: the primary CG is drawn with `0x203 (alpha=0, color=white)` = `0x00ffffff`. That
|
||
means "blend the tint (white) into the texel by strength 0" = **no tint, fully opaque CG** — but slice-A
|
||
treated the alpha byte as the object's opacity → the CG rendered fully transparent → grey. Fix (commit
|
||
5e4fdda): `RenderObject.TintStrength` split from `Alpha` (opacity); textured objects stay opaque and the
|
||
tint LERPs the RGB by strength (0=keep texel, 1=full tint). Surfaceless fills use the strength as fill
|
||
opacity. Verified: the opening event CGs render again (shot-confirmed).
|
||
|
||
2. **Effect pages: everything collapses into slot 0.** `set-texture` is dominantly
|
||
`set-texture (GLOBAL resId)(GLOBAL slot)(local colorkey)` (543× across the corpus); the **slot is a
|
||
global**. In our run every such global resolves to **0**, so the background (`BG030A`), event CGs, and
|
||
the effect spritesheet (`AE001H`, an 800×400 4×2 grid of blob frames) **all set-texture into slot 0**.
|
||
Objects live-reference their slot, so loading the effect **evicts** the BG → grey; and the effect is
|
||
drawn full-screen from slot 0 (its object `src=(0,0 800x600)`) → the whole sheet (blob grid) covers the
|
||
screen. ⇒ The layering failure is a **slot-assignment** problem. **CONFIRMED CAUSE (2026-07-08, live
|
||
`AGE_DIAG_SETTEX` trace):** every `set-texture` slot = `G[0x62452]`, written by `query-gfx-object?`
|
||
(`0x215`) which returns **-1** for the (correctly-unregistered) CG/effect handles → the fallback at
|
||
SC0000 `label_12649` does `G[0x62452] = lookup-array-2d(rec[s3]=G[0x3239], G[0x62450], 3, 0)` = **0**
|
||
because the slot table `rec[s3]`/`G[0x3239]` is **empty**. That table is filled by `call label_125bd`
|
||
(SC0000 `0x50f`, slots 4..11), which is reached **only through the scene-coroutine framework** — the
|
||
`G[0xaba5c]` gate (`0x450`) + op `0x140` (`u0041F9C0`, coroutine LABEL/yield `"LABEL" "J"` @ `0x46d`).
|
||
**Fixed by the bounded scene-coroutine host model:** `0x140` runs the setup body once, `label_125bd`
|
||
fills the eight slot records, and SC0000 resource `0x23` loads into assigned slot 5 instead of slot 0.
|
||
This was not a compositor/z-order/blend bug. Diagnostics: `AGE_DIAG_SETTEX=1` env → VM logs each `set-texture`
|
||
slot operand + `query-gfx-object?` result.
|
||
|
||
### Differential offset-path oracle — engine-vs-VM control-flow diff (2026-07-09)
|
||
|
||
**Method (lever #3 of the RE-front-loading program).** Run the same scene in the real engine and our C#
|
||
VM and diff the **executed script-offset path**. Both run the same bytecode, so the opcode at each offset
|
||
is static (from disasm); the first place the two offset sequences differ is exactly the branch/opcode/state
|
||
we modeled wrong — cheaper and higher-signal than diffing effects, and precisely where the render-drift
|
||
walk-backs lived. Tools: `tools/frida/trace_engine_ops.py` (engine capture) · `Age.Cli trace <SCENE>
|
||
--boot --trace-json` (VM capture) · `tools/diff_optrace.py` (align + first-divergence). Spec/plan:
|
||
`docs/superpowers/{specs,plans}/2026-07-09-differential-oracle*`.
|
||
|
||
**Capture method that WORKS = the operand hook `vm_operand_fetch@0x41b940`** (thiscall `ecx=ctx`; per op,
|
||
`offset=(pc−codebase)/4` from `cur_ctx_index@0x53d14` / `frame_pc@0x53d2c` / `frame_codebase@0x53d28`,
|
||
per-context stride `0x78`). Validation: **100% of captured offsets land on valid SC0000 instruction
|
||
starts** — proves both the ctx-field offsets and the `(pc−codebase)/4` math. **The tick hook
|
||
`adv_interpreter_tick@0x410fb0` does NOT work** — plain-JS `Interceptor.attach` there sees `ecx≠ctx`
|
||
(0 entries), so the recon gate's *tick* path is closed too, not just the CModule path noted under Frame
|
||
cadence. Two capture caveats, both handled:
|
||
- **Frida must hook BEFORE the scene loads.** The scene-entry setup (decl preamble + first CG load at
|
||
`0x802 mov G[0x62424]=0x23; call label_12649`) runs in a µs burst at load; a capture started mid-scene
|
||
floors at ~`0x80d` and misses it. `trace_engine_ops.py` writes `build/tracer-live.flag` once the hook is
|
||
installed → launch it in the background and gate the New-Game trigger on that flag.
|
||
- **Operand mode skips zero-operand ops** (stmt-begin/end markers, script-entry `0x259`) — they never
|
||
trigger an operand fetch. `diff_optrace.py` filters the VM trace to argc≥1 ops (same subsequence);
|
||
control flow is preserved (markers don't branch). `--full` disables it for a hypothetical tick capture.
|
||
|
||
**Codebase identification.** The engine trace tags each op with its script's `codebase` pointer (a per-run
|
||
heap address). `pick_scene_codebase` picks the codebase whose offset sequence shares the longest common
|
||
prefix with the VM trace. From a boot→opening capture (7 codebases / 265k ops), SC0000 = `0x09c1afe8`
|
||
(13252 ops, 100% valid, first offset `1` = offset 0 filtered).
|
||
|
||
**FIRST DIVERGENCE FOUND (the tool's first catch + self-test).** On the SC0000 opening the VM and engine
|
||
agree for **27 ops** (including the coroutine op `0x7b` @ `0x79`, which matched), then **diverge at offset
|
||
`0x8d` = op `0xa0` (jcc) on `global-int G[0x6c1]`:** the engine falls through to `0x94` (the op-`0x90`
|
||
hotspot-chrome registration block) ⇒ `G[0x6c1]≠0`; the VM jumps to `label_df` (`0xdf`) ⇒ `G[0x6c1]==0`.
|
||
Because both ran `1..0x8d` identically, `G[0x6c1]` is set **before** SC0000 — by pre-scene *system* boot
|
||
the VM's cold `--boot` (INITCONFIG/INIT2/INIT) doesn't replicate (the **two-boot gap**). `G[0x6c1]` is an
|
||
unlabeled but heavily-used scalar (766 uses) in the **same cluster as the op-`0x90` hotspot flags
|
||
`G[0x6c9..0x6cd]`** = ADV-chrome/input state. Headlessly benign (no input) but a genuine VM-side state gap
|
||
— and a clean demonstration that the oracle localizes a mis-modeled branch to a single instruction. It is
|
||
NOT the predicted coroutine yield (`0x140`/`~0x50f`); the oracle reports whatever diverges *first*, and it
|
||
surfaced an earlier state hole. **This is now the repeatable way to localize a mis-modeled op/state.**
|
||
Phase-2 extension (deferred): effects-diff (global-bank / gfx-registry writes) for branchy scenes.
|
||
|
||
### ADV control-strip buttons and native hotspot callbacks (2026-07-18)
|
||
|
||
The five controls at the lower right of ordinary ADV scenes are script-driven retained UI, backed by
|
||
`SO001.AGF` in system surface slot 17. They are not Godot-style widgets and opcode `0x90` is not an
|
||
immediate hit-test branch. The shared ADV routine copied into all 301 ADV scripts registers five rectangles
|
||
at `(684|706|728|750|772,572)`, nominally `20x20`, plus three keyboard/pad records. Native
|
||
`op_0x90_handler@0x41fc80` stores `(x,y,x+w,y+h)` and three callback PCs through
|
||
`input_hotspot_register_rect_callbacks@0x403d70`; `op 0x94` then arms the input service. The bounds are
|
||
compared inclusively.
|
||
|
||
`input_hotspot_update_cursor_hit@0x403e90` publishes the first matching record index.
|
||
`input_hotspot_poll_hover_callback@0x4040b0` maps the stored PCs exactly: operand 5 on pointer entry and
|
||
operand 6 on pointer exit. A direct move from one hotspot to another dispatches the old record's exit first
|
||
and the new record's entry on the next poll. `input_hotspot_take_click_callback@0x404330` resets the
|
||
registry and returns operand 7 on activation. The interpreter temporarily redirects the current script PC
|
||
to these callbacks; registration itself returns normally. Companion op `0x97` finds an identical registered
|
||
rectangle and binds its fifth operand as an input-bit index; SC0000 uses bits 0, 8, and 7 for its three
|
||
`1x1` keyed records.
|
||
|
||
The five entry callbacks set `G[0x6c9..0x6cd]` one at a time and call the common redraw routine at SC0000
|
||
`label_11ffa`; exit clears the corresponding flag and redraws. The redraw takes three pieces from SO001:
|
||
|
||
- the always-visible base strip from source `(0,254,114,25)` to screen `(681,570)`;
|
||
- one 132x27 textual tooltip from source y=227; and
|
||
- the same generic 20x20 hover overlay from source `(114,254)` over the selected icon, rendered through
|
||
mode-1 `0x203` at alpha `0x80`.
|
||
|
||
This gives the exact left-to-right behavior:
|
||
|
||
| x | Hover flag | SO001 tooltip source x | Label/action on activation |
|
||
|---:|---|---:|---|
|
||
| 684 | `G[0x6c9]` | 396 | **History** — cancel hotspot wait, call `HISTORY.BIN`, then rebuild chrome |
|
||
| 706 | `G[0x6ca]` | 264 | **Auto message** — `0x1b6` read, toggle, `0x1b7` write |
|
||
| 728 | `G[0x6cb]` | 0 | **Message skip** — `0x88(1)` enables all-message skip |
|
||
| 750 | `G[0x6cc]` | 132 | **Read-message skip** — `0x1cb` read, toggle, `0x1ca` write `message:ReadTextSkip` |
|
||
| 772 | `G[0x6cd]` | 528 | **Hide window** — `0x199` yields into the ADV/HIDEWIN coroutine flow |
|
||
|
||
The redraw also overlays persistent active-state cells for Auto, Message skip, and Read-message skip from
|
||
SO001 source x `154/174/194` at y=254. After the four state/coroutine actions, the script waits 100 ms,
|
||
reads the virtual cursor with `0x109`, alternates its y by one pixel, writes it with `0x10a`, and resets
|
||
transient skip/input state through `0x101`. That deliberate cursor jiggle re-arms entry/exit processing.
|
||
|
||
**No hover sound.** Manual correction on 2026-07-18 confirms these five ADV controls are silent on hover.
|
||
This matches the static evidence: none of the entry/exit callbacks executes `0xb4/0xb5` or another script
|
||
audio opcode, and the decoded native registration/hover-dispatch chain contains no audio call. The port
|
||
should not invent a sound asset or host audio event for this interaction.
|
||
|
||
The `/v2` Ghidra image names/comments the opcode handlers, registry/hit-test/dispatch helpers, message-skip,
|
||
auto-message, read-skip, cursor, and coroutine operations described above; saved 2026-07-18.
|
||
|
||
**Port implementation (2026-07-18).** The C# VM now models the per-frame registry and dispatches its three
|
||
local callbacks on the VM thread through a callback-only host wake channel, leaving the surrounding ADV input
|
||
wait parked. Godot feeds scaled native-screen pointer coordinates and consumes activation before ordinary
|
||
page advance. The blocking-host model retains the registered definitions across a normal action callback to
|
||
represent the native scheduler's subsequent shared-registration pass; explicit op `0x93` still clears them,
|
||
and callbacks such as History rebuild through their script path. The existing bytecode therefore owns the
|
||
SO001 hover/active redraw rather than a parallel widget layer; callback completion publishes one retained
|
||
frame even though the enclosing wait remains static. `0x1b6`/`0x1b7` are implemented as VM service
|
||
state for the first Auto action bridge; timed automatic page advance remains separate follow-up work.
|
||
|
||
**Port correction from manual validation (2026-07-18).** Merely implementing op `0x90` was insufficient in
|
||
the cold single-scene harness. The visible SO001 strip is drawn independently, while `jcc@0x8d` skips its five
|
||
rectangles when inherited `G[0x6c1]` is zero; the existing trace did exactly that. Even with the native value
|
||
one, `cancel-hotspot-wait@0x622` clears the early pass before page-one `wait-for-input@0x83c`, and the native
|
||
ADV coroutine later republishes it. Godot now seeds `adv_chrome_enabled=1` as part of the same bounded
|
||
SYSTEM4 chrome bootstrap as SO000/SO001. The blocking VM retains canceled definitions only as inactive
|
||
coroutine templates, replaces them if script registration runs first, and otherwise re-arms them at a stable
|
||
message wait. A full SC0000 regression proves the real History enter callback and retained-frame publication,
|
||
not just a synthetic registry path.
|
||
|
||
### ADV Auto-message timing and voice completion (2026-07-18)
|
||
|
||
Auto advance is a native input-service policy, not a script sleep or a fixed synthetic click. The native
|
||
state is split between `ctx+0x55104` (`auto_message_enabled`) and `ctx+0x6dbe4`
|
||
(`adv_auto_voice_pending`). `op_0xc4_handler@0x420610` sets the latter when it queues voice playback, while
|
||
`op_0x1bc_handler@0x416c20` clears it at the next message boundary.
|
||
|
||
`op_0x72_handler@0x41e690` arms `message:AutoMessageTime1` when Auto is enabled and the current message has
|
||
no queued voice. For a voiced message, `adv_input_service_poll@0x411230` instead waits until the native voice
|
||
service reports playback complete, clears `adv_auto_voice_pending`, and then arms
|
||
`message:AutoMessageTime0`. Expiry follows the same wait-release path as ordinary ADV input. Both branches
|
||
substitute 100 ms when their configuration getter returns zero.
|
||
|
||
The two settings are script-visible rather than constants embedded in the wait handler:
|
||
|
||
- op `0x1b8` reads selector 0 = post-voice `AutoMessageTime0`, selector 1 = unvoiced
|
||
`AutoMessageTime1`;
|
||
- op `0x1b9` writes the same selectors;
|
||
- `CONFIG.BIN` initializes them to 500 ms and 2000 ms respectively, and its UI adjusts either setting in
|
||
500-ms steps over 500..9500 ms.
|
||
|
||
**Port implementation.** The VM retains the enable bit, both configured delays, and per-message voice flag,
|
||
then supplies their live state to the blocking host wait. `GodotAdvHost` polls a small deterministic timer
|
||
from the existing monotonic frame clock. Unvoiced pages wait Time1; voiced pages remain parked through actual
|
||
`AudioStreamPlayer` playback and then wait Time0. A queued/started/completed generation counter closes the
|
||
deferred-call race between the VM thread queuing a voice and Godot beginning playback. Turning Auto off
|
||
cancels an armed deadline, and turning it back on starts a fresh one. This keeps timing in the host service
|
||
boundary and configuration in the VM, without scene offsets, wall-clock sleeps, or Auto-specific input
|
||
injection. Timer and VM regressions cover both paths, exact deadlines, disable/re-enable, the native zero
|
||
fallback, `0x1b8`/`0x1b9`, and the `0xc4`/`0x1bc` voice-state lifecycle. The `/v2` handlers and input poller
|
||
are named/commented and saved.
|
||
|
||
### ADV all-message Skip service (2026-07-18)
|
||
|
||
The x=728 control enables a persistent interpreter service; it is not a one-page advance and its active
|
||
overlay is not the transient run-state bit. `op_0x88_set_message_skip@0x41f130` writes the requested value to
|
||
`ctx+0x13dc` (`message_skip_enabled`) and `ctx+0x550fc` (`message_skip_display_enabled`). While the former is
|
||
nonzero, `adv_interpreter_tick@0x410fb0` injects input bit `0x40` on every interpreter tick. The input service
|
||
turns that into `run_state_flags & 0x08000000`, completing text/input waits and selecting the already-reversed
|
||
skip endpoints for retained transitions.
|
||
|
||
The state queries are deliberately different:
|
||
|
||
- op `0x19a` returns persistent `message_skip_display_enabled` for SO001's active x=728 overlay;
|
||
- op `0x1c7` returns the transient skip run-state bit, which may be driven by persistent op-`0x88` state or
|
||
a physical fast-forward input such as Ctrl;
|
||
- op `0x101` clears transient input/run-state fields after an ADV chrome action, but does not touch either
|
||
persistent op-`0x88` field. The following interpreter tick therefore re-arms Skip.
|
||
|
||
All 301 ordinary ADV button callbacks call `0x88(1)`. `CALLBACK_LOAD.BIN` contains the corpus's only
|
||
`0x88(0)` reset. The engine supports an optional click-cancel state machine, but
|
||
`engine_settings_register_defaults@0x46be30` registers `set:CancelMesSkipOnClick=0` only as the generic
|
||
fallback. Himegari's SYS4INI trailer overrides it with `CANCELMESSKIPONCLICK=2`, enabling the
|
||
press/release cancellation path. The port currently preserves the fallback instead because it does not yet
|
||
apply the trailer's ADV policy settings; that is now a cataloged parity gap rather than evidence that
|
||
Himegari intended click-cancel to remain disabled.
|
||
|
||
Voice playback also has a native Skip queue. `op_0xc4_handler@0x420610` plays immediately while the skip bit
|
||
is clear. While it is set, the handler replaces `ctx+0x6dbf4/+0x6dbf8` with the latest requested voice instead
|
||
of starting it. When Skip/read-skip input ends, `adv_interpreter_tick` starts that latest voice and clears the
|
||
queue; skipped voices do not accumulate.
|
||
|
||
**Port implementation.** The VM owns persistent op-`0x88` state and keeps op `0x19a` separate from the
|
||
combined op-`0x1c7` persistent/host-input query. `GodotAdvHost` completes text reveal and stable message waits
|
||
while Skip is active; the existing `0x1c7` transition branches continue to publish their completed endpoints.
|
||
Voice requests replace one host-side deferred payload during Skip and the latest payload starts when
|
||
`0x88(0)` arrives. Enabling Skip through the real SC0000 hotspot callback wakes the parked wait without
|
||
creating a synthetic pointer click. Focused tests cover `0x88` enable/disable, `0x19a`, `0x1c7`, transient
|
||
`0x101`, retained host state, and the actual x=728 callback. The new EngineCtx fields are applied to `/v2`;
|
||
the affected handlers, interpreter tick, and settings-default initializer are named/commented and saved.
|
||
|
||
**Manual pacing correction.** The first port build released skipped text/waits correctly but then let the
|
||
background VM free-run to the next non-skipped service boundary. That produced whole-scene bursts separated
|
||
by explicit sleeps: visibly an immediate jump, a slow point, then another immediate jump. Native
|
||
`adv_interpreter_tick` still dispatches exactly one opcode per engine tick while persistent Skip removes the
|
||
ordinary waits. `GodotAdvHost.FrameYield` now consumes one rendered-frame pulse per opcode only while
|
||
message Skip is active. Normal opcode bursts retain the existing run-to-service-boundary model; Skip gains
|
||
the missing native governor and remains fast without teleporting between blocking points. A regression proves
|
||
op `0x88` state reaches the host before the following cadence yields. Validation is engine 168/168,
|
||
zero-warning Godot build, and threaded `SELFTEST OK`.
|
||
|
||
**ADV entry/exit lifecycle.** The script comments `savemesskip` and `loadmesskip` describe a temporary
|
||
suspension boundary, not a second saved preference. `op_0x19b_suspend_adv_skip_service@0x416560` clears
|
||
active `ctx+0x13dc`, run-state bit `0x08000000`, and `ctx+0x55100` (`adv_skip_service_enabled`) while
|
||
deliberately preserving `ctx+0x550fc`, the all-message Skip toggle returned by op `0x19a`.
|
||
`op_0x19c_resume_adv_skip_service@0x4165a0` sets the lifecycle gate again and reconstructs active
|
||
fast-forward from persistent all-message Skip or `adv_read_skip_state`; the separate startup guard at
|
||
`ctx+0x6f86c` can suppress that reactivation. SC0000 brackets ADV teardown/setup with this pair, as do
|
||
branch transitions and `CALLBACK_LOAD.BIN`.
|
||
|
||
The port therefore keeps persistent Skip separate from currently active fast-forward. Opcode `0x19b`
|
||
deactivates the host service without changing the control-strip toggle, and `0x19c` recomputes the host
|
||
service from the persistent toggle plus the host's live read-skip channel. This also makes op `0x1c7`
|
||
correctly report inactive during the suspended interval. Focused regressions cover preservation across the
|
||
pair and read-skip-only reactivation. Implementing the `message:ReadTextSkip` preference itself remains
|
||
deferred with ops `0x1ca`/`0x1cb` and the profile-owned `ReadTextDB`; the lifecycle implementation does not
|
||
invent a storage backend.
|
||
|
||
### Opcode `0x1ad` marks the numbered-save resume-frame boundary (2026-07-20)
|
||
|
||
Opcode `0x1ad` is a zero-operand persistence marker, not an input reset or modal-UI synchronization call.
|
||
Its real dispatch handler is `op_0x1ad_mark_save_resume_frame@0x416b70`. Aside from recording the generic
|
||
one-dword instruction length at `ctx+0x53d88 + curCtx*0x78`, it performs one semantic write:
|
||
`ctx->save_frame_boundary_index = ctx->cur_ctx_index` (`ctx+0x9928c = ctx+0x53d14`).
|
||
|
||
`context_state_serialize@0x40d320` consumes the mark for numbered-save layouts 2 and 3. A negative mark
|
||
falls back to the current frame; otherwise the serializer copies script frames `0..mark` inclusive and
|
||
forces the marked frame's saved return entry to `-1`, making that frame the top/terminal activation after
|
||
load. `op_0x2_exit_or_return_frame@0x417940` clears the mark when normal return unwinds below it. Scene/context reset also
|
||
initializes it to `-1`. Thus the mark selects both the highest saved activation and the frame at which a
|
||
loaded game resumes; the opcode itself neither reads nor writes a file.
|
||
|
||
Corpus placement agrees with the native dataflow: 1,928 executions appear across 304 scripts. Roots such as
|
||
`CAMP`, `FIELD`, and `FORT` mark their main frame near entry; SC0000's six sites are its startup path and the
|
||
return paths from `HISTORY`, `MENU`, `HIDEWIN`, and `INPUTNAME`. Those calls re-establish the enclosing ADV
|
||
frame as the safe numbered-save resume point after modal/nested scripts finish.
|
||
|
||
**Port implication:** the port-owned JSON session snapshot still persists only global integer/string banks,
|
||
but the VM now tracks `0x1ad` as an identity reference to the active `ExecFrame`. The marker survives nested
|
||
calls and clears when that same frame unwinds, exposing the inclusive zero-based cutoff for the native
|
||
serializer. The following numbered-payload slice must consume this boundary while serializing the frame
|
||
chain; no script seed or offset-specific shortcut is involved.
|
||
|
||
### Native persistence opcode family and file layouts (resolved 2026-07-24)
|
||
|
||
The remaining `SAVE.BIN` family is now mapped. It separates three storage domains rather than exposing one
|
||
generic save operation:
|
||
|
||
| Opcode | Native operation |
|
||
|---|---|
|
||
| `0x19e(status,slot)` | write `SAVE%02d.DAT`; truncate/replace after any incompatible-save prompt |
|
||
| `0x19f(status,slot)` | load numbered data only, with runtime-frame and history restoration disabled; unused by Himegari's shipped corpus |
|
||
| `0x1a0(status,slot,...metadata)` | validate only the fixed header and return date/time plus accumulated playtime |
|
||
| `0x1a1(status,slot)` | full numbered load, including active frames and history, then resume through `CALLBACK_LOAD`/`0xae` |
|
||
| `0x1a9(cell)` / `0x1aa(cell)` | store/restore a selected string cell in shared `SAVE.DAT` |
|
||
| `0x1ab(status,slot)` | delete the numbered `.DAT` and `.STH` pair |
|
||
| `0x1ac(status,src,dst)` | copy the numbered `.DAT` and `.STH` pair, replacing destination files |
|
||
| `0x1ad()` | select the highest frame included in a numbered save; no file I/O (documented above) |
|
||
| `0x1ae(status,slot,surface)` | write `SAVE%02d.STH` from a surface |
|
||
| `0x1af(status,slot,surface)` | load `SAVE%02d.STH` into a surface |
|
||
|
||
Opcode `0x19d`, adjacent in number and used by CGMODE/ED/HMODE/MMODE, queries the shared-profile catalog
|
||
unlock database:
|
||
|
||
- `op_0x19d_is_catalog_resource_unlocked@0x427810` fetches `(out, packed_resource_id)`. Packed append
|
||
ids return zero when the configured save version is older than 3.10; otherwise it calls
|
||
`asset_catalog_is_resource_unlocked@0x415920` on `EngineCtx+0x9c24c`.
|
||
- Base ids use the marker table at asset-catalog `+0x41c`. A nonzero high byte selects the append
|
||
pointer at `+0x3844 + selector*4`; the low 24 bits are the table index.
|
||
- The marker is valid exactly when its low word equals
|
||
`low16(index * 0x053d6f99 + 0xb0b0b0b0)`. `asset_catalog_mark_resource_opened@0x44e410`, called only
|
||
after `asset_open_indexed_entry` succeeds, writes the plain marker and its modular-exponent encrypted
|
||
SAVE.DAT counterpart.
|
||
- `shared_profile_load` decrypts base and flattened append marker arrays through
|
||
`modular_exponent_u32@0x471e60`, then
|
||
`asset_catalog_restore_resource_unlock_marker@0x404ca0` installs and re-encrypts them under the
|
||
current session key. The two leading table DWORDs are private-exponent XOR `0x87912345` and modulus.
|
||
|
||
CGMODE passes each CGINIT full-image AGF id; HMODE passes each SPINIT scene-script id. The copied port
|
||
profile validates all 851 gallery images and all 118 H-scene scripts. The former all-locked presentation
|
||
was therefore an opcode/runtime integration defect, not missing save data. The port now decodes and
|
||
queries the native markers, marks successful VFS opens, and writes a native-decodable encrypted table.
|
||
The actual numbered/selected-cell persistence cluster remains the adjacent `0x19e` through `0x1af`
|
||
family described above.
|
||
|
||
**Numbered operation status contracts.** Save/load open failure is `1`; metadata uses `0=valid`,
|
||
`1=absent/open failure`, and `2=invalid/incompatible`. Delete/copy attempt both members of the pair and use
|
||
`0=both succeeded`, `1=DAT failed but STH succeeded`, `2=STH failed` (the STH result takes precedence).
|
||
Thumbnail I/O uses `0=success`, `1=open/create failure`, and `2=codec failure`. Full-load opcode `0x1a1`
|
||
expects its caller to pre-seed status zero: on success it starts the resume sequence without rewriting that
|
||
operand, while a missing file writes one.
|
||
|
||
#### Save-root resolution
|
||
|
||
Save paths are native engine policy, not script-provided strings. Every numbered/shared handler first calls
|
||
`save_root_resolve@0x40b880`, then formats one of the engine-owned names (`SAVE%2.2d.DAT`,
|
||
`SAVE%2.2d.STH`, `SAVE.DAT`, `SAVE.BAK`, `RT.DAT`, and their `$$` temporary names) under that root.
|
||
The script operands select the operation and numbered slot only.
|
||
|
||
The port preserves that ownership boundary while intercepting the enclosing profile root. For Himegari,
|
||
`Sys4PersistencePaths` validates that `SAVEPATH` is beneath `REGFILEPATH`, maps their relative `SAVE` tail
|
||
under Godot `user://`, and supplies `user://SAVE` to `DirectoryNativeDatStore`. This isolates authored port
|
||
saves from the original installation while retaining compatible file structure; another profile root can
|
||
be injected without changing script semantics.
|
||
|
||
The resolver reads two per-game settings from the SYS4INI-backed settings registry:
|
||
`set:UseAppDataFolder` and `set:SavePath`. When `UseAppDataFolder == 1`, the modern-Windows branch
|
||
loads `SHGetFolderPathA` and requests `CSIDL_LOCAL_APPDATA | CSIDL_FLAG_CREATE` (`0x801c`); it then
|
||
appends `SavePath`. Otherwise the configured `SavePath` is used directly. It probes
|
||
`<root>\SAVE.DAT`; if that file does not exist, it walks the root and creates missing directory
|
||
components before returning it.
|
||
|
||
Himegari's SYS4INI trailer supplies:
|
||
|
||
```text
|
||
USEAPPDATAFOLDER=1
|
||
SAVEPATH=Eushully\姫狩りダンジョンマイスター\SAVE
|
||
```
|
||
|
||
Thus its normal modern-Windows root is
|
||
`%LOCALAPPDATA%\Eushully\姫狩りダンジョンマイスター\SAVE`. The complete filenames and backup
|
||
lifecycle are fixed by AGE after that root has been selected; no persistence opcode accepts an arbitrary
|
||
path or filename.
|
||
|
||
#### Shared `SAVE.DAT`
|
||
|
||
`shared_profile_save@0x40c950` writes `$$SAVE.DAT`, replaces `SAVE.DAT`, and keeps `SAVE.BAK`; it performs
|
||
the analogous `$$RT.DAT` → `RT.DAT` / `RT.BAK` update for ReadTextDB. `shared_profile_load@0x40ccd0`
|
||
falls back from `SAVE.DAT` to `SAVE.BAK` and loads `RT.DAT` independently. Numbered `.DAT` writes do not
|
||
use this backup transaction, but a successful numbered serialization flushes the shared profile too.
|
||
|
||
The complete shared-payload byte structure, installed-file counts, and its separation from `RT.DAT` and
|
||
numbered slot state are canonical in `sys4-format-notes.md` under "Native persistence files." The
|
||
selected-cell address meanings are canonical in `vm-map/globals.toml`. Native writer/reader behavior
|
||
establishes that the fixed integer/string maps are profile-wide services rather than whole-bank snapshots;
|
||
their opaque catalog and extended sections are preserved by the port.
|
||
|
||
`SAVE.BIN` provides the game-level consumer proof for the menu split: it reads timestamp/playtime from
|
||
the numbered header, loads the `.STH` screenshot, restores the remaining row fields with selected-cell
|
||
ops, and scans the restored cleared-ending mask to draw the NG+ badges. Save and stage-select paths
|
||
snapshot the same preview cells before persistence.
|
||
|
||
#### Common `.DAT` container
|
||
|
||
`save_container_read_and_validate_header@0x4306f0` is the metadata-only path used by `0x1a0`.
|
||
The full writer/reader are `save_container_encode_and_write@0x42fac0` and
|
||
`save_container_read_and_decode@0x42ff80`. The canonical header, codec-frame, CRC, LZSS, and reversible
|
||
DWORD-transform specification is in `sys4-format-notes.md`. The mapped helpers are
|
||
`lzss_4k_compress@0x42ed30`,
|
||
`lzss_4k_decompress@0x42f050`, `save_payload_expand_multiply_transform@0x42f400`,
|
||
`save_payload_inverse_multiply_transform@0x42f4a0`, `crc32_msb_first@0x42f360`, and
|
||
`crc32_reflected@0x42f300`.
|
||
|
||
#### Numbered logical state and `.STH`
|
||
|
||
`context_state_serialize@0x40d320` chooses numbered logical layout 1, 2, or 3 from `SaveVersion1`;
|
||
layout 1 retains legacy `SaveVersion2` sublayouts 10 and 20. Layouts 2 and 3 serialize script contexts
|
||
`0..save_frame_boundary_index` inclusive (falling back to the current context), clear the terminal frame's
|
||
return target, and append `text_history_serialize`. The matching
|
||
`save_data_deserialize_and_begin_restore@0x40fd10` reconstructs the banks, resources, retained state,
|
||
history, and—when requested by `0x1a1`—the active frame chain consumed by `0xae`.
|
||
|
||
The complete Himegari layout-3 body, frame record, six-bank sequence, retained-gfx region, appended
|
||
text-history tail, and `.STH` BMP format are canonical in `sys4-format-notes.md`. Native thumbnail
|
||
helpers are `gfx_surface_write_bmp24_to_handle@0x434bf0` and
|
||
`gfx_surface_write_bmp24_to_path@0x475420`; the load side uses the renderer image decoder.
|
||
|
||
**1.0 implementation boundary:** reproduce these native binary domains and lifecycle first: shared
|
||
`SAVE.DAT`/`SAVE.BAK`, `RT.DAT`/`RT.BAK`, numbered `.DAT`, and paired BMP `.STH`. Keep the ownership behind
|
||
a profile/save service so extended mode can later add JSON inspection/export, namespaced mod state, migrations,
|
||
or a friendlier editor without changing compatibility-mode opcode semantics or the native import/export path.
|
||
|
||
**Port correspondence (2026-07-24):**
|
||
`Age.Engine.Persistence.NativeSaveContainerCodec` now reads and writes the common header, Shift-JIS game id,
|
||
SYSTEMTIME/playtime/version metadata, both CRC layers, version-2 compression wrapper, and exact reversible
|
||
DWORD transform. `Sys4.LzssEncoder` emits the same 4 KiB-ring token dialect already consumed by
|
||
`LzssDecoder`, falling back to native verbatim storage when compression does not shrink. The
|
||
`INativeDatStore` boundary and `DirectoryNativeDatStore` own shared `$$SAVE.DAT` → `SAVE.DAT` /
|
||
`SAVE.BAK` replacement/fallback and direct numbered `SAVE##.DAT` writes.
|
||
|
||
`SharedProfilePayloadCodec` owns the typed shared logical layout, including native CP932 strings and
|
||
lossless opaque-section preservation. `SharedProfile` owns selected integer/string maps and explicit
|
||
load/save lifecycle; `GameSession` injects it into every fresh VM. Opcodes `0x1a2`/`0x1a3` and
|
||
`0x1a9`/`0x1aa` now implement native upsert and missing-value defaults for direct cells and resolved
|
||
global pointers. `RT.DAT` is now implemented as the separate S3RT layer described below. The numbered store
|
||
validates its distinct compatibility id, queries metadata without decoding payloads, and performs exact
|
||
paired `.DAT`/`.STH` copy/delete status layering. `NumberedThumbnailCodec` reads and writes the native BMP
|
||
dialect through host surface capture/replacement, and ops `0x1a0`, `0x1ab`–`0x1af` (including `0x1ad`'s
|
||
frame marker) are wired. Godot's shared profile-path resolver injects the store at `user://SAVE`.
|
||
`NativeNumberedSaveCodec`, `NativeTextHistoryCodec`, and `NativeGfxPersistenceCodec` own the complete
|
||
layout-3 numbered body, global/frame/gfx state, and appended history tail. The VM wires `0x19e`,
|
||
data-only `0x19f`, full load
|
||
`0x1a1`, and the active branch of `0xae`; the existing `GameSession` JSON snapshot remains a separate
|
||
diagnostic/extended-mode surface.
|
||
|
||
#### Layout-3 restore mechanics and port correspondence (2026-07-24)
|
||
|
||
The port tracks the native global banks separately at runtime, captures the marked frame chain, serializes
|
||
live surfaces and retained objects, and applies the 20-byte surface records' explicit reload flags. Full
|
||
load first replaces each serialized mutable bank prefix while preserving initialization-authored cells
|
||
beyond its count, restores history/gfx and retained audio, unwinds the obsolete managed call chain, runs
|
||
`CALLBACK_LOAD.BIN` when
|
||
the mounted script provider resolves it, loads each saved script at its ordinary entry so its frame-local
|
||
prologue runs, lets that script reach its own `0xae` rendezvous, recursively reconstructs child frames,
|
||
resumes parents after their saved T2 call sites,
|
||
and finally resumes the terminal frame at its T1 boundary. Successful `0x19e` also flushes shared
|
||
`SAVE.DAT`/`RT.DAT`, matching `context_state_serialize`.
|
||
|
||
The prefix boundary is observable in the installed file: integer count `0x6241b` stops before the
|
||
initialization-authored unit/stage definition tables, and string count `0x315` stops exactly before
|
||
`unit_story_display_names`. Native `save_data_deserialize_and_begin_restore@0x40fd10` zeroes only those
|
||
counted regions; the port's former whole-dictionary clear erased the definitions needed by CHMENU,
|
||
unit-management, and SELSTAGE after load.
|
||
|
||
The fixed audio fields are also resolved. Payload `+0x008` is
|
||
`EngineCtx.current_bgm_track_id` (`ctx+0xa0b84`), maintained by
|
||
`bgm_play_track@0x464750` and queried by op `0xc0`; the installed FORT save contains `0x18`, matching
|
||
FORT's BGM024 command immediately before its T1 resume boundary. Payload `+0x00c..+0x033` copies
|
||
`EngineCtx.sfx_channel_resource_ids[10]` (`ctx+0x144e0`), and
|
||
`sfx_reload_saved_channels@0x482640` reopens each positive packed id after deserialization. The port now
|
||
tracks both lifecycles through `0xbf`/`0xc2` and `0xb4`/`0xb6`, serializes them, and restores the active
|
||
track plus saved SFX resources. The new EngineCtx fields and function annotations are applied to the
|
||
saved `/v2` image.
|
||
|
||
The surface records have a related preservation boundary. Resource id is at `+0x00`, packed color key at
|
||
`+0x04`, `+0x08` is a reload flag rather than a general presence bit, `+0x0c` remains unknown, and
|
||
`+0x10` marks a blank/mutable created surface. `gfx_surface_record_tables_init@0x472700` zeroes both
|
||
1,000-record tables and initializes every resource id to `-1`.
|
||
`gfx_surface_create_blank@0x477370` writes resource `-1` and created flag one;
|
||
`gfx_surface_load_asset@0x477c40` writes resource/color and clears the created flag. Neither operation
|
||
changes the reload flag. The layout-3 serializer copies all 20,000 live record bytes verbatim, rather
|
||
than deriving reloadability from the presence of an asset.
|
||
|
||
The restore loop calls `gfx_surface_load_asset` only when the saved reload flag is one and resource id is
|
||
nonnegative. Otherwise the already-initialized surface survives. The optional all-surface release is
|
||
separately gated by both `set:CreateObject` and `set:AutoFreeTex`; the registered defaults at
|
||
`engine_settings_register_defaults@0x46be30` are one and zero respectively, matching Himegari's active
|
||
path. Opcode `0x259` is also a real lifecycle operation:
|
||
`op_0x259_script_entry_clear_surface_persistence_flags@0x417660` clears record `+0x08` and `+0x0c`
|
||
across both native tables at every script entry.
|
||
Opcode `0x258` then declares the exceptions:
|
||
`op_0x258_set_surface_persistence_flags@0x4250a0` calls
|
||
`gfx_surface_set_persistence_flags@0x4159c0`, which replaces record `+0x08` from flags bit 0 and
|
||
record `+0x0c` from bit 1 in both tables. The port models bit 0 because layout-3 restoration consumes
|
||
that reload policy; the adjacent bit-1 consumer remains unnamed.
|
||
Installed slot 15 records resource `0x3383` with reload flag zero. SYSTEM4 loads that atlas before the
|
||
save UI, and BUNKI cuts its reusable choice-box corners, borders, and winged top ornament from it. The
|
||
port formerly released all 1,000 host surfaces unconditionally, so post-load BUNKI retained its black
|
||
backing but lost the atlas decoration. Restoration now preserves live surfaces and overlays only flagged
|
||
reload records by default, while `VmOptions.CreateObject && VmOptions.AutoFreeTextures` reproduces the
|
||
native all-release branch. Port-authored saves emit the exact resource/reload/created fields, including
|
||
`-1` for unused records, and opcode `0x259` clears the modeled reload policy without releasing textures.
|
||
The corrected native functions are renamed/commented in the saved `/v2` image.
|
||
|
||
#### Port-authored round trips: restored frame boundary and retained-object encoding (2026-07-24)
|
||
|
||
An immediate `installed SAVE00 -> port SAVE03` round trip isolated two independent writer defects.
|
||
The source file contains two frames (`SYSTEM4.BIN -> FORT.BIN`), while the first port rewrite contained
|
||
four: the same gameplay pair plus `SAVE.BIN` and its nested helper. Native opcode `0x1ad` selects the
|
||
terminal gameplay frame before opening modal helpers. Full restore through `0xae` reinstates that saved
|
||
terminal context as the effective boundary. The managed restore cleared its temporary restore state but
|
||
did not restore `_saveResumeFrame`, so the next `0x19e` fell back to the currently deepest frame. The port
|
||
now marks the terminal restored `ExecFrame` before resuming at T1; a regression loads two frames, saves
|
||
from a nested helper, and proves the rewrite still contains only the original two.
|
||
|
||
The first fresh rewrite (`SAVE04`) then exposed the other half of that managed/native representation gap.
|
||
It had two frames, but its restored SYSTEM4 ancestor changed from native `resume=8, call=8` to
|
||
`resume=-1, call=-1`. Native `op_0xae_continue_save_load_stack_restore@0x416790` restores the parent
|
||
context's real T1/T2/T3 coordinates before activating its child. The managed port instead leaves the
|
||
parent physically parked at the synthetic `0xae` rendezvous while `RunFrame` recursively runs FORT.
|
||
Recomputing a save record from that synthetic PC cannot find a T1 or T2 table entry. FORT itself loads
|
||
and runs, but when stage launch unwinds through SYSTEM4, the missing T2 continuation enters ordinary
|
||
Eushully boot.
|
||
|
||
Each restored `ExecFrame` now shadows its original `NativeSavedScriptFrame` while a restored descendant
|
||
is active. An intervening `0x19e` serializes those original T1/T2/T3 indices; T3 also reconstructs the
|
||
live local-return stack, and the saved T1 coordinate seeds the frame's read-message state. The shadow is
|
||
discarded only when that child genuinely returns and the parent resumes at its restored T2 continuation.
|
||
Restored root-reload and process-exit outcomes also propagate through the synthetic recursion. A focused
|
||
load -> nested save -> reload regression proves the parent indices survive and the child is not invoked
|
||
again through ordinary `CallScript`.
|
||
|
||
Manual slot 005 acceptance then proved that an immediate base rewrite could load and later launch a
|
||
stage. Slot 006, authored immediately after entering that stage, still restored a black dungeon with
|
||
visible but noninteractive UI. Its serialized FIELD range, 704 retained objects, map textures, and
|
||
native-looking 0.8 transform were intact. A software replay reproduced the black result because FIELD
|
||
had changed every restored map object's scale to `(0,0,1)`: global zoom selector `G[0x7682]` was 3, but
|
||
the frame-local lookup table that should yield 80 was still zero.
|
||
|
||
Ghidra resolves the ordering precisely. `script_frame_restore_saved_layout@0x40f2d0` calls
|
||
`script_frame_load_resource@0x40e980`; that loader allocates and zeroes locals, loads the script body,
|
||
and initializes its PC to the script codebase, not to `0xae`. The restored script therefore executes its
|
||
ordinary entry prologue before reaching the rendezvous. FIELD's prologue constructs the inline zoom
|
||
table `[40, 50, 64, 80, 100, 124, 156]`; the port's direct jump to `0xae` skipped those copies and let
|
||
FIELD apply zero scale after the gfx record restore.
|
||
|
||
Restored managed frames now likewise begin at script offset zero while retaining their saved T1/T2/T3
|
||
coordinates for `0xae`. Synthetic root and child regressions prove both prologues execute. Replaying the
|
||
unchanged real slot 006 now selects zoom 80, leaves the map at 0.8 scale, produces a nonblack dungeon
|
||
raster, and reaches the gameplay poll. No slot rewrite is required for this correction.
|
||
|
||
The same comparison exposed native retained-gfx pointer arithmetic. `context_state_serialize@0x40d320`
|
||
writes a handle and copies the meaningful `0xb5` DWORD / `0x2d4`-byte record, then advances its DWORD
|
||
cursor by `0x2d5`; `save_data_deserialize_and_begin_restore@0x40fd10` mirrors that stride. Early port
|
||
output packed entries contiguously. In addition, `gfx_object_init_default@0x472810` seeds six identity
|
||
matrices and three `0xffffffff` color defaults before the loader overwrites the complete record; the old
|
||
writer began with zero bytes and only filled modeled fields. It also wrote translation vectors over the
|
||
first row of the native 4x4 matrices instead of entries 12..14.
|
||
|
||
`NativeNumberedSaveCodec` now emits the native `0xb54`-byte entry stride and recognizes the old tight
|
||
experimental layout for compatibility. `NativeGfxPersistenceCodec` begins new records from the exact
|
||
native defaults, writes row-vector translation matrices correctly, and retains the original raw record
|
||
behind the semantic object so still-unnamed fields survive native load/save cycles. The `/v2` serializer
|
||
and deserializer comments record the corrected stride and overwrite behavior.
|
||
|
||
### Opcode `0xae` continues numbered-save stack restoration (2026-07-20)
|
||
|
||
Opcode `0xae` is the load-side rendezvous paired with serialized script-frame state. Its handler,
|
||
`op_0xae_continue_save_load_stack_restore@0x416790`, normally returns after recording its one-dword length.
|
||
It only becomes effectful while `ctx+0x53d24` (`save_load_stack_restore_active`) is set by
|
||
`save_data_deserialize_and_begin_restore@0x40fd10`. That deserializer restores the selected save layout,
|
||
loads `CALLBACK_LOAD.BIN` or the saved entry script, and resets the current context so ordinary opcode
|
||
`0xae` sites can rebuild the saved stack.
|
||
|
||
On an active restore, the handler reads `set:SaveVersion1`/`set:SaveVersion2`, selects the matching saved
|
||
frame layout, replaces the current frame PC with its saved resume or call target, and advances through the
|
||
serialized contexts. `script_frame_restore_saved_layout@0x40f2d0` loads each packed script and expands its
|
||
T1/T2/T3 indices back into live PC/call/return offsets. At the saved terminal context the handler clears
|
||
the restore flag and reinstates the saved context/return state. The corpus placement supports that control-flow role: 305 calls overwhelmingly follow
|
||
coroutine-resume or call boundaries, including SC0000's main-loop resume sequence.
|
||
|
||
The load order is significant: `script_frame_restore_saved_layout` delegates script creation to
|
||
`script_frame_load_resource@0x40e980`, which initializes the new frame PC to the script codebase.
|
||
Consequently the script runs its normal frame-local initialization and reaches `0xae` itself. Starting
|
||
a restored frame directly at `0xae` is not native behavior; in FIELD it leaves the local zoom table
|
||
zeroed and collapses the restored dungeon to a black point.
|
||
|
||
The port now implements both branches: an ordinary one-instruction no-op outside restoration, and the
|
||
T1/T2/T3-driven managed-frame reconstruction described above while a full numbered load is active.
|
||
|
||
The first interactive installed-save continuation exposed a dispatch-label integration error rather than
|
||
a native semantic error. Although `0xae` had this mapped semantic record and a VM case, its opcode-table
|
||
label still used upstream placeholder `u00415130`; the VM switches on the label, so the active case was
|
||
unreachable. The saved root entered `SYSTEM4.BIN` with restore state intact, but `0xae` fell through and
|
||
SYSTEM4 ran its ordinary `LOGO.BIN → OP.BIN → INIT.BIN → TITLE.BIN` boot path. The source label is now
|
||
`continue-save-load-stack-restore`. A real installed-save gate executes beyond the rendezvous and proves
|
||
`SYSTEM4.BIN → FORT.BIN → CHMENU.BIN` restoration to the gameplay poll. The synthetic two-frame gate
|
||
also asserts that its child enters with `FrameCause.SaveRestore`, preventing its ordinary call site from
|
||
masking another inactive-branch regression.
|
||
|
||
### ADV read-message Skip and shared `RT.DAT` history (2026-07-18)
|
||
|
||
Read-message Skip is backed by an engine-owned `ReadTextDB`, not a VM-global flag and not ordinary numbered
|
||
slot data. Each script frame supplies its raw packed SYS4/AAI resource id, a table of message code offsets,
|
||
and its count. `script_frame_load_resource@0x40e980` stores the same id used by
|
||
`asset_open_indexed_entry` at frame field `+0x04` (`EngineCtx+0x53d64`); base Himegari scripts therefore use
|
||
their SYS4INI file index, while append resources retain the high-byte pack selector.
|
||
`read_text_db_find_message_index@0x468f50` maps the current code dword offset through that table to a
|
||
per-script message index. `read_text_db_is_message_read@0x469930` formats the script id as an eight-digit
|
||
lowercase hexadecimal key, looks up that script's record, bounds-checks the index, and returns the stored
|
||
dword flag. `adv_refresh_read_skip_state@0x406cd0` and ops `0x6e/0x71/0x72` combine that result with
|
||
`message:ReadTextSkip`; a read page sets `run_state_flags & 0x08000000` and
|
||
`ctx->adv_read_skip_state`, which op `0x1cc` exposes to the scripts.
|
||
|
||
The write side records completion, rather than merely displaying text. Ordinary click/wheel advance and
|
||
Auto expiry queue `{script_id, message_index, message_count}` through
|
||
`read_text_db_queue_message@0x469340`; an op-`0x72` wait already being passed by Skip queues the same tuple
|
||
directly. Opcode `0x71` resets a text layout at the structural sites targeted by T1; it is not a pure runtime no-op:
|
||
`op_0x71_handler@0x41e540` snapshots the current code position and calls
|
||
`read_text_db_commit_pending@0x46ae20`, which grows or creates the per-script flag array and sets the queued
|
||
indices to one. This queued/commit seam lets the port reproduce native read eligibility without scene
|
||
offset lists or synthetic VM globals.
|
||
|
||
Persistence is shared across numbered save slots. `shared_profile_save@0x40c950` atomically rewrites
|
||
`SAVE.DAT`, then serializes `ReadTextDB` through `$$RT.DAT` to `RT.DAT`, with `RT.BAK` handling.
|
||
`shared_profile_load@0x40ccd0` loads `SAVE.DAT` (falling back to `SAVE.BAK`) and then independently loads
|
||
`RT.DAT` when present. Numbered saves use the separate `SAVE%2.2d.DAT` pattern. A successful context/slot
|
||
save calls the shared-profile writer directly. On accepted `WM_CLOSE`, `age_main_window_proc@0x486320`
|
||
queries `set:NoSaveDat` and calls the same writer only when that value is zero; forced and confirmed close
|
||
share that post-acceptance path. `engine_settings_register_defaults@0x46be30` registers `NoSaveDat=0`.
|
||
Thus the switch suppresses only the shutdown write, not the shared flush following a successful numbered
|
||
save.
|
||
|
||
The selected integer-cell portion of shared `SAVE.DAT` is the `0x1a2` store / `0x1a3` restore service
|
||
documented above. It is independent of the `RT.DAT` read-message database even though the shared-profile
|
||
writer updates both files in one lifecycle.
|
||
|
||
The `RT.DAT` header is `0x114` bytes: magic `0x54523353` (bytes `S3RT`), a compatibility id, a 256-byte
|
||
game id, version pair `1,0`, and script-record count. It is followed by all 12-byte script records containing
|
||
`{script_id, message_count, serialized_flags_pointer}` and then, in the same record order, the corresponding
|
||
`message_count` DWORD flag arrays. The native writer copies its live heap pointer into the third record word.
|
||
The loader ignores that address, allocates a fresh array, overwrites the word, and rebuilds the in-memory
|
||
hashtable; a portable structurally compatible writer can therefore emit zero without inventing an address.
|
||
The installed 76,752-byte native file validates the formula exactly: 192 records and 18,543 flag DWORDs.
|
||
Its SC0000 record (`script_id=0x22`) has 320 messages, exactly matching SC0000's F7/T1 count.
|
||
|
||
**Port implementation (2026-07-24):** `ReadTextDatabaseCodec` imports and emits the exact S3RT header,
|
||
record table, and ordered flag arrays, validates compatibility/game/version identity, accepts native nonzero
|
||
pointer residue, and writes zero in that ignored field. `DirectoryNativeDatStore` owns
|
||
`$$RT.DAT` → `RT.DAT` / `RT.BAK` replacement; load follows native behavior and reads `RT.DAT` directly
|
||
rather than treating `RT.BAK` as a fallback. `SharedProfile.ReadText` owns records and the pending queue
|
||
across fresh VMs, and shared-profile Save/Load updates SAVE.DAT and RT.DAT as separate native domains.
|
||
Godot now requests a VM stop, releases the blocking host, waits for the VM worker to leave its opcode
|
||
boundary, and performs one shared-profile flush before frontend teardown. Repeated exit notifications do
|
||
not rotate backups twice; expected I/O failures are reported without crashing teardown; self-test uses
|
||
`NoSaveDat`; and the loaded shared header's accumulated-playtime value seeds the new process baseline.
|
||
Focused restart coverage proves selected integer/string cells and committed read flags survive a clean
|
||
exit without any numbered slot write. `NoSaveDat` suppresses that exit write while a successful `0x19e`
|
||
still writes both shared files.
|
||
|
||
Scripts now retain their packed resource id and decoded F7/T1 table. Opcode `0x71` commits pending tuples
|
||
and snapshots its T1 coordinate; `0x6e`/`0x71`/`0x72` refresh read eligibility; wait completion queues
|
||
`{script_id,message_index,message_count}`. Opcodes `0x1ca`/`0x1cb` share the profile-lifetime
|
||
`message:ReadTextSkip` setting, while `0x1cc` reports the current message state. This implements native
|
||
read-message behavior without scene-offset special cases and leaves JSON/export tooling as extended mode.
|
||
|
||
The `/v2` Ghidra image now names/comments the lookup, queue, commit, mark, file read/write, shared-profile
|
||
save/load chain, exact `WM_CLOSE` gate, and `NoSaveDat=0` default; saved/refined through 2026-07-24.
|
||
|
||
### Remaining ADV control-strip actions and implementation cost (2026-07-18)
|
||
|
||
The five standard controls are now fully inventoried. Auto message, all-message Skip, and Hide Window have
|
||
working native-path services; Read-message Skip is the profile-wide `RT.DAT`/ReadTextDB slice above.
|
||
History remains a distinct retained-text subsystem rather than another variation of Skip.
|
||
|
||
| Control | Native action | Current port boundary | Relative cost |
|
||
|---|---|---|---|
|
||
| History (`x=684`) | Cancel the ADV hotspot wait and run `HISTORY.BIN` over the text manager's retained record stream | Input/callback infrastructure works; a bounded retained-history model, its read/write opcodes, and supporting text/presentation ops remain | Medium-high, bounded |
|
||
| Auto (`x=706`) | Toggle the Auto service | Implemented, including timed wait completion | Done |
|
||
| Message Skip (`x=728`) | Enable persistent all-message fast-forward | Implemented; pacing discrepancies remain a later fidelity adjustment | Done |
|
||
| Read-message Skip (`x=750`) | Toggle `message:ReadTextSkip`; gate advancement through shared ReadTextDB state | Native persistence and queue/commit/query flow investigated; service not implemented | Medium-high, bounded |
|
||
| Hide Window (`x=772`) | Op `0x199` enters the saved ADV coroutine handler, removes chrome, and runs `HIDEWIN.BIN` | Implemented through the native script path, including coroutine re-entry, per-frame callbacks, mouse/joy state, and `.CUR` resources | Done |
|
||
|
||
`HIDEWIN.BIN` is primarily an input/scheduler slice, not a new renderer. Its former eight effectful gaps were
|
||
cursor selection (`0x86/0x87`), mouse callback registration/dispatch (`0xcc/0xcd`), mouse-button state
|
||
(`0x108`), and joy callback registration/poll/dispatch (`0xfb/0xff/0x100`). The implementation below adds
|
||
those services plus real op-`0x199` frame redirection; the existing retained renderer supplies the visual
|
||
state while the script saves translations, hides the ADV chrome, permits view/pan input, and restores state.
|
||
|
||
`HISTORY.BIN` remains a larger subsystem, but the formerly ambiguous gaps are now bounded. Static runtime
|
||
coverage is still 47/78 distinct opcodes (781/854 instructions): this investigation refined semantics, not
|
||
the C# handler count. The 15 op-`0x64` sites decode count-prefixed inline integer arrays used for row
|
||
rectangles and coordinates. Ops `0xa1/0xa2/0xa3` are a generic value switch (`begin`, `add case`, `dispatch`),
|
||
not a History-specific menu/input service; HISTORY maps already-produced action values to local branches.
|
||
Op `0x12e` scans those rectangle/offset arrays for pointer hover. The Hide Window callback/input layer is
|
||
therefore already sufficient.
|
||
|
||
#### Retained History record model and lifetime (2026-07-18)
|
||
|
||
The native text manager at `ctx+0x14940` owns two vectors:
|
||
|
||
- manager `+0xd24/+0xd28`: 0x48-byte retained records;
|
||
- manager `+0xd34/+0xd38`: 8-byte logical entries `{layout_slot, first_record_index}`.
|
||
|
||
A normal record holds the layout slot at `+0x00`, geometry at `+0x04..+0x10`, value/metadata fields at
|
||
`+0x14/+0x18`, font/color/baseline state at `+0x1c..+0x24`, flags at `+0x28`, and an inline-or-heap string
|
||
object at `+0x2c` (length `+0x40`, capacity `+0x44`). Flag bit 0 begins a logical group; bit 1 is filtered by
|
||
History's navigation mode; `0x20000000` denotes typed metadata and `0x40000000` denotes a voice pair.
|
||
|
||
The write path is part of ordinary ADV execution:
|
||
|
||
- op `0x70` defines a text layout and op `0x71` resets one. Unless recording is suppressed, each appends
|
||
`{layout_slot,current_record_count}` and arms the next record's group-start bit. The op-`0x71` operand is a
|
||
layout slot—not a T1 anchor id—although T1 entries structurally target these reset sites. Its ReadTextDB
|
||
snapshot/commit work remains a second responsibility.
|
||
- op `0x6e`'s glyph builder appends normal text chunks with the active geometry/font/color state.
|
||
- voice op `0xc4` appends a `0x40000000` record containing `{voice_id,0}`; History replay op `0x1bd`
|
||
uses the same writer with `{voice_id,1}` when recording is enabled.
|
||
- op `0x1d2` appends a `0x20000000` record with operand 1 as the metadata type and operand 2 as its value.
|
||
It was previously misclassified as a safe statement marker; 17,323 corpus uses make this a foundational
|
||
correction.
|
||
- op `0x1bb(0)` writes suppression bit `0x80000000` at `ctx+0x55110`; op `0x1bb(1)` clears it. HISTORY uses
|
||
that pair at entry/exit so its own UI text is not added to the backlog.
|
||
- op `0x85` clears both vectors. Its 286 corpus uses are two sites in each of 143 ordinary ADV scripts,
|
||
generally bounding History to the current ADV block rather than an unbounded profile log.
|
||
|
||
The read side is the previously identified op-`0x1d0..0x1d4` family. Op `0x1d0` returns a logical entry's
|
||
layout slot and first record index; op `0x1d1` renders records until the next group boundary; op `0x1d3`
|
||
finds typed metadata; op `0x1d4` finds the voice pair. `HISTORY.BIN` dispatches pair variant 1 through op
|
||
`0x1bd` and variant 0 through ordinary op `0xc4`.
|
||
|
||
History is independent of `RT.DAT`, but native full save fidelity does serialize the live backlog.
|
||
`text_history_serialize@0x451d00` writes the index and packed records/strings after context/numbered-save
|
||
serialization, and `text_history_deserialize@0x456130` restores them on the matching load path. The port's
|
||
`NativeTextHistoryCodec` now implements that appended tail directly against the same live
|
||
`AdvTextHistory` model; it remains independent of shared profile state and `RT.DAT`.
|
||
|
||
History's display support consists of the ordinary presentation operations: primary/ruby font sizes
|
||
(`0x75/0x197`), font weight (`0x2bd`), colors/effect mode/offset (`0x76/0x77/0x78/0x1a4`), layout origin
|
||
(`0x198`), surface rectangle fill (`0x20b`), message-window alpha (`0x131`), and retained-object presentation
|
||
(`0x222`). The `0xd3/0xd4/0xd5` callback-sequence family drives the smooth scrollbar interpolation; omitting
|
||
it affects motion fidelity, not the backlog data model.
|
||
|
||
The first port slice now implements an engine/session-owned `AdvTextHistory` service. `GameSession` carries
|
||
one instance across VM scene runs, while a standalone VM owns its instance for the full top-level and nested
|
||
script lifetime. The model retains the logical index, typed text/metadata/voice records, group-start flags,
|
||
layout/cursor snapshots, and font/color/effect state. Ops `0x70`, `0x71`, `0x6e`, `0xc4`, `0x1d2`, `0x1bb`,
|
||
and `0x85` feed it directly; Godot remains only a presentation host and does not own the canonical backlog.
|
||
|
||
The model intentionally has no JSON or disk serialization. `GameSession.ToJson()` continues to snapshot
|
||
only the pre-existing global banks, so landing the live backlog does not silently choose a save/profile
|
||
backend. Native numbered-save restoration remains the explicit future integration seam described above.
|
||
The second port slice implements generic inline-array copying (`0x64`), formatted value dispatch
|
||
(`0xa1/0xa2/0xa3`), cumulative navigation (`0x1d0`), and metadata/voice queries (`0x1d3/0x1d4`). The C#
|
||
loader retains the original body dwords so `0x64` copies the file's plain count-prefixed values; AGE's
|
||
rotate/XOR work belongs to its native in-memory representation, not the SYS4 file format. Navigation mirrors
|
||
manager `+0xd6c`: layout define/reset updates the latest-entry anchor, while each `0x1d0` delta is cumulative
|
||
and does not mutate it. Query scans stop at the next group-start record; their third operands are unused by
|
||
the native helpers. History's remaining work is therefore presentation and interaction rather than backlog
|
||
data access.
|
||
|
||
The third port slice implements visible History presentation without moving canonical records into Godot.
|
||
Op `0x1d1` asks `AdvTextHistory` to select one retained group, skip metadata/voice records, and build a
|
||
host-facing render batch using the target layout configured by `0x70/0x71`, positioned by `0x198`, and
|
||
seeded by `0x7a`. `HISTORY.BIN` passes zero for flags and both color overrides, so this is its exact ordinary
|
||
binding path; the native flag-4 raster-without-binding and flag-8 side-effect modes remain outside the live
|
||
game call site. Godot owns only replaceable labels keyed by target layout, applies the retained font/color/
|
||
effect snapshot, and clears stale labels whenever `0x71` resets that layout.
|
||
|
||
The same slice implements the support calls at their natural seams. Op `0x131` reads a host configuration
|
||
property (currently defaulting to zero; choosing a profile/config persistence backend remains deferred),
|
||
`0x20b` replaces every clipped RGBA pixel in the addressed mutable-surface rectangle and clears modeled text
|
||
draws anchored inside it, and `0x222` requests one retained recomposition boundary. This pixel write matters
|
||
outside HISTORY: SYSTEM4 fills created surface 3 opaque white, then BUNKI applies mode-0 tint plus `0xd0`
|
||
opacity to produce its translucent menu interior. FIELD reuses the surface at `0x40` opacity under its
|
||
minimap. Surface text is now a list rather than one draw per slot: HISTORY's five 600x30
|
||
source rows therefore retain five independent speaker names, and compositor source-rectangle clipping maps
|
||
each one into its bound object. A real-script regression retains SC0000 page one, runs unmodified
|
||
`HISTORY.BIN`, observes a non-empty rendered row, and reaches its `(0,60000)` presentation call.
|
||
|
||
The fourth port slice implements History's basic pointer interaction and clean return. Op `0x12e` treats its
|
||
addressable operands as arrays in their actual VM domain (HISTORY's are local integers), scans from
|
||
`incoming_index+1`, and tests inclusive intersection. Rectangle fields are `[left,right,top,bottom]`; each
|
||
candidate's x/y offset is subtracted from the pointer before comparison with the reference rectangle.
|
||
HISTORY's decoded arrays contain fourteen candidates: scrollbar/control regions, bottom-right close region
|
||
8, and five 650x130 text rows at indices 9..13. Moving the pointer updates the selected index and executes the
|
||
script's existing redraw, while left-button release on region 8 sets the script's exit state and runs its
|
||
normal surface/layout/recording cleanup.
|
||
|
||
The host input bridge now distinguishes an enclosing ADV page wait from a script-owned timed raw-input loop.
|
||
Registration through op `0xcc` marks the current frame as the raw-input owner until that frame returns;
|
||
Godot routes physical button state and configured callback indices to it without signaling the parked ADV
|
||
page. This is frame-scoped callback state, not a HISTORY name/offset special case, and also matches the
|
||
existing HIDEWIN scheduler family. A real regression activates x=684 in SC0000, runs unmodified HISTORY,
|
||
selects/closes region 8, observes retained text, and returns to the same single page wait.
|
||
|
||
Manual comparison exposed a separate typed-address bug in the first display build. `HISTORY.BIN` copies its
|
||
button x/y tables into local integer cells `0x4` and `0x68`, then op `0x61` takes local pointers to selected
|
||
elements for `draw-texture`. A local base operand names that local cell; it is not a local value containing a
|
||
global address. The VM now retains the local/global domain in pointer values, so reads and writes through a
|
||
local pointer reach the correct bank. This restores the six controls at x=768/y=121..411, the close control
|
||
at x=768/y=549, and the hovered-row highlight while preserving RECOVER's global-array pointer behavior.
|
||
The Python A0 oracle uses the same typed-address model.
|
||
|
||
The History text batches already carry the native x origin 65 and cursor x 45. Their Godot labels were first
|
||
created with a full-rect anchor preset before being parented, which discarded the intended absolute placement
|
||
and caused a Godot parent/layout diagnostic. Dynamically composited ADV labels now use top-left anchors, and
|
||
all root Controls apply their presets only after parenting.
|
||
|
||
A second manual check separated the apparent bottom row from the real History batches: it was the ordinary
|
||
ADV dialogue Label leaking above HISTORY's full-screen retained surface. The real batches contained several
|
||
non-empty retained groups, but their target layouts had width/height zero and Godot clipped each to one pixel.
|
||
`HISTORY.BIN` deliberately only resets and repositions layouts 2..6; `SYSTEM4.BIN@0x7..0x82` defines and
|
||
resets all nine shared layouts before scene dispatch. In particular, slots 2..6 are 650x150 at x=65 and
|
||
y=0/150/300/450/600. The Phase-A single-scene bootstrap now carries forward that exact nine-layout prefix,
|
||
in the same category as its inherited SO000/SO001 state, until full SYSTEM4 replay replaces the bridge.
|
||
The multi-message regression proves that at least two non-empty rows retain 650x150 geometry.
|
||
|
||
While a nested timed raw-input frame owns the screen, Godot now hides the enclosing page's ordinary dialogue
|
||
Label and independently animated wait marker. Native rendering gets this layering naturally because both are
|
||
part of retained composition; the port must state it explicitly because those two elements are separate
|
||
Godot overlays. They reappear with the same parked page after the modal frame returns.
|
||
|
||
History's target-layout render batches are likewise presentation bindings, not part of the retained backlog.
|
||
`HISTORY.BIN@0x1100` re-enables recording with op `0x1bb(1)` after erasing its object range and releasing
|
||
surfaces `0xc0/0xc1`; that existing exit boundary now tells the host to discard all bound History batches.
|
||
The semantic text/index records remain untouched, so reopening History rebuilds fresh rows while exiting
|
||
cannot leave the old labels above the resumed ADV page.
|
||
|
||
Stored voice replay now follows the native split. Ordinary op `0xc4` and History op `0x1bd` share the
|
||
packed-resource resolver, Skip replacement queue, and Auto voice-pending state, but carry variants 0 and 1
|
||
respectively through `IHost.PlayVoice`. Both append their pair to the backlog when recording is enabled;
|
||
History's surrounding `0x1bb(0)` suppression prevents the replay from recording itself. Native
|
||
`voice_play_indexed_asset@0x488330` stores that variant in the channel-12 sound-buffer state before starting
|
||
playback. Its precise audible meaning remains unproven, so the Godot host retains it through its queue and
|
||
timeline rather than inventing different playback behavior.
|
||
|
||
A live replay trace exposed a corrected prerequisite on that path. The clicked SC0000 row found voice pair
|
||
`{0x24,0}` but returned at `HISTORY.BIN@0x9ee` because its required type-2 metadata lookup failed. Native
|
||
`op_0x1d2_append_text_history_metadata@0x41f9c0` fetches operand 2 and then operand 1 before passing them as
|
||
`(type,value)` to `text_history_append_typed_metadata@0x455f00`; the helper stores value at record `+0x14`
|
||
and type at `+0x18`. Thus SC0000's `0x1d2(2,0x11)` means type 2, character/value `0x11`, not the reverse.
|
||
The port now writes that pair in the native order, allowing HISTORY's type-2 gate to reach voice playback.
|
||
|
||
#### Timed local-callback scheduler and History smooth scrolling (2026-07-19)
|
||
|
||
Ops `0xd3/0xd4/0xd5` are a generic frame-local timed callback sequence, not a History-specific animation
|
||
primitive. The native state is a vector of 16-byte entries at `ctx+0x5f694`, with begin/end/capacity at
|
||
`+0x5f698/+0x5f69c/+0x5f6a0`; each entry is `{deadline_ms,aux,primary_pc,catchup_pc}`. The playback cursor is
|
||
`ctx+0x5f6a8`, the last appended index is `ctx+0x5f6a4`, and op `0xd5` uses the elapsed timer rooted at
|
||
`ctx+0x5f3ac`. These names are canonical in `vm-map/engine-ctx.toml` and applied to the `/v2` `EngineCtx`.
|
||
|
||
- op `0xd3` clears the vector, resets the cursor to zero, and initializes the last index and optional abort
|
||
PC to `-1`;
|
||
- op `0xd4(interval,count,primary,catchup)` appends `count` entries, accumulating each deadline relative to
|
||
the preceding one and storing the two local callback PCs;
|
||
- op `0xd5(abort_pc)` retains the active script identity, starts the timer, sorts the entries, and enables
|
||
scheduler run-state bit `0x40` while `cursor < last_index`. The final entry is therefore a non-dispatched
|
||
look-ahead sentinel, not another callback.
|
||
|
||
`timed_callback_sequence_tick@0x408170` sleeps until an entry is due. If the following entry's deadline is
|
||
already behind elapsed time, it dispatches the current entry's catch-up PC; otherwise it dispatches the
|
||
primary PC. It validates that the active frame still owns the recorded script, redirects that frame's PC,
|
||
and advances the cursor. The optional abort signal at `ctx+0xa0ce8` redirects to the op-`0xd5` fallback PC;
|
||
HISTORY passes `0xffffffff`, so that branch is outside its live route.
|
||
|
||
HISTORY builds deadlines 10, 20, 30, 40, 50, 51, and 52 ms. The first five advance a five-step scrollbar
|
||
interpolation; the catch-up form omits the expensive redraw/present call when the engine is late. The 51 ms
|
||
entry is a no-op callback and the 52 ms entry is its look-ahead sentinel. The port keeps the sequence on the
|
||
script frame, blocks only at these scheduler service boundaries using the host clock, and resumes op `0xd5`
|
||
after each local callback returns. Focused tests cover exact relative deadlines and the late catch-up choice.
|
||
|
||
Smooth-scroll scheduling is now implemented without changing History backlog ownership or choosing a
|
||
save/profile backend.
|
||
|
||
#### Mouse-wheel accumulation and History navigation (2026-07-19)
|
||
|
||
Opcode `0x10d` is the read half of AGE's generic Win32 mouse-wheel service. The newly recovered
|
||
`age_main_window_proc@0x486320` handles `WM_MOUSEWHEEL` (`0x20a`), sign-extends the high word of `wParam`,
|
||
and normally adds that value to `ctx+0x1c34`. In special run modes it instead maps the sign through the
|
||
configurable `set:WheelKeyUp`/`set:WheelKeyDown` actions, so the accumulator is specifically the ordinary
|
||
raw-wheel channel used by scripts such as HISTORY.
|
||
|
||
`op_0x10d_consume_mouse_wheel_delta@0x428cf0` writes the accumulated signed value to operand 1 and clears
|
||
the field immediately. HISTORY polls it twice: once while establishing the callback loop to discard stale
|
||
input, then once per timed mouse callback. The script only tests zero and sign, converting a positive or
|
||
negative native delta into one call of its existing smooth-scroll path.
|
||
|
||
The port follows that ownership directly: Godot converts wheel-up/down events to signed 120-unit native
|
||
deltas, the VM atomically accumulates them, and `0x10d` atomically reads and clears the total. This state is
|
||
runtime input only; it is neither a script seed nor profile/save data. A real SC0000-to-HISTORY regression
|
||
opens after eight retained messages and proves wheel-up selects older retained rows without releasing or
|
||
re-entering the enclosing ADV wait.
|
||
|
||
HISTORY is now 74/78 distinct opcodes and 849/854 instructions handled or safe-noop. Its remaining static
|
||
gaps are four supporting opcodes across five instructions: text-style setter `0x8b` occurs twice,
|
||
`0x1ce`/`0x20a` form a sprite-animation service pair, and `0x1cb` reads the deliberately deferred
|
||
Read-message Skip profile setting.
|
||
|
||
The original dependency order was **Hide Window first** to establish reusable callback/coroutine input, then
|
||
Read-message Skip, then History after both the input layer and message-completion seam exist. Hide Window is
|
||
now complete. Read-message Skip's semantics remain understood, but its implementation is deferred until the
|
||
port can choose storage for numbered saves and shared profile/global data as one architecture rather than
|
||
selecting an isolated ReadTextDB backend. History is therefore the sole remaining control-strip action under
|
||
active consideration; it can already reuse Hide Window's input layer, while any useful sharing with the
|
||
deferred ReadTextDB work should remain a seam rather than a storage dependency. The `/v2` image
|
||
names/comments the cursor, callback dispatch, retained-history navigation/render/metadata, and history-voice
|
||
opcode paths.
|
||
|
||
### ADV Hide Window implementation (2026-07-18)
|
||
|
||
The x=772 callback now follows the original control flow rather than a Godot-only visibility shortcut.
|
||
Opcode `0x199` saves the instruction after the yield, enters the handler-A PC registered by `0x7b`, and,
|
||
when HIDEWIN calls `0x199` again, enters handler B. Opcode `0x7c` then restores the saved ADV PC. This keeps
|
||
chrome removal/restoration, nested `call-script 0x20`, retained drawing, and the 100-ms cursor re-arm under
|
||
the shipped SC0000 bytecode.
|
||
|
||
The reusable input layer implements the eight previously effectful HIDEWIN gaps: `0x86/0x87` select and
|
||
clear indexed cursor resources; `0xcc/0xcd` register and dispatch the timed mouse callback; `0xfb`, `0xff`,
|
||
and `0x100` maintain and dispatch the frame-local 32-entry joy/input callback table; and `0x108` returns the
|
||
live mouse-button mask. Existing cursor coordinate ops `0x109/0x10a` are now effectful in the VM as well.
|
||
Godot supplies virtual-screen pointer coordinates, left/right mouse bits (`0x1`/`0x2`), and the script's
|
||
directional input indices (down/left/up/right = 0/1/2/3; accept/cancel = 4/5). The common index-10 release
|
||
callback is queued on action release.
|
||
|
||
Raw ids `0x3318..0x331f` resolve through SYS4INI to eight 32x32 monochrome Windows `.CUR` assets. The
|
||
runtime decodes their DIB XOR/AND masks and hotspots to RGBA textures and installs them through Godot's
|
||
custom-cursor API. FIELD separately selects raw id `0x32ce` (`CURSOR09.CUR`) while drag-panning; that asset
|
||
is an uncompressed 4-bpp, 16-color 32x32 cursor with hotspot `(16,14)`, not an EXE-embedded cursor. The
|
||
port decoder accepts both installed paletted formats, using their independent DWORD-aligned XOR/color and
|
||
1-bpp AND-mask strides. No replacement cursor art is authored by the port.
|
||
|
||
Every ordinary ADV script gates the handler-A call to HIDEWIN on `G[0x62425]`. No script writes that global,
|
||
and the complete boot-to-SC0000 VM-write capture does not contain it, so it is native scheduler-owned
|
||
inherited state rather than numbered save data or the data-only `--boot` prefix. The Godot scene bootstrap
|
||
mirrors the original enabled value as `adv_hide_window_enabled=1`, next to the already documented
|
||
`adv_chrome_enabled` state. A real-script regression activates SC0000's x=772 record, enters HIDEWIN.BIN,
|
||
services multiple timed input iterations, closes through the native right-button bit, and returns to the
|
||
parked ADV wait. Synthetic regressions separately cover both coroutine handlers, callback dispatch, live
|
||
pointer/button reads, cursor host forwarding, and real CUR decoding.
|
||
Validation is engine 175/175, opcode/global generator tests and lints clean, zero-warning Godot build, and
|
||
threaded `SELFTEST OK`.
|
||
|
||
**Manual-validation correction (2026-07-18).** Native `bit-set`/`bit-reset` operands are bit indices, not
|
||
literal masks: HIDEWIN sets index 1 at `0x13d`, tests mask `0x2` at `0x146`, and clears index 1 at `0x154`.
|
||
The old VM interpretation wrote mask `0x1`, so the right-button release edge could never reach the restore
|
||
path. `/v2` confirms the generic semantics in `op_0x135_handler@0x4296c0` and
|
||
`op_0x136_handler@0x429730`: `value |= 1 << index` and `value &= ~(1 << index)`, with indices 0..31 valid.
|
||
The raw op-`0x108` channel therefore supplies `0x1` for `VK_LBUTTON` and `0x2` for `VK_RBUTTON`, making a
|
||
right-button release one direct restore route. That is not the complete input model, however. Native
|
||
`input_poll_mouse_action_bits@0x460240` also maps the physical left/right buttons through configurable
|
||
logical actions (defaults 0/1) to callback indices 4/5, and HIDEWIN registers both indices to its restore
|
||
callback. A completed ordinary left click therefore restores the textbox as observed in the original game.
|
||
The raw left-button edge branch only supports moving an oversized retained display object while held; it is
|
||
not evidence that dungeon gameplay camera panning is enabled during an ordinary 800x600 VN scene.
|
||
|
||
The manual run also exposed that ADV text and the op-`0x72` wait indicator are currently Godot presentation
|
||
overlays rather than retained texture objects. The shipped handler correctly faded the textbox/chrome, but
|
||
those overlays ignored the op-`0x199` coroutine lifecycle and remained above the scene. The host now suspends
|
||
both when the first `0x199` saves/yields the active ADV page and restores them only when op `0x7c` restores
|
||
that saved page PC. The enclosing input wait remains parked throughout, so HIDEWIN continues to own mouse and
|
||
mapped action input instead of an overlay click accidentally advancing dialogue.
|
||
The Godot input adapter now queues the native primary-action callback while the ADV page is suspended and
|
||
does not release the enclosing dialogue wait; otherwise the restoring click would also advance the page.
|
||
The `/v2` bit and mouse-input helpers are annotated and saved. Validation: engine 177/177, opcode tests/lints
|
||
and vm0 RECOVER clean, zero-warning Godot build, and threaded `SELFTEST OK`.
|
||
|
||
### ADV retained text — ops `0x7a` / `0x204` and show-text publication (2026-07-10)
|
||
|
||
The SC0000 textbox uses two related native paths under the text manager at `ctx+0x14940`:
|
||
|
||
- **Op `0x7a` (`op_0x7a_handler@0x41eba0`) is `set-adv-text-cursor(layoutSlot,x,y)`.**
|
||
`adv_text_set_cursor@0x4530f0` treats slot 0 as the current slot at manager `+0x4c8`, resolves
|
||
`manager+0x414[slot]`, and `text_layout_set_cursor@0x452530` writes x/y to `+4/+8` of that
|
||
layout's last 20-byte record. SC0000 `0x9d3` and `0xbbf` set slot 1 to `(75,47)` for voiced
|
||
pages. The reset narration record is `(100,47,720,147,0)`.
|
||
- **Op `0x204` (`op_0x204_handler@0x422a60`) is immediate `draw-string(surface,x,y,string)`.**
|
||
`draw_string_to_surface@0x450150` locks the numbered D3D surface, selects the uncached or cached/effect
|
||
raster worker, consumes CP932 characters through GDI `GetGlyphOutlineA`, blends the bitmap in
|
||
`text_blit_glyph_bitmap@0x458c80`, and unlocks. Font/color/effect state is retained around manager
|
||
`+0x450/+0x458/+0x4d0/+0x544/+0x558`; the observed ADV glyph is white with a one-pixel `0x606060`
|
||
outline and advances 25 pixels. At SC0000 `0x9b2`, surface 13 is 400x30 and receives `"魔王"` at
|
||
`(1,1)`; the following `0x1fb` binds it to retained object `0xe678` at `(74,444)`, so the name begins
|
||
at screen `(75,445)`.
|
||
|
||
Show-text (`0x6e`) is the timed companion, not an immediate Label write. `adv_text_build_glyph_records`
|
||
`@0x4576c0` measures and rasterizes the complete CP932 line into one 20-byte record per glyph. Layout slot
|
||
1 has origin `(0,430)`, bounds `(720,147)`, reveal handle base `55000` (`0xd6d8`), and source surface
|
||
`slot+0x14 = 21`. `adv_text_publish_next_glyph@0x451220` advances the reveal index and publishes each
|
||
record with `gfx_object_bind_draw`. Native SC0000 records `ctx+0x14e9c = 50 ms`: page 1 builds 13 records
|
||
at `0x834`, then publishes them one at a time before reaching wait `0x83c`. A click during reveal completes
|
||
the remaining records and is consumed; the next click releases the stable wait.
|
||
|
||
The port retains the SC0000-visible contract without exposing thousands of individual host glyph objects:
|
||
surface strings remain associated with blank surface slots for later retained-object binding, while ADV
|
||
lines retain cursor/origin, start time, visible-glyph count, and completion state. The compositor renders
|
||
the surface-13 name at the bound `0xe678` transform and the dialogue at origin+cursor. `ShowText` parks the
|
||
VM until the 50 ms/glyph service completes or a click forces completion, so the completion click cannot
|
||
pre-arm the following `wait-for-input`. Local/global string-pointer operand tags (8/14) are now resolved by
|
||
the VM, which is required for SC0000's `lookup-array local-string-ptr -> draw-string` name path.
|
||
|
||
The 2026-07-27 retained-layout polish closes the non-dialogue half of this contract. `STUDY.BIN` selects
|
||
layout 7, changes its origin for each research row, and calls `MAMES.BIN`; the helper saves the current
|
||
glyph delay with op `0x7f`, sets zero with op `0x1b5`, emits two `0x6e/0x6f` lines, and restores the saved
|
||
delay. Native therefore publishes all six descriptions immediately and retains them until op `0x71`
|
||
resets layout 7, even though op `0x1bb(0)` suppresses History-backlog recording for the menu. The port now
|
||
keeps that live presentation stream separate from the persisted History backlog, captures layout/style
|
||
and execution-stack ownership per run, implements the delay pair and the font-height-plus-leading line
|
||
advance, and draws every retained run rather than replacing a singleton dialogue Label. Raw-input menus
|
||
retain runs owned by their own script stack while suppressing unrelated enclosing ADV runs, so STUDY's
|
||
MAMES descriptions remain visible without leaking the parent page above HISTORY. Consecutive same-line
|
||
literal/global-string runs are joined for presentation, preserving the common
|
||
`"literal" + dynamic name + "literal"` ADV idiom.
|
||
The same generic path covers `ITMES.BIN`, `SKMES.BIN`, and `INFOMES.BIN`; no STUDY-specific coordinates or
|
||
strings live in the runtime.
|
||
|
||
Detached labels must still follow the ordinary ADV page's retained visibility lifecycle. At SC0000
|
||
`show-text@0xb16 -> wait@0xb1e`, the first CG transition calls `0x127d1`: its chrome rebuild helper erases
|
||
and rebinds handles `0xd2f0..0xd2f6`, applies the configured backing alpha, then arms `d2f0`'s one-shot
|
||
color toward transparent. Before the animation wait begins, `label_1226b -> label_12350 -> label_12637`
|
||
executes `gfx_object_erase_range(0xd6d8,0x1f4)` and erases speaker-name handle `0xe678`. AGE therefore
|
||
removes the old glyphs **immediately as the window begins fading**; it does not fade glyph alpha with
|
||
`d2f0`, nor wait for the later op-`0x71` layout reset.
|
||
|
||
The port now retains SYSTEM4's op-`0x213` handle interval per ADV layout. A full op-`0x1f7` erase covering
|
||
that interval clears the corresponding detached Godot live runs at the same bytecode point. Partial object
|
||
erases do not discard the collapsed layout. This follows native lifetime without coupling text to the
|
||
configured message-window opacity or affecting callback-owned layouts.
|
||
|
||
**Deliberate Phase-A fidelity gap — Label rendering instead of native glyph objects.** The native engine
|
||
GDI-rasterizes CP932 glyph bitmaps and publishes retained 20-byte records one glyph at a time; the port
|
||
collapses that representation into Godot `Label` nodes for the ADV body and surface-bound speaker name.
|
||
Coordinates, layout origin/cursor, reveal timing, click consumption, and retained surface/object placement
|
||
are native-backed, but the final glyph rasterizer and object granularity are not. Consequences can include
|
||
small differences in glyph shape, advance/kerning, baseline, wrapping, clipping, and outline pixels, plus
|
||
missing behavior if a later scene depends on per-glyph color/effects, transforms, z-order, lifetime, or
|
||
furigana interaction. Treat any such discrepancy as debt in the text renderer, not evidence that `0x7a`,
|
||
`0x204`, or the ADV scheduler coordinates are inherently wrong. A future fidelity pass can replace the
|
||
Label backend with decoded glyph surfaces/retained glyph records behind the existing VM/host contract.
|
||
|
||
Matching evidence: `build/native-adv-text-trace.jsonl` and Godot timeline captures at `0x834`, `0x9b2`,
|
||
`0x9d3`, and `0xa0d`. Windowed page-1 pixels place glyphs at x=100 and native y=477; voiced page 7 has
|
||
non-overlapping name/dialogue bands at y=447–468, 478–500, and 507–530. A manual run progressed 14 pages:
|
||
11 clicks completed active reveals and 14 later clicks released 14 distinct waits through `0xe0c`.
|
||
|
||
#### Native font face, weight, antialiasing, and effects (2026-07-28)
|
||
|
||
The earlier retained-text work established *where* glyphs appear but deliberately left their final raster
|
||
appearance to Godot `Label`. Static reconstruction now identifies the complete native style selected by
|
||
Himegari and the specific reasons the port's text looks only approximate.
|
||
|
||
Himegari's decompressed SYS4INI settings trailer supplies `FONT=MS 明朝`, `DRAWMODE=1`,
|
||
`ENABLEANTIFONT=1`, and `ANTIFONTVERSION=3`. `engine_initialize_subsystems_from_settings@0x415390`
|
||
passes the face to `adv_text_manager_initialize@0x456800`, stores draw mode 1 at `ctx+0x99290`, applies
|
||
the enabled `message:UseAntiFont` default of 1 through `text_set_antialias_enabled@0x4137e0`, and stores
|
||
AntiFont version 3 at text-manager `+0x548`. The ordinary primary `LOGFONTA` begins as:
|
||
|
||
- `lfHeight=-24`, `lfWidth=-12`, `lfWeight=0`;
|
||
- no escapement/orientation, italic, underline, or strikeout;
|
||
- `DEFAULT_CHARSET`, default output/clip precision, default quality, and default pitch/family;
|
||
- `lfFaceName="MS 明朝"`.
|
||
|
||
Size opcode `0x75` keeps the negative pixel height and a negative half-width in both primary LOGFONT
|
||
states. It rounds ordinary odd requested heights down by one and special-cases 32/33 to 31, although every
|
||
Himegari call uses an even size from 8 through 24. Bold opcode `0x2bd` sets `lfWeight` to exactly 700, not
|
||
an arbitrary stroke expansion. Face opcode `0x1a5` is also effectful:
|
||
`op_0x1a5_set_font_face@0x42bbf0` calls `text_set_primary_font_face@0x42b1d0`, which copies the requested
|
||
face and constructs an `@%s` vertical-writing companion before
|
||
`text_rebuild_primary_font_resources@0x44f7f0` recreates the `CreateFontIndirectA` handles, metrics, and
|
||
glyph buffers. The 207 corpus calls use only `MS 明朝` (168) and `MS ゴシック` (39).
|
||
|
||
With Himegari's DrawMode 1 and AntiFont enabled, `GetGlyphOutlineA` requests
|
||
`GGO_GRAY4_BITMAP` with an identity `MAT2`. `text_raster_string_cached@0x45b600` caches the returned
|
||
0..16 coverage bitmap and composites it into the locked 16- or 32-bit destination with ordinary
|
||
source-over color/alpha interpolation. Layout measurement separately uses `GetTextExtentPoint32A` against
|
||
the selected GDI font, so face, explicit `lfWidth`, and weight affect advances and wrapping as well as
|
||
pixels.
|
||
|
||
The shipped effect profiles use only modes 1 and 3:
|
||
|
||
- mode 1 draws one effect-color copy at `(x+effect_x,y+effect_y)`, then the primary glyph; all 43
|
||
Himegari sites pair it with `(0,0)`;
|
||
- mode 3 samples an ellipse around the glyph. `text_compute_outline_ellipse_offset@0x459860` computes
|
||
`cos(angle)*effect_x` / `sin(angle)*effect_y`; the caller steps by
|
||
`360 / (sqrt(effect_x²+effect_y²)*8)`, blits the effect-colored glyph at every rounded offset, then
|
||
draws the primary glyph. All 164 paired sites use `(1,1)`, producing the normal one-pixel outline.
|
||
|
||
The ordinary ADV preset is therefore 24-pixel `MS 明朝`, weight 700, white fill, mode-3 `(1,1)` outline
|
||
in `0x606060`, with 8 pixels of line leading. The small menu-description preset is 16-pixel
|
||
`MS ゴシック`, weight 0, white fill, mode 1 with zero displacement, and 9 pixels of leading.
|
||
|
||
The port now carries opcode `0x1a5` through `AdvTextStyle` into live, retained, History, and surface-text
|
||
presentation. `godot/Main.cs` recognizes Himegari's two requested families and loads
|
||
`C:/Windows/Fonts/msmincho.ttc` or `msgothic.ttc` when present. Missing or unknown requested faces fall
|
||
back to the existing best-effort Japanese font probe, and regular/bold variants are cached per resolved
|
||
face so a runtime face switch cannot reuse the preceding family's synthetic-bold font. This restores the
|
||
important Mincho/Gothic distinction on a normal Windows installation without treating the proprietary
|
||
fonts as game assets.
|
||
|
||
Godot presentation now distinguishes the two shipped effect profiles. Mode 1 uses the Label shadow
|
||
layer as one effect-color glyph at the configured `(x,y)` displacement behind the primary glyph; this
|
||
also preserves Himegari's zero-offset double-composite profile instead of inventing an outline. Mode 3
|
||
uses a symmetric outline whose size is the maximum absolute configured radius. That is a good
|
||
backend-native approximation for every shipped mode-3 call because Himegari always requests equal
|
||
`(1,1)` radii. The same style applicator covers live ADV, retained History, and surface text. Mode 2
|
||
remains unimplemented and is unused by this game.
|
||
|
||
A 2026-07-28 Windows calibration compared exact-profile GDI
|
||
`CreateFontIndirectW`/`GetTextExtentPoint32W`/`GetGlyphOutlineW(GGO_GRAY4_BITMAP)` results with Godot's
|
||
loaded TTCs. Six mixed kanji, kana, punctuation, full-width digit, and Latin samples found exact
|
||
horizontal agreement for regular 24px Mincho and regular 16px Gothic. GDI also produced identical
|
||
measurements with the native negative half-height `lfWidth` and `lfWidth=0`; no horizontal scaling is
|
||
needed for those profiles on this installation.
|
||
|
||
The first numeric pass selected `VariationEmbolden=0.53` plus one pixel of glyph spacing because its raw
|
||
Godot atlas sum was close to GDI's raw 4-bit coverage sum and it bounded the six complete-string errors
|
||
to 0..3 pixels. A subsequent direct native/port SC0000 screenshot comparison proved that cross-API sum
|
||
was not a visually comparable quantity. For `――かつて、戦いがあった。`, both screenshots have the
|
||
same viewport-relative top (`y=478`) and bright horizontal bounds (`x=101..410`, 310px), confirming
|
||
placement and effective advance. Native nevertheless has 1,656 bright neutral pixels versus the port's
|
||
1,145 (+45%), 1,296 near-white pixels versus 713 (+82%), a 23px rather than 22px bright box, and roughly
|
||
twice the gray edge population. The port is materially underweight and softer despite requesting the
|
||
correct Mincho face.
|
||
|
||
The discrepancy is not an unknown value to tune. The native and port pipelines are behaviorally
|
||
different:
|
||
|
||
1. `adv_text_manager_construct@0x456570` creates an information DC with
|
||
`CreateICA("DISPLAY",NULL,NULL,NULL)`. AGE selects the `CreateFontIndirectA` result for its complete
|
||
`LOGFONTA` into this display IC. For SC0000 that state is height `-24`, width `-12`, weight `700`,
|
||
`DEFAULT_CHARSET`, default precision/quality/pitch, and `MS 明朝`. Windows GDI therefore owns font
|
||
mapping, grid fitting, and weight-700 synthesis.
|
||
2. `text_raster_string_cached@0x45b600` calls `GetGlyphOutlineA` with identity `MAT2` and
|
||
`GGO_GRAY4_BITMAP`. It retains GDI's `GLYPHMETRICS`, uses `gmCellIncX` as the per-glyph advance, and
|
||
consumes the returned 17-level coverage mask (`0..16`) directly.
|
||
3. `text_blit_glyph_bitmap@0x458c80` converts coverage `c` to integer alpha `c*255/16`. On a 32-bit
|
||
surface it takes `max(destination alpha, glyph alpha)` and interpolates each RGB channel as
|
||
`(dst*(255-alpha)+src*alpha)/255`.
|
||
4. Mode 3 does not invoke a font-renderer outline. Its step is
|
||
`360/(sqrt(effect_x²+effect_y²)*8)`, with nearest-integer ellipse coordinates. Radius `(1,1)` makes
|
||
twelve calls covering the eight neighboring offsets: each cardinal offset is visited twice and each
|
||
diagonal once, followed by one primary-color glyph blit at the unshifted position.
|
||
|
||
The port does none of those raster operations. It loads the same TTC into Godot/FreeType with grayscale
|
||
antialiasing, light hinting, automatic subpixel positioning, embedded bitmaps disabled, and otherwise
|
||
default `FontFile` policy. Bold is the unrelated `FontVariation.VariationEmbolden=0.53` operation plus
|
||
one pixel of artificial glyph spacing; mode 3 is Godot `Label`'s `outline_size=1`. Thus the family,
|
||
nominal size, placement, and total width can agree while stems, serifs, white-core coverage, and edge
|
||
pixels differ. The screenshot is useful evidence that the difference is visible, but it is not the
|
||
behavioral source.
|
||
|
||
The fidelity correction is consequently a decoded glyph-mask backend, not screenshot-driven embolden
|
||
calibration. A Windows reference implementation can call the same GDI APIs with the decoded `LOGFONTA`
|
||
and reproduce AGE's integer compositor. A portable backend must expose the same mask/metrics/compositing
|
||
contract while explicitly defining its font-substitution and rasterizer policy; FreeType output should
|
||
be treated as that backend's result, not claimed to be GDI-equivalent. Godot's one-pixel line-box
|
||
compensation remains independently supported by the matching viewport placement. Portable configurable
|
||
face substitutions/defaults remain deferred to the broader runtime configuration design. Implementation
|
||
is explicitly backlogged until gameplay settles; the scoped architecture and acceptance gates live in
|
||
`docs/remake-architecture-and-roadmap.md` under “AGE-exact glyph-mask text renderer.”
|
||
|
||
#### ADV wait indicator -- ops `0x73` / `0x72` (2026-07-11)
|
||
|
||
The small bat marker is a configured ADV-layout sprite, not a glyph or part of SO001. `SYSTEM4.BIN`
|
||
loads universal raw asset `0x337c` (`SO000.AGF`) into surface slot 12. SO000 decodes to a 360x27 strip of
|
||
twelve 30x27 bat frames. It then executes op `0x73` with
|
||
`(layout=1, x=385, y=140, surface=12, src=0,0, cell=30x27, terminalFrame=12, period=48ms)`.
|
||
The terminal value is exclusive/a frame count: native publishes frames `0..11` and wraps before frame
|
||
`12`.
|
||
Layout 1 begins at screen y=430, so the marker lands at `(385,570)`, matching the original screenshot.
|
||
|
||
Native `op_0x73_configure_wait_indicator@0x41e900` writes this descriptor through
|
||
`adv_text_configure_wait_indicator@0x44ff60` to layout fields `+0x3c..+0x60`; operand 10 configures the
|
||
animation period through `adv_indicator_set_frame_period@0x44d060`. `op_0x72_handler@0x41e690` later calls
|
||
the layout renderer with frame `-1` and arms the input-wait flags, which makes the configured animation
|
||
visible only after show-text has naturally or forcibly completed. The completion click remains consumed;
|
||
the following click releases the wait.
|
||
|
||
**Port implementation:** the single-scene harness now injects SYSTEM4's exact SO000 surface-12 and op-`0x73`
|
||
configuration alongside its existing SO001 shortcut. The VM forwards all ten operands through the host ABI;
|
||
`GodotAdvHost` retains configurations by layout and resolves slot 0 to the active Phase-A layout. A separate
|
||
Godot atlas overlay advances on the 48 ms host clock only while `IsWaiting`, then hides immediately when
|
||
input releases the wait. A 2026-07-27 polish fix removed the port's erroneous `TerminalFrame + 1`
|
||
interpretation, which had selected nonexistent frame `12` once per cycle and made Godot render one empty
|
||
atlas cell. Frame selection now treats the terminal value as the exclusive frame count and loops `0..11`.
|
||
Keeping this 30x27 overlay outside the software backbuffer preserves the static-wait compositor optimization.
|
||
Full SYSTEM4 replay remains Phase-B work; the shortcut carries the same state in the meantime.
|
||
|
||
### SC0000 native SFX / BGM-fade family — `0xb4`/`0xb5`/`0xb6`/`0xba`/`0xc2`/`0xd9` (2026-07-11)
|
||
|
||
The four SFX opcodes are a retained channel lifecycle, not immediate fire-and-forget calls. Handler
|
||
resolution and the saved `/v2` names are:
|
||
|
||
- `0xb4` `op_0xb4_sfx_load@0x4201d0` -> `sfx_channel_load@0x482500`: `(packed_raw_resource_id, channel)`
|
||
opens the universal SYS4INI entry directly when the high byte is zero, or the selected AAI append entry
|
||
when it is nonzero, then replaces the channel decoder/buffer without starting it. The manager supports
|
||
13 slots (`0..12`); SC0000 deliberately resets and uses the `0..9` subset.
|
||
- `0xb5` `op_0xb5_sfx_start_once@0x420210` -> `sfx_channel_start@0x4825d0`: starts the loaded channel with
|
||
logical loop mode 0.
|
||
- `0xb6` `op_0xb6_sfx_release@0x420250` -> `sfx_channel_release@0x482600` ->
|
||
`sound_buffer_destroy@0x4831a0`: stop/release and clear the retained resource/decoder; empty release is
|
||
safe. SC0000's `0x62b..0x646` and `0x120d..0x1228` are ten-channel reset sweeps.
|
||
- `0xba` `op_0xba_sfx_start_loop@0x420290` -> the same `sfx_channel_start@0x4825d0`: starts the loaded
|
||
channel with logical loop mode 1. `sound_stream_fill_quarter` rewinds the decoder at EOF until a later
|
||
load or release replaces the channel. It is not an alias of the one-shot `0xb5`.
|
||
|
||
`sound_decode_channel@0x483360` selects the decoder by file signature, constructs a DirectSound buffer, and
|
||
installs four quarter-buffer notifications. `sound_buffer_start@0x484270` primes the ring and synchronously
|
||
calls `IDirectSoundBuffer::Play(0,0,DSBPLAY_LOOPING)` before returning. That flag loops the streaming ring,
|
||
not the logical clip: `sound_stream_fill_quarter@0x483b70` rewinds the decoder only for logical mode 1;
|
||
otherwise it pads after EOF and `sound_buffer_stop@0x483aa0` stops playback. This family carries no volume or
|
||
pan operands. It inherits configured SFX volume and centered pan: the first-pair capture applies DirectSound
|
||
attenuation `-2377` to both channel loads, and the shared audio service later records centered `SetPan(0)`.
|
||
`sound_buffer_set_volume@0x483f80` computes that inherited attenuation; neither value is supplied by these
|
||
five handlers. The bounded port does not yet import native audio preferences, so its extracted-WAV bootstrap
|
||
uses unity gain and centered pan rather than hard-coding the captured user's setting.
|
||
|
||
The native trace in `build/native-sfx-trace.jsonl` captures SC0000's first pair: `0xb4@0xc29` resolves
|
||
raw catalog id `0x28` to `E0808.WAV`, loads channel 0, and `0xb5@0xc2e` starts it in the same millisecond. The next
|
||
`0xb4@0xc31` preloads the same WAV into engine-owned secondary channel 4 for a later service start. The
|
||
scratch global `G[0x6242d]` is maintained outside script-visible writes; the SC0000 port profile exposes it
|
||
as an external value of 4 rather than pretending the script assigned it.
|
||
|
||
**TITLE SFX packed-raw correction (2026-07-20).** TITLE preloads raw id `0x2aea` (`SE020.WAV`) into channel 2
|
||
and executes `0xb5(2)` whenever mouse/joy selection changes. Each activation path loads raw id `0x3321`
|
||
(`SE015.WAV`) into channel 1 and immediately starts it; GAMESTART additionally uses raw `0x2aeb`
|
||
(`SE013.WAV`). A synchronized Godot trace captured repeated hover starts and the Game Start activation, but
|
||
each preceding load recorded `file:null`: `GodotAdvHost.LoadSoundEffect` applied the active script's
|
||
scene-local manifest, and TITLE has only 14 local entries. `ResourceMap.ResolveSoundEffect` now uses the
|
||
native packed lookup and filters the selected record to OGG/WAV before the existing byte/player path.
|
||
The corrected synchronized trace resolves and starts `SE020`, `SE015`, and `SE013` through
|
||
TITLE→GAMESTART→TITLE, and the user confirmed hover, activation, and cancel sounds are audible. BGM remains
|
||
independent through its direct-name resolver; no callback, mixer, or channel workaround was needed.
|
||
|
||
That later service start is opcode `0x2bf`, now identified as the facade's `SetDelay` operation.
|
||
`op_0x2bf_schedule_sfx_start@0x425240` passes `(channel,start_mode,delay_ms)` to
|
||
`sfx_set_delay@0x482720` on the inline sound facade at `ctx+0x14024`. The worker accepts channels `0..9`
|
||
and stores five pieces of transient state: active at facade `+0x418`, elapsed/progress at `+0x440`, delay
|
||
at `+0x468`, start mode at `+0x490`, and an aggregate-active marker at `+0x4b8`. Its native error text is
|
||
literally `Function: SetDelay ... invalid Sound number`. The existing trace supplies the consumer proof:
|
||
after SC0000 executes `(channel 4, mode 0, 100 ms)`, the ordinary `sfx_channel_start` worker enters for
|
||
channel 4 about 109 ms later while the interpreter is parked at its `0x21c` presentation boundary. There
|
||
is no intervening `0xb5`. The port schedules the same already-loaded channel and invalidates the pending
|
||
callback if that channel is loaded again or released before its deadline.
|
||
|
||
Normal-speed windowed validation reached `wait-for-input@0x1a58` after 45.6 seconds without an audio stall;
|
||
the user confirmed the opening effects were audible and sounded good.
|
||
|
||
FIELD deliberately reasserts `play-bgm(stage_bgm_id)` from its common post-action/event cleanup at
|
||
`0x884a`; ordinary unit movement reaches that cleanup once per acting unit. The BGM facade already retains
|
||
the selected direct-name track id for op `0xc0` and numbered-save state. An identical `0xbf` request must
|
||
therefore preserve the current stream and playback position rather than reopen the OGG. The former host path
|
||
decoded and assigned a fresh Godot stream on every request, resetting the stage music after every player
|
||
move and repeatedly throughout enemy turns. The VM now treats a current-track reassertion as idempotent.
|
||
A different id still starts a new track, and `0xc2` target zero clears the retained id so a later request
|
||
can start that track normally.
|
||
|
||
**Forced BGM lifecycle siblings (mapped 2026-07-29).** The remaining `0xb7`/`0xb8`/`0xb9` gap is one
|
||
coherent BGM tranche:
|
||
|
||
- `op_0xb7_force_play_bgm_loop@0x4202d0` calls `bgm_force_play_track@0x464a90` with start mode one.
|
||
A nonzero operand replaces and starts that track; zero restarts the retained current track. Unlike
|
||
ordinary `0xbf`, the force worker has no same-track early return. CALLBACK_LOAD uses zero after a
|
||
numbered-load callback, and CONFIG uses BGM029 as its music-preview sample.
|
||
- `op_0xb8_stop_bgm@0x416ba0` calls `bgm_stop_release@0x464690`, clearing the retained track and stopping
|
||
the active backend. CONFIG uses it when leaving the preview path; MMODE uses it before constructing the
|
||
music-room screen.
|
||
- `op_0xb9_force_play_bgm_once@0x420330` calls the same force worker with start mode zero. Its sole
|
||
Himegari site starts BGM025 as STAGECLEAR's one-shot fanfare.
|
||
|
||
All three first complete and cancel an active `0xc2` fade by clearing run-state bit `0x200` and calling
|
||
`bgm_fade_tick(...,100)`. Both start variants store their mode at
|
||
`EngineCtx.current_bgm_start_mode` (`ctx+0xa0b8c`) before invoking the backend. Himegari's OGG backend
|
||
forwards that value through `FUN_00482f00` to `sound_buffer_start`; the already-decoded
|
||
`sound_stream_fill_quarter@0x483b70` rewinds at EOF only when the mode is nonzero. Thus the distinction is
|
||
logical loop versus one-shot, not a mixer flag. A compatible implementation must extend the existing BGM
|
||
host seam with explicit restart and loop ownership, preserve `0xbf`'s current idempotence, treat operand
|
||
zero through the VM's retained track, and make `0xb8` clear that retained state.
|
||
|
||
The port now implements that split directly. `IHost.RestartBgm(track,startMode)` is distinct from ordinary
|
||
`PlayBgm`, so `0xbf` remains same-track-idempotent while `0xb7`/`0xb9` always replace the stream. The VM
|
||
resolves operand zero through `_currentBgmTrackId`; zero with no retained track calls the same idempotent
|
||
stop path as native. `StopBgm` clears the VM track and makes Godot cancel voice ducking and any deferred
|
||
fade, stop the player, release its stream, and restore neutral gain. Godot sets `AudioStreamOggVorbis.Loop`
|
||
from the native start mode. The already-mapped `0xc2` target-zero endpoint now also calls this seam after
|
||
its blocking fade, replacing the former silent-but-still-bound Godot stream with native release behavior.
|
||
A focused regression covers same-track restart, zero aliasing, loop/one-shot selection, track replacement,
|
||
stop/clear, fade-to-zero release, and restart after stop; the threaded self-test exercises both loop modes
|
||
and real stop/release.
|
||
|
||
`0xc2` is BGM rather than SFX: `op_0xc2_bgm_fade@0x4204c0` sets run-state `0x200`, arms the service timer,
|
||
and calls `bgm_fade_arm@0x464830`. `bgm_fade_tick@0x464960` linearly interpolates current to target percent;
|
||
durations at least 1000 ms take 100 steps, shorter durations take 10, and target zero releases the source.
|
||
The VM is parked for the requested duration while the native main loop continues rendering. The Godot host
|
||
mirrors that ownership split: its VM worker waits on the fade clock only after publishing the completed
|
||
pre-fade graphics burst and releasing the script presentation barrier. This is required at
|
||
`SC0000@0x7c1`, where the three-second title-BGM fade begins after the New Game scene has already cleared
|
||
to black; retaining presentation ownership during the wait incorrectly froze the last menu frame until the
|
||
opening CG transition began. `0xd9` is adjacent startup control, not audio data: it clears
|
||
run/service bit `0x1000` in the primary and, when active, secondary context and has no VM-visible result.
|
||
|
||
Opcode `0x1cf` belongs to the voice/BGM envelope rather than SFX. Handler
|
||
`op_0x1cf_set_voice_bgm_duck_control@0x4209f0` replaces the transient mask at
|
||
`EngineCtx.voice_bgm_duck_control_flags` (`ctx+0x6dbf0`). Before ordinary or History voice playback,
|
||
`voice_bgm_duck_begin@0x406de0` checks that BGM is active, `MusicFadeOnVoicePlaying` is enabled, no explicit
|
||
`0xc2` fade is active, and mask bit 0 is clear. It then saves the current BGM volume at
|
||
`ctx+0x6dbe8` and applies `MusicFadeOnVoicePlayingVol`. The native settings registry supplies defaults of
|
||
enabled=`1` and target=`50` percent; SC0000 writes only control masks `0` and `1`.
|
||
|
||
The port therefore retains `0x1cf` as script-owned runtime state, applies the native registered default
|
||
attenuation when an unsuppressed voice begins, and restores the saved BGM level when that voice completes
|
||
or is stopped by message Skip. Godot also gives an explicit `0xc2` fade exclusive ownership of the BGM
|
||
envelope: voice start does not duck during that fade, a new fade cancels its predecessor, and starting a
|
||
replacement BGM cancels any still-live prior fade before restoring normal gain. The last rule closes a
|
||
main-thread scheduling race at the TITLE-to-SC0000 boundary. The VM's blocking fade deadline begins before
|
||
the deferred Godot tween is created, so `BGM005` could start during the tween's final frame and then be
|
||
overwritten to `-80 dB`; a later voice temporarily wrote the audible 50-percent duck level and restored
|
||
the erroneous silent level afterward. This introduces no profile record, boot seed, or persistence backend.
|
||
A future unified settings backend can replace the registered defaults without changing the opcode/host seam.
|
||
|
||
**Loop-start closeout (2026-07-28).** A complete Himegari-corpus scan found `0xba` at 334 sites in 100
|
||
scripts, and every site immediately follows `0xb4` with the same literal channel 9 for persistent UI/ambient
|
||
sound. The port now forwards the native start mode through its host seam and sets
|
||
Godot's WAV loop mode before playback; `0xb5` explicitly supplies mode 0 and `0xba` supplies mode 1. The
|
||
focused VM regression covers both modes, and the renamed/commented `/v2` handler is saved.
|
||
|
||
**CONFIG mixer ABI implemented (mapped 2026-07-28; landed 2026-07-29).** Four opcodes form one
|
||
internally dependent settings surface:
|
||
|
||
- `0xc5 get-audio-volume(category, out)` reads `sound:Volume0..Volume4`; selectors `0..4` mean
|
||
master/music/SFX/voice/movie and values are basis points (`0..10000`).
|
||
- `0xc6 set-audio-volume(category, basis_points)` writes the same registry cell and immediately reapplies
|
||
the live master/music/SFX/voice/movie volume. Master changes propagate through active movies and sound
|
||
buffers; category changes reach active BGM, ten SFX channels, voice channel 12, or the movie basis.
|
||
- `0x1ba set-audio-route-enabled(category, enabled)` accepts selectors `1..4` for music/SFX/voice/movie,
|
||
updates the live route, stops active playback when the route-specific worker requires it, and persists
|
||
`sound:Music/SE/Voice/Movie`.
|
||
- `0xc7 get-audio-route-enabled(category, out)` queries those four settings and writes boolean `0/1`.
|
||
|
||
CONFIG contains all 37 sites: nine volume reads, twelve volume writes, fifteen route writes, and one
|
||
route-query loop. Skipping `0xc5` leaves its output operand stale, so the visual slider calculation can be
|
||
wrong even before the user changes anything; skipping the setters makes its controls inert.
|
||
|
||
The native default registry initializes `sound:Volume0..Volume4` to `-1`. During audio startup, each
|
||
nonnegative entry is applied and `-1` deliberately leaves the hardware-default gain unchanged. Himegari's
|
||
CONFIG reset action later writes `10000/6500/5000/8000` for master/music/SFX/voice. Route defaults are
|
||
enabled after `NOSETMUSIC=3` transforms music to enabled state 2 and the generic SE/Voice/Movie flags begin
|
||
at one. The saved `/v2` image now records these defaults plus the exact route side effects:
|
||
`sound_set_music_route_enabled@0x407f80` rebuilds the current music route, disabling SFX calls its
|
||
active-channel stop helper, disabling voice stops channel 12, and movie enablement changes routing without
|
||
stopping the movie.
|
||
|
||
The port now owns one `AudioMixerSettings` instance across fresh scene VMs. `0xc5`/`0xc7` query it and
|
||
`0xc6`/`0x1ba` update it before calling the host's live-application seam. Godot maps selectors 0..4 to its
|
||
Master/Music/SFX/Voice/Movie buses; the parent Master bus and category bus multiply naturally, and route
|
||
changes mute immediately while preserving the native stop/restart distinctions above.
|
||
|
||
Persistence uses AGE's CP932 `SYS4REG.INI`, not a port-owned format.
|
||
`engine_resolve_sys4reg_ini_path@0x46b150` selects the fixed filename under the SYS4INI
|
||
`REGFILEPATH`; Himegari therefore uses
|
||
`%LOCALAPPDATA%\Eushully\姫狩りダンジョンマイスター\SYS4REG.INI`.
|
||
`engine_settings_load_sys4reg_ini@0x46b6e0` registers and imports the `[display]`, `[sound]`,
|
||
`[message]`, and `[system]` projections, including all nine audio keys. `Sys4RegIniStore` reads and
|
||
updates only `Music`, `SE`, `Voice`, `Movie`, and `Volume0..4`, preserving all unrelated lines, sections,
|
||
key spelling/order, CP932 text, and newline style. Missing keys use native defaults. Music retains AGE's
|
||
signed value band: Himegari's enabled state `2` disables to `-1` and re-enables to `2`, rather than being
|
||
silently normalized to `1`. Writes use a sibling temporary file followed by replacement.
|
||
|
||
AGE resolves save and settings locations independently, but Himegari makes `SAVEPATH` the `SAVE`
|
||
descendant of `REGFILEPATH`. `Sys4PersistencePaths` models both native resolutions together. Godot's
|
||
current profile override replaces the resolved `REGFILEPATH` directory with `user://`, preserving the
|
||
relative `SAVE` tail and yielding `user://SYS4REG.INI` plus `user://SAVE`. If a future profile supplies
|
||
unrelated paths, a single-root override is rejected instead of guessing; native resolution remains
|
||
available as the exact `USEAPPDATAFOLDER` plus independent-path policy.
|
||
|
||
### Scene-entry state snapshot — auto-seeding single-scene runs (2026-07-09)
|
||
|
||
**Problem the oracle surfaced:** single-scene VM runs diverge from the engine because they lack the
|
||
pre-scene global state the engine accumulates over `SYSTEM4 → … → TITLE → New Game`. `--boot` reproduces
|
||
only the data `*INIT` scripts; flags like `G[0x6c1]` (ADV-chrome enable) are set later and missed.
|
||
|
||
**Solution — `capture_global_writes.py`** hooks `vm_operand_write@0x425fb0` and logs every global-int
|
||
write as `(codebase, index, PLAINTEXT value)`. **Key: the helper receives the plaintext value** before the
|
||
engine encodes it into the obfuscated global store (rotate+XOR with the per-session cookie `ctx+0x55120`)
|
||
— which is exactly why the shelved flat-int32 scans (`global-memory-re.md`) found nothing, and why hooking
|
||
the WRITER is clean (no de-obfuscation). ABI: thiscall `ecx=ctx`, `[esp+4]`=operand index, `[esp+8]`=value;
|
||
the global index/type come from the instruction's operand slot (`framePc + idx*8`; `type=*(opnd-4)`,
|
||
`index=*opnd`), type 3 = global-int.
|
||
|
||
**Packer gotcha (solved):** AGE.EXE unpacks in-place at `0x400000`, so a `--spawn`-time hook hits packed
|
||
bytes → Frida "unable to intercept function at 00425FB0". Fix: poll `0x425fb0` until the real prologue
|
||
(`6aff 6836a85600 64a1…`) appears (unpack done), THEN attach. `--spawn` is required for completeness
|
||
(attach misses pre-attach boot writes); the tool also kills the spawned pid on setup failure so a JS error
|
||
can't leave a suspended windowless orphan. (First bug hit: `SIG` hex without `0x` → JS `create_script`
|
||
SyntaxError → resume never ran → orphaned suspended game.)
|
||
|
||
**Validated:** a real boot→New-Game→SC0000 capture (34,008 globals incl. `G[0x6c1]=1`, `G[0x62424]=0x23`
|
||
the resId) loaded via `Age.Cli trace SC0000 --state <snap> --trace-json` seeds the VM to match the engine's
|
||
**entire opening** (542 ops, no non-realignable fork) with ZERO manual seeding — confirming the
|
||
pre-scene-state theory and giving a general auto-seed for single-scene fidelity. **Residual:** a 2-op color
|
||
detour (`0x202/0x203` @ `0x122d0`, writing `G[0x62451]`) the full state does NOT fix = a real branch/op
|
||
difference to chase (not state). **Caveats:** snapshot is playthrough-specific (best for canonical entry
|
||
points — new-game opening, chapter starts); v1 captures global-INTS only (type 3; strings/floats TODO);
|
||
includes the scene's own early writes (can exclude by codebase for a pure pre-scene boundary).
|
||
|
||
---
|
||
|
||
### S4AC append catalogs and packed resource ids (2026-07-11)
|
||
|
||
`asset_mount_append_catalogs@0x44f120` scans `*.AAI`, constructs `AAIFileDB` objects, and calls
|
||
`aai_catalog_load@0x401110`. Installed `S4AC422` uses selector `1` at header offset `0x108`, expanded size
|
||
at `0x110`, packed size at `0x114`, and an LZSS directory stream at `0x118`. The expanded directory is the
|
||
same archive-count / 256-byte archive-name / file-count / 80-byte-record layout as SYS4INI. Its 81 names are
|
||
literal `$1$...` basenames in `APPEND01.ALF`.
|
||
|
||
After a successful load, the scan writes the catalog pointer to `FileDB+0x3028 + selector*4`; a later
|
||
successful discovery of the same selector overwrites that slot. `asset_open_indexed_entry@0x44f390` splits
|
||
nonzero-high-byte ids into `mounted_aai[id >> 24]` and record index `id & 0xffffff`, while high-byte-zero ids
|
||
stay in the base SYS4 table. `aai_open_indexed_entry@0x401630` then applies the same exact loose basename
|
||
before indexed ALF fallback as the base path. Thus append selection is explicit pack selection, not filename
|
||
replacement. The selector extraction is an arithmetic `SAR 24`, so ids whose high byte has its sign bit set
|
||
index before the mount table rather than slots `0x80..0xff`; the port rejects those selectors instead of
|
||
inventing unsigned behavior. `/v2` names/comments this mount/load/open chain and is saved.
|
||
|
||
**Universal packed resource addressing confirmed (2026-07-21).** `asset_catalog_parse_base_tables@0x44e7e0`
|
||
parses one entry count and copies one flat array of 0x50-byte SYS4INI records in serialized order.
|
||
`asset_open_indexed_entry@0x44f390` directly bounds-checks a high-byte-zero operand against that count and
|
||
indexes `entries[id]`; a nonzero high byte takes the AAI branch described above. It receives neither
|
||
`EngineCtx` nor a script-frame/scene identifier and contains no section-base calculation or fallback.
|
||
|
||
The callers pass their operands unchanged: `script_frame_load_resource@0x40e980`,
|
||
`gfx_op_0x1f9_load_surface@0x422360`, `op_0x249_load_raw_texture_surface@0x424b20`,
|
||
`voice_play_indexed_asset@0x488330`, SFX/cursor services, and
|
||
`movie_to_texture_open_asset_graph@0x463e20`. For movies, `op_0x236_play_movie_to_surface@0x423ee0`
|
||
fetches operand 1 and immediately forwards it to that helper. Thus scene-local and raw-fallback are not
|
||
native modes: ordinary resource operands are already universal packed SYS4INI/AAI ids.
|
||
|
||
**Append execution boundary resolved (2026-07-24).** Append startup is exposed to bytecode through
|
||
`op_0x143_run_mounted_append_autoruns@0x4172f0`, not through an explicit
|
||
`call-script 0x01000000`. The handler scans the `FileDB+0x3028` mount-pointer table as
|
||
`EngineCtx.mounted_aai_catalogs[1..255]` (`ctx+0x9f278` onward). For each non-null selector it enqueues
|
||
`selector << 24`: the packed id for that catalog's record zero. It does not search for an AUTORUN
|
||
basename. The selector loop batches all ids under `script_launch_dispatch_active`, advances the caller
|
||
past the opcode, then dispatches the queue.
|
||
|
||
`script_launch_queue_enqueue@0x40f820` appends to the embedded FIFO at `ctx+0x6f89c`.
|
||
`script_launch_queue_dispatch_next@0x40f6e0` suspends the current frame, records return sentinel `-10`,
|
||
and loads positive packed ids into reserved interpreter frame 37. When that script reaches
|
||
`op_0x2_exit_or_return_frame@0x417940`, the sentinel path launches the next queued id if present;
|
||
otherwise it restores the suspended caller. Mounted record-zero scripts therefore execute serially in
|
||
ascending selector order.
|
||
|
||
The sole corpus use is `INIT2@0x17f`. SYSTEM4 has already performed its UI, configuration, input-map,
|
||
and preload setup before calling INIT2. INIT2 then calls its 23 base data initializers—EBINIT through
|
||
SCINIT plus BTANINIT2—performs its base global/array registrations, executes op `0x143`, and calls
|
||
TUNE only after the queued append scripts return. Installed selector 1 record zero is
|
||
`$1$AUTORUN.BIN` (`0x01000000`), so the exact shipped order is base definitions, append deltas and
|
||
registrations, then TUNE and the remainder of SYSTEM4 boot. Catalog mounting itself still occurs earlier,
|
||
when the native SYS4INI/FileDB loader calls `asset_mount_append_catalogs`; mount only establishes
|
||
addressability, while op `0x143` is the explicit patch-application boundary.
|
||
|
||
The port implements the same observable boundary without reproducing the reserved native frame or sentinel.
|
||
`IScriptProvider` exposes mounted selector identity; op `0x143` snapshots, deduplicates, and sorts it,
|
||
constructs `selector << 24`, and executes each resolved record-zero script synchronously through the normal
|
||
nested-frame path. That preserves batching, serial order, caller suspension, global-bank sharing, and
|
||
whole-stack halt/reload propagation. Focused VM tests cover two selectors and failure resolution, while the
|
||
natural SYSTEM4 integration path proves `BTANINIT2 -> $1$AUTORUN -> $1$EBINIT -> TUNE`.
|
||
|
||
SC0010 supplies a clean corpus proof outside SC0000's base-zero coincidence. Its `set-texture 0x21` must
|
||
open raw entry `0x21` (`SO013A.AGF`); adding SC0010's catalog position `0x11e` instead selects unrelated
|
||
`COL0023.OGG`. Its `play-voice 0x120/0x121/0x122` operands directly select
|
||
`LILA1414/LILB0053/LILC0054.OGG`, whose SC0010-local file numbers are 2/3/4. The catalog grouping relation
|
||
is therefore `absolute id = group start + file_number`; the compiler has already performed that addition.
|
||
The port's former scene-first compatibility resolver inverted this relationship. On 2026-07-21 it was
|
||
replaced by typed `ResolvePacked` lookup for texture, voice, and both movie consumers; the VM now preserves
|
||
the operand unchanged and Godot cache/movie identities retain the complete packed selector. SC0010 low-id
|
||
and installed append-pack regressions cover the two failure modes. Ghidra `/v2` renames the catalog parser
|
||
and entry-name helper, corrects the opener/caller comments, and is saved.
|
||
|
||
---
|
||
|
||
### ADV text line spacing -- opcode `0x8b` (2026-07-19)
|
||
|
||
`op_0x8b_set_text_line_spacing@0x41f270` stores its single operand at ADV text-manager offset
|
||
`+0x560`, or `EngineCtx.text_line_spacing` (`ctx+0x14ea0`). This is pixel leading, not a font face,
|
||
weight, or effect selector. `adv_text_manager_initialize@0x456800` gives it a native default of 6.
|
||
|
||
The horizontal line-break path (`adv_text_append_line_break_horizontal@0x456fd0`) and retained History
|
||
renderer (`text_history_render_records@0x452970`) pass `manager+0x560 - manager+0x4cc` as the line
|
||
advance. Offset `+0x4cc` is the primary `LOGFONT.lfHeight`, which AGE stores as a negative pixel height,
|
||
so the effective pitch is `font pixel height + text_line_spacing`. The corresponding vertical-writing
|
||
path uses the same pitch to move to the next column. Corpus values support that interpretation: scripts
|
||
pair 8 pixels with 22/24-pixel Mincho text and 9 pixels with 16-pixel Gothic text.
|
||
|
||
History records do not snapshot the `+0x560` field. `text_history_append_text_record@0x456000` retains
|
||
font, color, effect, geometry, flags, and string state, while `text_history_render_records` reads the
|
||
manager's current line spacing when it encounters a retained line-break marker. The port therefore keeps
|
||
line spacing in the current ADV style manager and overrides a History render batch with that current value;
|
||
the individual record continues to supply its retained font/color/effect fields.
|
||
|
||
The `/v2` image names/comments the handler, manager initializer, horizontal/vertical line-break paths, and
|
||
their low-level cursor-advance helpers. The regenerated 58-field `EngineCtx` is applied and the program is
|
||
saved.
|
||
|
||
---
|
||
|
||
### ADV text publication and wait-indicator service -- opcodes `0x1ce` / `0x20a` (2026-07-20)
|
||
|
||
The earlier working label "sprite-animation service" was too broad. These operations belong specifically
|
||
to ADV text publication and the animated input-wait indicator (the bouncing marker shown after a page
|
||
finishes typing). They do not expose a generic retained-object animation channel.
|
||
|
||
`op_0x1ce_set_adv_wait_indicator_enabled@0x41f8c0` stores its operand at
|
||
`EngineCtx.adv_wait_indicator_enabled_value` (`ctx+0x5f734`). A nonzero value sets run-state bit
|
||
`0x40000000`, resets `adv_wait_indicator_frame` (`ctx+0x5f72c`) to zero, and starts
|
||
`adv_wait_indicator_timer` (`ctx+0x5f64c`). Zero erases the indicator belonging to
|
||
`adv_wait_indicator_layout_slot` (`ctx+0x6da84`) with the worker's frame `-2` mode and clears the run bit.
|
||
Every Himegari corpus use is `0x1ce(0)`, normally at entry to a modal script; `HISTORY.BIN@0x3` is the
|
||
concrete route relevant here.
|
||
|
||
`op_0x20a_publish_adv_text_layout@0x422ce0` first calls
|
||
`adv_text_publish_layout@0x450c80`. Slot zero selects the text manager's current layout; a nonzero operand
|
||
selects that indexed layout. The worker erases the layout's old retained-object range, walks its 20-byte
|
||
text records, and rebinds their draw objects. When run-state bit `0x40000000` is active, the opcode also
|
||
calls `adv_text_publish_wait_indicator_frame@0x453120` with the current indicator frame. The latter worker
|
||
uses frame `-1` to return/capture the terminal frame, `-2` to erase the indicator object, and a
|
||
nonnegative frame to choose and bind the corresponding atlas cell. All corpus uses are `0x20a(1)`;
|
||
`HISTORY.BIN@0x13ab` reaches it through the shared ADV redraw callback.
|
||
|
||
Opcode `0x72` owns the normal implicit start of the same service: it records the waiting layout, captures
|
||
the indicator's terminal frame, resets the active frame to zero, starts the timer, and raises run-state bit
|
||
`0x40000000`. Completing the input wait erases the object and clears the bit. In the original interpreter,
|
||
return from a nested modal script flows through that shared redraw/wait path and re-arms the parent marker.
|
||
The port's host blocks inside the enclosing wait while servicing the nested HISTORY callback, so it restores
|
||
the previously enabled parent marker at the callback-return boundary. This is lifecycle equivalence, not a
|
||
script-specific seed or global-state workaround.
|
||
|
||
The port exposes the two explicit operations as host services: `0x1ce` controls marker eligibility and
|
||
`0x20a` requests retained ADV publication. Godot's existing indicator clock/configuration remains the
|
||
animation implementation. The real SC0000-to-HISTORY regression proves HISTORY disables the marker on
|
||
entry, executes layout-slot-1 publication on return, clears transient History rows, and leaves the enclosing
|
||
ADV page wait in place. The expanded 63-field `EngineCtx` and semantic function annotations are applied to
|
||
the saved `/v2` image.
|
||
|
||
---
|
||
|
||
### Retained graphics lifecycle and render targets -- opcodes `0x1f6` / `0x23d` / `0x20d` / `0x20e` (2026-07-20)
|
||
|
||
The initially suspected `0x1f6` / `0x20e` pair is actually two independent lifecycle pairs. Opcode
|
||
`0x1f6` clears retained object records, while `0x23d` releases transient surface resources. Separately,
|
||
`0x20d` selects a Direct3D render target and `0x20e` clears the selected target to black. Their frequent
|
||
adjacency comes from scene setup and teardown, not from one shared service.
|
||
|
||
`op_0x1f6_clear_retained_gfx_objects@0x417430` passes the embedded retained-gfx manager at
|
||
`EngineCtx+0x46614` to `retained_gfx_objects_clear@0x47cab0`. The worker destroys the complete
|
||
handle-to-object map at manager `+0x408` (`EngineCtx+0x46a1c`), resets its sentinel/count and transient
|
||
dirty/force-completion/animation-clock state, but leaves the surface table and queued surface commands
|
||
intact. The port consequently clears `GfxState` objects without releasing their source slots, allowing a
|
||
later bind to recreate an object from an existing surface.
|
||
|
||
`op_0x23d_release_transient_surfaces@0x4175c0` loops over slots 42 through 999. For each slot it stops and
|
||
releases any movie-to-texture object at `EngineCtx+0x52bd4[slot]`, then calls
|
||
`retained_gfx_release_surface@0x474e40`. Slots 0 through 41 are deliberately preserved. The native release
|
||
worker can also retain a slot protected by its per-slot ownership guard; the port has no corresponding
|
||
external owner and releases the complete transient range. In the corpus, 146 of 149 `0x23d` calls directly
|
||
follow `0x1f6`, forming the full object-plus-resource reset.
|
||
|
||
`op_0x20d_select_render_target@0x422e10` forwards its operand to
|
||
`retained_gfx_select_render_target@0x479660`. Values below 1000 resolve a retained texture wrapper, acquire
|
||
texture level zero, and call `IDirect3DDevice9::SetRenderTarget(0, surface)`. Values at or above 1000 acquire
|
||
backbuffer zero instead and record current target `-1` at manager `+0xb530` (`EngineCtx+0x51b44`). Opcode
|
||
`0x20e` then calls `d3d_clear_render_target_black@0x471460`, which invokes `IDirect3DDevice9::Clear` with no
|
||
rectangles, `D3DCLEAR_TARGET | D3DCLEAR_ZBUFFER`, color zero, depth 1.0, and stencil zero. The port tracks
|
||
the selected target and forwards the clear to the host; the retained compositor reconstructs its main
|
||
backbuffer from black at publication boundaries, while offscreen clears discard both modeled pixels and
|
||
text draws.
|
||
|
||
Opcode `0x222` publishes its retained handle range into whichever target `0x20d` selected; it is not
|
||
merely an onscreen repaint request. SAVE.BIN provides a compact end-to-end example. It creates slot 2 at
|
||
800x600, selects and clears it, then publishes handles `[0,0x130b0)` to capture the underlying gameplay
|
||
without the save-menu objects. It next creates/selects/clears slot 192 at 112x84, binds slot 2 through
|
||
handle `0x15f90`, scales that object to 14%, publishes the two-handle range, and passes slot 192 to
|
||
`0x1ae`. Dungeon scripts use the same selected-target publication to construct map surfaces.
|
||
|
||
The port originally retained only an object-list snapshot for transitions and requested an ordinary
|
||
backbuffer repaint from `PresentObjectRange`; the selected surface's pixels remained its cleared black
|
||
allocation. That produced completely black port-authored `.STH` files and caused restored dungeon map
|
||
surfaces to turn black when their rebuild published offscreen. Godot now uses the platform-neutral
|
||
software affine compositor for selected-target `0x222`/`0x20c` publication, including source surfaces,
|
||
scaling, transforms, tint/opacity/blend, handle-range filtering, and real pixel clears. Backbuffer
|
||
publication remains on the existing renderer. `gfx_present_object_range@0x482230` records the refined
|
||
target ownership in the saved `/v2` image.
|
||
|
||
This trace also corrects an important base-pointer assumption in earlier graphics notes. Offsets `+0x408`
|
||
and `+0xb550` are relative to the retained-gfx manager at `EngineCtx+0x46614`, not to `EngineCtx` itself.
|
||
Their absolute locations are therefore `EngineCtx+0x46a1c` (object registry) and `EngineCtx+0x51b64`
|
||
(frame timer). The same correction moves the dirty/force-completion fields to `EngineCtx+0x51b6c` through
|
||
`+0x51b74`. This changes native field provenance and the Ghidra structure, but not the already implemented
|
||
host-side object/animation behavior, which was based on worker semantics and runtime traces rather than
|
||
directly reading those native addresses.
|
||
|
||
Corpus totals are 293 `0x1f6` calls in 146 scripts, 149 `0x23d` calls in 146 scripts, 113 `0x20d` calls in
|
||
24 scripts, and 345 `0x20e` calls in 167 scripts. In SC0000, the full reset appears as `0x1f6` then `0x23d`
|
||
at `0x4ed`; other paths use `0x1f6` then `0x20e` to discard retained objects and clear the already selected
|
||
backbuffer. Fresh offscreen surfaces commonly use `0x20d` then `0x20e` before drawing.
|
||
|
||
---
|
||
|
||
### Detached finite object animation -- opcodes `0x242` / `0x243` (2026-07-20)
|
||
|
||
`op_0x242_set_object_animation_detached@0x4249d0` fetches `(handle, flags)` and calls
|
||
`gfx_object_set_animation_control@0x47f1a0`, which get-or-creates the retained object and replaces its
|
||
32-bit control word at `obj+0x2d0`. Only bit 0 has a located consumer. It does not reset geometry, color,
|
||
or matrix state.
|
||
|
||
`gfx_object_apply_transform_channels@0x472f00` gives bit 0 two connected effects on the finite one-shot
|
||
group (packed color, scale, rotation, translation, and timed source rectangle):
|
||
|
||
- Manager `+0xb55c` value 1 normally forces every finite channel to its endpoint. Bit 0 masks that request
|
||
for this object.
|
||
- An unfinished ordinary object raises manager `+0xb560`, keeping blocking presentation active. A bit-0
|
||
object does not raise that flag, but still raises redraw-dirty `+0xb558`, so it continues animating while
|
||
script execution proceeds.
|
||
|
||
When no finite channel remains, the consumer clears bit 0 and the shared start timestamp. The control is
|
||
therefore a per-animation detachment flag, not a persistent object mode. Object initialization sets the
|
||
word to zero, and native clone copies it with the rest of the complete object record.
|
||
|
||
`op_0x243_force_complete_and_reset_anim_clock@0x4182d0` supplies the paired global operation. When service
|
||
flags manager `+0xb56c` bit 1 is clear, it sets force-complete `+0xb55c` (`EngineCtx+0x51b70`) to 1 and
|
||
zeros the separate animation-service elapsed/duration fields at `+0xb564/+0xb568`. The next composition
|
||
commits every ordinary finite group; detached objects ignore the request. The port performs that observable
|
||
commit directly when executing `0x243`, excludes detached groups from its blocking wait predicate, and
|
||
continues including them in its visual recomposition predicate until natural completion.
|
||
|
||
Corpus evidence is unusually sharp: all 303 calls pass an immediate flag, with zero used 302 times and one
|
||
used once. SC0000's two CG-loader sites (`0x12723`, `0x13310`) write zero. `BTL.BIN@0x2b4d` writes one on an
|
||
animated battle object after its texture/movie or sprite-cell setup, matching the nonblocking background
|
||
animation contract. No boot seed, script-offset branch, or persistence state is involved.
|
||
|
||
---
|
||
|
||
### ROOM character selection, profile voice defaults, and legacy screen crossfade — `0x60` / `0x6c` / `0x25` (2026-07-21)
|
||
|
||
ROOM's apparently connected missing greeting and instant menu presentation were three separate native
|
||
contracts. Opcode `0x60` is the random selector, `0x6c` establishes the default voice-setting array, and
|
||
`0x25` owns the blocking transition between two complete offscreen frames.
|
||
|
||
`op_0x60_handler@0x426970` calls the imported CRT `rand`, fetches operand 2, and writes
|
||
`rand() % bound` to operand 1. Bound zero first writes zero and then raises script error `0x10005`.
|
||
`ROOM.BIN@0x5` passes bound 4; the result selects four character resource sets. Results 0, 1, and 2 also
|
||
select six voice ids each (`0x3365..0x3376`), including their greeting/farewell pair. Result 3 deliberately
|
||
leaves those locals zero in the release script, so that presentation variant is silent by script design.
|
||
|
||
The voice-enabled default was being defeated earlier in boot. `op_0x6c_handler@0x426d90` resolves operand
|
||
1 as a writable integer address and fills operand-2 consecutive dwords with `EngineCtx::anti_tamper_b`, the
|
||
native encoded representation of logical zero. It is a zero-range operation, not the scalar
|
||
`copy-to-global` suggested by Kelebek's label. `INITCONFIG@0x30` therefore clears the 13-cell character
|
||
voice-setting table `G[0x2e49..0x2e55]`, after which `0x1a2` registers each cell with the shared profile
|
||
service. CONFIG indexes the same table for voice preview and its 0/1 enable/suppress choices. The old port
|
||
instead wrote the scalar count 13 into `G[0x2e49]`; ROOM treats a nonzero value in cell 0 as voice suppression and
|
||
skipped all greeting/farewell ids. Correct zero-range execution fixes the boot default without implementing
|
||
or choosing a persistence backend for `0x1a2`/`0x1a3`.
|
||
|
||
`op_0x25_handler@0x41ce00` starts the legacy transition manager at `EngineCtx+0x1c38`, sets run-state bit
|
||
8, and keeps the script parked until the transition completes. Its three operands are source surface,
|
||
target surface, and a timing argument. For arguments up to 64, the handler arms that many milliseconds per
|
||
tick and advances the 8-bit alpha by 16. Larger arguments use `argument/16` milliseconds and alpha step
|
||
1. The branch is expressed in decompilation as `((arg < 65) - 1 & 0xfffffff1) + 0x10`; evaluating both
|
||
outcomes is significant (`true -> 16`, `false -> 1`). `interval_timer_poll_elapsed_steps@0x44d080` returns any missed timer intervals, and
|
||
`screen_transition_tick@0x43a7a0` mode 4 composites source first and target over it at the accumulated alpha;
|
||
at `0x100` it commits the target. `screen_transition_begin@0x439da0` and
|
||
`screen_transition_finalize@0x4399c0` own the endpoints. Thus an argument 10 lasts about 160 ms, while 30
|
||
lasts about 480 ms; input can force the endpoint through the same transition-abort service. Manual ROOM
|
||
validation caught the initially inverted alpha-step branch.
|
||
|
||
ROOM explicitly constructs those full frames. It selects/clears surface 1 and calls `0x20c`, mutates the
|
||
retained room presentation, captures surface 2 through another `0x20c`, restores the backbuffer, and calls
|
||
`0x25(1,2,10)`. The same form fades its menu away at `0x856`; after the farewell and one-second hold,
|
||
`0x25(1,2,30)@0x8dc` fades to the frame prepared for TITLE return. The interactive port now snapshots
|
||
retained objects when `0x20c` targets an offscreen surface, composites the captured target over the captured
|
||
source on the shared frame clock, blocks the VM through the endpoint, and only then permits the following
|
||
surface release/root reload. Headless hosts retain their non-rendering no-op policy.
|
||
|
||
#### Single-surface black/white fade family — `0x21`–`0x24` (2026-07-29)
|
||
|
||
The widest remaining opcode `0x22` is not a new renderer. It and `0x21` are the single-surface siblings of
|
||
the already implemented mode-4 `0x25` transition. Dispatch-table resolution gives:
|
||
|
||
| op | handler | transition mode | endpoint |
|
||
|---|---|---:|---|
|
||
| `0x21` | `op_0x21_fade_surface_in_from_black@0x41cb00` | 0 | captured surface |
|
||
| `0x22` | `op_0x22_fade_surface_out_to_black@0x41cbc0` | 1 | black |
|
||
| `0x23` | `op_0x23_fade_surface_in_from_white@0x41cc80` | 2 | captured surface |
|
||
| `0x24` | `op_0x24_fade_surface_out_to_white@0x41cd40` | 3 | white |
|
||
|
||
All four take `(surface_slot, timing_argument)`, set run-state bit 8, and use the exact `0x25` timing
|
||
conversion: arguments up to 64 use `argument` milliseconds per tick with alpha step 16; larger arguments
|
||
use `argument/16` milliseconds with step 1. Modes 0/2 start with an opaque solid scratch surface over the
|
||
captured frame and lower its alpha to zero. Modes 1/3 keep the captured frame opaque and raise the
|
||
black/white scratch alpha to 255. Re-entry finalizes the named endpoint before script execution resumes.
|
||
|
||
Himegari uses only the black pair: three `0x21` calls and eight `0x22` calls, all `(1,30)` and therefore
|
||
about 480 ms. The scripts first select surface 1, clear it, publish a complete retained frame through
|
||
`0x20c`, restore the backbuffer, then fade. `0x21` is the menu-entry reveal; `0x22` is the corresponding
|
||
menu/scene exit to black.
|
||
|
||
The port dispatches both opcodes through the existing blocking `LegacyScreenTransition` clock and terminal
|
||
publication path. Its compositor already clears the whole frame to opaque black, so an empty source list
|
||
models black-to-captured and an empty target list models captured-to-black without overloading the mode-4
|
||
missing-surface fallback (which intentionally snapshots the live retained frame). This closes 11 formerly
|
||
skipped instructions across seven distinct scripts and reduces the effectful-gap inventory from 18 to 16
|
||
distinct opcodes / 28 instructions.
|
||
|
||
The same manual pass exposed a resource-addressing issue after `0x6c` was fixed: ROOM did execute
|
||
`play-voice`, but `GodotAdvHost` attempted only inferred SC-section resolution. ROOM's voice operands
|
||
`0x3365..0x3376` are universal packed SYS4INI indices (for example `0x3365` is `EUA0016.OGG`). The first
|
||
compatibility fix tried an SC section and then a type-checked base-catalog fallback. Subsequent native RE
|
||
proved there is no first stage: ROOM and SC scripts alike pass an already absolute packed id directly to
|
||
the catalog opener. SC0000's `0x24 -> MAN999.OGG` works because that scene's inferred group begins at zero.
|
||
|
||
---
|
||
|
||
### Startup string predicate and unit-data block copy — `0x194` / `0x1b0` (2026-07-21)
|
||
|
||
The natural Game Start capture reached two previously unnamed effectful handlers. They are independent
|
||
contracts even though both appeared in the same startup fallback inventory.
|
||
|
||
`op_0x194_string_equals@0x426e20` resolves operands 2 and 3 as SYS4 strings, passes their data pointers and
|
||
explicit byte lengths to the native comparison helper, and writes `comparison == 0` to integer operand 1.
|
||
It is therefore an equality predicate, not a string assignment. The release corpus uses the result in
|
||
conditional branches; `GAMESTART@0x134c` compares `INPUTNAME` with `"?"`, while INIT2 also compares
|
||
`INPUTNAME` with an empty string during default-name initialization.
|
||
|
||
The port now executes `0x194` through the common string resolver and ordinal equality, so every operand
|
||
form accepted by that resolver shares one contract: inline literal, global string, local string, global
|
||
string pointer, and local string pointer. Focused tests cover equality and inequality across those forms
|
||
plus the release GAMESTART compare-then-`jcc` shape. The full traced SYSTEM4-to-SC0000 regression reaches
|
||
GAMESTART without emitting a `0x194` fallback.
|
||
|
||
`op_0x63_take_address@0x426ac0` is the companion address operation. It passes operand 2 and unsubscripted
|
||
indices `-1/-1` to `vm_operand_resolve_address@0x425a50`, then stores the returned address in operand 1
|
||
through `vm_pointer_operand_write@0x416090`. The resolver returns the backing-cell address for direct
|
||
global/local integer or string operands; for pointer operands it returns the target already held by the
|
||
pointer, not the address of the pointer slot. Thus `0x63(dst_ptr, source)` is typed address aliasing. All 92
|
||
release-corpus sites use a local integer-pointer destination; sources are local pointers 81 times, local
|
||
integers seven times, and global integers four times.
|
||
|
||
`op_0x1b0_copy_dwords@0x427060` fetches operand 3 as a cell count, resolves addressable operands 1 and 2 as
|
||
source and destination, and calls `memcpy(destination, source, count * 4)`. The corpus has 65 calls: direct
|
||
global/local spans as well as local pointers, with 43 immediately preceded by `0x63`. The boot capture
|
||
reached the pair in `UNITECH`/`CALCCC`; those scripts use it to copy record-shaped arrays between per-entity
|
||
tables and working buffers.
|
||
|
||
The port now maps both operations onto its domain-preserving `VmAddress` model. Direct local/global cells
|
||
retain their bank, aliasing an existing pointer retains its target bank, and `0x1b0` copies consecutive
|
||
32-bit integer cells through the resolved endpoints. Focused tests cover local-to-global, global-to-local,
|
||
an alias of a `lookup-array` result, direct spans, and the native string-pointer destination form of
|
||
`0x63`. The step-traced SYSTEM4-to-SC0000 regression reaches both operations without fallback. Static
|
||
coverage is consequently 31/31 handled for UNITECH and 14/15 for CALCCC; CALCCC's only remaining gap is
|
||
the deliberately deferred shared-profile write `0x1a2`.
|
||
|
||
---
|
||
|
||
### ADV layout reset cursor and overflow bounds — `0x79` / `0x1c1` (2026-07-21)
|
||
|
||
SYSTEM4 uses these two three-operand operations as a pair while constructing its nine ADV layouts. They
|
||
configure persistent layout properties used by later resets; neither operation draws text or moves the
|
||
current cursor immediately.
|
||
|
||
`op_0x79_set_adv_text_reset_cursor@0x41eb50` reads `(layout_slot, x, y)` and calls
|
||
`adv_text_layout_set_reset_cursor@0x44fed0`. Slot zero resolves through the current-layout selector at
|
||
manager `+0x4c8`; otherwise the helper selects `manager+0x414[slot]`. It writes `x/y` to layout
|
||
`+0x1c/+0x20`. This is distinct from `0x7a`, whose `adv_text_set_cursor@0x4530f0` path modifies the live
|
||
cursor record immediately.
|
||
|
||
`op_0x1c1_set_adv_text_bounds@0x41f6c0` reads `(layout_slot, right, bottom)` and calls
|
||
`adv_text_layout_set_bounds@0x44ff00`, which writes the pair to layout `+0x24/+0x28`. They are absolute
|
||
layout-local overflow coordinates. `adv_text_layout_check_overflow@0x45efc0` compares glyph end x/y
|
||
against those fields and returns horizontal/vertical overflow bits. When a layout resets,
|
||
`adv_text_layout_reset_cursor_and_bounds_record@0x455070` replaces its retained state with a five-dword
|
||
record `{0, reset_x, reset_y, right, bottom}` copied from all four configured fields.
|
||
|
||
The corpus supplies a complete consistency check: all nine sites for each opcode are in SYSTEM4 and
|
||
alternate after layouts 1–9 are defined and initially reset. Layout 1 configures cursor `(100,47)` and
|
||
bounds `(720,147)`; layouts 2–6 use `(45,42)` and `(645,135)`; layout 7 uses `(53,10)` and `(495,180)`;
|
||
layout 8 uses `(53,10)` and `(495,60)`; layout 9 uses `(10,10)` and `(250,368)`. Layout 1 computes its
|
||
bounds as `100+620` and `47+100`, further excluding width/height-delta semantics.
|
||
|
||
The port now keeps configured reset cursor and right/bottom bounds in its engine-owned layout state.
|
||
`0x70` initializes bounds from width/height, `0x79` changes only the deferred reset cursor, `0x1c1`
|
||
changes the bounds, and `0x71` restores the configured cursor while notifying the host of the concrete
|
||
selected slot. Retained snapshots carry the boundaries, and ordinary/history Godot labels are positioned
|
||
from the layout origin and sized from `right-cursor_x` / `bottom-cursor_y`; the former hardcoded slot-1
|
||
rectangle is gone. The ordinary overlay remains bound to the parent input wait's captured layout while a
|
||
nested callback such as HISTORY selects and renders into layouts 2–6; closing the callback therefore
|
||
reveals the retained parent text at its original geometry rather than consulting the callback's last
|
||
current-layout selection. The direct-scene SYSTEM4 bootstrap also recognizes all nine configuration pairs and
|
||
evaluates the two constant `add` expressions used for slot 1 without entering SYSTEM4's control flow. This
|
||
is script-owned state, with no boot seed or game-specific coordinates in the runtime.
|
||
|
||
---
|
||
|
||
### Logical input-action configuration — `0xfe` / `0x107` / `0x10b` / `0x10c` (2026-07-21)
|
||
|
||
SYSTEM4's adjacent input setup block defines the logical action namespace later consumed by callback
|
||
poll/dispatch ops `0xff` and `0x100`. These calls are effectful engine configuration, not declarations:
|
||
|
||
- `op_0xfe_set_input_action_count@0x421390` stores an unsigned count below 32 at EngineCtx `+0x814` and
|
||
throws script error `0x10005` for an invalid count. SYSTEM4 sets 10, so callback dispatch scans actions
|
||
0 through 9. If the polled mask is empty, `op_0x100_dispatch_joy_callbacks@0x416f00` instead invokes
|
||
callback slot 10 and resumes after itself; that slot is the native no-input/release path, not action 10.
|
||
- `op_0x107_map_joystick_button@0x421550` writes physical joystick button numbers into the 32-entry
|
||
EngineCtx `+0x89c` table. Joystick axes directly emit actions 0=up, 1=right, 2=down, 3=left; table slot
|
||
N emits action N+4. SYSTEM4 maps physical buttons 0,3,2,1,6,7 to actions 4 through 9.
|
||
- `op_0x10b_map_mouse_button@0x4216f0` writes a logical button slot into the table at EngineCtx `+0x135c`,
|
||
indexed by physical mouse button. Polling adds four to the stored slot. Initialization zeroes this map,
|
||
making left mouse action 4 by default; SYSTEM4 `(3,1)` maps right mouse to action 7.
|
||
- `op_0x10c_map_keyboard_scancode@0x421730` translates a DirectInput DIK scan code through EngineCtx
|
||
`+0x1828` to a Win32 virtual key, then writes the action into the 256-entry table at `+0x1428`.
|
||
Initialization sets count 7 and prebinds 0=up, 1=right, 2=down, 3=left, 4=Enter, 5=Space, and
|
||
6=Backspace. SYSTEM4 raises the count to 10 and adds Z to action 4, C/LeftCtrl to action 6, X to action
|
||
7, PageUp to action 8, and PageDown to action 9; the earlier Enter/Space/Backspace mappings remain.
|
||
|
||
`input_poll_action_mask@0x4608b0` combines
|
||
`input_poll_keyboard_action_bits@0x4601a0`, `input_poll_mouse_action_bits@0x460240`, and
|
||
`input_poll_joystick_action_bits@0x460380`. The first three logical actions are therefore not a generic
|
||
Godot UI ordering; they are the native engine ABI established by the input manager and refined by scripts.
|
||
|
||
The same mask is also consumed directly by `adv_interpreter_tick@0x410fb0`. Logical action 6 is bit
|
||
`0x40`, the exact bit used by the transient ADV fast-forward/run-state path. There is no Ctrl-specific
|
||
branch: SYSTEM4's C and LeftCtrl mappings, the retained native Backspace default, and any joystick mapping
|
||
that emits action 6 all reach the same hold-to-fast-forward mechanism. Persistent op-`0x88` Skip injects
|
||
that same bit independently each tick.
|
||
|
||
The port now models this as one process-owned `InputBindings` service. It starts with the native seven
|
||
keyboard defaults, the four configuration handlers mutate it, and `0xff` combines live keyboard-VK,
|
||
left/right mouse, joystick-axis, and joystick-button state with the narrow logical injection used by tests.
|
||
`0x100` scans only actions below the configured count, resumes on itself for simultaneous held actions, and
|
||
uses callback slot `count` only for an empty mask. Godot translates layout-independent physical keys to the
|
||
native Win32 VK namespace and sends physical mouse/standard joy events through the service; it no longer
|
||
assigns Godot's `ui_*` actions directly to AGE indices. Direct-scene diagnostics replay the same 16 immediate
|
||
SYSTEM4 configuration calls through `InputBindingBootstrap`, while natural boot executes the real opcodes.
|
||
The live action-6 state now also feeds the ADV fast-forward host channel while the ADV lifecycle service is
|
||
enabled. That transient channel is kept separate from persistent op-`0x88` Skip, so releasing the held key
|
||
cannot clear the user's toggle; both channels share the existing text completion, wait advance, voice
|
||
deferral, and skip cadence. This needs no profile storage, boot seed, or game-specific conditional.
|
||
|
||
### ADV right-click/X system-menu path (2026-07-21)
|
||
|
||
Right-click during ADV is not a frontend-owned menu shortcut. It is the same script-owned keyed-hotspot
|
||
mechanism already partially modeled for SC0000. SYSTEM4 maps physical right mouse and keyboard X to logical
|
||
action 7. Each of the 136 SC-family scripts registers a 1x1 dummy rectangle whose activation callback is
|
||
the local branch that cancels the ADV hotspot wait, calls raw script id `0x1f` (`MENU.BIN`), then rebuilds
|
||
the parent ADV controls and redraws the retained page. Opcode `0x97` binds action 7 to that rectangle.
|
||
|
||
Native `adv_input_service_poll@0x411230` polls the configured logical-action mask first, then calls
|
||
`input_hotspot_poll_bound_action_callback@0x403fb0`. The helper scans armed records in registration order;
|
||
for each nonnegative op-`0x97` action index it tests `mask & (1 << action)` and returns the record's ordinary
|
||
activation callback PC. This identified the former port seam: `HotspotRegistry.BindKey` retained the action
|
||
on the matching record, but no code consumed `Entry.InputBit`; Godot routed only pointer-left activation and
|
||
action-4/5 page advance. Right mouse therefore reached `InputBindings` as action 7 without queuing MENU.
|
||
|
||
The implemented bridge is engine-generic. `HotspotRegistry.ActivateBoundActions` scans armed records in
|
||
registration order, consumes the first matching logical-action binding through the ordinary activation
|
||
target, and shares pointer activation's disarm/rearm lifecycle. Godot routes pressed keyboard, mouse, and
|
||
joystick logical-action masks through the VM before ordinary page advance and wakes the existing callback
|
||
service. There is no MENU-specific branch in Godot or the VM. A real SC0000 regression proves action 7
|
||
enters the release `MENU.BIN` and restores the parent ADV hotspot registry after controlled return.
|
||
|
||
The reached script path is promising but should be validated incrementally. `MENU.BIN` is 48/49 opcodes
|
||
handled and its only static gap is op `0x80`; `INFO.BIN`, which selects character/enemy/voice/affinity/item
|
||
information pages, is 30/30 handled. The large `CHMENU` and five `INFO*` detail screens are roughly
|
||
91.5–95% handled and may expose secondary visual/data gaps after the shell opens. SAVE and CONFIG are only
|
||
about 80% handled and remain separate storage/audio-settings work, not prerequisites for opening or closing
|
||
the system menu.
|
||
|
||
The sole `MENU.BIN` gap is now decoded: `op_0x80_set_default_gfx_object_slot@0x41ed40` stores operand 1 at
|
||
EngineCtx `+0x14e08`; `op_0x1d9_handler@0x420a30` substitutes that selected slot only when its explicit
|
||
object-slot operand is zero. MENU-family scripts select slots 7/8/9 on entry and restore slot 1 during
|
||
teardown. The port now retains this engine-owned selector in `GfxState.DefaultObjectSlot`; MENU's entry path
|
||
selects slot 8 in the regression. The surveyed MENU/INFO scripts do not themselves call op `0x1d9`, so this
|
||
state was not the cause of the former missing launch.
|
||
|
||
### Indexed two-key sort — `0x12f` (2026-07-21)
|
||
|
||
`op_0x12f_sort_indices_by_key_sum@0x429360` takes `(out_indices, key_a, key_b, count)`. It resolves the
|
||
first three operands as integer-array bases, seeds output index zero, and performs a stable insertion sort
|
||
over source indices `0..count-1`. The comparison is signed
|
||
`unchecked(key_a[index] + key_b[index])`; a new index moves left only when its sum is strictly smaller, so
|
||
equal sums preserve source order. The native handler's final loop does not change that result: it encodes
|
||
the directly written output cells back into AGE's protected integer-bank representation.
|
||
|
||
This is the cause of the first observed character-menu discrepancy. Natural New Game correctly runs
|
||
UNITECH and enters SC0000 with party slot 2 carrying flags `0x13` and character id 2. CHMENU constructs
|
||
100 party-slot sort keys, calls `0x12f` at `0x1c9b`, and reads the populated tail of the resulting index
|
||
permutation. The port's fallback no-op left that permutation zero-filled, so CHMENU selected slot zero and
|
||
rendered an apparently empty party even though the authoritative unit state already existed. The port now
|
||
implements the generic sort with addressed local/global arrays, native signed 32-bit addition and overflow,
|
||
stable equal-key ordering, repeated count reads, and the native unconditional output-index-zero write.
|
||
Focused tests cover stability, overflow, and zero count. A real-script regression carries natural
|
||
SYSTEM4-to-SC0000 state into CHMENU and stops after `0x1db8`, proving selected slot 2 survives the first
|
||
release roster sort without a fallback. No startup seed or menu-specific roster injection is involved.
|
||
|
||
### Numeric glyph styles/draws and half-byte string length — `0x13a` / `0x23b` / `0x1a6` (2026-07-21)
|
||
|
||
The missing values in the first working `DEBUGMAP` field HUD are not missing globals or a failed texture
|
||
load. They are generated by a dedicated decimal-glyph subsystem whose two opcodes were still skipped by
|
||
the port:
|
||
|
||
- `op_0x13a_register_numeric_glyph_style@0x421ab0` takes
|
||
`(style_index, surface_slot, atlas_x, atlas_y, digit_width, digit_height)`. It accepts style indices
|
||
`0..10` and stores the remaining five operands in the 20-byte record at
|
||
`EngineCtx+0x55180+style_index*0x14`; an invalid index raises the standard script error. The corpus has
|
||
74 registrations in 24 scripts, including eight in `DRAWCHP.BIN`.
|
||
- `op_0x23b_draw_decimal_glyphs@0x424190` takes
|
||
`(base_handle, style_index, value, x, y, digit_capacity, flags)`. It first erases the destination handle
|
||
range, splits the signed integer through division/remainder by ten, and binds one retained object per
|
||
displayed digit using horizontally adjacent cells from the selected style record. Flag bit `0x1`
|
||
includes leading zeroes, bit `0x2` centers the used digits, and bit `0x4` left-aligns them; without an
|
||
alignment flag the value is right-aligned in the requested capacity. An invalid or unregistered style
|
||
raises the script error. The corpus has 147 draws in the same 24 scripts; `DRAWCHP.BIN` has 22 and uses
|
||
them for the field HUD's turn/control/mana/level/HP/SP/FS values.
|
||
|
||
This path creates ordinary retained graphics objects, so the existing atlas decode and compositor are
|
||
the correct backend; it is not an immediate `GodotAdvHost.DrawTexture` raster operation. The port now
|
||
models all 11 EngineCtx style records in `GfxState` and implements both dispatches. Each `0x23b` call erases
|
||
its full destination-handle capacity and then uses the ordinary
|
||
`BindDraw` path for every displayed digit, preserving surface replacement, z-order, and compositor effects.
|
||
|
||
The absent unit and weapon names are a separate layout-compute gap. `DRAWCHP.BIN` does populate both
|
||
strings and calls `draw-string` at `0x9c6`/`0x9f5`, but first centers each one with opcode `0x1a6`.
|
||
`op_0x1a6_half_byte_strlen@0x427020` resolves the NUL-terminated engine byte string and writes
|
||
`strlen(bytes) >> 1`. The script multiplies that result by 21 and subtracts it from x=257 on a 263-pixel
|
||
scratch surface. With the opcode skipped, x remains 257 and Godot correctly clips nearly all of the text.
|
||
The port reproduces the encoded byte count rather than using the .NET UTF-16 character count. The VM's
|
||
native-string code page is configurable for other container frontends and defaults to SYS4's CP932; the
|
||
calculation also stops at an embedded NUL before shifting. Focused tests cover CP932 mixed-width strings,
|
||
all three numeric layout flags, zero padding, retained-object replacement, and invalid/unregistered styles.
|
||
|
||
### BUNKI popup sizing and placement — opcodes `0x2c5` and `0x195` (2026-07-21)
|
||
|
||
Field confirmation popups and TITLE's shipped developer menu share `BUNKI.BIN`; neither uses the ordinary
|
||
VN text-layout overlay. BUNKI creates surface 200, draws its strings into that surface through opcode
|
||
`0x204`, decorates it with the common menu frame, and binds the completed surface as a retained object.
|
||
|
||
`op_0x2c5_byte_strlen@0x42a690` takes `(destination, string)`, resolves operand 2 as an engine byte string,
|
||
scans to its NUL terminator, and writes the raw byte count. This differs from a Unicode character count but
|
||
matches BUNKI's later conversion: it finds the longest choice/title byte length, adds four bytes of padding,
|
||
multiplies by 21, and divides by two to obtain the full-width glyph-space estimate used for panel width and
|
||
the shared left edge of the primary labels.
|
||
|
||
Before implementation, the port skipped `0x2c5`, leaving BUNKI's maximum-length local at zero. For FIELD's
|
||
`待機`/`帰還`/`キャンセル` popup, the panel still hits the same 240-pixel minimum but the computed label
|
||
origin moves from surface x=67 to x=120, a 53-pixel right shift which makes `キャンセル` touch/spill beyond
|
||
the frame. TITLE's longer developer choices should expand the surface beyond 240 pixels; the skipped result
|
||
instead keeps the minimum panel and makes the overflow much larger.
|
||
|
||
The corrected native/port comparison also exposes an independent 30-pixel vertical error. Dispatch slot
|
||
`ctx[0x26c93+0x195]` resolves to `op_0x195_string_not_equals@0x426f20`, the inverse of sibling opcode
|
||
`0x194`: it compares two complete engine strings and writes one when their byte ranges differ. BUNKI invokes
|
||
it three times to test whether optional title global string `0x7da` is nonempty. The skipped opcode does not
|
||
write zero for an empty title. At the final test (`BUNKI@0x905`), destination local `0x99` still contains a
|
||
nonzero graphics handle from frame construction, so the following branch falsely draws/reserves the title
|
||
row and advances the choice y cursor by `0x1e` even though the empty title has no visible glyphs. Implementing
|
||
`0x195` therefore removes the exact one-row downward shift; this is not a Godot font-baseline discrepancy or
|
||
an inherited VN cursor indent.
|
||
|
||
Both operations are now implemented as shared VM semantics. `0x2c5` uses the same configurable native-string
|
||
encoding helper as `0x1a6` (CP932 for SYS4), stops at an embedded NUL, and writes the unshifted byte count.
|
||
`0x195` is ordinal inequality through the existing literal/global/local/string-pointer resolver and always
|
||
writes zero or one, so stale destinations cannot leak into the branch. Focused tests cover mixed-width CP932,
|
||
embedded-NUL termination, all observed comparison operand classes, and the exact BUNKI empty-title stale-handle
|
||
case. The full 281-test engine suite, zero-warning Godot build, and threaded frontend selftest pass; manual
|
||
DEBUGMAP and developer-menu visual rechecks remain.
|
||
|
||
#### BUNKI popup publication and BUNKIMOVE lifecycle (2026-07-27)
|
||
|
||
BUNKI's opening/closing effect is an offscreen capture, not an independent decoration behind a stable
|
||
popup. BUNKI builds the complete panel under handles `60000..60099`, including surface-200 text, and calls
|
||
`BUNKIMOVE.BIN`. BUNKIMOVE selects 800x600 surface 2, publishes that retained range into it, restores the
|
||
backbuffer, binds the capture at handle `59999`, and scales it horizontally and then vertically through
|
||
timed callbacks. Each transition frame publishes `[0,60000)`, deliberately including the animated capture
|
||
while excluding BUNKI's stable objects. On open, BUNKI publishes the stable menu only after BUNKIMOVE
|
||
returns; on close, it removes the stable range after the reverse animation.
|
||
|
||
The port previously treated a backbuffer `0x222` as a generic repaint request and reconstructed every
|
||
visible retained object. It therefore drew handles `60000+` at full size above handle `59999`, making the
|
||
real popup appear immediately while its opening animation ran behind it; closing had the symmetric error.
|
||
Godot now retains zero-based backbuffer publication ranges and filters reconstruction to the active range.
|
||
Nonzero ranges remain incremental overlays over a full reconstruction because the port does not preserve
|
||
native D3D backbuffer pixels between publications.
|
||
|
||
Surface text also remains modeled metadata rather than pixels. Selected-target publication now projects
|
||
that metadata into the destination surface, and the final Godot labels inherit the retained object's
|
||
projected scale and rotation. Consequently BUNKIMOVE's captured labels follow the same transform as its
|
||
panel instead of escaping as full-size Control overlays.
|
||
|
||
### Formatted integers on text surfaces — opcode `0x205` (2026-07-21)
|
||
|
||
The deployment picker opened from an empty DEBUGMAP deployment slot uses a third numeric path. Its red
|
||
unit-information card is built by `DRAWENP.BIN` on temporary surface `0x51`: ordinary `0x204` calls draw
|
||
labels and separators such as `LV` and `/`, while 16 calls to previously skipped opcode `0x205` draw the
|
||
level, current/maximum HP/SP/FS, and the two stat columns. This explains why the values alone were absent
|
||
even after the separate `0x13a`/`0x23b` retained-glyph HUD path was restored.
|
||
|
||
`op_0x205_handler@0x422ab0` takes
|
||
`(surface_slot, x, y, value, field_width, flags)`. It records the 13-dword instruction length, calls
|
||
`format_integer_for_surface@0x407190`, and sends the returned string and adjusted x coordinate to the same
|
||
`draw_string_to_surface@0x450150` worker used by opcode `0x204`. The field width includes an optional sign.
|
||
Flag bit `0x1` zero-pads, `0x2` centers omitted leading cells, and `0x4` left-aligns; the default keeps the
|
||
field's right edge fixed by moving x right for omitted cells. Bits `0x8`/`0x10` request a plus sign for a
|
||
positive/zero value, while bit `0x20` gives zero a minus sign. Bit `0x10000`, used by every `DRAWENP` site,
|
||
keeps half-width ASCII and uses half the current font-cell advance; without it,
|
||
`ascii_to_fullwidth_cp932_inplace@0x417800` converts the formatted field to full-width CP932.
|
||
|
||
The VM now routes the formatted result through the existing styled surface-text host path, preserving the
|
||
font, color, and effect changes surrounding each call. Focused tests reproduce DRAWENP's exact level-80
|
||
call and cover half-width right alignment, zero padding, and the observed full-width left-aligned variant.
|
||
DRAWENP is now 36/36 opcodes and 611/611 instructions handled; all 284 engine tests, the zero-warning Godot
|
||
build, and threaded frontend selftest pass.
|
||
|
||
### DEBUGMAP-to-battle frontier opcode cluster (2026-07-21)
|
||
|
||
A static call-graph/coverage pass from `FIELD.BIN` through selection, movement, combat, damage, growth, and
|
||
status helpers shows that the next gameplay risk is presentation plumbing rather than battle arithmetic.
|
||
The following native handlers were decoded while bounding that slice:
|
||
|
||
- `op_0x191_absolute_value@0x426de0` writes
|
||
`(value ^ (value >> 31)) - (value >> 31)` through operand 1. This is signed 32-bit absolute value; all
|
||
five corpus sites are in `SELACT.BIN`, normalizing a signed preview delta before display.
|
||
- `op_0xd0_get_monotonic_time_ms@0x428860` writes `timeGetTime()` to operand 1. `BTL.BIN` samples it around
|
||
its timed callback/HP presentation, and `MVRTN.BIN` contains the other two calls. The port's host-owned
|
||
`FrameClock.NowMs` is the matching monotonic, speed-scaled service timebase.
|
||
- `op_0x23c_sample_frame_time@0x417580` shifts `EngineCtx+0x51b64` to `+0x51b68`, then stores
|
||
`timeGetTime()` as the new current timestamp. BTL callback frames, FIELD/USEMAGIC movie loops, ADDEXP,
|
||
and SHOWGROW place it next to presentation boundaries.
|
||
- `op_0x23a_query_movie_surface_active@0x42a440` writes zero for an empty surface slot; otherwise it writes
|
||
whether the movie-surface field at `+0x42c` is nonzero. All four corpus sites use that result as a movie
|
||
completion-loop predicate: BTL scans its active combat surfaces, while FIELD and USEMAGIC poll slot 42.
|
||
- `op_0x24e_set_gfx_animation_service_flags@0x425070` copies its operand directly to
|
||
`EngineCtx.gfx_animation_service_flags` (`+0x51b80`). BTL brackets combat presentation with 1/0 and
|
||
GAMECLEAR uses 3/0. Bit 1 is independently consumed by opcode `0x243` to suppress a force-complete and
|
||
animation-clock-reset request.
|
||
- `op_0x207_copy_surface_rect@0x422b50` builds source/destination rectangles from
|
||
`(source_surface, destination_surface, source_x, source_y, width, height, destination_x, destination_y)`
|
||
and calls `gfx_copy_surface_rect@0x477da0`. The worker validates both slots, clips both rectangles while
|
||
preserving their correspondence, treats an empty intersection as success, marks the destination dirty,
|
||
and copies through locked D3D surfaces. Its 15 corpus sites are isolated to DRAWMINIMAP (8), STATUS (3),
|
||
READICON (2), and DRAWTIP (2).
|
||
- `op_0x2c0_schedule_voice_playback@0x425290` forwards
|
||
`(voice_id, playback_variant, delay_ms)` to `voice_schedule_delayed_playback@0x488480` on the service at
|
||
`EngineCtx+0x14508`. The setter marks one pending request active and clears its start timestamp. The main
|
||
engine tick calls `voice_tick_delayed_playback@0x4884d0`, which captures the first tick then, after
|
||
unsigned elapsed time reaches the delay, clears the request and invokes
|
||
`voice_play_indexed_asset(voice_id, playback_variant)`. BTL's sole call schedules a randomized combat
|
||
voice with variant 0 and an entity-specific delay; it is presentation-only, not battle state.
|
||
|
||
These handlers and their newly understood workers are renamed/commented in Ghidra `/v2`; the program was saved.
|
||
The exact source metadata lives in `vm-map/opcodes.toml`. The port now implements this cluster against the
|
||
shared Godot frame clock, retained graphics state, movie decoder state, mutable RGBA surfaces, and delayed
|
||
voice service. `0x207` copies colorkey-baked source pixels into immutable published snapshots so compositor
|
||
reads cannot race VM-side mutations. The first complete player attack remains the manual acceptance test.
|
||
BTL's two `0x1a2` shared-profile writes were initially deferred because they do not feed same-exchange
|
||
combat state; the unified profile service now handles them through the ordinary opcode dispatch.
|
||
|
||
### Movement/attack flood-fill FIFO -- opcodes `0x132`-`0x134` (2026-07-21)
|
||
|
||
The DEBUGMAP symptom "selected unit can wait on its origin, but has no blue reachable tiles and cannot
|
||
move" is caused by the only three effectful gaps in `MVSEEK.BIN`, not by `CALCSCOPE` or FIELD input. AGE
|
||
provides 11 context-owned integer FIFO slots:
|
||
|
||
- `op_0x132_reset_int_queue@0x4217d0` validates `queue_id <= 10`, destroys any existing object in the
|
||
selected slot, and allocates a fresh 0x1c-byte FIFO. `int_queue_construct@0x4074c0` allocates 0x100
|
||
dwords, uses another 0x100 dwords as its growth quantum, and zeros the read/end/high-water indices.
|
||
- `op_0x133_enqueue_int@0x4218d0` validates the slot and calls `int_queue_enqueue@0x408930`. The helper
|
||
appends at the end, first compacting consumed entries when possible or growing storage when necessary.
|
||
- `op_0x134_try_dequeue_int@0x429620` writes `(success=1, value)` and advances the read index when the FIFO
|
||
is nonempty; otherwise it writes `success=0`. Its value output is not meaningful on failure, and both
|
||
shipped callers branch on success before inspecting it.
|
||
|
||
Only `MVSEEK.BIN` and `ATSEEK.BIN` use this cluster. Both reset queue 0, pack map coordinates as
|
||
`(x << 16) + y`, enqueue the origin, and repeatedly dequeue a tile and enqueue accepted neighbors.
|
||
`MVSEEK` writes the origin's movement cost before invoking the queue ops. The former generic stubs left the
|
||
origin valid but left `0x134`'s zero-initialized success local unchanged, so the flood fill exited on its
|
||
first loop test. This exactly explained why clicking the occupied tile still reached Wait while neither
|
||
reachable overlays nor movement targets existed; `ATSEEK` was blocked identically.
|
||
|
||
The port now retains 11 VM-lifetime integer FIFO slots and implements reset/enqueue/try-dequeue with native
|
||
signed-dword behavior. It diagnoses invalid or never-reset slots; shipped scripts always reset queue 0
|
||
first. On empty dequeue it writes `success=0` and retains the value destination rather than reproducing the
|
||
native handler's unusable implementation-pointer value. Focused tests cover independent slots, FIFO order,
|
||
signed values, empty reads, and reset replacement. Real-script tests seed a bounded passable grid and prove
|
||
that shipped `MVSEEK.BIN` populates all four neighboring movement costs while `ATSEEK.BIN` populates attack
|
||
distance 1 around the origin. Both scripts are now 100% handled. The handlers and queue helpers are
|
||
renamed/commented in Ghidra `/v2`; the program was saved.
|
||
|
||
The first live recheck after that FIFO implementation still showed no movement, exposing the caller layer
|
||
that the direct real-script tests had bypassed. SYSTEM4 does not leave these workers to ordinary
|
||
`call-script`: its only three opcode `0x06` sites preload `ATSEEK.BIN` (`0x337f`) into EngineCtx frame slot
|
||
`0x1d`, `SETROUTE.BIN` (`0x3380`) into slot `0x1e`, and `MVSEEK.BIN` (`0x3381`) into slot `0x1f`. FIELD and
|
||
the route helpers then contain 64 total opcode `0x08` calls targeting only those three slots. Both opcodes
|
||
were still generic effectful stubs, so live FIELD never entered MVSEEK despite the now-correct worker.
|
||
|
||
Native `op_0x6_preload_script_slot@0x41bdb0` temporarily selects a caller-specified context index (valid
|
||
range 0..39), calls the ordinary script resource loader there, then restores the current index without
|
||
executing the loaded script. `op_0x8_call_preloaded_script_slot@0x41bf00` switches to the selected loaded
|
||
context, records the caller context as its return target, resets its PC to codebase, and begins execution.
|
||
`op_0x2_exit_or_return_frame@0x417940` disposes only an adjacent child (`parent+1 == current`); the
|
||
non-adjacent service slots therefore survive return with their local banks allocated.
|
||
|
||
The VM now models those persistent preloaded frames, clears them on root-scene reset, and emits normal
|
||
call-script trace events when invoked. A focused worker proves repeated `0x08` calls restart code while
|
||
retaining locals. A second regression uses the exact SYSTEM4 ABI—`0x06(0x3381,0x1f)` followed by
|
||
`0x08(0x1f)`—to run the shipped MVSEEK and populate all four neighboring movement costs. FIELD's five
|
||
formerly skipped `0x08` instructions are handled, as are all indirect MVSEEK/ATSEEK/SETROUTE consumers.
|
||
The two native handlers are renamed/commented in Ghidra `/v2`; the program was saved.
|
||
Manual DEBUGMAP acceptance confirms that reachable-tile overlays and movement now work through this live
|
||
route and that player combat is reachable. Any remaining work at this frontier should start from the
|
||
concrete combat discrepancies observed in that run rather than from movement search or dispatch.
|
||
|
||
---
|
||
|
||
## Native walls backlog (targets for this loop)
|
||
|
||
- ~~**call-script dispatch**~~ — **SOLVED** (above): `call-script <id>` = raw SYS4INI file index.
|
||
- **decision→scene** — how `0x62ccf`/the decision selects the next `SCxxxx`. Now narrower: scenes load
|
||
via `call-script`/the same SYS4INI-index loader, so the open question is only where the decision
|
||
value is turned into a scene *id* (a caller of SCJUMP; re-aimed away from `u00428010`).
|
||
- ~~**op `0x60`**~~ — **SOLVED**: CRT `rand() % bound`, including the zero-bound script-error path.
|
||
- ~~**gfx command-buffer**~~ — **DONE** (the `0x212–0x21a` positioned-object subsystem = the rendering
|
||
drift): all 14 ops reversed + implemented against a host-side `GfxState`, and the missing INIT2 boot
|
||
state supplied via `--boot`. CGs render (screenshot-confirmed). See the op `0x215` finding + "The render
|
||
drift's SECOND half" above. The then-remaining `AE*` blend and cold-anchor work is resolved by the later
|
||
blend, geometry, animation, and retained-presentation sections.
|