From 097473fe1616b0795ae6b77e12ba7c908c080f6e Mon Sep 17 00:00:00 2001 From: gamer147 Date: Mon, 20 Jul 2026 18:13:32 -0400 Subject: [PATCH] Land SYSTEM4-rooted Godot boot --- docs/asset-resolution-re.md | 30 ++--- docs/engine-re.md | 41 ++++--- docs/opcode-reference.md | 8 +- docs/phase-b-framework.md | 9 ++ docs/tools-reference.md | 19 +-- engine/Age.Engine.Tests/CallScriptTests.cs | 53 ++++++++- .../NaturalBootIntegrationTests.cs | 111 ++++++++++++++++++ .../RenderObjectBlendTests.cs | 4 +- .../SoftwareAffineRasterizerTests.cs | 16 +++ engine/Age.Engine/Hosting/IHost.cs | 5 + engine/Age.Engine/Model/BlendKind.cs | 3 +- engine/Age.Engine/Model/GfxState.cs | 11 +- .../Model/SoftwareAffineRasterizer.cs | 19 ++- engine/Age.Engine/Sys4/ResourceMap.cs | 8 ++ engine/Age.Engine/Vm/VirtualMachine.cs | 71 +++++++---- godot/GodotAdvHost.cs | 46 ++++++-- godot/Main.cs | 34 +++--- vm-map/opcodes.toml | 8 +- 18 files changed, 385 insertions(+), 111 deletions(-) create mode 100644 engine/Age.Engine.Tests/NaturalBootIntegrationTests.cs diff --git a/docs/asset-resolution-re.md b/docs/asset-resolution-re.md index 8d9b117..e09d9dd 100644 --- a/docs/asset-resolution-re.md +++ b/docs/asset-resolution-re.md @@ -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 diff --git a/docs/engine-re.md b/docs/engine-re.md index 845c511..b2ede68 100644 --- a/docs/engine-re.md +++ b/docs/engine-re.md @@ -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 diff --git a/docs/opcode-reference.md b/docs/opcode-reference.md index 2165cac..33b701e 100644 --- a/docs/opcode-reference.md +++ b/docs/opcode-reference.md @@ -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. diff --git a/docs/phase-b-framework.md b/docs/phase-b-framework.md index 7b0a856..dc8ece1 100644 --- a/docs/phase-b-framework.md +++ b/docs/phase-b-framework.md @@ -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 B1–B3; 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. diff --git a/docs/tools-reference.md b/docs/tools-reference.md index 03ecbe7..b259d25 100644 --- a/docs/tools-reference.md +++ b/docs/tools-reference.md @@ -132,21 +132,24 @@ at 2.5M lines). All observe-only → parity preserved; all on `run`/`play`/`swee - **Godot** accepts **`--trace-histogram `** — 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 [-- ]`. -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 ` — 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 ` — 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 `, 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 [--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 [--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 [--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 ` — 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 ` — scale sleeps and retained presentation clocks without throttling ordinary opcode bursts or auto-advancing input waits. Values 0.05–8 are accepted; `--speed 0.25` is useful for transform inspection, while 1.0 is normal playback. -- `--gfx-log ` — **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 ` — **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 ` — 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-.jsonl`, adding one record per diff --git a/engine/Age.Engine.Tests/CallScriptTests.cs b/engine/Age.Engine.Tests/CallScriptTests.cs index 7ef898a..cb0e918 100644 --- a/engine/Age.Engine.Tests/CallScriptTests.cs +++ b/engine/Age.Engine.Tests/CallScriptTests.cs @@ -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 _contexts = new(); + public List 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); + } } diff --git a/engine/Age.Engine.Tests/NaturalBootIntegrationTests.cs b/engine/Age.Engine.Tests/NaturalBootIntegrationTests.cs new file mode 100644 index 0000000..241c531 --- /dev/null +++ b/engine/Age.Engine.Tests/NaturalBootIntegrationTests.cs @@ -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 Entered = new(); + public Action? 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)); + } +} diff --git a/engine/Age.Engine.Tests/RenderObjectBlendTests.cs b/engine/Age.Engine.Tests/RenderObjectBlendTests.cs index 20ae9ce..29008a4 100644 --- a/engine/Age.Engine.Tests/RenderObjectBlendTests.cs +++ b/engine/Age.Engine.Tests/RenderObjectBlendTests.cs @@ -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] diff --git a/engine/Age.Engine.Tests/SoftwareAffineRasterizerTests.cs b/engine/Age.Engine.Tests/SoftwareAffineRasterizerTests.cs index 430c310..3c5b1f1 100644 --- a/engine/Age.Engine.Tests/SoftwareAffineRasterizerTests.cs +++ b/engine/Age.Engine.Tests/SoftwareAffineRasterizerTests.cs @@ -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, diff --git a/engine/Age.Engine/Hosting/IHost.cs b/engine/Age.Engine/Hosting/IHost.cs index 59eb243..82b785c 100644 --- a/engine/Age.Engine/Hosting/IHost.cs +++ b/engine/Age.Engine/Hosting/IHost.cs @@ -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. diff --git a/engine/Age.Engine/Model/BlendKind.cs b/engine/Age.Engine/Model/BlendKind.cs index 9326a1c..934c5bd 100644 --- a/engine/Age.Engine/Model/BlendKind.cs +++ b/engine/Age.Engine/Model/BlendKind.cs @@ -1,6 +1,5 @@ namespace Age.Engine.Model; /// 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. +/// blend (fades); Additive = native SRCALPHA/ONE glow composition. public enum BlendKind { Opaque, Alpha, Additive } diff --git a/engine/Age.Engine/Model/GfxState.cs b/engine/Age.Engine/Model/GfxState.cs index 316e11a..3bb2ca3 100644 --- a/engine/Age.Engine/Model/GfxState.cs +++ b/engine/Age.Engine/Model/GfxState.cs @@ -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) diff --git a/engine/Age.Engine/Model/SoftwareAffineRasterizer.cs b/engine/Age.Engine/Model/SoftwareAffineRasterizer.cs index e6ce0ae..d8b70a3 100644 --- a/engine/Age.Engine/Model/SoftwareAffineRasterizer.cs +++ b/engine/Age.Engine/Model/SoftwareAffineRasterizer.cs @@ -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); } diff --git a/engine/Age.Engine/Sys4/ResourceMap.cs b/engine/Age.Engine/Sys4/ResourceMap.cs index 9aa2d99..0f6aafd 100644 --- a/engine/Age.Engine/Sys4/ResourceMap.cs +++ b/engine/Age.Engine/Sys4/ResourceMap.cs @@ -34,6 +34,14 @@ public sealed class ResourceMap entry.Name.EndsWith(".AGF", StringComparison.OrdinalIgnoreCase) ? entry : null; } + /// Resolve an already-normalized raw catalog id without applying a scene section base. + public AssetEntry? ResolveRawTexture(long rawId) + { + var entry = _catalog.ResolveRaw(rawId); + return entry is { IsPlaceholder: false } && + entry.Name.EndsWith(".AGF", StringComparison.OrdinalIgnoreCase) ? entry : null; + } + /// Decode an AGF directly from loose-first VFS bytes. public RgbaImage DecodeTexture(AssetEntry entry) => AgfDecoder.Decode(_store, entry); diff --git a/engine/Age.Engine/Vm/VirtualMachine.cs b/engine/Age.Engine/Vm/VirtualMachine.cs index 4f45a0a..2269188 100644 --- a/engine/Age.Engine/Vm/VirtualMachine.cs +++ b/engine/Age.Engine/Vm/VirtualMachine.cs @@ -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])); diff --git a/godot/GodotAdvHost.cs b/godot/GodotAdvHost.cs index de7874d..984e277 100644 --- a/godot/GodotAdvHost.cs +++ b/godot/GodotAdvHost.cs @@ -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 _scriptContexts = new(); private readonly object _imageLock = new(); private readonly Dictionary _images = new(); // raw catalog id -> decoded pixels - private readonly Dictionary _surfaceResources = new(); // surface slot -> scene/raw resource id + private readonly Dictionary _surfaceResources = new(); // surface slot -> normalized raw catalog id private readonly Dictionary _movieFrames = new(); private readonly Dictionary _movieBySurface = new(); private readonly HashSet _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 ?? "")}"); @@ -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, diff --git a/godot/Main.cs b/godot/Main.cs index 1c3a353..eb34ff7 100644 --- a/godot/Main.cs +++ b/godot/Main.cs @@ -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 : 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 : scale explicit op-0xc8 holds double speed = 1.0; // --speed : 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) diff --git a/vm-map/opcodes.toml b/vm-map/opcodes.toml index 528f7f7..0d7c1d1 100644 --- a/vm-map/opcodes.toml +++ b/vm-map/opcodes.toml @@ -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