Land SYSTEM4-rooted Godot boot

This commit is contained in:
gamer147
2026-07-20 18:13:32 -04:00
parent 9b35fc9e61
commit d4510242d6
18 changed files with 385 additions and 111 deletions

View File

@@ -75,13 +75,13 @@ highest-risk area of the port. This doc is the steering state; it feeds the A2b
AGE game with the same container — **the "scope" was just which SYS4INI section the scene lives in.**
(The old `play-bgm 5→BGM006` validation point was a mis-attribution — the real game plays BGM005.)
**System/global-id exception (identified 2026-07-10; not yet implemented).** Some SYSTEM4 loads use
**System/global-id exception (identified 2026-07-10; implemented).** Some SYSTEM4 loads use
the SYS4INI record's universal `raw_index` directly, including the two `@` placeholder records, rather
than a scene-local manifest index. `SYSTEM4.BIN` writes `G[0x69b]=0x337e`, then
`set-texture(G[0x69b], slot=0x11)`. SYS4INI `raw_index 0x337e` is `DATA1/SO001.AGF`, the shared
800×300 RGBA system-chrome sheet. The filtered `files[]` list omits placeholders, so treating `0x337e`
as a `files[]` position currently mis-resolves it to `SETROUTE.BIN`. This path needs a distinct
`raw_index → entry` lookup; the scene-manifest rule above remains correct for SC texture/voice ids.
800×300 RGBA system-chrome sheet. `ResourceMap.ResolveTexture` therefore tries the active script's
scene manifest first and then the distinct universal raw-id lookup; the scene-manifest rule above
remains correct for ordinary SC texture/voice ids.
*How we got here (condensed):* first confirmed `resId == file_number` via Frida load-order correlation
for SC0000's opening, but `file_number` is not globally unique so a per-scene "scope" was needed. A long
@@ -104,16 +104,18 @@ highest-risk area of the port. This doc is the steering state; it feeds the A2b
model approximated the game's immediate-mode blit-onto-slot-0 canvas. See `docs/phase-a-slice-plan.md`
(A2b section) for the implementation history and current retained-object model.
**Current system-chrome shortcut/gap (confirmed 2026-07-10).** `Main --boot` executes only
`INITCONFIG/INIT2/INIT` through `CaptureHost` and copies their globals into the scene VM; it does not
replay SYSTEM4's graphics side effects through `GodotAdvHost`. `convert_agf.py --scene SC0000` also
converts only SC0000's manifest, so `build/textures/SO001.BMP` is absent. `CALLBACK_WINDOW.BIN` expects
slot `0x11` to already contain SO001, draws the 800×227 textbox from `(0,0)`, and crops the lower-right
buttons from the same sheet. In the port slot 17 is unpopulated, so retained handle `0xd2f0` resolves as
a colored surfaceless object and the compositor draws the observed opaque black fill. A temporary decode
verified SO001 is 800×300, 32-bpp, with substantial per-pixel alpha; the current rasterizer already
consumes source alpha. The missing prerequisites are system-asset resolution/conversion and retained
slot initialization, not new textbox drawing or button interaction.
**System chrome ownership (resolved 2026-07-20).** The normal Godot entry now runs SYSTEM4 as the live
root, so its SO000/SO001 loads, ADV-layout definitions, and retained slot initialization execute through
`GodotAdvHost` before TITLE and child scenes. Asset lookup decodes VFS-owned AGF bytes directly and does
not depend on a scene-limited converted BMP. `CALLBACK_WINDOW.BIN` consequently inherits slot `0x11`
with SO001 and can crop its textbox/buttons normally. The explicit `--scene SC0000 --boot` diagnostic
still injects the same known inherited layout and surface state because it intentionally bypasses
SYSTEM4; that shortcut is no longer the shipped/default route.
The active manifest is frame-local, not fixed to the root scene: every VM call frame brackets host work
with its script context. `set-texture` resolves the local id at load time and retains the normalized raw
catalog id in the graphics surface, so that surface remains stable after a nested helper returns or a
sibling script becomes active. Voice, SFX, and movie calls likewise resolve against the executing frame.
4. **Audio.** **✅ WIRED (2026-07-06), VFS bytes complete (2026-07-11).** `IHost.PlayBgm/PlayVoice` +
VM dispatch (`play-bgm` 0xbf / `play-voice` 0xc4, both argc 1); `ResourceMap.ReadAudio` opens the
resolved catalog entry through `IAssetStore`; `GodotAdvHost` passes the bytes to `Main`'s players

View File

@@ -245,9 +245,9 @@ optionally calls `LOGO.BIN` and `OP.BIN`, calls the one-op `INIT.BIN`, and enter
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;
the Godot `--boot` path reaches the complete list indirectly because it executes `INIT2` with call-script
resolution enabled.
`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`.
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:
@@ -262,6 +262,11 @@ SYS4INI id `0x22` (`SC0000.BIN`) when the mapping is zero, and executes computed
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.
@@ -852,18 +857,26 @@ Ghidra functions renamed + plate-commented, saved).
(`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** (`local_2c`: 0 opaque, 1 alpha
`SRCALPHA/INVSRCALPHA`, 2/3 additive/special for glow/flash) and passes a modulation color/alpha to the
device draw. Slice A ports the **alpha** path (fades); additive (glow) is deferred (its `local_2c` source
field is `obj+0x30`, the value written by op `0x203`. Mode 1 uses packed ARGB alpha as opacity and RGB
as multiplicative D3D modulation. For a **textured mode-0** object, preserved `0xffffffff` is opaque
- **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. Modes 2/3 remain separately scoped beyond the completed mode-1 path, and
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.
**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 6-8% object opacity;
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
@@ -908,8 +921,8 @@ 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 now uses native alpha opacity plus RGB
modulation. `draw-string 0x204`/`0x7a` remains a separate dependency.
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
@@ -1004,13 +1017,13 @@ AE001H, exactly matching the reported white pulse. Follow-up native dataflow clo
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/INVSRCALPHA. AE001H therefore cycles
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 alpha regressions cover the
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

View File

@@ -387,9 +387,9 @@ The handler clears the map embedded at retained-gfx owner+0x408, resets its coun
- **evidence:** Ghidra handler 0x4228d0 packs operands 4/5 and calls worker 0x47ea00(handle,delay,duration,packed). Consumer 0x472f00: shared start +0x34; color delay/duration +0x38/+0x4c; current/target +0x60/+0x64; frame clock retained-gfx owner+0xb550 (EngineCtx+0x51b64); bytewise integer LERP; natural or owner+0xb55c (EngineCtx+0x51b70) forced completion. /v2 annotated and saved 2026-07-10.
### 0x203 `gfx-draw-color` (gfx-draw-color, argc 4)
- **summary:** 0x203 (handle)(mode)(alpha)(color) — worker stores the D3D blend selector at obj+0x30 and STATIC packed color at obj+0x60; the handler's ctx+0x53d88 write is the generic 9-dword instruction length. Negative alpha/RGB preserve current static bytes. Mode 0 is the opaque textured path: preserved 0xffffffff is identity (the alpha byte is not tint strength); mode 1 is SRCALPHA/INVSRCALPHA with ARGB alpha opacity and multiplicative RGB modulation; mode 2 is the 0x223 transition-source identity path. Surfaceless mode-0 fill consumption remains a distinct case.
- **summary:** 0x203 (handle)(mode)(alpha)(color) — worker stores the D3D blend selector at obj+0x30 and STATIC packed color at obj+0x60; the handler's ctx+0x53d88 write is the generic 9-dword instruction length. Negative alpha/RGB preserve current static bytes. Mode 0 is the default textured path: preserved 0xffffffff is identity (the alpha byte is not tint strength). Mode 1 is SRCALPHA/ONE additive glow with ARGB alpha scaling the source contribution and RGB providing multiplicative modulation. Mode 2 conditionally forces ONE/ZERO for the selected render target and is used by 0x223 transition sources; mode 3 selects a subtractive special path. Surfaceless mode-0 fill consumption remains distinct.
- **grounding:** source=investigation, confidence=high
- **evidence:** Ghidra handler 0x4229a0; negative operands read current obj+0x60, then worker 0x47e9b0 stores op2 at obj+0x30 and ARGB at +0x60. gfx_object_composite call-site 0x47f78f passes +0x30/+0x60 directly to gfx_object_blit_d3d9; mode 1 sets D3DRS SRCALPHA/INVSRCALPHA and the packed color is the device draw modulation. Mode 2 transition setup and synchronized pixels prove 0xffffffff is identity, not solid white. SC0000 page 14 adds the mode-0 endpoint proof: after the EV052CA->EV052DA 0x223 crossfade, 0x203@0x12478 restores the base CG to mode 0 with preserved 0xffffffff; native keeps EV052DA visible while the port's tint-strength interpretation turns every texel white.
- **evidence:** Ghidra handler 0x4229a0; negative operands read current obj+0x60, then worker 0x47e9b0 stores op2 at obj+0x30 and ARGB at +0x60. gfx_object_composite call-site 0x47f78f passes +0x30/+0x60 directly to gfx_object_blit_d3d9. Correct D3D9 constants at 0x4774c0 prove mode 1 writes SRCBLEND=5/SRCALPHA and DESTBLEND=2/ONE, not INVSRCALPHA. TITLE draws two opaque-black, no-colorkey SO022 flame sprites with mode 1; additive composition removes black and preserves the blue glow, verified in a windowed capture. Mode 2 transition setup and synchronized pixels prove 0xffffffff is identity, not solid white. SC0000 page 14 adds the mode-0 endpoint proof: after the EV052CA->EV052DA 0x223 crossfade, 0x203@0x12478 restores the base CG to mode 0 with preserved 0xffffffff; native keeps EV052DA visible while the port's tint-strength interpretation turns every texel white.
### 0x208 `get-texture-size` (get-texture-size, argc 3)
- **summary:** 0x208 (slot)(out_w)(out_h) — writes the loaded texture's width/height into two output globals; keystone for bytecode-computed sprite/bg geometry (SC0000 label_12649)
@@ -522,9 +522,9 @@ This is a target-pixel operation, not retained-object teardown. It invokes IDire
- **evidence:** Native /v2 decompile: worker stores period at obj+0x230, frame_count at +0x238, columns at +0x23c. Interpolator computes ((now-start)/period)%frame_count, then offsets both source-rect X bounds by rect_width*(frame%columns) and Y bounds by rect_height*(frame/columns). SC0000 uses (100,8,4) with AE001H's eight 200x200 cells in a 4x2 800x400 sheet.
### 0x232 `u00421EF0` (u00421EF0, argc 4)
- **summary:** 0x232 anim-color (handle)(period)(alpha)(color): ping-pong the temporary packed ARGB passed to the normal object blit. Handler resolves negative alpha/RGB from static color obj+0x60 and clamps alpha above 255. Blend selector obj+0x30 is unchanged: mode 0 keeps default opaque blending (animated alpha is inert; RGB is vertex modulation), while mode 1 consumes ARGB alpha as opacity. Fresh static color is 0xffffffff. The C# VM resolves sentinels and consumes sampled ARGB through the unchanged mode-specific path. See docs/engine-re.md §SC0000 anim cluster.
- **summary:** 0x232 anim-color (handle)(period)(alpha)(color): ping-pong the temporary packed ARGB passed to the normal object blit. Handler resolves negative alpha/RGB from static color obj+0x60 and clamps alpha above 255. Blend selector obj+0x30 is unchanged: mode 0 keeps default blending (animated alpha is inert; RGB is vertex modulation), while mode 1 uses sampled ARGB alpha as the SRCALPHA scale for additive composition. Fresh static color is 0xffffffff. The C# VM resolves sentinels and consumes sampled ARGB through the unchanged mode-specific path. See docs/engine-re.md §SC0000 anim cluster.
- **grounding:** source=investigation, confidence=high
- **evidence:** Ghidra /v2: gfx_op_0x232_anim_color@0x423c30 resolves sentinels then calls gfx_worker_anim_color@0x47ef50; gfx_object_anim_interpolate@0x473ed0 samples static obj+0x60 toward target obj+0x240 into a temporary packed color; gfx_object_composite@0x47f650 passes that color plus unchanged selector obj+0x30 to gfx_object_blit_d3d9@0x4774c0. Blit mode 0 enables no alpha blend and passes RGB as modulation; mode 1 enables SRCALPHA/INVSRCALPHA. gfx_object_init_default@0x472810 initializes obj+0x60=0xffffffff. SC0000 0x1a0e (handle,1200,224,-1) is therefore 0xffffffff<->0xe0ffffff with inert alpha and identity RGB: no visible pulse. C# regressions cover exact AE001H visual invariance, negative-RGB preservation, mode-0 RGB modulation, and mode-1 alpha opacity.
- **evidence:** Ghidra /v2: gfx_op_0x232_anim_color@0x423c30 resolves sentinels then calls gfx_worker_anim_color@0x47ef50; gfx_object_anim_interpolate@0x473ed0 samples static obj+0x60 toward target obj+0x240 into a temporary packed color; gfx_object_composite@0x47f650 passes that color plus unchanged selector obj+0x30 to gfx_object_blit_d3d9@0x4774c0. Blit mode 0 leaves the default path and passes RGB as modulation; mode 1 sets SRCALPHA/ONE additive composition. gfx_object_init_default@0x472810 initializes obj+0x60=0xffffffff. SC0000 0x1a0e (handle,1200,224,-1) is therefore 0xffffffff<->0xe0ffffff with inert alpha and identity RGB in mode 0: no visible pulse. C# regressions cover exact AE001H visual invariance, negative-RGB preservation, mode-0 RGB modulation, and mode-1 additive scaling.
### 0x234 `anim-start` (anim-start, argc 5)
- **summary:** (handle)(period_ms)(axis_x)(axis_y)(axis_z) — configure cyclic rotation. Worker stores period obj+0x228, start obj+0x214=0, and float axis obj+0x244; each frame uses integer degrees floor(((now-start)%period)*360/period). gfx_object_composite right-multiplies this separately anchored transform after the one-shot scale/rotation/translation product, so cyclic rotation also rotates the translation vector.

View File

@@ -90,6 +90,15 @@ coverage is 100% for all 23 data initializers, CALCARR, and TUNE; the remaining
SYSTEM4-rooted path visible and interactive in Godot, then investigate only the gaps actually reached on
that route instead of treating every static gap as a prerequisite.
**Godot root landing (2026-07-20).** The no-argument Godot/run-godot path now starts SYSTEM4 directly and
does not apply the direct-SC0000 layout/surface bootstrap or the diagnostic `--boot` prefix. A windowed run
reaches and renders TITLE using SYSTEM4-owned retained state. A real-script integration test drives TITLE's
Game Start input, GAMESTART's release-gated default selection, and proves the same VM enters SC0000 through
SYSTEM4's computed resource id `G[0x699]=0x22`; `G[0]=1` and the script-produced ADV-chrome flag
`G[0x6c1]=1` are present at that boundary. `--scene SC0000 --boot` remains available only as the explicit
single-scene diagnostic harness. This lands the boot/title/New Game entry half of B1B3; proving a completed
scene return plus boundary cleanup still belongs to B1 completion.
## Stage B1 — Persistent session and scene coordinator
Replace the single-SC0000-root assumption with an application-owned session that runs SYSTEM4 as its root.

View File

@@ -132,21 +132,24 @@ at 2.5M lines). All observe-only → parity preserved; all on `run`/`play`/`swee
- **Godot** accepts **`--trace-histogram <file>`** — profile the **real** run (headless flow diverges because
`wait-for-input` is a no-op there; the real run to page 1 is ~562 steps with **0** sleeps vs headless's 2M
steps / 493k sleeps). Dumped when the scene ends or the window closes. e.g.
`godot --path godot -- --boot --shot out/p1.png --trace-histogram out/hist.txt`.
`godot --path godot -- --scene SC0000 --boot --shot out/p1.png --trace-histogram out/hist.txt`.
**Godot frontend** (`S:/Godot/Godot_v4.7…`; project = `godot/`). Toolchain: `godot --headless --path godot
--import``dotnet build godot/Himegari.csproj``godot [--headless] --path godot [-- <userargs>]`.
Plays the real bytecode with call-script execution on (subroutines run live). `--headless` can't render
texture ops (no GPU context) — run windowed for real scenes. User args (after `--`):
- `--scene <NAME>` — which scene to play (default `SC0000`), e.g. `--scene SC0240` (executes 29 nested subroutines).
Plays the real bytecode with call-script execution on (subroutines run live). A no-argument launch, including
`run-godot.ps1`, starts the persistent `SYSTEM4.BIN` root and reaches TITLE naturally. `--headless` can't
render texture ops (no GPU context) — run windowed for real scenes. User args (after `--`):
- `--scene <NAME>` — override the default `SYSTEM4` root with a direct diagnostic scene, e.g. `--scene SC0240` (executes 29 nested subroutines).
- `--selftest` — headless; runs a **synthesized** scene through the thread/suspend/`CallDeferred` plumbing and asserts it matches a live headless run (full handling; no vm0/frozen golden). Exits.
- `--seed 0xADDR=VAL` (repeatable) — seed initial global state, e.g. `--seed 0xa57=1` unlocks Lily's form-A voiced dialogue.
- `--boot`run SYSTEM4's state prefix (`INITCONFIG/INIT2/INIT`) via `GameSession` before the scene, so scene-assumed boot state (chiefly INIT2's gfx handle array) is present. **Needed for the gfx CGs to render** (without it the opening event CGs collapse/drift). e.g. `godot --path godot -- --boot`.
- `--boot`direct-scene diagnostic only: with `--scene <non-SYSTEM4>`, run the old
`INITCONFIG/INIT2/INIT` state prefix before that isolated scene. The normal SYSTEM4-rooted launch neither
needs nor applies it. e.g. `godot --path godot -- --scene SC0000 --boot`.
- `--shot <png> [--shot-page N]` — capture page N to a PNG then quit (dev screenshot). At scene end it also prints the call-scripts executed as nested frames.
- `--shot-sequence <dir> [--frames N]` — dump one PNG per rendered frame (`frame_0000.png…`, default N=180 ≈ 3s @60fps) then quit, auto-advancing past input waits. Verifies time-based retained effects and publication boundaries as distinct frames, which a single `--shot` cannot. CPU/IO-heavy by design (a PNG every frame); a dev diagnostic, not a normal run. e.g. `godot --path godot -- --boot --shot-sequence out/seq --frames 300`.
- `--shot-sequence <dir> [--frames N]` — dump one PNG per rendered frame (`frame_0000.png…`, default N=180 ≈ 3s @60fps) then quit, auto-advancing past input waits. Verifies time-based retained effects and publication boundaries as distinct frames, which a single `--shot` cannot. CPU/IO-heavy by design (a PNG every frame); a dev diagnostic, not a normal run. e.g. `godot --path godot -- --scene SC0000 --boot --shot-sequence out/seq --frames 300`.
- `--sleep-scale <f>` — multiply every explicit `sleep` (op 0xc8) duration by `f` (default 1.0). This stretches only script-authored sleep holds; it does not slow ordinary opcode bursts or replace `0x20c`/`0x21c` presentation pacing. Debug-only; leave at 1.0 for real playback.
- `--speed <f>` — scale sleeps and retained presentation clocks without throttling ordinary opcode bursts or auto-advancing input waits. Values 0.058 are accepted; `--speed 0.25` is useful for transform inspection, while 1.0 is normal playback.
- `--gfx-log <file>`**compositor + op diagnostic** (the tool that root-caused the grey background). Logs, per rendered frame, only the objects whose draw outcome **CHANGED** (drawn↔skip↔gone, resId, resolved file, `slot`, `src`/`dst`, `op`acity, `tintStr`ength) — quiet until something actually changes, so the exact frame a layer drops out (and why) stands out. Also traces every `set-texture`/`create-texture` **slot assignment** (via `GodotAdvHost.TraceOps`). Works live or with `--shot-sequence`. Use it before theorising about layering/blend/geometry: it showed the grey BG = the slot-selecting globals resolving to 0 → every texture collapsing into slot 0 (see engine-re.md §"Grey-background root cause"). e.g. `godot --path godot -- --boot --gfx-log out/gfx.log` then click to the bad page.
- `--gfx-log <file>`**compositor + op diagnostic** (the tool that root-caused the grey background). Logs, per rendered frame, only the objects whose draw outcome **CHANGED** (drawn↔skip↔gone, resId, resolved file, `slot`, `src`/`dst`, `op`acity, `tintStr`ength) — quiet until something actually changes, so the exact frame a layer drops out (and why) stands out. Also traces every `set-texture`/`create-texture` **slot assignment** (via `GodotAdvHost.TraceOps`). Works live or with `--shot-sequence`. Use it before theorising about layering/blend/geometry: it showed the grey BG = the slot-selecting globals resolving to 0 → every texture collapsing into slot 0 (see engine-re.md §"Grey-background root cause"). e.g. `godot --path godot -- --scene SC0000 --boot --gfx-log out/gfx.log` then click to the bad page.
Matrix-channel outcomes also include `base`, `anchor`, projected `dst`, sampled `scale`/`trans`, and
one-shot-plus-cyclic `rot`ation angles. Active op-`0x202` outcomes include packed `color=current->target`
and `colorProgress`, synchronized with the same frame/clock in `--timeline-log`. Parent directories are
@@ -155,7 +158,7 @@ texture ops (no GPU context) — run windowed for real scenes. User args (after
- `--timeline-log <jsonl>` — diagnostic-only synchronized event stream for a real Godot run. Records every
executed script byte offset/opcode, virtual time/frame, VM state changes (`running`, `sleep`, `input-wait`,
`halted`), BGM events, and changed visible-object compositor outcomes in one ordered JSONL file. Combine with
`--boot --shot-sequence ... --gfx-log ...` to distinguish control-flow stalls from retained-object/compositor
`--scene SC0000 --boot --shot-sequence ... --gfx-log ...` to distinguish control-flow stalls from retained-object/compositor
failures at an exact bytecode boundary. Relative output paths are project-relative (`godot/`).
**Godot page locator:** every normal run recreates `build/page-map-<SCENE>.jsonl`, adding one record per

View File

@@ -9,16 +9,19 @@ public class CallScriptTests
{
// Opcodes (from build/opcodes.json): exit=0x2, call-script=0x3(argc1), mov=0x55(argc2).
// Operand types: imm=0, global-int=3, local-int=9.
private const uint OP_EXIT = 0x2, OP_CALLSCRIPT = 0x3, OP_MOV = 0x55;
private const uint OP_EXIT = 0x2, OP_CALLSCRIPT = 0x3, OP_MOV = 0x55, OP_SETTEXTURE = 0x1f9;
private sealed class NullHost : IHost
private class NullHost : IHost
{
public virtual void EnterScriptContext(string scriptName) { }
public virtual void ExitScriptContext() { }
public virtual long ResolveTextureResourceId(long resourceId) => resourceId;
public void ShowText(int o, string t) { }
public void WaitForInput() { }
public void Sleep(long duration) { }
public void FrameYield() { }
public void CreateTexture(int s, int w, int h) { }
public void SetTexture(long r, int s) { }
public virtual void SetTexture(long r, int s) { }
public void DrawTexture(int s, int sx, int sy, int w, int h, int dx, int dy) { }
public (int Width, int Height) GetTextureSize(int s) => (0, 0);
public void PlayBgm(long id) { }
@@ -32,6 +35,29 @@ public class CallScriptTests
public Script? GetById(long id) => _m.TryGetValue(id, out var s) ? s : null;
}
private sealed class ContextHost : NullHost
{
private readonly Stack<string> _contexts = new();
public List<string> Events { get; } = new();
public List<(long ResourceId, int Slot)> Textures { get; } = new();
public override void EnterScriptContext(string scriptName)
{
_contexts.Push(scriptName);
Events.Add($"enter:{scriptName}");
}
public override void ExitScriptContext()
{
Events.Add($"exit:{_contexts.Pop()}");
}
public override long ResolveTextureResourceId(long resourceId)
=> resourceId + (_contexts.Peek() == "CALLEE" ? 700 : 70);
public override void SetTexture(long resourceId, int slot) => Textures.Add((resourceId, slot));
}
// Build a Script from raw dwords via the real loader (guarantees identical decode).
private static Script Asm(OpcodeTable t, string name, params uint[] body)
{
@@ -105,4 +131,25 @@ public class CallScriptTests
vm.Run();
Assert.Equal(1, vm.Globals[0x20]); // caller's local 0 unchanged by callee's local 0
}
[Fact]
public void ScriptLocalTextureIdsFollowTheActiveNestedFrame()
{
var t = Table();
var callee = Asm(t, "CALLEE",
OP_SETTEXTURE, 0, 7, 0, 2, 0, uint.MaxValue,
OP_EXIT);
var caller = Asm(t, "CALLER",
OP_SETTEXTURE, 0, 7, 0, 1, 0, uint.MaxValue,
OP_CALLSCRIPT, 0, 5,
OP_SETTEXTURE, 0, 7, 0, 3, 0, uint.MaxValue,
OP_EXIT);
var host = new ContextHost();
var vm = new VirtualMachine(caller, t, host, null, new MapProvider(new() { [5] = callee }));
vm.Run();
Assert.Equal(new[] { (77L, 1), (707L, 2), (77L, 3) }, host.Textures);
Assert.Equal(new[] { "enter:CALLER", "enter:CALLEE", "exit:CALLEE", "exit:CALLER" }, host.Events);
}
}

View File

@@ -0,0 +1,111 @@
using Age.Engine.Diagnostics;
using Age.Engine.Sys4;
using Age.Engine.Vm;
using Xunit;
public class NaturalBootIntegrationTests
{
private sealed class ReachedSc0000Exception : Exception { }
private sealed class StopAtSc0000Sink : ITraceSink
{
public readonly List<string> Entered = new();
public Action<string>? OnEnter;
public bool TracingSteps => false;
public void Emit(in TraceEvent e)
{
if (e.Kind == TraceEventKind.FrameEnter && e.Name != null)
{
Entered.Add(e.Name);
OnEnter?.Invoke(e.Name);
if (e.Name.Equals("SC0000.BIN", StringComparison.OrdinalIgnoreCase))
throw new ReachedSc0000Exception();
}
}
}
private sealed class NewGameInputHost : RecordingHost
{
public VirtualMachine Vm = null!;
private long _now;
public int TitlePollSleeps;
private bool _inGameStart;
private int _gameStartPollSleeps;
public override long InputClockMilliseconds => _now;
public void BeginGameStart()
{
_inGameStart = true;
_gameStartPollSleeps = 0;
Vm.UpdateMouseButtonState(0x1, false);
Vm.UpdateInputCallbackState(4, false);
Vm.QueueInputCallback(10);
}
public override void Sleep(long duration)
{
base.Sleep(duration);
_now += Math.Max(1, duration);
if (duration > 1) return;
// TITLE's first menu entry is Game Start. Hold the pointer over its native 800x600
// rectangle, then provide one complete primary-button edge to its raw input callback.
int polls = _inGameStart ? ++_gameStartPollSleeps : ++TitlePollSleeps;
int cycle = polls % 200;
if (_inGameStart && polls <= 200) return;
if (cycle == 1)
{
int input = _inGameStart && polls <= 400 ? 0 : 4;
if (!_inGameStart)
{
Vm.UpdatePointer(400, 300);
Vm.UpdateMouseButtonState(0x1, true);
}
Vm.UpdateInputCallbackState(input, true);
}
// TITLE polls its registered mouse callback every 50 ms and activates on the release edge.
// Keep the button down through one callback, then release it before the next.
else if (cycle == 120)
{
int input = _inGameStart && polls <= 400 ? 0 : 4;
if (!_inGameStart) Vm.UpdateMouseButtonState(0x1, false);
Vm.UpdateInputCallbackState(input, false);
Vm.QueueInputCallback(10); // native/main-thread release callback unlocks the menu input gate
}
}
}
[Fact]
public void System4Root_NewGameSelectionNaturallyCallsSc0000()
{
var table = OpcodeTableJson.Load(Paths.OpcodesJson);
var scripts = Sys4ScriptProvider.Load(table);
var host = new NewGameInputHost();
var sink = new StopAtSc0000Sink();
var vm = new VirtualMachine(scripts.RequireByName("SYSTEM4.BIN"), table, host,
new VmOptions(MaxSteps: 5_000_000), scripts, sink);
host.Vm = vm;
sink.OnEnter = name =>
{
if (name.Equals("GAMESTART.BIN", StringComparison.OrdinalIgnoreCase)) host.BeginGameStart();
};
var exception = Record.Exception(() => vm.Run());
Assert.True(exception is ReachedSc0000Exception,
$"halt={vm.HaltReason}; title_sleeps={host.TitlePollSleeps}; entered={string.Join(",", sink.Entered)}");
Assert.Equal(new[]
{
"SYSTEM4.BIN", "INITCONFIG.BIN", "INIT2.BIN",
}, sink.Entered.Take(3));
Assert.Contains("TITLE.BIN", sink.Entered);
Assert.Contains("GAMESTART.BIN", sink.Entered);
Assert.Contains("UNITECH.BIN", sink.Entered);
Assert.Contains("CALCARR.BIN", sink.Entered);
Assert.Equal("SC0000.BIN", sink.Entered[^1]);
Assert.Equal(1, vm.Globals.GetValueOrDefault(0));
Assert.Equal(0x22, vm.Globals.GetValueOrDefault(0x699));
Assert.Equal(1, vm.Globals.GetValueOrDefault(0x6c1));
}
}

View File

@@ -94,7 +94,7 @@ public class RenderObjectBlendTests
}
[Fact]
public void Mode1_UsesArgbAlphaAsOpacityAndRgbAsMultiplicativeModulation()
public void Mode1_UsesAdditiveBlendWithArgbSourceScaleAndRgbModulation()
{
var g = WithVisibleObject(0x100, resId: 5, colorKey: -1);
g.SetStaticObjectColorResolved(0x100, 1, 0x40, 0x80ff40);
@@ -103,7 +103,7 @@ public class RenderObjectBlendTests
Assert.Equal(0, ro.TintStrength);
Assert.Equal(0x80ff40, ro.Tint);
Assert.True(ro.MultiplyTint);
Assert.Equal(BlendKind.Alpha, ro.Blend);
Assert.Equal(BlendKind.Additive, ro.Blend);
}
[Fact]

View File

@@ -99,6 +99,22 @@ public class SoftwareAffineRasterizerTests
Assert.Equal(new byte[4], hidden);
}
[Fact]
public void BlitRgba_AdditiveMakesBlackTransparentAndAddsScaledColor()
{
var identity = new Affine2D(1, 0, 0, 1, 0, 0);
byte[] background = { 40, 50, 60, 255 };
byte[] black = { 0, 0, 0, 255 };
SoftwareAffineRasterizer.BlitRgba(background, 1, 1, black, 1, 1, 0, 0, 1, 1,
identity, 0xffffff, 0, 1, multiplyTint: true, blend: BlendKind.Additive);
Assert.Equal(new byte[] { 40, 50, 60, 255 }, background);
byte[] blueGlow = { 16, 32, 200, 128 };
SoftwareAffineRasterizer.BlitRgba(background, 1, 1, blueGlow, 1, 1, 0, 0, 1, 1,
identity, 0xffffff, 0, 0.5f, multiplyTint: true, blend: BlendKind.Additive);
Assert.Equal(new byte[] { 43, 57, 109, 255 }, background);
}
// Pre-fast-path affine algorithm retained here as an independent differential oracle.
private static void ReferenceBlit(byte[] dst, int dstW, int dstH, byte[] src, int srcW,
int srcX, int srcY, int width, int height, Affine2D transform,

View File

@@ -15,6 +15,11 @@ public readonly record struct SurfaceRectFill(
public interface IHost
{
// Script-local resource ids resolve against the currently executing frame's SYS4INI section.
// Interactive hosts track this stack; headless hosts may keep the no-op/default identity behavior.
void EnterScriptContext(string scriptName) { }
void ExitScriptContext() { }
long ResolveTextureResourceId(long resourceId) => resourceId;
void ShowText(int offset, string text);
// Native ADV text subsystem: op 0x7a updates the selected layout's last 20-byte cursor record;
// op 0x204 rasterizes a string into a numbered surface before 0x1fb binds that surface.

View File

@@ -1,6 +1,5 @@
namespace Age.Engine.Model;
/// <summary>How an object's surface composites onto the canvas. Opaque = straight copy; Alpha = source-alpha
/// blend (fades). Additive (glow/flash, native blit mode 2/3) is a documented seam — NOT implemented in the
/// blend/transparency slice; see docs/superpowers/specs/2026-07-08-blend-transparency-design.md.</summary>
/// blend (fades); Additive = native SRCALPHA/ONE glow composition.</summary>
public enum BlendKind { Opaque, Alpha, Additive }

View File

@@ -665,10 +665,15 @@ public sealed class GfxState
{
var (a, r, g, b) = BlendMath.UnpackArgb(sampledColor);
tint = ((long)r << 16) | ((long)g << 8) | (long)b;
if (o.StaticColorMode == 1 || (o.StaticColorMode == 0 && o.OneShotColorBlend))
if (o.StaticColorMode == 1)
{
// Native mode 1 enables SRCALPHA/ONE additive blending and passes packed ARGB as
// D3D modulation. Black therefore contributes nothing (TITLE's SO022 flames),
// while the high byte scales the additive source contribution.
alpha = a; strength = 0; blend = BlendKind.Additive; multiplyTint = true;
}
else if (o.StaticColorMode == 0 && o.OneShotColorBlend)
{
// Native mode 1 enables SRCALPHA/INVSRCALPHA and passes packed ARGB as D3D
// modulation. Its high byte is opacity, not mode-0 tint/fill strength.
alpha = a; strength = 0; blend = BlendKind.Alpha; multiplyTint = true;
}
else if (o.StaticColorMode == 2)

View File

@@ -5,7 +5,8 @@ public static class SoftwareAffineRasterizer
{
public static void BlitRgba(byte[] dst, int dstW, int dstH, byte[] src, int srcW, int srcH,
int srcX, int srcY, int width, int height, Affine2D localToDest,
long tint, float tintStrength, float opacity, bool multiplyTint = false)
long tint, float tintStrength, float opacity, bool multiplyTint = false,
BlendKind blend = BlendKind.Alpha)
{
if (width <= 0 || height <= 0) return;
int istr = (int)(System.Math.Clamp(tintStrength, 0f, 1f) * 255);
@@ -15,7 +16,7 @@ public static class SoftwareAffineRasterizer
if (TryIntegerTranslation(localToDest, out int tx, out int ty))
{
BlitTranslated(dst, dstW, dstH, src, srcW, srcX, srcY, width, height,
tx, ty, tr, tg, tb, istr, ia, multiplyTint);
tx, ty, tr, tg, tb, istr, ia, multiplyTint, blend);
return;
}
if (!localToDest.TryInverse(out var inv)) return;
@@ -30,7 +31,7 @@ public static class SoftwareAffineRasterizer
int sr=multiplyTint ? src[si]*tr/255 : (src[si]*(255-istr)+tr*istr)/255;
int sg=multiplyTint ? src[si+1]*tg/255 : (src[si+1]*(255-istr)+tg*istr)/255;
int sb=multiplyTint ? src[si+2]*tb/255 : (src[si+2]*(255-istr)+tb*istr)/255;
Blend(dst,di,sr,sg,sb,sa);
Blend(dst,di,sr,sg,sb,sa,blend);
}
}
@@ -67,7 +68,8 @@ public static class SoftwareAffineRasterizer
private static void BlitTranslated(byte[] dst, int dstW, int dstH, byte[] src, int srcW,
int srcX, int srcY, int width, int height, int tx, int ty,
int tr, int tg, int tb, int istr, int ia, bool multiplyTint)
int tr, int tg, int tb, int istr, int ia, bool multiplyTint,
BlendKind blend)
{
int x0 = System.Math.Max(0, tx), y0 = System.Math.Max(0, ty);
int x1 = (int)System.Math.Min(dstW, (long)tx + width);
@@ -84,7 +86,7 @@ public static class SoftwareAffineRasterizer
int sr = multiplyTint ? src[si] * tr / 255 : (src[si] * (255 - istr) + tr * istr) / 255;
int sg = multiplyTint ? src[si + 1] * tg / 255 : (src[si + 1] * (255 - istr) + tg * istr) / 255;
int sb = multiplyTint ? src[si + 2] * tb / 255 : (src[si + 2] * (255 - istr) + tb * istr) / 255;
Blend(dst, di, sr, sg, sb, sa);
Blend(dst, di, sr, sg, sb, sa, blend);
}
}
}
@@ -108,7 +110,12 @@ public static class SoftwareAffineRasterizer
x1=System.Math.Min(dw,(int)System.Math.Ceiling(System.Math.Max(System.Math.Max(a.X,b.X),System.Math.Max(c.X,d.X))));
y1=System.Math.Min(dh,(int)System.Math.Ceiling(System.Math.Max(System.Math.Max(a.Y,b.Y),System.Math.Max(c.Y,d.Y))));
}
private static void Blend(byte[] d,int i,int r,int g,int b,int a){
private static void Blend(byte[] d,int i,int r,int g,int b,int a,BlendKind blend=BlendKind.Alpha){
if(blend==BlendKind.Additive){
d[i]=(byte)System.Math.Min(255,d[i]+r*a/255);d[i+1]=(byte)System.Math.Min(255,d[i+1]+g*a/255);
d[i+2]=(byte)System.Math.Min(255,d[i+2]+b*a/255);d[i+3]=(byte)System.Math.Min(255,d[i+3]+a);
return;
}
d[i]=(byte)((r*a+d[i]*(255-a))/255);d[i+1]=(byte)((g*a+d[i+1]*(255-a))/255);
d[i+2]=(byte)((b*a+d[i+2]*(255-a))/255);d[i+3]=(byte)System.Math.Min(255,d[i+3]+a);
}

View File

@@ -34,6 +34,14 @@ public sealed class ResourceMap
entry.Name.EndsWith(".AGF", StringComparison.OrdinalIgnoreCase) ? entry : null;
}
/// <summary>Resolve an already-normalized raw catalog id without applying a scene section base.</summary>
public AssetEntry? ResolveRawTexture(long rawId)
{
var entry = _catalog.ResolveRaw(rawId);
return entry is { IsPlaceholder: false } &&
entry.Name.EndsWith(".AGF", StringComparison.OrdinalIgnoreCase) ? entry : null;
}
/// <summary>Decode an AGF directly from loose-first VFS bytes.</summary>
public RgbaImage DecodeTexture(AssetEntry entry) => AgfDecoder.Decode(_store, entry);

View File

@@ -341,32 +341,48 @@ public sealed class VirtualMachine
previousRawInputFrame = _rawInputFrame;
}
var prev = _cur; _cur = frame; _depth++;
_sink.Emit(TraceEvent.FrameEnter(frame.Script.Name, _depth, cause, callId));
var outcome = FrameOutcome.RanOff;
int pc = frame.Pc;
while (pc >= 0 && pc < frame.Script.Instructions.Count)
bool hostContextEntered = false;
try
{
if (Steps >= _o.MaxSteps) { HaltReason ??= "STEP-LIMIT"; outcome = FrameOutcome.Halted; break; }
Steps++;
if (_sink.TracingSteps) _sink.Emit(TraceEvent.Step(pc, frame.Script.Instructions[pc], _depth));
int next = Step(frame.Script.Instructions[pc], pc);
_host.FrameYield();
if (next == FRAME_RETURN) { outcome = FrameOutcome.Returned; break; }
if (next == HALT) { outcome = FrameOutcome.Halted; break; }
pc = next;
_host.EnterScriptContext(frame.Script.Name);
hostContextEntered = true;
_sink.Emit(TraceEvent.FrameEnter(frame.Script.Name, _depth, cause, callId));
var outcome = FrameOutcome.RanOff;
int pc = frame.Pc;
while (pc >= 0 && pc < frame.Script.Instructions.Count)
{
if (Steps >= _o.MaxSteps) { HaltReason ??= "STEP-LIMIT"; outcome = FrameOutcome.Halted; break; }
Steps++;
if (_sink.TracingSteps) _sink.Emit(TraceEvent.Step(pc, frame.Script.Instructions[pc], _depth));
int next = Step(frame.Script.Instructions[pc], pc);
_host.FrameYield();
if (next == FRAME_RETURN) { outcome = FrameOutcome.Returned; break; }
if (next == HALT) { outcome = FrameOutcome.Halted; break; }
pc = next;
}
_sink.Emit(TraceEvent.FrameExit(frame.Script.Name, _depth, outcome.ToString()));
return outcome;
}
_sink.Emit(TraceEvent.FrameExit(frame.Script.Name, _depth, outcome.ToString()));
lock (_interactiveLock)
finally
{
if (cause == FrameCause.CallScript)
_interactiveFrame = previousInteractiveFrame?.Hotspots.Armed == true
? previousInteractiveFrame : null;
else if (ReferenceEquals(_interactiveFrame, frame))
_interactiveFrame = null;
if (ReferenceEquals(_rawInputFrame, frame)) _rawInputFrame = previousRawInputFrame;
lock (_interactiveLock)
{
if (cause == FrameCause.CallScript)
_interactiveFrame = previousInteractiveFrame?.Hotspots.Armed == true
? previousInteractiveFrame : null;
else if (ReferenceEquals(_interactiveFrame, frame))
_interactiveFrame = null;
if (ReferenceEquals(_rawInputFrame, frame)) _rawInputFrame = previousRawInputFrame;
}
try
{
if (hostContextEntered) _host.ExitScriptContext();
}
finally
{
_cur = prev; _depth--;
}
}
_cur = prev; _depth--;
return outcome;
}
private bool ServiceHotspotCallback()
@@ -951,12 +967,17 @@ public sealed class VirtualMachine
Gfx.ClearSurface((int)Read(a[0]));
_host.CreateTexture((int)Read(a[0]), (int)Read(a[1]), (int)Read(a[2])); return pc + 1;
case "set-texture": // 0x1f9 (resId)(slot)(colorkey) — load a file into the slot's surface
{
long requestedResourceId = Read(a[0]);
long resolvedResourceId = _host.ResolveTextureResourceId(requestedResourceId);
if (_diagSetTexture) // AGE_DIAG_SETTEX: log the SLOT operand source (literal vs which global) — grey-BG slot dig
System.Console.Error.WriteLine($"[settex] resId=0x{Read(a[0]):x} slot={(int)Read(a[1])} " +
System.Console.Error.WriteLine($"[settex] resId=0x{requestedResourceId:x}->0x{resolvedResourceId:x} slot={(int)Read(a[1])} " +
$"slotOp=(type={a[1].Type} val=0x{a[1].Value:x}){(a[1].Type == 3 ? $" G[0x{a[1].Value:x}]" : "")}");
_host.ReleaseSurface((int)Read(a[1]));
Gfx.SetSurface((int)Read(a[1]), Read(a[0]), a.Count > 2 ? Read(a[2]) : 0);
_host.SetTexture(Read(a[0]), (int)Read(a[1])); return pc + 1; // host still tracks dims for get-texture-size
Gfx.SetSurface((int)Read(a[1]), resolvedResourceId, a.Count > 2 ? Read(a[2]) : 0);
_host.SetTexture(resolvedResourceId, (int)Read(a[1]));
return pc + 1; // host still tracks dims for get-texture-size
}
case "draw-texture": // 0x1fb (handle)(slot)(srcX)(srcY)(w)(h)(dstX)(dstY) — bind object -> surface + rect + pos
Gfx.BindDraw(Read(a[0]), (int)Read(a[1]), (int)Read(a[2]), (int)Read(a[3]),
(int)Read(a[4]), (int)Read(a[5]), (int)Read(a[6]), (int)Read(a[7]));

View File

@@ -12,10 +12,12 @@ public sealed class GodotAdvHost : IHost
{
private readonly Main _main;
private readonly ResourceMap _res;
private readonly string _scene; // e.g. "SC0000" — for section_base
private readonly string _rootScene;
private readonly object _scriptContextLock = new();
private readonly Stack<string> _scriptContexts = new();
private readonly object _imageLock = new();
private readonly Dictionary<int, RgbaImage?> _images = new(); // raw catalog id -> decoded pixels
private readonly Dictionary<int, long> _surfaceResources = new(); // surface slot -> scene/raw resource id
private readonly Dictionary<int, long> _surfaceResources = new(); // surface slot -> normalized raw catalog id
private readonly Dictionary<long, (RgbaImage Image, string Name, int RawIndex)> _movieFrames = new();
private readonly Dictionary<int, long> _movieBySurface = new();
private readonly HashSet<long> _completedMovies = new();
@@ -59,10 +61,33 @@ public sealed class GodotAdvHost : IHost
public GodotAdvHost(Main main, ResourceMap res, string scene, Age.Engine.Hosting.FrameClock clock,
PageLocatorState locator, GodotTimelineLog? timeline = null)
{
_main = main; _res = res; _scene = scene; _clock = clock;
_main = main; _res = res; _rootScene = scene; _clock = clock;
_locator = locator; _timeline = timeline;
}
private string CurrentScene
{
get { lock (_scriptContextLock) return _scriptContexts.TryPeek(out var scene) ? scene : _rootScene; }
}
public void EnterScriptContext(string scriptName)
{
string scene = System.IO.Path.GetFileNameWithoutExtension(scriptName).ToUpperInvariant();
lock (_scriptContextLock) _scriptContexts.Push(scene);
_timeline?.Event("script-context-enter", new() { ["scene"] = scene });
}
public void ExitScriptContext()
{
string? scene = null;
lock (_scriptContextLock)
if (_scriptContexts.TryPop(out var popped)) scene = popped;
if (scene != null) _timeline?.Event("script-context-exit", new() { ["scene"] = scene });
}
public long ResolveTextureResourceId(long resourceId)
=> _res.ResolveTexture(CurrentScene, resourceId)?.RawIndex ?? resourceId;
public void ShowText(int offset, string text)
{
Captured.Add((offset, text));
@@ -225,7 +250,7 @@ public sealed class GodotAdvHost : IHost
if (!_waitIndicators.TryGetValue(_activeWaitLayout, out config)) return null;
if (!_surfaceResources.TryGetValue(config.SurfaceSlot, out resourceId)) return null;
}
var asset = _res.ResolveTexture(_scene, resourceId);
var asset = _res.ResolveRawTexture(resourceId);
var image = asset != null ? Decode(asset) : null;
if (asset == null || image == null || config.CellWidth <= 0 || config.CellHeight <= 0) return null;
int frames = System.Math.Max(1, config.TerminalFrame + 1);
@@ -510,7 +535,7 @@ public sealed class GodotAdvHost : IHost
_surfaceText.Remove(slot);
_surfaceResources[slot] = resourceId;
}
var asset = _res.ResolveTexture(_scene, resourceId);
var asset = _res.ResolveRawTexture(resourceId);
var image = asset != null ? Decode(asset) : null;
_slotDims[slot] = image != null ? (image.Width, image.Height) : (0, 0);
if (TraceOps) Godot.GD.Print($"[op] set-texture slot={slot} resId=0x{resourceId:x} -> {(asset?.Name ?? "<none>")}");
@@ -531,15 +556,16 @@ public sealed class GodotAdvHost : IHost
lock (_imageLock)
if (_movieFrames.TryGetValue(resId, out var movie))
return (movie.Image, movie.Name, movie.RawIndex, true);
var asset = _res.ResolveTexture(_scene, resId);
var asset = _res.ResolveRawTexture(resId);
var image = asset != null ? Decode(asset) : null;
return asset != null && image != null ? (image, asset.Name, asset.RawIndex, false) : null;
}
public void PlayMovieToSurface(long resourceId, int surfaceSlot, long movieFlags, long syncMask)
{
var asset = _res.Resolve(_scene, resourceId);
if (asset == null) { Godot.GD.Print($"movie unresolved {_scene}:0x{resourceId:x}"); return; }
string scene = CurrentScene;
var asset = _res.Resolve(scene, resourceId);
if (asset == null) { Godot.GD.Print($"movie unresolved {scene}:0x{resourceId:x}"); return; }
try
{
var movie = _res.ReadMovie(asset);
@@ -689,7 +715,7 @@ public sealed class GodotAdvHost : IHost
public void PlayVoice(long id, int playbackVariant)
{
var asset = _res.Resolve(_scene, id);
var asset = _res.Resolve(CurrentScene, id);
var audio = asset != null ? LoadAudio(asset) : null;
_timeline?.Event("voice", new() { ["id"] = id, ["file"] = audio?.Name,
["playback_variant"] = playbackVariant });
@@ -722,7 +748,7 @@ public sealed class GodotAdvHost : IHost
public void LoadSoundEffect(long resourceId, int channel)
{
if ((uint)channel >= (uint)_sfxNames.Length) return;
var asset = _res.Resolve(_scene, resourceId);
var asset = _res.Resolve(CurrentScene, resourceId);
var audio = asset != null ? LoadAudio(asset) : null;
_sfxNames[channel] = audio?.Name;
_timeline?.Event("sfx-load", new() { ["resource"] = resourceId, ["channel"] = channel,

View File

@@ -153,8 +153,8 @@ public partial class Main : Godot.Control
var userArgs = OS.GetCmdlineUserArgs();
_selftest = System.Array.IndexOf(userArgs, "--selftest") >= 0;
bool boot = System.Array.IndexOf(userArgs, "--boot") >= 0; // run SYSTEM4's state prefix first
string scene = "SC0000"; // --scene <NAME>: which scene to play (default SC0000)
bool boot = System.Array.IndexOf(userArgs, "--boot") >= 0; // diagnostic prefix for direct-scene runs
string scene = "SYSTEM4"; // natural persistent root; --scene keeps direct diagnostics
var seeds = new List<(int Addr, long Val)>(); // --seed 0xADDR=VAL (repeatable) — initial global state
double sleepScale = 1.0; // --sleep-scale <f>: scale explicit op-0xc8 holds
double speed = 1.0; // --speed <f>: sleeps + retained presentation clocks
@@ -200,6 +200,8 @@ public partial class Main : Godot.Control
Sys4ScriptProvider? scripts = null;
if (_selftest) (script, provider) = BuildSelfTestScene(table);
else { scripts = Sys4ScriptProvider.Load(table); script = scripts.RequireByName(scene + ".BIN"); provider = scripts; }
bool directSceneHarness = !_selftest
&& !scene.Equals("SYSTEM4", System.StringComparison.OrdinalIgnoreCase);
if (_timelineLogPath != null) _timeline = new GodotTimelineLog(_timelineLogPath);
if (!_selftest && pageMapPath == null)
pageMapPath = System.IO.Path.Combine(Paths.Build, $"page-map-{scene.ToUpperInvariant()}.jsonl");
@@ -220,12 +222,12 @@ public partial class Main : Godot.Control
// single-scene harness starts after that prefix, so carry forward its exact script-owned state
// alongside the inherited SO000/SO001 state below. Full Phase-B SYSTEM4 replay will replace this
// bootstrap as one unit; HISTORY depends on the 650x150 dimensions of layouts 2..6 for clipping.
if (!_selftest)
if (directSceneHarness)
AdvTextLayoutBootstrap.ApplyLeadingDefinitionsAndResets(
scripts!.RequireByName("SYSTEM4.BIN"), table, _vm.TextHistory);
// SYSTEM4 loads the shared SO001 chrome sheet into surface slot 17 before any scene runs.
// Seed that inherited retained-surface state without replaying the entrypoint's unrelated UI flow.
if (!_selftest && resources.ResolveName("SO001.AGF") is { } systemChrome)
if (directSceneHarness && resources.ResolveName("SO001.AGF") is { } systemChrome)
{
_host.SetTexture(systemChrome.RawIndex, 0x11);
_vm.Gfx.SetSurface(0x11, systemChrome.RawIndex, 0);
@@ -233,7 +235,7 @@ public partial class Main : Godot.Control
// SYSTEM4 also loads SO000 and configures op 0x73 before entering scene code. The Phase-A
// single-scene harness does not replay those graphics side effects, so inject their exact state
// alongside the existing SO001 bootstrap until Phase B runs the complete SYSTEM4 entrypoint.
if (!_selftest && resources.ResolveName("SO000.AGF") is { } waitIndicator)
if (directSceneHarness && resources.ResolveName("SO000.AGF") is { } waitIndicator)
{
_host.SetTexture(waitIndicator.RawIndex, 0x0c);
_vm.Gfx.SetSurface(0x0c, waitIndicator.RawIndex, 0xff00);
@@ -242,7 +244,7 @@ public partial class Main : Godot.Control
}
// --boot: run SYSTEM4's state prefix (INITCONFIG/INIT2/INIT) so the scene sees boot state — chiefly
// INIT2's gfx handle array 0x62455.. (skips the UI scripts LOGO/OP/TITLE). State carries via globals.
if (boot && !_selftest)
if (boot && directSceneHarness)
{
var session = new GameSession();
foreach (var b in new[] { "INITCONFIG.BIN", "INIT2.BIN", "INIT.BIN" })
@@ -254,13 +256,13 @@ public partial class Main : Godot.Control
// The native SYSTEM4 UI boot enables standard ADV chrome after the data-only *INIT prefix above.
// Without this inherited value the visible SO001 strip is still drawn, but every ADV script skips
// its five pointer rectangles and registers only the off-screen keyboard/pad records.
if (!_selftest)
if (directSceneHarness)
{
_vm.Globals[0x6c1] = 1;
// The native ADV scheduler supplies this Hide Window permission outside script-visible
// writes. Every ADV scene gates its HIDEWIN call on it after opcode 0x199 re-entry.
_vm.Globals[0x62425] = 1;
}
// The native ADV scheduler supplies this Hide Window permission outside script-visible writes.
// It is an engine service default, not a scene bootstrap, and remains present for either entry path.
if (!_selftest) _vm.Globals[0x62425] = 1;
// Native AGE owns this transient secondary-SFX channel outside script-visible writes.
// The matching SC0000 trace has value 4 at 0xc31; seed only this proven profile/slice.
if (!_selftest && scene.Equals("SC0000", System.StringComparison.OrdinalIgnoreCase))
@@ -526,7 +528,7 @@ public partial class Main : Godot.Control
else
{
BlitLayer(texture.Value.Image, texture.Value.AssetId, v.ColorKey, v.Tint, strength, v.SrcX, v.SrcY, v.W, v.H,
localToDest, opacity, v.MultiplyTint, texture.Value.IsDynamic);
localToDest, opacity, v.MultiplyTint, texture.Value.IsDynamic, v.Blend);
var raw = _vm.Gfx.TryGet(v.Handle);
outcome = $"slot={raw?.SourceSlot} DRAWN resId=0x{v.SurfaceResId:x} {texture.Value.Name} " +
$"src=({v.SrcX},{v.SrcY} {v.W}x{v.H}) base=({v.DstX},{v.DstY}) " +
@@ -710,7 +712,7 @@ public partial class Main : Godot.Control
if (texture == null) continue;
BlitLayer(texture.Value.Image, texture.Value.AssetId, source.ColorKey, source.Tint, source.TintStrength / 255f,
source.SrcX, source.SrcY, source.W, source.H, affine, opacity, source.MultiplyTint,
texture.Value.IsDynamic);
texture.Value.IsDynamic, source.Blend);
}
drawn++;
}
@@ -749,11 +751,11 @@ public partial class Main : Godot.Control
// Blit one object's surface rect. Static source pixels are cached per (assetId, colorKey): on first use, texels
// matching the surface colorkey are made transparent (native bakes the key at load — engine-re.md §Blend).
// Mode 0 uses tintStrength to LERP texel RGB toward tint. Mode 1 sets multiplyTint and uses packed RGB as
// multiplicative modulation while alpha is object opacity.
// Mode 0 uses tintStrength to LERP texel RGB toward tint. Mode 1 uses packed RGB modulation and
// SRCALPHA/ONE additive composition; black source pixels therefore contribute nothing.
private void BlitLayer(RgbaImage decoded, int assetId, long colorKey, long tint, float tintStrength, int srcX, int srcY, int w, int h,
Age.Engine.Model.Affine2D localToDest, float alpha = 1f, bool multiplyTint = false,
bool dynamic = false)
bool dynamic = false, BlendKind blend = BlendKind.Alpha)
{
var cacheKey = (assetId, colorKey);
int sourceWidth, sourceHeight;
@@ -796,7 +798,7 @@ public partial class Main : Godot.Control
if (sw <= 0 || sh <= 0) return;
Age.Engine.Model.SoftwareAffineRasterizer.BlitRgba(
_screenPixels, ScreenWidth, ScreenHeight, sourcePixels, sourceWidth, sourceHeight,
srcX, srcY, sw, sh, localToDest, tint, tintStrength, alpha, multiplyTint);
srcX, srcY, sw, sh, localToDest, tint, tintStrength, alpha, multiplyTint, blend);
}
private void FillAffineQuad(int w, int h, Age.Engine.Model.Affine2D localToDest, long tint, float alpha)

View File

@@ -4843,12 +4843,12 @@ abi_source = "kelebek+decode-validated"
[opcode.semantics]
name = "gfx-draw-color"
category = "draw"
summary = "0x203 (handle)(mode)(alpha)(color) — worker stores the D3D blend selector at obj+0x30 and STATIC packed color at obj+0x60; the handler's ctx+0x53d88 write is the generic 9-dword instruction length. Negative alpha/RGB preserve current static bytes. Mode 0 is the opaque textured path: preserved 0xffffffff is identity (the alpha byte is not tint strength); mode 1 is SRCALPHA/INVSRCALPHA with ARGB alpha opacity and multiplicative RGB modulation; mode 2 is the 0x223 transition-source identity path. Surfaceless mode-0 fill consumption remains a distinct case."
summary = "0x203 (handle)(mode)(alpha)(color) — worker stores the D3D blend selector at obj+0x30 and STATIC packed color at obj+0x60; the handler's ctx+0x53d88 write is the generic 9-dword instruction length. Negative alpha/RGB preserve current static bytes. Mode 0 is the default textured path: preserved 0xffffffff is identity (the alpha byte is not tint strength). Mode 1 is SRCALPHA/ONE additive glow with ARGB alpha scaling the source contribution and RGB providing multiplicative modulation. Mode 2 conditionally forces ONE/ZERO for the selected render target and is used by 0x223 transition sources; mode 3 selects a subtractive special path. Surfaceless mode-0 fill consumption remains distinct."
noop_headless = false
source = "investigation"
confidence = "high"
depends_on = []
evidence = "Ghidra handler 0x4229a0; negative operands read current obj+0x60, then worker 0x47e9b0 stores op2 at obj+0x30 and ARGB at +0x60. gfx_object_composite call-site 0x47f78f passes +0x30/+0x60 directly to gfx_object_blit_d3d9; mode 1 sets D3DRS SRCALPHA/INVSRCALPHA and the packed color is the device draw modulation. Mode 2 transition setup and synchronized pixels prove 0xffffffff is identity, not solid white. SC0000 page 14 adds the mode-0 endpoint proof: after the EV052CA->EV052DA 0x223 crossfade, 0x203@0x12478 restores the base CG to mode 0 with preserved 0xffffffff; native keeps EV052DA visible while the port's tint-strength interpretation turns every texel white."
evidence = "Ghidra handler 0x4229a0; negative operands read current obj+0x60, then worker 0x47e9b0 stores op2 at obj+0x30 and ARGB at +0x60. gfx_object_composite call-site 0x47f78f passes +0x30/+0x60 directly to gfx_object_blit_d3d9. Correct D3D9 constants at 0x4774c0 prove mode 1 writes SRCBLEND=5/SRCALPHA and DESTBLEND=2/ONE, not INVSRCALPHA. TITLE draws two opaque-black, no-colorkey SO022 flame sprites with mode 1; additive composition removes black and preserves the blue glow, verified in a windowed capture. Mode 2 transition setup and synchronized pixels prove 0xffffffff is identity, not solid white. SC0000 page 14 adds the mode-0 endpoint proof: after the EV052CA->EV052DA 0x223 crossfade, 0x203@0x12478 restores the base CG to mode 0 with preserved 0xffffffff; native keeps EV052DA visible while the port's tint-strength interpretation turns every texel white."
[[opcode.semantics.args]]
i = 1
@@ -6048,12 +6048,12 @@ abi_source = "kelebek+decode-validated"
[opcode.semantics]
name = "u00421EF0"
category = "draw"
summary = "0x232 anim-color (handle)(period)(alpha)(color): ping-pong the temporary packed ARGB passed to the normal object blit. Handler resolves negative alpha/RGB from static color obj+0x60 and clamps alpha above 255. Blend selector obj+0x30 is unchanged: mode 0 keeps default opaque blending (animated alpha is inert; RGB is vertex modulation), while mode 1 consumes ARGB alpha as opacity. Fresh static color is 0xffffffff. The C# VM resolves sentinels and consumes sampled ARGB through the unchanged mode-specific path. See docs/engine-re.md §SC0000 anim cluster."
summary = "0x232 anim-color (handle)(period)(alpha)(color): ping-pong the temporary packed ARGB passed to the normal object blit. Handler resolves negative alpha/RGB from static color obj+0x60 and clamps alpha above 255. Blend selector obj+0x30 is unchanged: mode 0 keeps default blending (animated alpha is inert; RGB is vertex modulation), while mode 1 uses sampled ARGB alpha as the SRCALPHA scale for additive composition. Fresh static color is 0xffffffff. The C# VM resolves sentinels and consumes sampled ARGB through the unchanged mode-specific path. See docs/engine-re.md §SC0000 anim cluster."
noop_headless = false
source = "investigation"
confidence = "high"
depends_on = []
evidence = "Ghidra /v2: gfx_op_0x232_anim_color@0x423c30 resolves sentinels then calls gfx_worker_anim_color@0x47ef50; gfx_object_anim_interpolate@0x473ed0 samples static obj+0x60 toward target obj+0x240 into a temporary packed color; gfx_object_composite@0x47f650 passes that color plus unchanged selector obj+0x30 to gfx_object_blit_d3d9@0x4774c0. Blit mode 0 enables no alpha blend and passes RGB as modulation; mode 1 enables SRCALPHA/INVSRCALPHA. gfx_object_init_default@0x472810 initializes obj+0x60=0xffffffff. SC0000 0x1a0e (handle,1200,224,-1) is therefore 0xffffffff<->0xe0ffffff with inert alpha and identity RGB: no visible pulse. C# regressions cover exact AE001H visual invariance, negative-RGB preservation, mode-0 RGB modulation, and mode-1 alpha opacity."
evidence = "Ghidra /v2: gfx_op_0x232_anim_color@0x423c30 resolves sentinels then calls gfx_worker_anim_color@0x47ef50; gfx_object_anim_interpolate@0x473ed0 samples static obj+0x60 toward target obj+0x240 into a temporary packed color; gfx_object_composite@0x47f650 passes that color plus unchanged selector obj+0x30 to gfx_object_blit_d3d9@0x4774c0. Blit mode 0 leaves the default path and passes RGB as modulation; mode 1 sets SRCALPHA/ONE additive composition. gfx_object_init_default@0x472810 initializes obj+0x60=0xffffffff. SC0000 0x1a0e (handle,1200,224,-1) is therefore 0xffffffff<->0xe0ffffff with inert alpha and identity RGB in mode 0: no visible pulse. C# regressions cover exact AE001H visual invariance, negative-RGB preservation, mode-0 RGB modulation, and mode-1 additive scaling."
[[opcode.semantics.args]]
i = 1