From fdcfbd9fa6158b5971a4628f3b816621c7124d63 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Tue, 21 Jul 2026 14:43:24 -0400 Subject: [PATCH] Implement movie surface stop-time queries --- docs/engine-re.md | 46 +++++++++++++- docs/opcode-reference.md | 33 ++++++----- docs/phase-a-slice-plan.md | 21 +++++++ docs/phase-b-framework.md | 11 ++++ docs/platform-portability.md | 6 +- engine/Age.Engine.Tests/MovieOpcodeTests.cs | 66 +++++++++++++++++++++ engine/Age.Engine.Tests/TestSupport.cs | 10 +++- engine/Age.Engine/Hosting/IHost.cs | 7 ++- engine/Age.Engine/Model/GfxState.cs | 37 +++++++++++- engine/Age.Engine/Vm/VirtualMachine.cs | 25 +++++++- godot/DirectShowMovieDecoder.cs | 25 ++++++++ godot/GodotAdvHost.cs | 18 +++--- godot/Main.cs | 31 ++++++++-- tools/age_opcodes_himegari.py | 7 +++ vm-map/opcodes.toml | 47 ++++++++------- 15 files changed, 330 insertions(+), 60 deletions(-) diff --git a/docs/engine-re.md b/docs/engine-re.md index 9a173a2..a7ab547 100644 --- a/docs/engine-re.md +++ b/docs/engine-re.md @@ -1102,13 +1102,13 @@ annotated in Ghidra, saved. **In scope (built this slice):** | op | handler / worker | semantics | |---|---|---| -| `0x22f` | `gfx_op_0x22f_set_position_anim` → `gfx_worker_set_translation` | set object **position** (translation vec `obj+0x5d4`); base transform, not a ping-pong channel | +| `0x22f` | `gfx_op_0x22f_set_position_anim` → `gfx_worker_set_translation` | set object **position** (translation vec `obj+0x5d4`); base transform, not a ping-pong channel. Operand 2 is also retained as channel control state; the port currently ignores that field. | | `0x229` | `op_0x229_set_gfx_range_transform` → `gfx_range_transform_reset` / `select_handles` / `set_anchor` | reset/select the shared **retained-object range transform**; not a per-object position setter (superseded finding above) | | `0x239` | `gfx_op_0x239_set_srcrect_cell` → `gfx_worker_set_srcrect_cell` | one-shot **spritesheet-cell** channel: delay/duration `obj+0x48/+0x5c`, total frames/columns `obj+0x238/+0x23c`, target frame `obj+0x234` | | `0x231` | `gfx_op_0x231_anim_srcrect` → `gfx_worker_anim_srcrect` | looping **spritesheet-cell** channel: milliseconds per frame `obj+0x230`, total frames `obj+0x238`, columns `obj+0x23c`; row-major and wraps, not ping-pong | | `0x232` | `gfx_op_0x232_anim_color` → `gfx_worker_anim_color` | **animate color**: bit2 active, period `obj+0x220`, target `obj+0x240` → interpolator COLOR channel (ping-pong). Negative alpha/RGB preserve corresponding bytes from static color `obj+0x60`; alpha >255 clamps. Distinct from one-shot `0x202`/static `0x203` | | `0x228` | `gfx_op_0x228_query_position` → `gfx_object_query_translation_target` (`0x47cdd0`) | **query** the decomposed target-translation matrix (x,y,z), `obj+0x1ac/+0x1b0/+0x1b4`, → operand slots 3/4/5; success is 0 and missing is 1 | -| `0x23f` | `gfx_op_0x23f_query_object` (`FUN_0042a520`) | **query** an object status/value → operand slot 1 | +| `0x23f` | `op_0x23f_query_surface_stop_time_ms` (`0x42a520`) | **query loaded-surface DirectShow stop position in integer milliseconds** → operand slot 1, or -1 for an empty movie surface. Implemented with an explicit warning/-1 safety path when host timing metadata is unavailable. | **Follow-up resolution (2026-07-10):** `0x21f` is the one-shot axis-angle channel and is implemented with affine rasterization. `0x223` is **not affine**: `gfx_queue_surface_alpha_transition` (`0x47f440`) inserts @@ -1223,6 +1223,48 @@ The `/v2` image names/comments the handler; movie ctor/interface/open/play/volum route helpers; renderer media-type/sample workers; and stop/detach/destructor lifecycle. The image was saved after annotation. +### Movie-surface stop-time query `0x23f` (2026-07-21) + +The exact ABI is `query-surface-stop-time-ms(out_stop_time_ms, surface_slot)`. Handler +`op_0x23f_query_surface_stop_time_ms@0x42a520` records a 5-dword instruction length, fetches operand 2, +and directly indexes `EngineCtx+0x52bd4[surface_slot]`. A null surface writes `-1` to operand 1. A non-null +surface dereferences the `CMovieToTexture+0x414` interface pointer and calls vtable slot `+0x28` with a +stack `double` output parameter. + +That interface is conclusively `IMediaPosition`: graph initialization queries IID +`{56A868B2-0AD4-11CE-B03A-0020AF0BA770}`, and the inherited `IUnknown` + `IDispatch` layout places +`get_Duration` at `+0x1c`, `put_CurrentPosition` at `+0x20`, `get_CurrentPosition` at `+0x24`, and +`get_StopTime` at `+0x28`. Neighboring op `0x23e` calls `+0x24`, while movie op `0x245` calls `+0x20`, +which independently confirms the slot mapping. Thus `0x23f` does **not** call `get_Duration`; it asks for +the configured playback stop position. With an ordinary freshly opened graph that position normally +equals the media duration, explaining why every observed consumer uses it as a lifetime. + +Native multiplies the returned `REFTIME` seconds by the double `1000.0` at `0x5713e8`, passes the x87 +value to the compiler helper at `0x550850`, and writes the low 32-bit integer to operand 1. The helper's +SSE2 branch uses `CVTTSD2SI`; its x87 fallback corrects the current rounding result to the same behavior, +so conversion is truncation toward zero. The COM `HRESULT` is ignored and the local output is not +preinitialized: the handler assumes any non-null movie surface has a usable `IMediaPosition`. It is a +pure query—no run-state bit, service boundary, seek, or playback mutation occurs. + +Himegari has 23 calls in 17 scripts: BTL, DEBUGADV2, FIELD (2), SC0000/10/20/40/50/60/70/80/90, +SC0100 (2), SC0110 (2), SC0120 (2), SC0130 (3), and USEMAGIC. Every site is associated with a preceding +`0x236` load of the queried surface; the repeated ADV form has five table writes between load and query. +FIELD supplies the strongest unit evidence: one path computes `stop_time_ms / 16 + 1` for a 16 ms callback +schedule, and another clamps it to 600 ms before calling DRAWVOL. This corroborates the native decode and +rules out the former port behavior, `retained gfx object exists ? 0 : -1`. + +**Port implementation (2026-07-21):** `DirectShowMovieDecoder` now queries +`IMediaPosition::get_StopTime` after the graph reaches its running state and before synchronous `0x236` +initialization returns. It applies native seconds-to-milliseconds truncation and hands the value through +`IHost.PlayMovieToSurface` into a movie-surface record owned by `GfxState`. Decoder construction moved from +the deferred Godot callback to the VM-side synchronous open boundary; the ready decoder is staged in a +thread-safe pending registry and adopted by the main thread before frame sampling, preserving asynchronous +presentation. `0x23f` silently returns -1 for an empty movie slot. If the movie record exists but DirectShow +returned an error, non-finite value, or value outside native signed-32-bit range, the port emits +`movie stop-time unavailable ...; returning -1` and returns -1. This is the chosen safe substitute for +native's ignored-HRESULT/uninitialized-output edge case. A normal Game Start through SC0000 was manually +validated without the warning, confirming that the installed movie's ordinary metadata path succeeds. + ### Grey-background root cause — slot collision + tint-strength (2026-07-08, gfx-log) Diagnosed with the new `--gfx-log` compositor/op trace (docs/tools-reference.md). The grey background has diff --git a/docs/opcode-reference.md b/docs/opcode-reference.md index 294962f..2fcb920 100644 --- a/docs/opcode-reference.md +++ b/docs/opcode-reference.md @@ -589,7 +589,7 @@ Implemented through IHost.PlayModalMovieToSurface. ResourceMap.ResolveRawMovie d - **grounding:** source=investigation, confidence=high, noop_headless=True - **evidence:** Ghidra handler 0x417550 -> gfx_command_queue_clear 0x47cb10, which destroys queued nodes and restores the sentinel links/count. -### 0x228 `u00421940` (u00421940, argc 5) +### 0x228 `query-gfx-translation-target` (u00421940, argc 5) - **summary:** 0x228 query translation target (succ)(handle)(outX)(outY)(outZ): clone the retained object, decompose its target translation matrix at obj+0x17c, and return matrix translation obj+0x1ac/+0x1b0/+0x1b4. Returns succ=0 when found; when absent, writes succ=1 and leaves outputs untouched. The C# VM queries TranslationTarget independently of V24. See docs/engine-re.md §SC0000 anim cluster. - **grounding:** source=investigation, confidence=high - **evidence:** Ghidra /v2 handler gfx_op_0x228_query_position@0x42a3a0 calls gfx_object_query_translation_target@0x47cdd0. The worker copies the complete 0xb5-dword object record, passes copied obj+0x17c to matrix4_decompose_affine@0x48d7c8, and returns its translation outputs; the decomposition reads matrix elements +0x30/+0x34/+0x38, corresponding to obj+0x1ac/+0x1b0/+0x1b4. SC0000 AE001H queries this before each 0x220 leg. C# regression covers targets (40,-20), (50,-80), (130,-100), plus the missing-object output-preservation path. @@ -614,16 +614,17 @@ Implemented through IHost.PlayModalMovieToSurface. ResourceMap.ResolveRawMovie d - **grounding:** source=investigation, confidence=high - **evidence:** Ghidra /v2: op_0x22d_set_gfx_range_scale_target@0x423990 divides operands 3..5 by 100 and calls gfx_range_transform_set_scale_target@0x472d50. The worker arms the embedded transform object's ordinary scale channel (delay obj+0x3c, duration +0x50, target matrix +0xac), which gfx_range_transform_sample_frame@0x476df0 samples before range composition. FIELD has the sole corpus call, a 300 ms camera zoom. -### 0x22f `u00421DD0` (u00421DD0, argc 5) -- **summary:** 0x22f set-position (handle)(op2)(x)(y)(z): set the object base position (direct transform, not ping-pong). Worker gfx_worker_set_translation @0x472e90. C# VM: sets V24. See docs/engine-re.md §SC0000 anim cluster. -- **grounding:** source=kelebek, confidence=low +### 0x22f `set-gfx-position-current` (u00421DD0, argc 5) +- **summary:** (handle)(control)(x)(y)(z) — set the object's current/base translation matrix. The native worker also retains operand 2 as channel control state; the port currently applies x/y/z but ignores that control operand. +- **grounding:** source=investigation, confidence=high +- **evidence:** Ghidra /v2: gfx_op_0x22f_set_position_anim@0x423b00 fetches x/y/z as floats and passes all five operands to gfx_worker_set_translation@0x472e90. The worker builds the current translation matrix with x/y/z and stores operand 2 in the channel record. This is distinct from the shared range transform at 0x229. The C# handler sets V24 directly and is therefore position-correct but does not yet model operand 2. -### 0x231 `u00421EA0` (u00421EA0, argc 4) +### 0x231 `animate-gfx-srcrect-loop` (u00421EA0, argc 4) - **summary:** (handle)(frame_period_ms)(frame_count)(column_count) — loop row-major through the spritesheet. Every frame preserves draw-texture's source-rectangle width/height; frame=floor(elapsed/frame_period)%frame_count, src offset=(frame%columns*width, frame/columns*height). Worker gfx_worker_anim_srcrect @0x47eec0; consumer gfx_object_anim_interpolate @0x473ed0. - **grounding:** source=investigation, confidence=high - **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) +### 0x232 `animate-gfx-color-loop` (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 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 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. @@ -644,7 +645,7 @@ The handler requires an existing destination texture, allocates/reuses a 0x478-b - **summary:** (duration) — set the GLOBAL animation clock: native ctx+0x51b78=0 (elapsed), +0x51b7c=duration. The generic instruction length is 3 dwords. NON-BLOCKING: only configures; the render loop advances it and interpolates all animating objects. SC0000 opening @0x123bd/@0x13858. Handler 0x4240e0; Kelebek VA 0x422390 is drift. - **grounding:** source=investigation, confidence=high -### 0x239 `u004223C0` (u004223C0, argc 6) +### 0x239 `animate-gfx-srcrect-target` (u004223C0, argc 6) - **summary:** (handle)(delay_ms)(duration_ms)(frame_count)(column_count)(target_frame) — one-shot row-major source-rectangle cell channel. Worker gfx_worker_set_srcrect_cell @0x47ed90 stores timing at obj+0x48/+0x5c, layout at +0x238/+0x23c, and target at +0x234. C# currently retains the endpoint cell immediately. - **grounding:** source=investigation, confidence=high - **evidence:** Native /v2 worker and gfx_object_apply_transform_channels decompile. The consumer advances target_frame cells over duration after delay, preserves the existing source-rect dimensions, and commits the endpoint. @@ -656,9 +657,12 @@ The handler requires an existing destination texture, allocates/reuses a 0x478-b For each fixed slot in [42,1000), the handler stops/releases the movie-to-texture object at ctx+0x52bd4[slot], then invokes the ordinary retained-gfx surface-release worker. Protected/externally owned slots may be retained by the worker's per-slot guard. This is the resource half of the common 0x1f6/0x23d full-reset sequence. -### 0x23f `u00422930` (u00422930, argc 2) -- **summary:** 0x23f query-object (out)(handle): return object status (FUN_0042a520; -1 if none). C# VM: 0 if the object exists else -1. See docs/engine-re.md §SC0000 anim cluster. -- **grounding:** source=kelebek, confidence=low +### 0x23f `query-surface-stop-time-ms` (u00422930, argc 2) +- **summary:** (out_stop_time_ms)(surface_slot) — query the DirectShow stop position retained by a loaded movie surface, convert seconds to integer milliseconds by truncating toward zero, and write -1 when the movie slot is empty. The port retains this metadata during 0x236 graph initialization; unavailable metadata emits a warning and also returns -1. +- **grounding:** source=investigation, confidence=high +- **evidence:** Ghidra /v2: op_0x23f_query_surface_stop_time_ms@0x42a520 indexes EngineCtx surface array operand 2, returns -1 for a null slot, otherwise dereferences movie+0x414 IMediaPosition and calls vtable+0x28 get_StopTime. The adjacent op 0x23e uses the same interface at vtable+0x24 get_CurrentPosition; movie op 0x245 uses +0x20 put_CurrentPosition, independently confirming the documented vtable layout. The returned seconds are multiplied by g_dMillisecondsPerSecond@0x5713e8 (double 1000.0) and truncated by crt_ftol2_sse_truncate@0x550850 before vm_operand_write(1). All 23 Himegari sites in 17 scripts are associated with a preceding op 0x236 movie load to the queried surface. FIELD divides one result by 16 and adds 1 to build a 16 ms callback schedule; another path clamps the result to 600 ms before DRAWVOL. + +The surface object's +0x414 member is IMediaPosition. Its vtable +0x28 entry is get_StopTime (after IUnknown, IDispatch, get_Duration, put_CurrentPosition, and get_CurrentPosition), returning a REFTIME double in seconds. Native multiplies by the double constant 1000.0 and calls the compiler float-to-integer helper, whose SSE2 and x87 paths both truncate toward zero. It does not inspect the getter HRESULT. For a valid graph the default stop time normally equals media duration, which explains duration-style consumers, but the exact ABI is stop position rather than get_Duration. The handler only queries state; it does not yield or alter playback. Port safety extension: a modeled movie surface whose decoder cannot supply a finite signed-32-bit stop time reports a warning and returns -1 instead of reproducing native's uninitialized-output failure path. ### 0x242 `set-object-animation-detached` (set-object-animation-detached, argc 2) - **summary:** Replace the retained object's animation-control word at obj+0x2d0. Bit 0 detaches finite one-shot channels from blocking presentation and protects them from 0x243 forced completion until they finish naturally. @@ -871,6 +875,11 @@ op 0x90 (u0041BEB0, argc 7): `0x90 x y w h tgt_a tgt_b tgt_c`. Kelebek left it " ## marker +### 0x1a8 `instruction-marker-noop` (dev_ukn, argc 0) +- **summary:** Zero-operand structural marker. The native shared 0xaf/0x1a8 handler only records this instruction's one-dword length and returns. +- **grounding:** source=investigation, confidence=high, noop_headless=True +- **evidence:** Ghidra /v2: op_0xaf_0x1a8_handler@0x416650 writes frame_instruction_word_count=1 for the current script context and immediately returns. Himegari has 1,989 op-0x1a8 sites. The port's explicit no-op matches native behavior. + ### 0x1bf `call-end` (u004156C0, argc 0) - **summary:** zero-arg; call->0x1bf->stmt-end — end-of-call-statement marker - **grounding:** source=inference, confidence=med, noop_headless=True @@ -1142,10 +1151,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=med -### 0x1a8 `dev_ukn` (dev_ukn, argc 0) -- **summary:** — -- **grounding:** source=kelebek, confidence=low - ### 0x1a9 `u00428090` (u00428090, argc 1) - **summary:** — - **grounding:** source=kelebek, confidence=low diff --git a/docs/phase-a-slice-plan.md b/docs/phase-a-slice-plan.md index 983d96c..f4762bf 100644 --- a/docs/phase-a-slice-plan.md +++ b/docs/phase-a-slice-plan.md @@ -2238,3 +2238,24 @@ and closed without losing the underlying page, then classify only the secondary **Manual validation:** passed on 2026-07-21. The system menu opens from ADV and returns successfully through the implemented script-owned action path; no underlying-page restoration discrepancy was reported. + +### Slice B1 movie-surface stop-time query implemented (2026-07-21) + +Native RE corrected op `0x23f` from the port's former retained-object existence test to +`query-surface-stop-time-ms(out, surface_slot)`. It calls `IMediaPosition::get_StopTime`, multiplies seconds +by 1000, truncates toward zero, and returns -1 only for an empty movie slot. All 23 Himegari sites are +associated with a preceding non-modal `0x236`; FIELD consumes the result as an effect lifetime. + +The Windows decoder now obtains the stop time inside `0x236`'s synchronous graph-construction boundary and +hands it into engine-owned per-surface movie state before the VM advances. The already initialized decoder +is staged for main-thread adoption, so frame delivery remains asynchronous without reopening the graph. +`0x23f` returns the retained value. If a movie exists but its decoder supplies no finite signed-32-bit +timing value, the host reports a warning containing script/offset/surface context and returns -1; a genuinely +empty movie slot returns -1 silently. Focused tests cover all three outcomes and the real SC0000 DirectShow +payload exposes a positive stop time. Validation: 270 engine tests, zero-warning Godot build, and threaded +Godot selftest. + +**Manual validation:** passed on 2026-07-21. A normal Game Start through SC0000's movie-backed opening +produced no `movie stop-time unavailable` warning, confirming the ordinary DirectShow timing handoff. + +**Next:** continue with the next concrete discrepancy reached by manual testing. diff --git a/docs/phase-b-framework.md b/docs/phase-b-framework.md index 6c99ef4..dc474f3 100644 --- a/docs/phase-b-framework.md +++ b/docs/phase-b-framework.md @@ -423,6 +423,17 @@ decoder-format gap, not missing or EXE-embedded artwork. The CUR decoder now han and 4-bpp formats with independent XOR/AND strides; archive-backed pixel/hotspot tests pass. Manual acceptance confirms the gripped cursor now displays correctly during drag-panning. +A follow-up full-corpus analytics audit corrected a metadata artifact: ops `0x228`, `0x22f`, `0x231`, +`0x232`, `0x239`, and `0x23f` had detailed native RE but still retained opaque semantic names, while +`0x1a8` was an unnamed explicit no-op. Their semantic names are now current, and native proves `0x1a8` +is a structural marker whose handler only records instruction length. The same audit exposed a real runtime +gap, now closed: `0x23f` queries a loaded surface's DirectShow stop position in truncated integer +milliseconds (all 23 sites are associated with a preceding `0x236`). On a freshly opened graph the stop +position normally equals its duration; FIELD uses it for 16 ms animation scheduling. The port now queries +that value during synchronous movie-graph initialization and retains it per movie surface. Empty movie +slots return -1; unavailable timing metadata warns and returns -1 rather than exposing native's undefined +failure output. + ## Later Phase B breadth Once the natural spine and first gameplay loop are trustworthy, broaden in independent tracks: diff --git a/docs/platform-portability.md b/docs/platform-portability.md index 378d023..aa11186 100644 --- a/docs/platform-portability.md +++ b/docs/platform-portability.md @@ -52,10 +52,12 @@ VM op 0x236 (non-modal) / op 0x20f (modal) ``` Everything before and after `DirectShowMovieDecoder` is portable. The backend currently exposes the right -conceptual operations (`TryTakeFrame`, `IsCompleted`, and `Dispose`) but they are not formalized as an +conceptual operations (`StopTimeMs`, `TryTakeFrame`, `IsCompleted`, and `Dispose`) but they are not formalized as an interface. A future cleanup should: -1. Add an `IMovieDecoder` contract for frame delivery, completion, failure, and disposal. +1. Add an `IMovieDecoder` contract for synchronous stop-time metadata, frame delivery, completion, failure, + and disposal. Stop time must be available before `0x236` returns so an immediately following `0x23f` + remains deterministic. 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. diff --git a/engine/Age.Engine.Tests/MovieOpcodeTests.cs b/engine/Age.Engine.Tests/MovieOpcodeTests.cs index ca6a066..def615e 100644 --- a/engine/Age.Engine.Tests/MovieOpcodeTests.cs +++ b/engine/Age.Engine.Tests/MovieOpcodeTests.cs @@ -100,6 +100,70 @@ public class MovieOpcodeTests Assert.Equal(0x33, vm.Gfx.SnapshotVisibleObjects().Single().SurfaceResId); } + [Fact] + public void QueryMovieStopTimeReturnsTheValueRetainedByPlayMovie() + { + var table = OpcodeTableJson.Load(Paths.OpcodesJson); + var script = ScriptAssembler.Assemble(table, "MOVIE-TIME", new List<(int, Operand[])> + { + (0x236, new[] { new Operand(0, 0x33), new Operand(0, 5), new Operand(0, 2), new Operand(0, 0) }), + (0x23f, new[] { new Operand(3, 0x1234), new Operand(0, 5) }), + (0x2, System.Array.Empty()), + }, System.Array.Empty()); + var host = new RecordingHost { MovieStopTimeMs = 1876 }; + var vm = new VirtualMachine(script, table, host); + + vm.Run(); + + Assert.Equal(1876, vm.Globals[0x1234]); + Assert.True(vm.Gfx.TryGetMovieStopTime(5, out long? retained)); + Assert.Equal(1876, retained); + Assert.Empty(host.Warnings); + + vm.Gfx.SetSurface(5, 0x34, 0); // replacing the native surface tears down its movie metadata + Assert.False(vm.Gfx.TryGetMovieStopTime(5, out _)); + } + + [Fact] + public void QueryMovieStopTimeReturnsMinusOneWithoutWarningForAnEmptyMovieSlot() + { + var table = OpcodeTableJson.Load(Paths.OpcodesJson); + var script = ScriptAssembler.Assemble(table, "EMPTY-MOVIE-TIME", new List<(int, Operand[])> + { + (0x23f, new[] { new Operand(3, 0x1234), new Operand(0, 5) }), + (0x2, System.Array.Empty()), + }, System.Array.Empty()); + var host = new RecordingHost(); + var vm = new VirtualMachine(script, table, host); + vm.Gfx.GetOrCreate(5); // retained-object existence is unrelated to the native movie-object slot + + vm.Run(); + + Assert.Equal(-1, vm.Globals[0x1234]); + Assert.Empty(host.Warnings); + } + + [Fact] + public void QueryMovieStopTimeWarnsAndReturnsMinusOneWhenMetadataIsUnavailable() + { + var table = OpcodeTableJson.Load(Paths.OpcodesJson); + var script = ScriptAssembler.Assemble(table, "MISSING-MOVIE-TIME", new List<(int, Operand[])> + { + (0x236, new[] { new Operand(0, 0x33), new Operand(0, 5), new Operand(0, 2), new Operand(0, 0) }), + (0x23f, new[] { new Operand(3, 0x1234), new Operand(0, 5) }), + (0x2, System.Array.Empty()), + }, System.Array.Empty()); + var host = new RecordingHost(); + var vm = new VirtualMachine(script, table, host); + + vm.Run(); + + Assert.Equal(-1, vm.Globals[0x1234]); + string warning = Assert.Single(host.Warnings); + Assert.Contains("movie stop-time unavailable MISSING-MOVIE-TIME@", warning); + Assert.Contains("surface=5; returning -1", warning); + } + [Fact] public void Sc0000MoviePayloadReadsFromArchiveVfsAndIsMpegProgramStream() { @@ -136,6 +200,8 @@ public class MovieOpcodeTests var entry = resources.Resolve("SC0000", 0x33)!; using var decoder = new DirectShowMovieDecoder(resources.ReadMovie(entry)); + Assert.True(decoder.StopTimeMs > 0, "DirectShow should expose a positive IMediaPosition stop time"); + var deadline = DateTime.UtcNow.AddSeconds(10); RgbaImage? frame = null; while (DateTime.UtcNow < deadline && !decoder.TryTakeFrame(out frame)) diff --git a/engine/Age.Engine.Tests/TestSupport.cs b/engine/Age.Engine.Tests/TestSupport.cs index d3cdff7..82c381c 100644 --- a/engine/Age.Engine.Tests/TestSupport.cs +++ b/engine/Age.Engine.Tests/TestSupport.cs @@ -38,6 +38,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 long? MovieStopTimeMs; 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(); @@ -45,11 +46,13 @@ internal class RecordingHost : IHost public readonly List<(long Resource, int Slot)> Textures = new(); public readonly List MessageSkipChanges = new(); public readonly List PhysicalMessageSkipChanges = new(); + public readonly List Warnings = new(); public readonly List CursorResources = new(); public readonly List AdvPagePresentationSuspended = new(); public int CursorClearCount; public int SceneContextResets; public long TextureResourceIdOffset; + public void ReportWarning(string message) => Warnings.Add(message); public long ResolveTextureResourceId(long resourceId) => resourceId + TextureResourceIdOffset; public void ShowText(int offset, string text) => Lines.Add((offset, text)); public void SetAdvTextCursor(int layoutSlot, int x, int y) => TextCursors.Add((layoutSlot, x, y)); @@ -145,8 +148,11 @@ internal class RecordingHost : IHost => ScheduledSfxStarts.Add((channel, startMode, delayMs)); public void ReleaseSoundEffect(int channel) => SfxReleases.Add(channel); 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 long? PlayMovieToSurface(long resourceId, int surfaceSlot, long movieFlags, long syncMask) + { + Movies.Add((resourceId, surfaceSlot, movieFlags, syncMask)); + return MovieStopTimeMs; + } public void PlayModalMovieToSurface(long rawResourceId, int surfaceSlot, long movieFlags) => ModalMovies.Add((rawResourceId, surfaceSlot, movieFlags)); } diff --git a/engine/Age.Engine/Hosting/IHost.cs b/engine/Age.Engine/Hosting/IHost.cs index f9e7f0f..8b94317 100644 --- a/engine/Age.Engine/Hosting/IHost.cs +++ b/engine/Age.Engine/Hosting/IHost.cs @@ -15,6 +15,8 @@ public readonly record struct SurfaceRectFill( public interface IHost { + /// Report a recoverable runtime discrepancy while allowing script execution to continue. + void ReportWarning(string message) => System.Console.Error.WriteLine(message); // 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) { } @@ -111,7 +113,10 @@ public interface IHost void FadeBgm(int targetPercent, long durationMs) { } // 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) { } + /// The initialized movie graph's stop position in truncated integer milliseconds, or null + /// when the host could not obtain usable timing metadata. Native op 0x23f queries this state + /// immediately after 0x236 returns. + long? PlayMovieToSurface(long resourceId, int surfaceSlot, long movieFlags, long syncMask) => null; // 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. diff --git a/engine/Age.Engine/Model/GfxState.cs b/engine/Age.Engine/Model/GfxState.cs index a00f240..305b41c 100644 --- a/engine/Age.Engine/Model/GfxState.cs +++ b/engine/Age.Engine/Model/GfxState.cs @@ -299,6 +299,7 @@ public sealed class GfxState _objects.Clear(); _fieldTable.Clear(); _surfaces.Clear(); + _movieStopTimesMs.Clear(); _surfaceTransitions.Clear(); CurrentObject = 0; CurrentRenderTargetSlot = -1; @@ -314,8 +315,32 @@ public sealed class GfxState // ---- surfaces (image buffers per slot): ctx+0x52bd4[slot], from create/set-texture ---- private readonly Dictionary _surfaces = new(); + // A separate entry models the native CMovieToTexture object attached to a surface. A null value means + // the movie object exists but its host decoder supplied no usable IMediaPosition stop time. + private readonly Dictionary _movieStopTimesMs = new(); private readonly Dictionary _surfaceTransitions = new(); - public void SetSurface(int slot, long resId, long colorKey) { lock (_lock) { _surfaces[slot] = (resId, colorKey); } } + public void SetSurface(int slot, long resId, long colorKey) + { + lock (_lock) + { + _surfaces[slot] = (resId, colorKey); + _movieStopTimesMs.Remove(slot); + } + } + + /// Op 0x236 handoff: retain the initialized movie graph's IMediaPosition stop time. Null + /// deliberately distinguishes a movie surface with unavailable metadata from an empty movie slot. + public void SetMovieStopTime(int slot, long? stopTimeMs) + { + lock (_lock) _movieStopTimesMs[slot] = stopTimeMs; + } + + /// Op 0x23f query. False means no movie object occupies the slot; true with a null value + /// means the movie exists but its stop-time query failed or returned unusable metadata. + public bool TryGetMovieStopTime(int slot, out long? stopTimeMs) + { + lock (_lock) return _movieStopTimesMs.TryGetValue(slot, out stopTimeMs); + } /// Op 0x20d: select a surface as the D3D render target; values at or above 1000 restore the /// device backbuffer in the native engine. @@ -333,6 +358,7 @@ public sealed class GfxState for (int slot = firstSlot; slot < end; slot++) { _surfaces.Remove(slot); + _movieStopTimesMs.Remove(slot); _surfaceTransitions.Remove(slot); } if (CurrentRenderTargetSlot >= firstSlot && CurrentRenderTargetSlot < end) @@ -429,7 +455,14 @@ public sealed class GfxState o.ColorAnim = true; } } - public void ClearSurface(int slot) { lock (_lock) { _surfaces[slot] = (0, 0); } } // create-texture (blank) + public void ClearSurface(int slot) + { + lock (_lock) + { + _surfaces[slot] = (0, 0); // create-texture (blank) + _movieStopTimesMs.Remove(slot); + } + } /// Op 0x223: queue a type-0 timed alpha transition into a target surface slot. public void QueueSurfaceAlphaTransition(long commandKey, int targetSlot, diff --git a/engine/Age.Engine/Vm/VirtualMachine.cs b/engine/Age.Engine/Vm/VirtualMachine.cs index 47d650d..c946c31 100644 --- a/engine/Age.Engine/Vm/VirtualMachine.cs +++ b/engine/Age.Engine/Vm/VirtualMachine.cs @@ -1425,7 +1425,8 @@ public sealed class VirtualMachine // The native CMovieToTexture renderer replaces the pixels of the already-created surface. // Retain the same resource binding so the compositor resolves live movie frames for its objects. Gfx.SetSurface(surfaceSlot, resourceId, 0); - _host.PlayMovieToSurface(resourceId, surfaceSlot, Read(a[2]), Read(a[3])); + long? stopTimeMs = _host.PlayMovieToSurface(resourceId, surfaceSlot, Read(a[2]), Read(a[3])); + Gfx.SetMovieStopTime(surfaceSlot, stopTimeMs); return pc + 1; // native cmd size 9 resumes at the next instruction; playback is asynchronous } // ---- gfx command-buffer ops (VM-internal GfxState; docs/engine-re.md op-contract table) ---- @@ -1490,8 +1491,26 @@ public sealed class VirtualMachine else Write(a[0], 1); // native missing-object path leaves output operands untouched return pc + 1; } - case "u00422930": // 0x23f query-object: (out)(handle) <- 0 if the object exists, else -1 - Write(a[0], Gfx.TryGet(Read(a[1])) != null ? 0 : -1); return pc + 1; + case "u00422930": // pre-reference compatibility + case "query-surface-stop-time-ms": // 0x23f (out_stop_time_ms)(surface_slot) + { + int surfaceSlot = (int)Read(a[1]); + if (!Gfx.TryGetMovieStopTime(surfaceSlot, out long? stopTimeMs)) + { + Write(a[0], -1); // native null CMovieToTexture slot + return pc + 1; + } + if (!stopTimeMs.HasValue) + { + _host.ReportWarning( + $"movie stop-time unavailable {_cur.Script.Name}@0x{ins.Offset:x} " + + $"surface={surfaceSlot}; returning -1"); + Write(a[0], -1); + return pc + 1; + } + Write(a[0], stopTimeMs.Value); + return pc + 1; + } case "set-gfx-geom3-c": // 0x1ff: set current translation matrix Gfx.SetCurrentTranslation(Read(a[0]), (Read(a[1]), Read(a[2]), Read(a[3]))); return pc + 1; case "u00420620": // upstream ABI label diff --git a/godot/DirectShowMovieDecoder.cs b/godot/DirectShowMovieDecoder.cs index db79f5b..898e2b2 100644 --- a/godot/DirectShowMovieDecoder.cs +++ b/godot/DirectShowMovieDecoder.cs @@ -35,6 +35,9 @@ internal sealed class DirectShowMovieDecoder : IDisposable, ISampleGrabberCB private IMediaControl? _control; public bool IsCompleted => _completed; + /// The graph's IMediaPosition stop time converted exactly as native op 0x23f does: + /// seconds * 1000, truncated toward zero. Null means DirectShow supplied no usable value. + public long? StopTimeMs { get; private set; } public DirectShowMovieDecoder(MoviePayload movie) { @@ -112,6 +115,13 @@ internal sealed class DirectShowMovieDecoder : IDisposable, ISampleGrabberCB int stateHr = _control.GetState(5000, out int graphState); if (stateHr < 0) Check(stateHr, "wait for running movie graph"); if (graphState != 2) throw new InvalidOperationException($"movie graph entered unexpected state {graphState}"); + var mediaPosition = (IMediaPosition)_graphObject; + int stopHr = mediaPosition.get_StopTime(out double stopTimeSeconds); + double stopTimeMilliseconds = stopTimeSeconds * 1000.0; + if (stopHr >= 0 && double.IsFinite(stopTimeMilliseconds) + && stopTimeMilliseconds >= int.MinValue + && stopTimeMilliseconds <= int.MaxValue) + StopTimeMs = (long)System.Math.Truncate(stopTimeMilliseconds); _ready.Set(); var mediaEvent = (IMediaEvent)_graphObject; while (!_stopping) @@ -265,6 +275,21 @@ internal sealed class DirectShowMovieDecoder : IDisposable, ISampleGrabberCB [ComImport, Guid("56A868B1-0AD4-11CE-B03A-0020AF0BA770"), InterfaceType(ComInterfaceType.InterfaceIsDual)] private interface IMediaControl { [PreserveSig] int Run(); [PreserveSig] int Pause(); [PreserveSig] int Stop(); [PreserveSig] int GetState(int timeout, out int state); } + [ComImport, Guid("56A868B2-0AD4-11CE-B03A-0020AF0BA770"), InterfaceType(ComInterfaceType.InterfaceIsDual)] + private interface IMediaPosition + { + [PreserveSig] int get_Duration(out double seconds); + [PreserveSig] int put_CurrentPosition(double seconds); + [PreserveSig] int get_CurrentPosition(out double seconds); + [PreserveSig] int get_StopTime(out double seconds); + [PreserveSig] int put_StopTime(double seconds); + [PreserveSig] int get_PrerollTime(out double seconds); + [PreserveSig] int put_PrerollTime(double seconds); + [PreserveSig] int put_Rate(double rate); + [PreserveSig] int get_Rate(out double rate); + [PreserveSig] int CanSeekForward(out int canSeekForward); + [PreserveSig] int CanSeekBackward(out int canSeekBackward); + } [ComImport, Guid("56A868B6-0AD4-11CE-B03A-0020AF0BA770"), InterfaceType(ComInterfaceType.InterfaceIsDual)] private interface IMediaEvent { diff --git a/godot/GodotAdvHost.cs b/godot/GodotAdvHost.cs index 8ee0a2b..f9006a8 100644 --- a/godot/GodotAdvHost.cs +++ b/godot/GodotAdvHost.cs @@ -74,6 +74,8 @@ public sealed class GodotAdvHost : IHost _locator = locator; _timeline = timeline; } + public void ReportWarning(string message) => System.Console.Error.WriteLine(message); + private string CurrentScene { get { lock (_scriptContextLock) return _scriptContexts.TryPeek(out var scene) ? scene : _rootScene; } @@ -767,12 +769,13 @@ public sealed class GodotAdvHost : IHost return asset != null && image != null ? (image, asset.Name, asset.RawIndex, false) : null; } - public void PlayMovieToSurface(long resourceId, int surfaceSlot, long movieFlags, long syncMask) + public long? PlayMovieToSurface(long resourceId, int surfaceSlot, long movieFlags, long syncMask) { 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); + if (asset == null) { Godot.GD.Print($"movie unresolved {scene}:0x{resourceId:x}"); return null; } + return StartMovie(asset, resourceId, surfaceSlot, movieFlags, syncMask, modal: false, + out long? stopTimeMs) ? stopTimeMs : null; } public void PlayModalMovieToSurface(long rawResourceId, int surfaceSlot, long movieFlags) @@ -788,7 +791,8 @@ public sealed class GodotAdvHost : IHost _modalMovieWaiting = true; try { - if (!StartMovie(asset, rawResourceId, surfaceSlot, movieFlags, 0, modal: true)) return; + if (!StartMovie(asset, rawResourceId, surfaceSlot, movieFlags, 0, modal: true, + out _)) return; _timeline?.State("modal-movie-wait", new() { ["resource"] = rawResourceId, ["surface"] = surfaceSlot, ["file"] = asset.Name, @@ -818,8 +822,9 @@ public sealed class GodotAdvHost : IHost } private bool StartMovie(AssetEntry asset, long resourceId, int surfaceSlot, long movieFlags, - long syncMask, bool modal) + long syncMask, bool modal, out long? stopTimeMs) { + stopTimeMs = null; try { var movie = _res.ReadMovie(asset); @@ -835,8 +840,7 @@ public sealed class GodotAdvHost : IHost ["resource"] = resourceId, ["surface"] = surfaceSlot, ["file"] = movie.Name, ["flags"] = movieFlags, ["sync_mask"] = syncMask, ["modal"] = modal, }); - _main.CallDeferred("PlayMovie", movie.Bytes, movie.Name, resourceId, asset.RawIndex); - return true; + return _main.TryPlayMovie(movie.Bytes, movie.Name, resourceId, asset.RawIndex, out stopTimeMs); } catch (System.Exception e) { diff --git a/godot/Main.cs b/godot/Main.cs index 2ee98ba..504a649 100644 --- a/godot/Main.cs +++ b/godot/Main.cs @@ -52,6 +52,9 @@ public partial class Main : Godot.Control private IReadOnlyList _debugSceneEntries = System.Array.Empty(); private readonly Age.Engine.Hosting.FrameClock _clock = new(); private readonly System.Collections.Generic.Dictionary _movies = new(); + // 0x236 creates its graph synchronously on the VM thread so 0x23f can query timing immediately. + // Presentation ownership transfers here; _Process adopts staged decoders before sampling frames. + private readonly System.Collections.Concurrent.ConcurrentDictionary _pendingMovies = new(); private readonly System.Collections.Generic.HashSet _movieFrameSeen = new(); private GodotTraceSink _trace = null!; private PageLocatorState _locator = null!; @@ -318,6 +321,7 @@ public partial class Main : Godot.Control _timeline?.SetFrame(_timelineFrame, _clock.NowMs); _host?.PulseFrame(); UpdateVoicePlaybackState(); + AdoptPendingMovies(); UpdateMovieFrames(); if (!_selftest && _vm != null && _host != null && _host.ShouldRecomposite(_vm.Gfx)) Recomposite(); // native publishes retained mutations only at present/service boundaries @@ -595,6 +599,8 @@ public partial class Main : Godot.Control public override void _ExitTree() { DumpHistogram(); _host?.Stop(); _timeline?.Dispose(); _locator?.Dispose(); + foreach (var movie in _pendingMovies.Values) movie.Decoder.Dispose(); + _pendingMovies.Clear(); foreach (var movie in _movies.Values) movie.Decoder.Dispose(); _movies.Clear(); } @@ -1162,19 +1168,35 @@ public partial class Main : Godot.Control CreateTween().TweenProperty(_bgm, "volume_db", targetDb, realDurationSeconds); } - public void PlayMovie(byte[] mpegBytes, string assetName, long resourceId, int rawIndex) + public bool TryPlayMovie(byte[] mpegBytes, string assetName, long resourceId, int rawIndex, + out long? stopTimeMs) { - if (_movies.Remove(resourceId, out var prior)) prior.Decoder.Dispose(); + stopTimeMs = null; try { var payload = new Age.Engine.Sys4.MoviePayload(assetName, mpegBytes); - _movies[resourceId] = new MovieRuntime(assetName, rawIndex, new DirectShowMovieDecoder(payload)); - GD.Print($"movie started {assetName} ({mpegBytes.Length} bytes from VFS)"); + var runtime = new MovieRuntime(assetName, rawIndex, new DirectShowMovieDecoder(payload)); + stopTimeMs = runtime.Decoder.StopTimeMs; + while (!_pendingMovies.TryAdd(resourceId, runtime)) + if (_pendingMovies.TryRemove(resourceId, out var prior)) prior.Decoder.Dispose(); + return true; } catch (System.Exception e) { GD.Print($"movie decode failed {assetName}: {e.Message}"); _host.NotifyMovieCompleted(resourceId); // release a pending 0x21c boundary on deterministic load failure + return false; + } + } + + private void AdoptPendingMovies() + { + foreach (var (resourceId, _) in _pendingMovies) + { + if (!_pendingMovies.TryRemove(resourceId, out var movie)) continue; + if (_movies.Remove(resourceId, out var prior)) prior.Decoder.Dispose(); + _movies[resourceId] = movie; + GD.Print($"movie started {movie.Name} ({movie.Decoder.StopTimeMs?.ToString() ?? "unknown"} ms from VFS)"); } } @@ -1195,6 +1217,7 @@ public partial class Main : Godot.Control public void StopMovie(long resourceId) { + if (_pendingMovies.TryRemove(resourceId, out var pending)) pending.Decoder.Dispose(); if (_movies.Remove(resourceId, out var movie)) { movie.Decoder.Dispose(); diff --git a/tools/age_opcodes_himegari.py b/tools/age_opcodes_himegari.py index 7ef07fd..0fcc6d7 100644 --- a/tools/age_opcodes_himegari.py +++ b/tools/age_opcodes_himegari.py @@ -46,6 +46,7 @@ INFERRED: dict[int, dict] = { 0x19a: dict(name='get-message-skip', category='input', noop=False, confidence='high', source='investigation', summary='(out) - return the current all-message skip state set by op 0x88.'), 0x19b: dict(name='suspend-adv-skip-service', category='input', noop=False, confidence='high', source='investigation', summary="() - suspend active ADV fast-forward while preserving the user's persistent all-message Skip toggle."), 0x19c: dict(name='resume-adv-skip-service', category='input', noop=False, confidence='high', source='investigation', summary='() - enable the ADV skip service and recompute active fast-forward from persistent all-message Skip or the live read-skip channel.'), + 0x1a8: dict(name='instruction-marker-noop', category='marker', noop=True, confidence='high', source='investigation', summary="Zero-operand structural marker. The native shared 0xaf/0x1a8 handler only records this instruction's one-dword length and returns."), 0x1b6: dict(name='get-auto-message', category='input', noop=False, confidence='high', source='investigation', summary='(out) - return whether automatic message advance is enabled.'), 0x1b7: dict(name='set-auto-message', category='input', noop=False, confidence='high', source='investigation', summary='(enabled) - enable or disable automatic message advance.'), 0x1b8: dict(name='get-auto-message-time', category='input', noop=False, confidence='high', source='investigation', summary='(selector)(out) - read an Auto-message delay from engine configuration: selector 0 = post-voice AutoMessageTime0, selector 1 = unvoiced AutoMessageTime1.'), @@ -65,6 +66,12 @@ INFERRED: dict[int, dict] = { 0x1ff: dict(name='set-current-translation', category='draw', noop=False, confidence='high', source='investigation', summary="0x1ff (handle)(x)(y)(z) — immediately replace the retained object's current translation matrix at obj+0x16c. This is the direct-current companion to 0x220's delayed target at obj+0x1ac."), 0x20a: dict(name='publish-adv-text-layout', category='adv', noop=False, confidence='high', source='investigation', summary='(layout_slot) - republish one ADV text layout and, while active, its current wait-indicator frame.'), 0x21b: dict(name='line-id?', category='marker', noop=True, confidence='med', source='harness', summary='1 imm; mov->0x21b->stmt-end; near save/load-messkip — likely line/stmt id, verify not msg-control'), + 0x228: dict(name='query-gfx-translation-target', category='draw', noop=False, confidence='high', source='investigation', summary='0x228 query translation target (succ)(handle)(outX)(outY)(outZ): clone the retained object, decompose its target translation matrix at obj+0x17c, and return matrix translation obj+0x1ac/+0x1b0/+0x1b4. Returns succ=0 when found; when absent, writes succ=1 and leaves outputs untouched. The C# VM queries TranslationTarget independently of V24. See docs/engine-re.md §SC0000 anim cluster.'), + 0x22f: dict(name='set-gfx-position-current', category='draw', noop=False, confidence='high', source='investigation', summary="(handle)(control)(x)(y)(z) — set the object's current/base translation matrix. The native worker also retains operand 2 as channel control state; the port currently applies x/y/z but ignores that control operand."), + 0x231: dict(name='animate-gfx-srcrect-loop', category='draw', noop=False, confidence='high', source='investigation', summary="(handle)(frame_period_ms)(frame_count)(column_count) — loop row-major through the spritesheet. Every frame preserves draw-texture's source-rectangle width/height; frame=floor(elapsed/frame_period)%frame_count, src offset=(frame%columns*width, frame/columns*height). Worker gfx_worker_anim_srcrect @0x47eec0; consumer gfx_object_anim_interpolate @0x473ed0."), + 0x232: dict(name='animate-gfx-color-loop', category='draw', noop=False, confidence='high', source='investigation', 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.'), + 0x239: dict(name='animate-gfx-srcrect-target', category='draw', noop=False, confidence='high', source='investigation', summary='(handle)(delay_ms)(duration_ms)(frame_count)(column_count)(target_frame) — one-shot row-major source-rectangle cell channel. Worker gfx_worker_set_srcrect_cell @0x47ed90 stores timing at obj+0x48/+0x5c, layout at +0x238/+0x23c, and target at +0x234. C# currently retains the endpoint cell immediately.'), + 0x23f: dict(name='query-surface-stop-time-ms', category='draw', noop=False, confidence='high', source='investigation', summary='(out_stop_time_ms)(surface_slot) — query the DirectShow stop position retained by a loaded movie surface, convert seconds to integer milliseconds by truncating toward zero, and write -1 when the movie slot is empty. The port retains this metadata during 0x236 graph initialization; unavailable metadata emits a warning and also returns -1.'), 0x258: dict(name='decl?', category='marker', noop=True, confidence='low', source='harness', summary='2 imm; runs in a chain right after script-entry 0x259, enumerating ids — prologue declaration/registration?'), 0x259: dict(name='script-entry', category='marker', noop=True, confidence='low', source='harness', summary='zero-arg; the first instruction of a script (offset 0), opens the decl chain that 0x258 continues — script/prologue entry marker, structural'), } diff --git a/vm-map/opcodes.toml b/vm-map/opcodes.toml index 893eb39..fc318ca 100644 --- a/vm-map/opcodes.toml +++ b/vm-map/opcodes.toml @@ -3606,14 +3606,14 @@ argc = 0 abi_source = "kelebek+decode-validated" [opcode.semantics] -name = "dev_ukn" -category = "unknown" -summary = "" -noop_headless = false -source = "kelebek" -confidence = "low" +name = "instruction-marker-noop" +category = "marker" +summary = "Zero-operand structural marker. The native shared 0xaf/0x1a8 handler only records this instruction's one-dword length and returns." +noop_headless = true +source = "investigation" +confidence = "high" depends_on = [] -evidence = "" +evidence = "Ghidra /v2: op_0xaf_0x1a8_handler@0x416650 writes frame_instruction_word_count=1 for the current script context and immediately returns. Himegari has 1,989 op-0x1a8 sites. The port's explicit no-op matches native behavior." [[opcode]] op = 0x1a9 @@ -5768,7 +5768,7 @@ argc = 5 abi_source = "kelebek+decode-validated" [opcode.semantics] -name = "u00421940" +name = "query-gfx-translation-target" category = "draw" summary = "0x228 query translation target (succ)(handle)(outX)(outY)(outZ): clone the retained object, decompose its target translation matrix at obj+0x17c, and return matrix translation obj+0x1ac/+0x1b0/+0x1b4. Returns succ=0 when found; when absent, writes succ=1 and leaves outputs untouched. The C# VM queries TranslationTarget independently of V24. See docs/engine-re.md §SC0000 anim cluster." noop_headless = false @@ -5953,14 +5953,14 @@ argc = 5 abi_source = "kelebek+decode-validated" [opcode.semantics] -name = "u00421DD0" +name = "set-gfx-position-current" category = "draw" -summary = "0x22f set-position (handle)(op2)(x)(y)(z): set the object base position (direct transform, not ping-pong). Worker gfx_worker_set_translation @0x472e90. C# VM: sets V24. See docs/engine-re.md §SC0000 anim cluster." +summary = "(handle)(control)(x)(y)(z) — set the object's current/base translation matrix. The native worker also retains operand 2 as channel control state; the port currently applies x/y/z but ignores that control operand." noop_headless = false -source = "kelebek" -confidence = "low" +source = "investigation" +confidence = "high" depends_on = [] -evidence = "" +evidence = "Ghidra /v2: gfx_op_0x22f_set_position_anim@0x423b00 fetches x/y/z as floats and passes all five operands to gfx_worker_set_translation@0x472e90. The worker builds the current translation matrix with x/y/z and stores operand 2 in the channel record. This is distinct from the shared range transform at 0x229. The C# handler sets V24 directly and is therefore position-correct but does not yet model operand 2." [[opcode.semantics.args]] i = 1 @@ -6015,7 +6015,7 @@ argc = 4 abi_source = "kelebek+decode-validated" [opcode.semantics] -name = "u00421EA0" +name = "animate-gfx-srcrect-loop" category = "draw" summary = "(handle)(frame_period_ms)(frame_count)(column_count) — loop row-major through the spritesheet. Every frame preserves draw-texture's source-rectangle width/height; frame=floor(elapsed/frame_period)%frame_count, src offset=(frame%columns*width, frame/columns*height). Worker gfx_worker_anim_srcrect @0x47eec0; consumer gfx_object_anim_interpolate @0x473ed0." noop_headless = false @@ -6051,7 +6051,7 @@ argc = 4 abi_source = "kelebek+decode-validated" [opcode.semantics] -name = "u00421EF0" +name = "animate-gfx-color-loop" 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 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 @@ -6227,7 +6227,7 @@ argc = 6 abi_source = "kelebek+decode-validated" [opcode.semantics] -name = "u004223C0" +name = "animate-gfx-srcrect-target" category = "draw" summary = "(handle)(delay_ms)(duration_ms)(frame_count)(column_count)(target_frame) — one-shot row-major source-rectangle cell channel. Worker gfx_worker_set_srcrect_cell @0x47ed90 stores timing at obj+0x48/+0x5c, layout at +0x238/+0x23c, and target at +0x234. C# currently retains the endpoint cell immediately." noop_headless = false @@ -6383,23 +6383,24 @@ argc = 2 abi_source = "kelebek+decode-validated" [opcode.semantics] -name = "u00422930" +name = "query-surface-stop-time-ms" category = "draw" -summary = "0x23f query-object (out)(handle): return object status (FUN_0042a520; -1 if none). C# VM: 0 if the object exists else -1. See docs/engine-re.md §SC0000 anim cluster." +summary = "(out_stop_time_ms)(surface_slot) — query the DirectShow stop position retained by a loaded movie surface, convert seconds to integer milliseconds by truncating toward zero, and write -1 when the movie slot is empty. The port retains this metadata during 0x236 graph initialization; unavailable metadata emits a warning and also returns -1." noop_headless = false -source = "kelebek" -confidence = "low" +source = "investigation" +confidence = "high" depends_on = [] -evidence = "" +details = "The surface object's +0x414 member is IMediaPosition. Its vtable +0x28 entry is get_StopTime (after IUnknown, IDispatch, get_Duration, put_CurrentPosition, and get_CurrentPosition), returning a REFTIME double in seconds. Native multiplies by the double constant 1000.0 and calls the compiler float-to-integer helper, whose SSE2 and x87 paths both truncate toward zero. It does not inspect the getter HRESULT. For a valid graph the default stop time normally equals media duration, which explains duration-style consumers, but the exact ABI is stop position rather than get_Duration. The handler only queries state; it does not yield or alter playback. Port safety extension: a modeled movie surface whose decoder cannot supply a finite signed-32-bit stop time reports a warning and returns -1 instead of reproducing native's uninitialized-output failure path." +evidence = "Ghidra /v2: op_0x23f_query_surface_stop_time_ms@0x42a520 indexes EngineCtx surface array operand 2, returns -1 for a null slot, otherwise dereferences movie+0x414 IMediaPosition and calls vtable+0x28 get_StopTime. The adjacent op 0x23e uses the same interface at vtable+0x24 get_CurrentPosition; movie op 0x245 uses +0x20 put_CurrentPosition, independently confirming the documented vtable layout. The returned seconds are multiplied by g_dMillisecondsPerSecond@0x5713e8 (double 1000.0) and truncated by crt_ftol2_sse_truncate@0x550850 before vm_operand_write(1). All 23 Himegari sites in 17 scripts are associated with a preceding op 0x236 movie load to the queried surface. FIELD divides one result by 16 and adds 1 to build a 16 ms callback schedule; another path clamps the result to 600 ms before DRAWVOL." [[opcode.semantics.args]] i = 1 -role = "" +role = "out_stop_time_ms" observed_types = ["l-int", "l-ptr"] [[opcode.semantics.args]] i = 2 -role = "" +role = "surface_slot" observed_types = ["imm", "g-int", "l-int"] [[opcode]]