Prevent failed combat movies from blocking

This commit is contained in:
gamer147
2026-07-21 20:46:10 -04:00
parent 80b5105032
commit 633e3b3085
10 changed files with 84 additions and 37 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<long, MovieRuntime> _pendingMovies = new();
private readonly System.Collections.Generic.HashSet<long> _movieFrameSeen = new();
private readonly System.Collections.Generic.HashSet<long> _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()

View File

@@ -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."),

View File

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