From f53056818de3de6fc4dd057c26c406a8fb9fd819 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Tue, 21 Jul 2026 20:46:10 -0400 Subject: [PATCH] Prevent failed combat movies from blocking --- docs/asset-resolution-re.md | 27 ++++++++++++++------- docs/engine-re.md | 13 ++++++---- docs/opcode-reference.md | 4 +-- docs/phase-b-framework.md | 14 ++++++----- engine/Age.Engine.Tests/MovieOpcodeTests.cs | 12 +++++---- engine/Age.Engine/Vm/VirtualMachine.cs | 5 +++- godot/GodotAdvHost.cs | 16 +++++++++--- godot/Main.cs | 24 +++++++++++++++--- tools/age_opcodes_himegari.py | 2 +- vm-map/opcodes.toml | 4 +-- 10 files changed, 84 insertions(+), 37 deletions(-) diff --git a/docs/asset-resolution-re.md b/docs/asset-resolution-re.md index 55808a9..f1d9a01 100644 --- a/docs/asset-resolution-re.md +++ b/docs/asset-resolution-re.md @@ -343,19 +343,28 @@ bytes) and `MVB238` (`0x2b94`, 143,364 bytes) both declare 280x352 and fail at completion, and is stopped by script cleanup. This is decisive backend evidence rather than a resolver, VFS, signature, or corrupt-asset problem. -The user nevertheless observed the combat presentation stall after that sequence. The existing failure -path calls `NotifyMovieCompleted` immediately, and the successful `movie stopped MVB908` line proves the -shared `0x21c` movie wait reached completion at least for the logged sequence. The log contains no VM offset -or transition-state record after cleanup, so it does not yet prove that DirectShow itself owns the final -stall. One remaining non-native input is concrete: failed graphs make `0x23f` return `-1`, which BTL stores -in its per-effect duration table at `0x2b31`; a real fallback decoder must instead supply duration and normal -completion. If the stall survives that backend correction, capture the VM/service boundary at the stall and -treat it as a separate BTL timed-presentation bug. +The user nevertheless observed the combat presentation stall after that sequence. Static BTL tracing rules +out `0x23f == -1` as an infinite-loop mechanism by itself. `local 0x44`, the total effect horizon, is first +set to `base_time + 1000` at `0x2230`; each effect can only extend that maximum with +`effect_start + duration` at `0x2b64..0x2b8b`. A failed duration of -1 can understate that one extension but +cannot make the callback count negative. After callbacks, `0x2492..0x2515` independently scans movie +surfaces 7..10 through `0x23a`, sleeping 16 ms only while one reports active. The successful +`movie stopped MVB908` line is emitted by the subsequent `0x2518` cleanup, proving that explicit movie wait +exited in the logged run. The final stall therefore was not localized; it may be after movie cleanup. + +The port now nevertheless makes decoder failure safe and deterministic. A valid `0x236` movie whose host +backend cannot initialize is marked completed explicitly and receives stop time 0, meaning an immediate +effect, rather than the former diagnostic -1. Empty movie slots retain native `0x23f == -1`. Successfully +started decoders also carry a completion watchdog: the larger of five seconds or reported stop time plus +two seconds (capped at five minutes), with a 30-second default when timing is unavailable. Expiry forces the +same completed state so a backend that starts but never signals EOF cannot hold `0x21c` indefinitely. If the +reported combat stall survives this guard, capture the VM/service coordinate after `0x2518` and treat it as +a separate BTL timed-presentation bug. The remaining implementation boundary is the already-planned decoder interface/factory plus a software MPEG backend that handles the installed non-16-aligned effects, and preservation of the destination surface's created dimensions instead of replacing every movie surface with the SC0000-specific 800x600 -value. Failure must remain nonblocking and suppress bogus still-AGF fallback. Regressions must cover at +value. Failure is now nonblocking and suppresses bogus still-AGF fallback. Regressions must cover at least a 280x352 effect (`MVB001`), a 400x400 effect (`MVB914`), immediate `0x23f` stop time, RGBA frame publication, completion, failure completion, and release. diff --git a/docs/engine-re.md b/docs/engine-re.md index c96c85d..439e27d 100644 --- a/docs/engine-re.md +++ b/docs/engine-re.md @@ -1268,11 +1268,14 @@ initialization returns. It applies native seconds-to-milliseconds truncation and `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. +presentation. `0x23f` silently returns -1 for an empty movie slot. Native ignores the HRESULT for a non-null +movie's stop-time query and has no meaningful contract for the port-only case where the installed host +decoder cannot construct a graph for a shipped asset. The port therefore models backend failure as an +explicitly completed movie with stop time 0. This gives BTL an immediate-effect duration rather than feeding +its timeline -1. Started decoders also have a duration-based completion watchdog (minimum five seconds, +reported stop time plus two seconds, maximum five minutes; 30 seconds without timing) so missing EOF cannot +hold the shared `0x21c` wait indefinitely. A normal Game Start through SC0000 was manually validated with +ordinary positive timing. ### Grey-background root cause — slot collision + tint-strength (2026-07-08, gfx-log) diff --git a/docs/opcode-reference.md b/docs/opcode-reference.md index a839a48..8b4f95e 100644 --- a/docs/opcode-reference.md +++ b/docs/opcode-reference.md @@ -786,11 +786,11 @@ First erase digit_capacity objects beginning at base_handle. Then split value by 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 `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. +- **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. Port-only host decoder failure is modeled as an explicitly completed, zero-duration movie. - **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. +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. Native has no meaningful answer for a port host that cannot build a graph for a valid shipped MPEG. Port safety extension: op 0x236 normalizes missing host metadata to stop time 0 and marks decoder failure completed, while a truly empty movie slot still returns -1. ### 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. diff --git a/docs/phase-b-framework.md b/docs/phase-b-framework.md index 56c0efd..5f072ad 100644 --- a/docs/phase-b-framework.md +++ b/docs/phase-b-framework.md @@ -577,12 +577,14 @@ resolution; VM surface state and Godot caches retain the full selector. Focused texture/voice ids and append-pack identity; 302 engine tests, a zero-warning Godot build, and threaded selftest pass. Manual combat acceptance confirms the split: 400x400 MVB908 plays and completes, while 280x352 MVB961/MVB238 resolve correctly but DirectShow rejects their graph connection with `0x80040217`. -The user observed a stall after the sequence; failed movies are marked complete and the successful movie's -stop log proves the shared movie wait resumed, but failed `0x23f` duration remains `-1` and the final stalled -VM/service coordinate was not logged. **NEXT:** replace the decoder boundary with a software MPEG path that -returns real duration/frames for 280x352 effects and regress failure as nonblocking. If the stall remains, -capture it as a separate BTL timed-presentation issue. Preserve created destination dimensions and failed -movie identity through the backend change. +The user observed a stall after the sequence. Static BTL tracing proves failed `0x23f == -1` cannot create +an infinite callback count: the total horizon starts at `base_time + 1000`, and the explicit post-callback +loop polls `0x23a` until surfaces 7..10 are inactive. MVB908's stop log occurs after that loop. As a bounded +safety correction, decoder failure now becomes an explicitly completed zero-duration effect, while started +movies have a stop-time-based completion watchdog so a missing EOF cannot hold `0x21c` forever. **NEXT:** +manually recheck the same exchange. If it still stalls, capture the VM/service coordinate after BTL cleanup +as a separate timed-presentation issue; otherwise proceed to the software MPEG backend for real 280x352 +duration/frames. Preserve created destination dimensions and failed-movie identity through that backend change. **Mutable-surface fill/blend regression corrected.** The first visual recheck exposed BUNKI's menu interior as transparent. SYSTEM4 creates 800x600 surface 3 and fills it opaque white through `0x20b`; the metadata-only diff --git a/engine/Age.Engine.Tests/MovieOpcodeTests.cs b/engine/Age.Engine.Tests/MovieOpcodeTests.cs index 6eda5fe..a2a6463 100644 --- a/engine/Age.Engine.Tests/MovieOpcodeTests.cs +++ b/engine/Age.Engine.Tests/MovieOpcodeTests.cs @@ -171,13 +171,14 @@ public class MovieOpcodeTests } [Fact] - public void QueryMovieStopTimeWarnsAndReturnsMinusOneWhenMetadataIsUnavailable() + public void LoadedMovieWithoutBackendMetadataUsesImmediateZeroDuration() { 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) }), + (0x23a, new[] { new Operand(3, 0x1235), new Operand(0, 5) }), (0x2, System.Array.Empty()), }, System.Array.Empty()); var host = new RecordingHost(); @@ -185,10 +186,11 @@ public class MovieOpcodeTests 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); + Assert.Equal(0, vm.Globals[0x1234]); + Assert.True(vm.Gfx.TryGetMovieStopTime(5, out long? retained)); + Assert.Equal(0, retained); + Assert.Equal(0, vm.Globals[0x1235]); + Assert.Empty(host.Warnings); } [Fact] diff --git a/engine/Age.Engine/Vm/VirtualMachine.cs b/engine/Age.Engine/Vm/VirtualMachine.cs index 5ea3272..625a65e 100644 --- a/engine/Age.Engine/Vm/VirtualMachine.cs +++ b/engine/Age.Engine/Vm/VirtualMachine.cs @@ -1684,7 +1684,10 @@ 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); - Gfx.SetMovieStopTime(surfaceSlot, stopTimeMs); + // Native never encounters a missing system decoder for shipped assets. If a host backend + // cannot initialize one, model the valid movie as completing immediately: BTL feeds this + // value into its effect timeline, where zero is a safe duration and -1 is not meaningful. + Gfx.SetMovieStopTime(surfaceSlot, stopTimeMs ?? 0); 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) ---- diff --git a/godot/GodotAdvHost.cs b/godot/GodotAdvHost.cs index 23d3d98..eefd352 100644 --- a/godot/GodotAdvHost.cs +++ b/godot/GodotAdvHost.cs @@ -897,8 +897,9 @@ public sealed class GodotAdvHost : IHost string scene = CurrentScene; var asset = _res.ResolveMovie(resourceId); 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; + StartMovie(asset, resourceId, surfaceSlot, movieFlags, syncMask, modal: false, + out long? stopTimeMs); + return stopTimeMs ?? 0; } public bool IsMovieSurfaceActive(int surfaceSlot) @@ -972,7 +973,14 @@ public sealed class GodotAdvHost : IHost ["resource"] = resourceId, ["surface"] = surfaceSlot, ["file"] = movie.Name, ["flags"] = movieFlags, ["sync_mask"] = syncMask, ["modal"] = modal, }); - return _main.TryPlayMovie(movie.Bytes, movie.Name, resourceId, asset.PackedId, out stopTimeMs); + bool started = _main.TryPlayMovie(movie.Bytes, movie.Name, resourceId, asset.PackedId, + out stopTimeMs); + if (!started) + { + stopTimeMs = 0; + NotifyMovieCompleted(resourceId); + } + return started; } catch (System.Exception e) { @@ -984,6 +992,8 @@ public sealed class GodotAdvHost : IHost _completedMovies.Remove(resourceId); } _slotDims.Remove(surfaceSlot); + stopTimeMs = 0; + NotifyMovieCompleted(resourceId); Godot.GD.Print($"movie read failed {asset.Name}: {e.Message}"); return false; } diff --git a/godot/Main.cs b/godot/Main.cs index 3288b36..926f9d8 100644 --- a/godot/Main.cs +++ b/godot/Main.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using System.Runtime.Versioning; using System.Text.Json; @@ -56,6 +57,7 @@ public partial class Main : Godot.Control // 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 readonly System.Collections.Generic.HashSet _movieCompletionNotified = new(); private GodotTraceSink _trace = null!; private PageLocatorState _locator = null!; private bool _locatorHudVisible; @@ -1190,7 +1192,6 @@ public partial class Main : Godot.Control 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; } } @@ -1202,6 +1203,7 @@ public partial class Main : Godot.Control if (!_pendingMovies.TryRemove(resourceId, out var movie)) continue; if (_movies.Remove(resourceId, out var prior)) prior.Decoder.Dispose(); _movies[resourceId] = movie; + _movieCompletionNotified.Remove(resourceId); GD.Print($"movie started {movie.Name} ({movie.Decoder.StopTimeMs?.ToString() ?? "unknown"} ms from VFS)"); } } @@ -1217,7 +1219,14 @@ public partial class Main : Godot.Control if (_movieFrameSeen.Add(resourceId)) GD.Print($"movie first frame {movie.Name}: {frame.Width}x{frame.Height} RGBA8 at render frame {_timelineFrame}"); } - if (movie.Decoder.IsCompleted) _host.NotifyMovieCompleted(resourceId); + bool watchdogExpired = Stopwatch.GetElapsedTime(movie.StartedAtTimestamp).TotalMilliseconds + >= movie.WatchdogMs; + if ((movie.Decoder.IsCompleted || watchdogExpired) && _movieCompletionNotified.Add(resourceId)) + { + if (watchdogExpired && !movie.Decoder.IsCompleted) + GD.Print($"movie completion watchdog {movie.Name}: forcing completion after {movie.WatchdogMs} ms"); + _host.NotifyMovieCompleted(resourceId); + } } } @@ -1230,9 +1239,18 @@ public partial class Main : Godot.Control GD.Print($"movie stopped {movie.Name} at render frame {_timelineFrame}"); } _movieFrameSeen.Remove(resourceId); + _movieCompletionNotified.Remove(resourceId); } - private sealed record MovieRuntime(string Name, int AssetId, DirectShowMovieDecoder Decoder); + private sealed record MovieRuntime(string Name, int AssetId, DirectShowMovieDecoder Decoder, + long StartedAtTimestamp, long WatchdogMs) + { + public MovieRuntime(string name, int assetId, DirectShowMovieDecoder decoder) + : this(name, assetId, decoder, Stopwatch.GetTimestamp(), + decoder.StopTimeMs is >= 0 and var stopTime + ? System.Math.Clamp(stopTime + 2000, 5000, 300000) + : 30000) { } + } public void AppendLine(string text) => _text.Text += text + "\n"; public void PageBreak() diff --git a/tools/age_opcodes_himegari.py b/tools/age_opcodes_himegari.py index 3d5630b..99c59b4 100644 --- a/tools/age_opcodes_himegari.py +++ b/tools/age_opcodes_himegari.py @@ -75,7 +75,7 @@ INFERRED: dict[int, dict] = { 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.'), 0x23b: dict(name='draw-decimal-glyphs', category='draw', noop=False, confidence='high', source='investigation', summary='Draw an integer as decimal glyph objects from a style registered by opcode 0x13a.'), - 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.'), + 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. Port-only host decoder failure is modeled as an explicitly completed, zero-duration movie.'), 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'), 0x2c5: dict(name='byte-string-length', category='compute', noop=False, confidence='high', source='investigation', summary="Write the resolved NUL-terminated engine string's raw byte length."), diff --git a/vm-map/opcodes.toml b/vm-map/opcodes.toml index e322e39..de1522f 100644 --- a/vm-map/opcodes.toml +++ b/vm-map/opcodes.toml @@ -6396,12 +6396,12 @@ abi_source = "kelebek+decode-validated" [opcode.semantics] name = "query-surface-stop-time-ms" category = "draw" -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." +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. Port-only host decoder failure is modeled as an explicitly completed, zero-duration movie." noop_headless = false source = "investigation" confidence = "high" depends_on = [] -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." +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. Native has no meaningful answer for a port host that cannot build a graph for a valid shipped MPEG. Port safety extension: op 0x236 normalizes missing host metadata to stop time 0 and marks decoder failure completed, while a truly empty movie slot still returns -1." 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]]