Implement modal startup movies

This commit is contained in:
gamer147
2026-07-20 20:23:15 -04:00
parent 6dd0dc027b
commit a82f25230c
16 changed files with 293 additions and 49 deletions

View File

@@ -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

View File

@@ -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 |

View File

@@ -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:

View File

@@ -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

View File

@@ -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.

View File

@@ -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.

View File

@@ -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<Operand>()),
}, System.Array.Empty<string>());
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<Operand>()),
}, System.Array.Empty<string>());
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()
{

View File

@@ -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);

View File

@@ -36,6 +36,7 @@ internal class RecordingHost : IHost
public readonly List<int> 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<int> ClearedRenderTargets = new();
public readonly List<(int First, int Count)> ReleasedSurfaceRanges = new();
public readonly List<bool> 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

View File

@@ -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) { }
}

View File

@@ -41,6 +41,16 @@ public sealed class ResourceMap
entry.Name.EndsWith(".AGF", StringComparison.OrdinalIgnoreCase) ? entry : null;
}
/// <summary>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.</summary>
public AssetEntry? ResolveRawMovie(long rawId)
{
var entry = _catalog.ResolveRaw(rawId);
return entry is { IsPlaceholder: false } &&
entry.Name.EndsWith(".AGF", StringComparison.OrdinalIgnoreCase) ? entry : null;
}
/// <summary>Decode an AGF directly from loose-first VFS bytes.</summary>
public RgbaImage DecodeTexture(AssetEntry entry) => AgfDecoder.Decode(_store, entry);

View File

@@ -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)
{

View File

@@ -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)

View File

@@ -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();
}

View File

@@ -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"

View File

@@ -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]]