From df17747f636ac72a4199a6461fc8b632b397b9ae Mon Sep 17 00:00:00 2001 From: gamer147 Date: Mon, 20 Jul 2026 20:23:15 -0400 Subject: [PATCH] Implement modal startup movies --- docs/asset-resolution-re.md | 28 ++++++-- docs/engine-ctx-reference.md | 1 + docs/engine-re.md | 22 ++++++ docs/opcode-reference.md | 33 +++++---- docs/phase-b-framework.md | 12 ++++ docs/platform-portability.md | 11 +-- engine/Age.Engine.Tests/MovieOpcodeTests.cs | 54 +++++++++++++++ .../NaturalBootIntegrationTests.cs | 7 ++ engine/Age.Engine.Tests/TestSupport.cs | 3 + engine/Age.Engine/Hosting/IHost.cs | 4 ++ engine/Age.Engine/Sys4/ResourceMap.cs | 10 +++ engine/Age.Engine/Vm/VirtualMachine.cs | 23 ++++++- godot/GodotAdvHost.cs | 67 ++++++++++++++++++- godot/Main.cs | 13 ++++ vm-map/engine-ctx.toml | 5 ++ vm-map/opcodes.toml | 49 +++++++------- 16 files changed, 293 insertions(+), 49 deletions(-) diff --git a/docs/asset-resolution-re.md b/docs/asset-resolution-re.md index 4b76c80..3665b9a 100644 --- a/docs/asset-resolution-re.md +++ b/docs/asset-resolution-re.md @@ -115,8 +115,8 @@ highest-risk area of the port. This doc is the steering state; it feeds the A2b 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 and movie calls likewise resolve against the executing frame; - SFX is the separate universal packed-id family documented below. + sibling script becomes active. Voice and scene-movie op `0x236` likewise resolve against the executing + frame; modal whole-movie op `0x20f` and SFX are separate universal raw/packed-id families documented below. 4. **Audio.** **BGM/voice/SFX wired; VFS bytes complete (2026-07-11); packed-raw SFX corrected 2026-07-20.** `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 @@ -268,7 +268,7 @@ Channel, loop, interruption, timing, fade, load/start, and release behavior is u ### SC0000 movie payload and presentation (2026-07-11) -The only implemented site is SC0000 `0x236@0x13c8`. Scene-local resource `0x33` resolves through the +The scene-local implementation was first validated at SC0000 `0x236@0x13c8`. Resource `0x33` resolves through the authoritative catalog to `DATA1.ALF:CHAPTER.AGF` (archive offset 3,908,816; size 8,194,052). Despite the `.AGF` name, its payload begins with MPEG program-stream pack start code `00 00 01 BA`; the installed asset is MPEG-1 program stream video at 800x600, 29.97 fps, approximately 11.98 seconds, with video stream `E0` @@ -296,6 +296,24 @@ unrendered. The real-scene trace remains a separate extracted-present test because the current `Paths.Scripts()` test bootstrap still locates its root `*.BIN` fixtures there; migrating that test/bootstrap path is unrelated to movie asset loading and was not folded into this slice. +### Modal startup/ending movie resources (implemented 2026-07-20) + +Opcode `0x20f` uses universal raw SYS4INI indexes rather than the executing script's manifest. Its complete +corpus is `LOGO.BIN (0x335f,42,4)`, `OP.BIN (0x3364,42,4)`, and +`ED.BIN (0x3324,42,dynamic_flags)`. Raw records `0x335f`, `0x3364`, and `0x3324` are respectively +`LOGO.AGF`, `OP.AGF`, and `ED.AGF`; all begin with MPEG pack code `00 00 01 BA`. The current +`ResourceMap.Resolve(scene,id)` path correctly returns out-of-range for the large LOGO/OP values, proving +that an implementation must expose a typed raw-movie resolver rather than add manifest fallbacks globally. +Native `0x20f` also arms modal run-state `0x2000`; unlike `0x236`, these six-instruction wrapper scripts +depend on the movie service itself to park until EOF/input cancellation before they release surface 42. + +`ResourceMap.ResolveRawMovie` now supplies that typed universal lookup, while `ReadMovie` remains the MPEG +signature gate. `IHost.PlayModalMovieToSurface` is distinct from the scene-local non-modal call: Godot +reuses the asynchronous DirectShow frame decoder and retained compositor but parks the VM thread until EOF +or mouse/Accept/Cancel input. The wrapper's following release then tears down the completed/cancelled movie. +The decoder still intentionally leaves audio unrendered; OP/ED audio parity needs an explicit synchronized +movie-audio/backend contract rather than an unmanaged default-device side path. + VFS-B passes its bounded gates in `Sys4AssetStoreTests`: the installed AAI expands from the LZSS stream at `0x118` (expanded size at `0x110`, packed size at `0x114`) to one `APPEND01.ALF` archive and 81 80-byte records. All records carry selector 1 and literal `$1$` names. The full directory has stable SHA-256 @@ -307,8 +325,8 @@ through `0x01xxxxxx`; direct base-name lookup deliberately does not see append r ### Deliberate non-goals - Writing/repacking ALF or AAI; loose overrides already provide the native mod/translation workflow. -- AGF encoding, movie audio, or generalized video APIs. The implemented movie path is deliberately limited - to SC0000's existing `0x236` site and Windows' native DirectShow MPEG decoder. +- AGF encoding, movie audio, or generalized video APIs. Implemented `0x236` and `0x20f` playback remains + deliberately limited to Windows' native DirectShow MPEG decoder. - A generalized multi-mod dependency manager. Start with native game-root loose overrides; configurable ordered mod roots can be layered onto the same store later. - Removing the extraction/conversion tools immediately. They remain independent parity oracles until the diff --git a/docs/engine-ctx-reference.md b/docs/engine-ctx-reference.md index 9889dfc..c29f6dd 100644 --- a/docs/engine-ctx-reference.md +++ b/docs/engine-ctx-reference.md @@ -39,6 +39,7 @@ Struct `EngineCtx`, size `0xa1000`. Applied to the Ghidra `/v2` image (dispatch- | `0x53d60` | `ctx_record_base` | `void*` | 0x78-byte context-record array base (coroutine/script contexts) | | `0x53d64` | `frame_script_resource_id` | `uint` | raw packed SYS4/AAI resource id for this 0x78-byte script frame; persisted ReadTextDB script key | | `0x53d88` | `frame_instruction_word_count` | `int` | current decoded instruction length in dwords for each 0x78-byte script frame; interpreter advances PC by this value * 4 | +| `0x54ff0` | `initial_root_run` | `int` | initialized to 1 at AGE context construction; queried by op 0x130 and cleared by op 0x9 before root script id 0 is reloaded | | `0x550fc` | `message_skip_display_enabled` | `int` | persistent all-message Skip state returned by op 0x19a for the ADV control-strip active overlay | | `0x55100` | `adv_skip_service_enabled` | `int` | ADV presentation lifecycle gate: op 0x19b clears it while preserving the persistent Skip toggle; op 0x19c sets it before recomputing active message/read skip | | `0x55104` | `auto_message_enabled` | `int` | ADV Auto mode flag; op 0x1b6 reads, op 0x1b7 writes, adv_input_service_poll consumes | diff --git a/docs/engine-re.md b/docs/engine-re.md index 586ea41..70891dd 100644 --- a/docs/engine-re.md +++ b/docs/engine-re.md @@ -249,6 +249,28 @@ not a thin handle seed: it calls 23 data initializers in order (`EBINIT`, `CNINI Godot now runs SYSTEM4 itself by default, so this complete sequence and its host-visible side effects execute in one VM; Godot `--boot` remains only for an explicit direct-scene diagnostic such as `--scene SC0000`. +**Startup-video gate and modal player (2026-07-20).** The apparently optional `LOGO`/`OP` calls are +deterministic first-process-run behavior, not a profile/save decision. `SYSTEM4@0x29a` calls op `0x130` and +executes `LOGO.BIN@0x2a4` then `OP.BIN@0x2a7` when its result is nonzero. Native +`op_0x130_get_initial_root_run@0x4295b0` returns `EngineCtx+0x54ff0`; context construction initializes that +field to one, while `op_0x9_handler` is its only later writer and clears it before resetting engine state and +reloading root script id zero. The port now owns the same flag in the persistent VM: it begins at one, +`0x130` writes it, and the existing op-`0x9` lifecycle boundary clears it. It is not a boot seed or profile +value. Full native whole-stack root reload remains part of the broader scene-coordinator work. + +Both child scripts create/draw 800x600 surface 42 and call op `0x20f`; their resource ids are universal raw +SYS4INI indexes `0x335f`/`LOGO.AGF` and `0x3364`/`OP.AGF`. `ED.BIN` is the only other corpus user, with +`0x3324`/`ED.AGF`. All three payloads begin with MPEG program-stream pack code `00 00 01 BA`. +`op_0x20f_play_modal_movie_to_surface@0x422e50` shares the movie allocation/open/audio setup used by +non-modal scene-movie op `0x236`, then starts playback and sets run-state bit `0x2000`; the engine main loop +and window procedure treat that state as the modal whole-movie service. This parks the script at the opcode +until EOF or input cancellation, after which the script's following instructions release the object/surface. +The port implements `0x20f` through a typed raw-movie resolver and a distinct modal host call. The existing +asynchronous decoder publishes frames through the retained surface while only the VM thread is parked; +EOF, mouse click, Accept, or Cancel resumes the wrapper so its scripted cleanup releases surface 42. +`0x236` retains its scene-local, non-modal contract. Both paths still intentionally leave the MPEG audio +pin unrendered; synchronized movie audio remains a deliberate backend/audio-clock slice. + 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: diff --git a/docs/opcode-reference.md b/docs/opcode-reference.md index 9230cb9..6d51767 100644 --- a/docs/opcode-reference.md +++ b/docs/opcode-reference.md @@ -221,6 +221,13 @@ Companion op 0x8f `call` is INTRA-script (a local JSR), not cross-script -- see This also names the whole call graph statically (build/callscript-names.json). +### 0x9 `exit-script` (exit-script, argc 0) +- **summary:** () - terminate the active script lifecycle and return to root script id 0. Before the native engine resets/reloads the root, it clears the initial-root-run flag queried by op 0x130 so LOGO/OP are not replayed. +- **grounding:** source=investigation, confidence=high +- **evidence:** Ghidra /v2: op_0x9_handler@0x418f50 stores zero to EngineCtx+0x54ff0, calls scene_context_init_reset, and loads root script resource id 0. Corpus sites are terminal scene/control exits rather than ordinary local returns. + +The port retains its earlier frame/session-boundary representation of root return, but now performs the proven process-lifecycle side effect by clearing its VM-owned initial-root-run flag. A faithful whole-stack root reload remains part of the broader persistent scene-coordinator work, not the startup-movie slice. + ### 0x7b `coroutine-save-yield-handlers` (u0041ADB0, argc 2) - **summary:** (handler1_pc)(handler2_pc) — scene-coroutine: save the two per-frame yield/resume handler PCs. Native writes op1→ctx[0x6da88+idx*4], op2→ctx[0x6db28+idx*4] (idx=ctx[0x53d14] script-context index); its generic handler prologue records the 5-dword instruction length. SC0000 0x79: `0x7b label_3c9 label_41e` registers the ADV per-frame render→poll→yield handlers. Part of the scene-coroutine framework (see engine-re.md §Scene-coroutine framework); pairs with 0x7c (resume) + 0x140 (loop iterator). - **grounding:** source=investigation, confidence=high @@ -283,6 +290,13 @@ Native handler sleep_op_0xc8 @0x420ec0 is NON-BLOCKING: it arms a timer (sleep_t - **grounding:** source=investigation, confidence=high, noop_headless=True - **evidence:** Ghidra op 0xd9 handler 0x416da0: ctx->run_state_flags &= ~0x1000; when ctx+0x6f8b8 is nonzero, also clears bit 0x1000 at ctx+0x53d20. No operands, calls, or return value. +### 0x130 `get-initial-root-run` (get-initial-root-run, argc 1) +- **summary:** (out) - write the engine's initial-root-run flag. It is initialized to 1 when the AGE context is constructed and cleared by op 0x9 before that opcode resets state and reloads root script id 0. SYSTEM4 uses the value to call LOGO.BIN and OP.BIN only on the process's initial root run. +- **grounding:** source=investigation, confidence=high +- **evidence:** Ghidra /v2: op_0x130_get_initial_root_run@0x4295b0 copies EngineCtx+0x54ff0 to operand 1. FUN_00413860 initializes +0x54ff0 to 1 at 0x413cc7/0x413d15; op_0x9_handler@0x418f50 is its only later writer and clears it before scene_context_init_reset plus script_frame_load_resource(...,0). Corpus: sole site SYSTEM4@0x29a branches to LOGO.BIN then OP.BIN only when the returned value is nonzero. + +Implemented as process-lifecycle state owned by the persistent VM: it begins at one and op 0x9 clears it. It is not a script global, save/profile value, command-line seed, or script-name special case. + ### 0x140 `coroutine-label-yield` (u0041F9C0, argc 4) - **summary:** (out)(name_str)(sub_str)(in) — scene-coroutine LOOP ITERATOR / labeled yield. Handler copies name/sub strings + the int operand and calls the NATIVE video/transition service (*DAT_005c6018)(8, ctx[0x54fe8], &{name,sub,in}); writes the returned PC-like value to operand 1. In SC0000 label_462 'ループ開始' (@0x46d): `out=G[0x6be]=LABEL('J',G[0x6be])`; loop runs the intro-setup body (incl. call label_125bd = slot-table fill G[0x3239..0x324e]=4..11) and jmps back until out==G[0x6c3] (a per-scene exit-PC immediate) → mov aba5c 0 → content. The gate G[0xaba5c]==1 that opens this loop is NATIVE scene-entry state (no script sets it to 1). DAT_005c6018 is runtime-resolved (all xrefs READ) = SAME class as the DirectDraw workers we don't model. PORT = HOST-MODEL IMPLEMENTED: synthesize the ADV scene-entry gate, run the LABEL/J setup body once, then return the structurally discovered per-scene terminal; do not emulate the video service. See engine-re.md §Scene-coroutine framework. - **grounding:** source=investigation, confidence=med @@ -425,6 +439,13 @@ For slots below 1000 the native worker obtains that surface's level-0 D3D textur This is a target-pixel operation, not retained-object teardown. It invokes IDirect3DDevice9::Clear with zero rectangles, flags D3DCLEAR_TARGET|D3DCLEAR_ZBUFFER (3), color 0, depth 1.0, and stencil 0. The port tracks 0x20d target ownership and forwards the clear to the host; its retained compositor already rebuilds the backbuffer from black. +### 0x20f `play-modal-movie-to-surface` (play-modal-movie-to-surface, argc 3) +- **summary:** (raw_resource_id)(surface_slot)(movie_flags) - open a universal raw-catalog MPEG asset into an existing retained surface, start its native movie graph, and arm modal run-state bit 0x2000 so script execution remains parked until the movie completes or is skipped. This is the LOGO/OP/ED whole-movie path, distinct from non-modal scene movie op 0x236. +- **grounding:** source=investigation, confidence=high +- **evidence:** Ghidra /v2: op_0x20f_play_modal_movie_to_surface@0x422e50 shares the movie-object allocation, DirectShow graph open, audio-route, and volume setup used by 0x236, then calls movie_start_modal_playback@0x463280, ORs EngineCtx+0xa0ce4 with 0x2000, and marks movie presentation dirty. The main loop and window procedure special-case run-state 0x2000. Corpus has exactly three sites: LOGO (0x335f,42,4), OP (0x3364,42,4), and ED (0x3324,42,dynamic flags). Those ids are universal raw SYS4INI indexes for MPEG-pack LOGO.AGF, OP.AGF, and ED.AGF; each script releases its surface only after 0x20f resumes. + +Implemented through IHost.PlayModalMovieToSurface. ResourceMap.ResolveRawMovie deliberately bypasses scene manifests while retaining MPEG signature validation in ReadMovie. Godot reuses the asynchronous decoder/retained-surface compositor, parks only the VM thread until EOF, and treats mouse click or Accept/Cancel input as completion before wrapper cleanup releases the decoder. MPEG audio remains a separate backend/audio-clock contract. + ### 0x212 `set-gfx-field64` (set-gfx-field64, argc 2) - **summary:** 0x212 (obj_idx)(val) — handler gfx_op_0x212_set_field64 @0x4230c0: obj=[ctx+0x14d54 + obj_idx*4]; if obj: *(obj+0x64)=val. The generic instruction length is 5 dwords. See docs/engine-re.md gfx op-contract table. - **grounding:** source=investigation, confidence=high @@ -795,10 +816,6 @@ op 0x90 (u0041BEB0, argc 7): `0x90 x y w h tgt_a tgt_b tgt_c`. Kelebek left it " - **summary:** — - **grounding:** source=kelebek, confidence=low -### 0x9 `exit-script` (exit-script, argc 0) -- **summary:** — -- **grounding:** source=kelebek, confidence=med - ### 0x21 `u00418860` (u00418860, argc 2) - **summary:** — - **grounding:** source=kelebek, confidence=low @@ -975,10 +992,6 @@ op 0x90 (u0041BEB0, argc 7): `0x90 x y w h tgt_a tgt_b tgt_c`. Kelebek left it " - **summary:** — - **grounding:** source=kelebek, confidence=low -### 0x130 `u00415F40` (u00415F40, argc 1) -- **summary:** — -- **grounding:** source=kelebek, confidence=low - ### 0x132 `u0041EF00` (u0041EF00, argc 1) - **summary:** — - **grounding:** source=kelebek, confidence=low @@ -1143,10 +1156,6 @@ op 0x90 (u0041BEB0, argc 7): `0x90 x y w h tgt_a tgt_b tgt_c`. Kelebek left it " - **summary:** — - **grounding:** source=kelebek, confidence=low -### 0x20f `u00420E40` (u00420E40, argc 3) -- **summary:** — -- **grounding:** source=kelebek, confidence=low - ### 0x22a `u00421A90` (u00421A90, argc 3) - **summary:** — - **grounding:** source=kelebek, confidence=low diff --git a/docs/phase-b-framework.md b/docs/phase-b-framework.md index 92e6edb..4e1c532 100644 --- a/docs/phase-b-framework.md +++ b/docs/phase-b-framework.md @@ -106,6 +106,18 @@ while Godot treated them as active-script manifest ids. The new packed-raw resol through the existing channel players. A synchronized TITLE→GAMESTART→TITLE trace records every load/start with its filename, and manual validation confirms they are audible; BGM remains unaffected. +**Pre-title video sequence implemented (2026-07-20).** SYSTEM4 already owns the native sequence; the +port did not lose an executable-side launcher. Its sole op `0x130` call returns an engine initial-root flag +that is one at context construction and cleared only when op `0x9` resets/reloads root script id zero. +SYSTEM4 calls `LOGO.BIN` and `OP.BIN` only while that flag is nonzero. The former stubbed-zero output +explained the direct jump to TITLE. LOGO and OP then use the modal movie op +`0x20f` with raw catalog movies `0x335f`/`LOGO.AGF` and `0x3364`/`OP.AGF`; existing `0x236` is the distinct +non-modal, scene-local movie-to-surface path. The VM now models the initial-root flag and clears it at its +existing op-`0x9` lifecycle boundary. Godot resolves a typed raw MPEG asset, reuses the asynchronous decoder +and retained compositor, and parks the VM until EOF or mouse/Accept/Cancel input. Focused natural-boot tests +prove `LOGO -> OP -> INIT -> TITLE` ordering and exact movie operands. MPEG audio remains explicitly deferred +until the decoder abstraction has an engine-owned synchronized audio/volume contract. + ## 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/platform-portability.md b/docs/platform-portability.md index b897777..378d023 100644 --- a/docs/platform-portability.md +++ b/docs/platform-portability.md @@ -23,7 +23,7 @@ also softer Windows assumptions that should be tested or replaced before claimin | Area | Current dependency | Runtime impact | Portability status / future action | |---|---|---|---| -| SC0000 movie decode | `godot/DirectShowMovieDecoder.cs`: DirectShow COM objects plus `ole32.dll` `CoInitializeEx` / `CoUninitialize` | Movie startup cannot run outside Windows | Introduce a decoder interface and backend factory; retain DirectShow on Windows while adding a portable MPEG backend | +| AGE movie decode (`0x236` scene movies; `0x20f` modal LOGO/OP/ED) | `godot/DirectShowMovieDecoder.cs`: DirectShow COM objects plus `ole32.dll` `CoInitializeEx` / `CoUninitialize` | Movie playback cannot run outside Windows | Introduce a decoder interface and backend factory; retain DirectShow on Windows while adding a portable MPEG backend | | Movie integration | `godot/Main.cs` directly constructs and stores `DirectShowMovieDecoder`; `Main` is marked `SupportedOSPlatform("windows")` | The frontend has no runtime fallback or OS-specific source selection | Type `MovieRuntime` against the decoder interface, select by platform/build, and move the Windows annotation to the DirectShow backend | | Movie audio | DirectShow connects only the video pin to the sample grabber/null renderer | The MPEG audio stream is intentionally silent on every platform | Design a PCM/audio-clock contract or let a future backend own synchronized A/V; separate feature slice | | ADV font discovery | `godot/Main.cs` probes `C:/Windows/Fonts` for Japanese fonts | Harmless fallback today, but appearance depends on host fonts | Bundle/configure a redistributable font or add platform-specific discovery | @@ -42,8 +42,8 @@ Godot abstractions. The existing connection is localized but one abstraction short of being replaceable without edits: ``` -VM op 0x236 - -> IHost.PlayMovieToSurface +VM op 0x236 (non-modal) / op 0x20f (modal) + -> IHost.PlayMovieToSurface / PlayModalMovieToSurface -> VFS-owned MoviePayload bytes -> DirectShowMovieDecoder -> newest RGBA frame @@ -59,8 +59,9 @@ interface. A future cleanup should: 2. Add an injected factory that accepts `MoviePayload` and selects an available backend. 3. Keep DirectShow in a Windows-specific source set or assembly, with its platform annotation local to it. 4. Implement a portable MPEG program-stream backend that produces the same top-down RGBA8 frames. -5. Preserve newest-frame-wins delivery, asynchronous playback, synchronous open failure, EOF notification, - and the retained-surface lifecycle already validated for opcode `0x236`. +5. Preserve newest-frame-wins delivery, asynchronous decode, synchronous open failure, EOF notification, + and the retained-surface lifecycle already validated for opcode `0x236`; expose modal completion/cancel + separately for `0x20f` rather than blocking inside the decoder backend. 6. Treat synchronized movie audio as a separate extension of the contract rather than coupling it to the compositor. diff --git a/engine/Age.Engine.Tests/MovieOpcodeTests.cs b/engine/Age.Engine.Tests/MovieOpcodeTests.cs index c85a68b..242622d 100644 --- a/engine/Age.Engine.Tests/MovieOpcodeTests.cs +++ b/engine/Age.Engine.Tests/MovieOpcodeTests.cs @@ -8,6 +8,46 @@ using Xunit; public class MovieOpcodeTests { + [Fact] + public void InitialRootFlagStartsSetAndClearsWhenExitScriptRuns() + { + var table = OpcodeTableJson.Load(Paths.OpcodesJson); + var root = ScriptAssembler.Assemble(table, "ROOT", new List<(int, Operand[])> + { + (0x130, new[] { new Operand(3, 0x100) }), + (0x9, System.Array.Empty()), + }, System.Array.Empty()); + var vm = new VirtualMachine(root, table, new RecordingHost()); + + vm.Run(); + Assert.Equal(1, vm.Globals[0x100]); + + vm.Globals[0x100] = -1; + vm.Run(); + Assert.Equal(0, vm.Globals[0x100]); + } + + [Fact] + public void ModalMovieDispatchesRawResourceSurfaceAndFlags() + { + var table = OpcodeTableJson.Load(Paths.OpcodesJson); + var script = ScriptAssembler.Assemble(table, "MODAL", new List<(int, Operand[])> + { + (0x20f, new[] { new Operand(0, 0x335f), new Operand(0, 42), new Operand(0, 4) }), + (0x55, new[] { new Operand(3, 0x1234), new Operand(0, 0x5678) }), + (0x2, System.Array.Empty()), + }, System.Array.Empty()); + var host = new RecordingHost(); + var vm = new VirtualMachine(script, table, host); + + vm.Run(); + + Assert.Equal(new[] { (0x335fL, 42, 4L) }, host.ModalMovies); + Assert.Equal(0x5678, vm.Globals[0x1234]); + vm.Gfx.BindDraw(1, 42, 0, 0, 1, 1, 0, 0); + Assert.Equal(0x335f, vm.Gfx.SnapshotVisibleObjects().Single().SurfaceResId); + } + [Fact] public void Sc0000ResumesImmediatelyAfterMovieOpcodeAtBytecodeOffset13d1() { @@ -68,6 +108,20 @@ public class MovieOpcodeTests Assert.Equal(8_194_052, movie.Bytes.Length); } + [Theory] + [InlineData(0x335f, "LOGO.AGF")] + [InlineData(0x3364, "OP.AGF")] + public void ModalMoviePayloadResolvesFromUniversalRawCatalog(int rawId, string expectedName) + { + var catalog = Sys4AssetCatalog.Load(Paths.Sys4Ini); + var resources = new ResourceMap(catalog, new Sys4AssetStore(catalog, Paths.GameDir)); + var entry = resources.ResolveRawMovie(rawId); + + Assert.Equal(expectedName, entry?.Name); + var movie = resources.ReadMovie(entry!); + Assert.Equal(new byte[] { 0, 0, 1, 0xba }, movie.Bytes[..4]); + } + [Fact] public void Sc0000MoviePayloadDecodesAn800By600FrameOnWindows() { diff --git a/engine/Age.Engine.Tests/NaturalBootIntegrationTests.cs b/engine/Age.Engine.Tests/NaturalBootIntegrationTests.cs index 241c531..87b89d3 100644 --- a/engine/Age.Engine.Tests/NaturalBootIntegrationTests.cs +++ b/engine/Age.Engine.Tests/NaturalBootIntegrationTests.cs @@ -99,6 +99,13 @@ public class NaturalBootIntegrationTests { "SYSTEM4.BIN", "INITCONFIG.BIN", "INIT2.BIN", }, sink.Entered.Take(3)); + int logo = sink.Entered.IndexOf("LOGO.BIN"); + int opening = sink.Entered.IndexOf("OP.BIN"); + int init = sink.Entered.IndexOf("INIT.BIN"); + int title = sink.Entered.IndexOf("TITLE.BIN"); + Assert.True(logo >= 0 && logo < opening && opening < init && init < title, + $"entered={string.Join(",", sink.Entered)}"); + Assert.Equal(new[] { (0x335fL, 42, 4L), (0x3364L, 42, 4L) }, host.ModalMovies); Assert.Contains("TITLE.BIN", sink.Entered); Assert.Contains("GAMESTART.BIN", sink.Entered); Assert.Contains("UNITECH.BIN", sink.Entered); diff --git a/engine/Age.Engine.Tests/TestSupport.cs b/engine/Age.Engine.Tests/TestSupport.cs index 0a77193..ac06c77 100644 --- a/engine/Age.Engine.Tests/TestSupport.cs +++ b/engine/Age.Engine.Tests/TestSupport.cs @@ -36,6 +36,7 @@ internal class RecordingHost : IHost public readonly List SfxReleases = new(); public readonly List<(int Target, long Duration)> BgmFades = new(); public readonly List<(long Resource, int Surface, long Flags, long SyncMask)> Movies = new(); + public readonly List<(long Resource, int Surface, long Flags)> ModalMovies = new(); public readonly List ClearedRenderTargets = new(); public readonly List<(int First, int Count)> ReleasedSurfaceRanges = new(); public readonly List MessageSkipChanges = new(); @@ -128,6 +129,8 @@ internal class RecordingHost : IHost public void FadeBgm(int targetPercent, long durationMs) => BgmFades.Add((targetPercent, durationMs)); public void PlayMovieToSurface(long resourceId, int surfaceSlot, long movieFlags, long syncMask) => Movies.Add((resourceId, surfaceSlot, movieFlags, syncMask)); + public void PlayModalMovieToSurface(long rawResourceId, int surfaceSlot, long movieFlags) + => ModalMovies.Add((rawResourceId, surfaceSlot, movieFlags)); } internal sealed class MapProvider : IScriptProvider diff --git a/engine/Age.Engine/Hosting/IHost.cs b/engine/Age.Engine/Hosting/IHost.cs index 82b785c..013a6b9 100644 --- a/engine/Age.Engine/Hosting/IHost.cs +++ b/engine/Age.Engine/Hosting/IHost.cs @@ -103,4 +103,8 @@ public interface IHost // Native op 0x236 binds a DirectShow movie decoder to an existing retained texture surface. // Playback is non-modal: the VM advances to the following instruction while the host publishes frames. void PlayMovieToSurface(long resourceId, int surfaceSlot, long movieFlags, long syncMask) { } + // Native op 0x20f uses a universal raw-catalog id and parks script execution until the movie + // reaches EOF or the player cancels it. The decoder remains asynchronous; the interactive host + // owns the modal wait so its render loop can continue publishing frames. + void PlayModalMovieToSurface(long rawResourceId, int surfaceSlot, long movieFlags) { } } diff --git a/engine/Age.Engine/Sys4/ResourceMap.cs b/engine/Age.Engine/Sys4/ResourceMap.cs index 5bd0170..76c1305 100644 --- a/engine/Age.Engine/Sys4/ResourceMap.cs +++ b/engine/Age.Engine/Sys4/ResourceMap.cs @@ -41,6 +41,16 @@ public sealed class ResourceMap entry.Name.EndsWith(".AGF", StringComparison.OrdinalIgnoreCase) ? entry : null; } + /// Resolve op 0x20f's universal raw-catalog movie id without applying the executing + /// script's manifest base. AGE stores these MPEG program streams under .AGF names; ReadMovie + /// validates the payload signature before playback. + public AssetEntry? ResolveRawMovie(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 2269188..13d93c5 100644 --- a/engine/Age.Engine/Vm/VirtualMachine.cs +++ b/engine/Age.Engine/Vm/VirtualMachine.cs @@ -35,6 +35,7 @@ public sealed class VirtualMachine private long _autoMessageTime0Ms = 500; private long _autoMessageTime1Ms = 2000; private bool _autoVoicePending; + private bool _initialRootRun = true; private volatile bool _messageSkipEnabled; private volatile bool _messageSkipServiceActive; private AdvTextStyle _advTextStyle = AdvTextStyle.Default; @@ -590,8 +591,13 @@ public sealed class VirtualMachine Write(a[0], visits == 0 ? (terminal == 0 ? 1 : 0) : terminal); return pc + 1; } - case "exit": - case "exit-script": return FRAME_RETURN; + case "exit": return FRAME_RETURN; + case "exit-script": + // Native op 0x9 clears the process-initial root flag before returning control to + // the root-script loader. Root reload itself remains represented by the port's + // existing frame/session boundary; retaining the flag here prevents LOGO/OP replay. + _initialRootRun = false; + return FRAME_RETURN; case "call-script": { long id = a.Count > 0 ? Read(a[0]) : 0; @@ -1028,6 +1034,19 @@ public sealed class VirtualMachine _host.FadeBgm((int)Read(a[0]), Read(a[1])); return pc + 1; case "u00415880": // 0xd9 / semantics: clear-run-state-0x1000 return pc + 1; + case "get-initial-root-run": // 0x130 (out) + Write(a[0], _initialRootRun ? 1 : 0); + return pc + 1; + case "play-modal-movie-to-surface": // 0x20f (raw resource)(surface)(movie flags) + { + long rawResourceId = Read(a[0]); + int surfaceSlot = (int)Read(a[1]); + // The modal and scene-local paths share retained-surface composition. The host's + // distinct entry point preserves 0x20f's raw-id resolver and blocking lifecycle. + Gfx.SetSurface(surfaceSlot, rawResourceId, 0); + _host.PlayModalMovieToSurface(rawResourceId, surfaceSlot, Read(a[2])); + return pc + 1; + } case "u004221A0": // pre-reference compatibility case "play-movie-to-surface": // 0x236 (resource)(surface)(movie flags)(sync mask) { diff --git a/godot/GodotAdvHost.cs b/godot/GodotAdvHost.cs index cceb9b8..ccb2336 100644 --- a/godot/GodotAdvHost.cs +++ b/godot/GodotAdvHost.cs @@ -48,11 +48,14 @@ public sealed class GodotAdvHost : IHost private long _waitIndicatorStartedMs; private bool _waitIndicatorEnabled; private volatile bool _advPagePresentationSuspended; + private volatile bool _modalMovieWaiting; + private volatile bool _modalMovieCancelled; private GfxState? _foregroundGfx; public volatile bool IsWaiting; public volatile bool IsTransitionWaiting; public volatile bool IsSleeping; public volatile bool IsTextRevealing; + public bool IsModalMovieWaiting => _modalMovieWaiting; private int _presentRequested = 1; private long _transitionStartedAtMs = -1; public long TransitionStartedAtMs => System.Threading.Interlocked.Read(ref _transitionStartedAtMs); @@ -342,6 +345,13 @@ public sealed class GodotAdvHost : IHost // the foreground lifecycle; it never pre-arms or advances the following stable input wait. public void SignalInput() { + if (_modalMovieWaiting) + { + _modalMovieCancelled = true; + _timeline?.State("modal-movie-cancel", new()); + _frameSignal.Set(); + return; + } if (IsTextRevealing) { lock (_textLock) _advTextForceComplete = true; @@ -566,6 +576,54 @@ public sealed class GodotAdvHost : IHost string scene = CurrentScene; var asset = _res.Resolve(scene, resourceId); if (asset == null) { Godot.GD.Print($"movie unresolved {scene}:0x{resourceId:x}"); return; } + StartMovie(asset, resourceId, surfaceSlot, movieFlags, syncMask, modal: false); + } + + public void PlayModalMovieToSurface(long rawResourceId, int surfaceSlot, long movieFlags) + { + var asset = _res.ResolveRawMovie(rawResourceId); + if (asset == null) + { + Godot.GD.Print($"modal movie unresolved raw:0x{rawResourceId:x}"); + return; + } + + _modalMovieCancelled = false; + _modalMovieWaiting = true; + try + { + if (!StartMovie(asset, rawResourceId, surfaceSlot, movieFlags, 0, modal: true)) return; + _timeline?.State("modal-movie-wait", new() + { + ["resource"] = rawResourceId, ["surface"] = surfaceSlot, ["file"] = asset.Name, + }); + while (!_stopping && !_modalMovieCancelled) + { + lock (_imageLock) + if (_completedMovies.Contains(rawResourceId)) break; + _frameSignal.WaitOne(50); + } + + // Cancellation is a completed modal presentation from the script's perspective. The + // wrapper's following surface-release opcode performs the ordinary decoder teardown. + if (_modalMovieCancelled) + lock (_imageLock) _completedMovies.Add(rawResourceId); + _timeline?.State("running", new() + { + ["modal_movie_complete"] = !_modalMovieCancelled, + ["modal_movie_cancelled"] = _modalMovieCancelled, + }); + } + finally + { + _modalMovieWaiting = false; + _modalMovieCancelled = false; + } + } + + private bool StartMovie(AssetEntry asset, long resourceId, int surfaceSlot, long movieFlags, + long syncMask, bool modal) + { try { var movie = _res.ReadMovie(asset); @@ -579,11 +637,16 @@ public sealed class GodotAdvHost : IHost _timeline?.Event("movie-start", new() { ["resource"] = resourceId, ["surface"] = surfaceSlot, ["file"] = movie.Name, - ["flags"] = movieFlags, ["sync_mask"] = syncMask, + ["flags"] = movieFlags, ["sync_mask"] = syncMask, ["modal"] = modal, }); _main.CallDeferred("PlayMovie", movie.Bytes, movie.Name, resourceId, asset.RawIndex); + return true; + } + catch (System.Exception e) + { + Godot.GD.Print($"movie read failed {asset.Name}: {e.Message}"); + return false; } - catch (System.Exception e) { Godot.GD.Print($"movie read failed {asset.Name}: {e.Message}"); } } public void ReleaseSurface(int slot) diff --git a/godot/Main.cs b/godot/Main.cs index eb34ff7..2f090f1 100644 --- a/godot/Main.cs +++ b/godot/Main.cs @@ -386,6 +386,12 @@ public partial class Main : Godot.Control _vm.UpdatePointer(p.X, p.Y); int nativeButtonBit = mb.ButtonIndex == MouseButton.Left ? 0x1 : 0x2; _vm.UpdateMouseButtonState(nativeButtonBit, mb.Pressed); + if (mb.Pressed && _host.IsModalMovieWaiting) + { + _host.SignalInput(); + GetViewport().SetInputAsHandled(); + return; + } // AGE exposes the physical left button twice: raw mask 0x1 for the timed mouse callback, // and the configured primary action (default input callback index 4). Script-owned callback // loops consume both channels without releasing the enclosing ADV page wait. @@ -408,6 +414,13 @@ public partial class Main : Godot.Control UpdateAgeInputCallback(e, "ui_right", 3); UpdateAgeInputCallback(e, "ui_accept", 4); UpdateAgeInputCallback(e, "ui_cancel", 5); + if (_host.IsModalMovieWaiting + && (e.IsActionPressed("ui_accept") || e.IsActionPressed("ui_cancel"))) + { + _host.SignalInput(); + GetViewport().SetInputAsHandled(); + return; + } if (e.IsActionPressed("ui_accept") && !_host.IsAdvPagePresentationSuspended && !_vm.IsRawInputCallbackActive) _host.SignalInput(); } diff --git a/vm-map/engine-ctx.toml b/vm-map/engine-ctx.toml index f04c18c..a8e3fbf 100644 --- a/vm-map/engine-ctx.toml +++ b/vm-map/engine-ctx.toml @@ -173,6 +173,11 @@ name = "frame_instruction_word_count" type = "int" note = "current decoded instruction length in dwords for each 0x78-byte script frame; interpreter advances PC by this value * 4" [[field]] +offset = 0x54ff0 +name = "initial_root_run" +type = "int" +note = "initialized to 1 at AGE context construction; queried by op 0x130 and cleared by op 0x9 before root script id 0 is reloaded" +[[field]] offset = 0x550fc name = "message_skip_display_enabled" type = "int" diff --git a/vm-map/opcodes.toml b/vm-map/opcodes.toml index df70e59..d53537f 100644 --- a/vm-map/opcodes.toml +++ b/vm-map/opcodes.toml @@ -180,13 +180,14 @@ abi_source = "kelebek+decode-validated" [opcode.semantics] name = "exit-script" -category = "unknown" -summary = "" +category = "control" +summary = "() - terminate the active script lifecycle and return to root script id 0. Before the native engine resets/reloads the root, it clears the initial-root-run flag queried by op 0x130 so LOGO/OP are not replayed." noop_headless = false -source = "kelebek" -confidence = "med" +source = "investigation" +confidence = "high" depends_on = [] -evidence = "" +evidence = "Ghidra /v2: op_0x9_handler@0x418f50 stores zero to EngineCtx+0x54ff0, calls scene_context_init_reset, and loads root script resource id 0. Corpus sites are terminal scene/control exits rather than ordinary local returns." +details = "The port retains its earlier frame/session-boundary representation of root return, but now performs the proven process-lifecycle side effect by clearing its VM-owned initial-root-run flag. A faithful whole-stack root reload remains part of the broader persistent scene-coordinator work, not the startup-movie slice." [[opcode]] op = 0x21 @@ -2612,23 +2613,24 @@ observed_types = ["imm", "l-int"] [[opcode]] op = 0x130 -label = "u00415F40" +label = "get-initial-root-run" argc = 1 abi_source = "kelebek+decode-validated" [opcode.semantics] -name = "u00415F40" -category = "unknown" -summary = "" +name = "get-initial-root-run" +category = "control" +summary = "(out) - write the engine's initial-root-run flag. It is initialized to 1 when the AGE context is constructed and cleared by op 0x9 before that opcode resets state and reloads root script id 0. SYSTEM4 uses the value to call LOGO.BIN and OP.BIN only on the process's initial root run." noop_headless = false -source = "kelebek" -confidence = "low" +source = "investigation" +confidence = "high" depends_on = [] -evidence = "" +evidence = "Ghidra /v2: op_0x130_get_initial_root_run@0x4295b0 copies EngineCtx+0x54ff0 to operand 1. FUN_00413860 initializes +0x54ff0 to 1 at 0x413cc7/0x413d15; op_0x9_handler@0x418f50 is its only later writer and clears it before scene_context_init_reset plus script_frame_load_resource(...,0). Corpus: sole site SYSTEM4@0x29a branches to LOGO.BIN then OP.BIN only when the returned value is nonzero." +details = "Implemented as process-lifecycle state owned by the persistent VM: it begins at one and op 0x9 clears it. It is not a script global, save/profile value, command-line seed, or script-name special case." [[opcode.semantics.args]] i = 1 -role = "" +role = "out_initial_root_run" observed_types = ["l-int"] [[opcode]] @@ -5169,33 +5171,34 @@ evidence = "Ghidra /v2: op_0x20e_clear_render_target@0x4174f0 passes renderer ct [[opcode]] op = 0x20f -label = "u00420E40" +label = "play-modal-movie-to-surface" argc = 3 abi_source = "kelebek+decode-validated" [opcode.semantics] -name = "u00420E40" -category = "unknown" -summary = "" +name = "play-modal-movie-to-surface" +category = "draw" +summary = "(raw_resource_id)(surface_slot)(movie_flags) - open a universal raw-catalog MPEG asset into an existing retained surface, start its native movie graph, and arm modal run-state bit 0x2000 so script execution remains parked until the movie completes or is skipped. This is the LOGO/OP/ED whole-movie path, distinct from non-modal scene movie op 0x236." noop_headless = false -source = "kelebek" -confidence = "low" +source = "investigation" +confidence = "high" depends_on = [] -evidence = "" +evidence = "Ghidra /v2: op_0x20f_play_modal_movie_to_surface@0x422e50 shares the movie-object allocation, DirectShow graph open, audio-route, and volume setup used by 0x236, then calls movie_start_modal_playback@0x463280, ORs EngineCtx+0xa0ce4 with 0x2000, and marks movie presentation dirty. The main loop and window procedure special-case run-state 0x2000. Corpus has exactly three sites: LOGO (0x335f,42,4), OP (0x3364,42,4), and ED (0x3324,42,dynamic flags). Those ids are universal raw SYS4INI indexes for MPEG-pack LOGO.AGF, OP.AGF, and ED.AGF; each script releases its surface only after 0x20f resumes." +details = "Implemented through IHost.PlayModalMovieToSurface. ResourceMap.ResolveRawMovie deliberately bypasses scene manifests while retaining MPEG signature validation in ReadMovie. Godot reuses the asynchronous decoder/retained-surface compositor, parks only the VM thread until EOF, and treats mouse click or Accept/Cancel input as completion before wrapper cleanup releases the decoder. MPEG audio remains a separate backend/audio-clock contract." [[opcode.semantics.args]] i = 1 -role = "" +role = "raw_resource_id" observed_types = ["imm"] [[opcode.semantics.args]] i = 2 -role = "" +role = "surface_slot" observed_types = ["imm"] [[opcode.semantics.args]] i = 3 -role = "" +role = "movie_flags" observed_types = ["imm", "l-int"] [[opcode]]