Harden movie playback lifecycle and diagnostics

This commit is contained in:
gamer147
2026-07-22 09:44:57 -04:00
parent 5b66cb2cd2
commit 740e10c550
25 changed files with 1145 additions and 147 deletions

2
.gitignore vendored
View File

@@ -16,6 +16,8 @@ __pycache__/
# .NET (engine/) build output
engine/**/bin/
engine/**/obj/
tools/**/bin/
tools/**/obj/
.vs/
# Godot / C# (future)

View File

@@ -45,6 +45,7 @@ S:\Game Hacking\Eushully\Himegari\ ← workspace root (three siblings)
│ ├── extract_phase2.py batch: disasm + text + data extraction
│ ├── extract_init.py, global_map.py … *INIT parsers, global-var map builder
│ ├── validate_opcode_table*.py decode-coverage validators
│ ├── movie-corpus-gate/ C# full-corpus FFmpeg decode/lifecycle acceptance tool
│ └── probe_*.py format reverse-engineering probes (historical)
├── bin/ 3rd-party binaries we use (not ours, not the game's)

View File

@@ -361,12 +361,15 @@ same completed state so a backend that starts but never signals EOF cannot hold
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 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.
The FFmpeg replacement and destination-surface work are recorded in `docs/platform-portability.md` and
`docs/phase-b-framework.md`. Movie identity also remains typed across the entire retained-surface lifecycle.
The packed catalog is immutable while mounted, so once an id enters the movie path its `.AGF`-named MPEG
payload cannot fall through to the ACGF still decoder before the first frame or after the last live binding
is detached. The latter guard closes a real cross-thread cleanup window: BTL clears `GfxState` and releases
the host surface in one VM instruction, while the compositor may hold a snapshot from immediately before
that pair. Without remembered movie typing, the snapshot briefly attempted to decode MVB955/MVB004/MVB914
as still images after successful playback and printed false `expected an ACGF image` warnings. This does not
retain a frame or playback instance; it only prevents an immutable movie resource from being reclassified.
### Modal startup/ending movie resources (implemented 2026-07-20)

View File

@@ -605,7 +605,7 @@ dependency pin, corpus codec inventory, timing contract, and packaging rules liv
and a monotonic clock, publish only due frames, delay EOF completion through the final presentation interval,
and retain the existing watchdog. Make FFmpeg the default with no dimension/effect dispatch and exercise both
non-modal `0x236` and modal/cancellable `0x20f` paths.
4. **Next — run the installed-corpus gate.** Every one of the 213 MPEG payloads must open, report its expected display
4. **Completed — run the installed-corpus gate.** Every one of the 213 MPEG payloads must open, report its expected display
dimensions and a positive stop time, produce a correctly sized RGBA frame, maintain nondecreasing timestamps,
reach EOF, and dispose within a bound. Normal tests use small project-authored 280-wide and aligned MPEG
fixtures so decoder behavior is never disabled when the original install is absent; the full local sweep is
@@ -643,9 +643,47 @@ clock tests cover due-frame publication, final completion, cancellation, and fai
formerly failing 280x352 `MVB961` and aligned `MVB908`. A natural headless SYSTEM4 smoke played and released
`LOGO.AGF` at its reported 7288 ms, then opened 106919 ms `OP.AGF` and published its first frame before bounded
shutdown. The user then confirmed the opening movies play correctly in a normal windowed run. The complete suite
is 318 tests, the Godot build is warning-free, and threaded selftest passes. **NEXT:** run the 213-payload
installed-corpus gate, then validate SC0000 CHAPTER and the original battle exchange before removing DirectShow
or judging the tabled combat stall.
is 318 tests, the Godot build is warning-free, and threaded selftest passes. That established the prerequisite
for the installed-corpus gate recorded below.
Step 4 completed on 2026-07-22. `tools/movie-corpus-gate` selects MPEG program streams by signature from the
complete native-order VFS catalog and runs the unpaced FFmpeg session to EOF. The acceptance run discovered the
expected 213 assets and passed all 213 under the 30-second per-item bound; every stream reported independent
matching dimensions, positive duration/frame rate, correctly sized RGBA frames, nondecreasing timestamps,
changing imagery, EOF, and clean teardown. The run decoded 15,788 frames across all twelve installed dimension
variants in 8.1 seconds; the longest item, 263-second `ED.AGF`, decoded in 4.4 seconds. Detailed compatibility
evidence lives in `docs/platform-portability.md`, and reproduction/report options live in
`docs/tools-reference.md`. This moved acceptance to step 5's windowed SC0000 `CHAPTER`, aligned plus 280-wide
combat effects, combat cleanup, and prior-stall checks. DirectShow remains unselected but in-tree until those
manual checks pass.
The first post-corpus combat recheck still stalled after an apparently absent effect. The ordinary Godot log
ruled out an FFmpeg decode/EOF failure: both reached battle batches opened, published first frames, and stopped
(`MVB005`/`MVB952`/`MVB913`, then `MVB921`/`MVB126`/`MVB953`) with no decoder failure or watchdog. F6 was added as
an observe-only bounded JSON snapshot of the current/recent VM path, host waits, movie/surface lifecycle, and
blocking finite gfx channels.
The first F6 capture localized the stall exactly to BTL's `0x2492..0x2515` movie polling loop, parked at the
16 ms sleep `BTL@0x250a`. Gfx had no blocking timed presentation, and the only real decoder (`MVB033`, surface
44) was complete. Surfaces 7 and 8 nevertheless retained incomplete/no-frame registrations for already stopped
`MVB953` and `MVB126`, so op `0x23a` could never clear. The user's observation that the last effect frame remained
until replacement was the same ownership defect: movie frames, completion, and decoder dictionaries were keyed
by shared resource id even though BTL can bind/restart one asset through multiple surfaces. Releasing one binding
removed shared state while another surface registration survived forever.
Movie ownership is now keyed by a unique playback instance; resource id is asset identity only. Each surface
resolves its own instance frame/completion, replacing a surface invalidates late callbacks from only its prior
instance, and releasing one of two same-resource playbacks cannot affect the other. Blank pre-roll surfaces no
longer borrow a concurrent instance's frame, and console/timeline diagnostics include playback ids. Duplicate-
resource and replacement regressions pass, as do all 327 engine tests, the zero-warning Godot build, threaded
selftest, and the 213/213 unpaced corpus gate. A quick repeat of the same combat exchange no longer stalled.
The run did expose false `AGF decode failed ... expected an ACGF image` lines after successful first frames and
immediately before teardown. This was not FFmpeg failure: the compositor could retain a pre-release `GfxState`
snapshot for one frame after the host detached the movie binding, then send the `.AGF`-named MPEG id through the
still-image fallback. Runtime-learned movie resource typing now survives instance teardown and suppresses only
that invalid fallback; it does not retain the frame or decoder. The focused cleanup regression, all 328 engine
tests, zero-warning Godot build, and threaded selftest pass. **NEXT:** perform a longer combat/return-to-FIELD
acceptance run, then delete DirectShow if the remaining live gate stays clean.
**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

@@ -25,8 +25,8 @@ replaced before claiming portable exports.
| Area | Current dependency | Runtime impact | Portability status / future action |
|---|---|---|---|
| AGE movie decode (`0x236` scene movies; `0x20f` modal LOGO/OP/ED) | `FfmpegMovieDecoder` is the selected live factory over the project-owned `native/age_movie_ffmpeg` ABI; `DirectShowMovieDecoder` remains unselected pending deletion | Windows-x64 live playback now covers formerly rejected 280-wide effects, but other native targets and the full 213-payload gate remain | Run the corpus and windowed live gates, then delete DirectShow and add target-specific native builds |
| Movie integration | `MovieRuntime` owns `IMovieDecoder` from an injected factory; the FFmpeg worker paces PTS against a monotonic clock and supports cancellation/failure completion | Backend ownership is portable, while `Main` remains annotated Windows because only the win-x64 bundle is available | Add Linux/macOS builds and remove the Windows annotation after DirectShow is deleted |
| AGE movie decode (`0x236` scene movies; `0x20f` modal LOGO/OP/ED) | `FfmpegMovieDecoder` is the selected live factory over the project-owned `native/age_movie_ffmpeg` ABI; `DirectShowMovieDecoder` remains unselected pending deletion | Windows-x64 passes the complete 213-payload installed-corpus gate; other native targets and the remaining windowed CHAPTER/combat checks are pending | Run the windowed live gates, then delete DirectShow and add target-specific native builds |
| Movie integration | Each surface owns a unique playback-instance id; `MovieRuntime` owns `IMovieDecoder` from an injected factory, and the FFmpeg worker paces PTS against a monotonic clock with cancellation/failure completion | Concurrent/restarted uses of one asset have independent frame/completion/teardown state; `Main` remains annotated Windows because only the win-x64 bundle is available | Recheck the corrected combat lifecycle, then add Linux/macOS builds and remove the Windows annotation after DirectShow is deleted |
| Movie audio | FFmpeg detects the audio stream but the current ABI returns video frames only | MPEG movie audio remains intentionally silent | Extend the ABI with timestamped PCM and select an audio/presentation clock; separate feature slice |
| ADV font discovery | `godot/Main.cs` probes `C:/Windows/Fonts` for Japanese fonts | Harmless fallback today, but appearance depends on host fonts | Bundle/configure a redistributable font or add platform-specific discovery |
| Filesystem semantics | Several filename and containment comparisons use `OrdinalIgnoreCase`; installed assets are conventionally uppercase | Needs validation on case-sensitive filesystems; may hide casing or containment mistakes | Add Linux/macOS tests with mixed-case synthetic roots and use filesystem-appropriate containment rules |
@@ -135,6 +135,16 @@ smoke completed 7288 ms `LOGO.AGF`, opened 106919 ms `OP.AGF`, and published fra
DirectShow; the user subsequently confirmed both opening movies work in normal windowed playback. Full
export/source-offer packaging and non-Windows builds remain outstanding.
The Windows-x64 installed-corpus gate passed on 2026-07-22 with the pinned dependency: all 213 signature-selected
MPEG payloads opened and reached EOF under a 30-second per-item bound. It decoded all 15,788 frames across twelve
display sizes (120x120 through 800x600), independently matched sequence-header dimensions, found positive
duration and frame-rate metadata, verified tightly packed RGBA size and nondecreasing timestamps, observed
changing imagery in every asset, and closed every session. The set includes all 184 video-only and 29 audio-bearing
streams; audio presence is detected but PCM remains intentionally undelivered. The 263-second `ED.AGF` was the
slowest decode at 4.4 seconds in the deliberately unpaced gate, so its prior 30-second failure was test-harness
whole-frame hashing overhead rather than a decoder incompatibility. The disposable machine report is
`build/movie-corpus-ffmpeg.json`; invocation and report semantics are canonical in `docs/tools-reference.md`.
## Cross-platform validation gates
Before advertising a platform as supported:

View File

@@ -72,6 +72,13 @@ All opcode knowledge (ABI, semantics, provenance, `depends_on`) is hand-edited *
|---|---|---|---|
| `locate_page.py` | Resolve a run-relative ADV page number to its canonical wait script/offset, last show-text instruction, call stack, and nearby disassembly. Pure selection/window logic is tested by `test_locate_page.py`. | `py -3.11 -X utf8 tools/locate_page.py SC0000 14 [--map <jsonl>] [--context N]` | `build/page-map-<SCENE>.jsonl` + script corpus → stdout |
In a live Godot run, **F6** writes an observe-only stall snapshot under
`user://diagnostics/stall-<timestamp>.json`, prints the absolute path, and copies
`SCRIPT@offset · stall snapshot <path>` to the clipboard. The JSON contains the current call stack, a bounded
128-instruction ring, VM/host wait flags, surface/movie completion state, decoder state, and the exact finite
graphics channels capable of holding an op-`0x21c` presentation wait. Press it while the apparent stall is still
active; unlike `--timeline-log`, it does not require a special launch or emit a continuous per-op stream.
## Native FFmpeg movie shim (Windows x64)
These PowerShell tools build the selected Windows-x64 live movie backend. The dependency manifest pins an
@@ -81,6 +88,7 @@ immutable LGPL shared FFmpeg archive and SHA-256.
|---|---|---|---|
| `native/age_movie_ffmpeg/bootstrap-win64.ps1` | Download, hash-check, extract, and version-check the pinned FFmpeg SDK. Returns the resolved SDK root. | `.\\native\\age_movie_ffmpeg\\bootstrap-win64.ps1 [-Destination <dir>]` | `dependency-win64.json`, network/archive cache → disposable `build/downloads/`, `build/ffmpeg-sdk/` |
| `native/age_movie_ffmpeg/build-win64.ps1` | Discover the MSVC x64 toolchain, build `age_movie_ffmpeg.dll`, and stage its exact shared-library/license dependencies. | `.\\native\\age_movie_ffmpeg\\build-win64.ps1 -SdkRoot <bootstrap-output> [-OutputDirectory <dir>]` | C ABI source + FFmpeg SDK → disposable `build/native/win-x64/` by default |
| `tools/movie-corpus-gate` | Discover every MPEG program stream stored under an `.AGF` catalog entry, decode every video frame through the unpaced FFmpeg session, validate independent sequence dimensions, metadata, RGBA size, timestamps, EOF, timeout, and teardown, then emit a per-asset JSON report. Exit 0 means the expected corpus count and every asset passed; exit 1 is a gate failure; exit 2 means the native shim is absent. | `dotnet run --project tools/movie-corpus-gate -- --output build/movie-corpus-ffmpeg.json --expected-count 213 --max-item-ms 30000` · optional `--native-dir <dir>` | `SYS4INI.BIN` + loose/ALF VFS assets + staged FFmpeg shim → stdout progress + disposable `build/movie-corpus-ffmpeg.json` |
The managed isolated probes load from `AGE_FFMPEG_NATIVE_DIR` when set, then application-local and
`runtimes/win-x64/native` locations. Build the shim before running the `FfmpegShim*` tests; no original-game
@@ -88,6 +96,10 @@ movie is copied into the repository or native output. When `build/native/win-x64
`dotnet build godot/Himegari.csproj` also stages the shim, its five DLL dependencies, and `FFmpeg-LICENSE.txt`
beside `Himegari.dll` for development playback.
The corpus gate intentionally bypasses presentation waits: it validates decode compatibility and lifecycle,
not wall-clock playback pacing. `--expected-count` makes additions, omissions, or profile changes explicit;
changing the pinned FFmpeg dependency requires rerunning this gate.
## Engine (C#) — VM core, CLI, Godot frontend
The `engine/` .NET solution (`AgeEngine.sln`) is the runtime VM; `godot/` is the ADV frontend. Not

View File

@@ -26,6 +26,11 @@
<Compile Include="..\..\godot\MovieRuntime.cs" Link="MovieRuntime.cs" />
<Compile Include="..\..\godot\FfmpegMovieNative.cs" Link="FfmpegMovieNative.cs" />
<Compile Include="..\..\godot\FfmpegMovieDecoder.cs" Link="FfmpegMovieDecoder.cs" />
<Compile Include="..\..\godot\PageLocatorState.cs" Link="PageLocatorState.cs" />
<Compile Include="..\..\godot\GodotTimelineLog.cs" Link="GodotTimelineLog.cs" />
<Compile Include="..\..\godot\GodotTraceSink.cs" Link="GodotTraceSink.cs" />
<Compile Include="..\..\godot\MovieSurfaceRegistry.cs" Link="MovieSurfaceRegistry.cs" />
<Compile Include="..\..\tools\movie-corpus-gate\MovieCorpusGate.cs" Link="MovieCorpusGate.cs" />
<Compile Include="..\..\godot\DirectShowMovieDecoder.cs" Link="DirectShowMovieDecoder.cs" />
</ItemGroup>

View File

@@ -0,0 +1,24 @@
using Age.Engine.Model;
public class GfxDiagnosticSnapshotTests
{
[Fact]
public void SnapshotIdentifiesOnlyChannelsThatCanBlockPresentationWait()
{
var gfx = new GfxState();
gfx.BindDraw(0x100, 7, 0, 0, 32, 32, 0, 0);
gfx.SetTranslationChannel(0x100, 25, 300, (20, 30, 0));
gfx.BindDraw(0x200, 8, 0, 0, 32, 32, 0, 0);
gfx.SetScaleChannel(0x200, 0, 500, (200, 200, 100));
gfx.SetOneShotAnimationControl(0x200, 1);
GfxDiagnosticSnapshot snapshot = gfx.CaptureDiagnosticSnapshot(1000);
Assert.True(snapshot.HasActiveTimedPresentation);
var blocking = Assert.Single(snapshot.BlockingObjects);
Assert.Equal(0x100, blocking.Handle);
Assert.True(blocking.TranslationEnabled);
Assert.Equal(25, blocking.TranslationDelayMs);
Assert.Equal(300, blocking.TranslationDurationMs);
}
}

View File

@@ -0,0 +1,31 @@
using Age.Engine.Diagnostics;
using Age.Engine.Model;
public class GodotTraceSinkTests
{
[Fact]
public void SnapshotRetainsCurrentStackAndBoundedRecentSteps()
{
using var locator = new PageLocatorState("SYSTEM4", null);
var sink = new GodotTraceSink(locator);
sink.Emit(TraceEvent.FrameEnter("SYSTEM4.BIN", 0, FrameCause.TopScene));
sink.Emit(TraceEvent.FrameEnter("BTL.BIN", 1, FrameCause.CallScript, 0x2b10));
for (int index = 0; index < 140; index++)
{
var instruction = new Instruction(0x2400 + index, 0x100 + index,
Array.Empty<Operand>());
sink.Emit(TraceEvent.Step(index, instruction, 1));
}
GodotTraceSnapshot snapshot = sink.Snapshot();
Assert.Equal("BTL.BIN", snapshot.CurrentScript);
Assert.Equal(0x2400 + 139, snapshot.CurrentOffset);
Assert.Equal(0x100 + 139, snapshot.CurrentOpcode);
Assert.Equal(new[] { "SYSTEM4.BIN", "BTL.BIN" }, snapshot.CallStack);
Assert.Equal(128, snapshot.RecentSteps.Count);
Assert.Equal(0x2400 + 12, snapshot.RecentSteps[0].Offset);
Assert.Equal(0x2400 + 139, snapshot.RecentSteps[^1].Offset);
}
}

View File

@@ -0,0 +1,102 @@
using Age.Engine.Sys4;
public class MovieCorpusGateTests
{
private sealed class FakeFrameSource(FfmpegMovieInfo info,
params FfmpegVideoFrame[] frames) : IFfmpegFrameSource
{
private readonly Queue<FfmpegVideoFrame> _frames = new(frames);
public FfmpegMovieInfo Info { get; } = info;
public bool Disposed { get; private set; }
public bool TryDecodeNextVideoFrame(out FfmpegVideoFrame frame)
=> _frames.TryDequeue(out frame!);
public void Dispose() => Disposed = true;
}
[Fact]
public void SequenceHeaderProbeReadsIndependentDimensions()
{
byte[] payload = SyntheticPayload(320, 240);
Assert.True(MovieCorpusDiscovery.TryReadSequenceDimensions(payload, out int width, out int height));
Assert.Equal(320, width);
Assert.Equal(240, height);
Assert.False(MovieCorpusDiscovery.TryReadSequenceDimensions(new byte[] { 0, 0, 1, 0xba }, out _, out _));
}
[Fact]
public void GateReportsFramesMetadataTimingAndTeardown()
{
var source = new FakeFrameSource(new FfmpegMovieInfo(16, 16, 100, 20, 1, false),
Frame(16, 16, 1, 0), Frame(16, 16, 2, 50));
var input = new MovieCorpusInput(0x2bc2, "SYNTH.AGF", () =>
new MoviePayload("SYNTH.AGF", SyntheticPayload(16, 16)));
MovieCorpusReport report = new MovieCorpusGate(_ => source).Run([input], 1, 1000);
Assert.True(report.Passed);
var item = Assert.Single(report.Movies);
Assert.True(item.Passed);
Assert.Equal("0x2bc2", item.PackedIdHex);
Assert.Equal(16, item.ExpectedWidth);
Assert.Equal(2, item.FrameCount);
Assert.Equal(0, item.FirstPresentationTimeMs);
Assert.Equal(50, item.LastPresentationTimeMs);
Assert.True(item.FramesChanged);
Assert.True(source.Disposed);
}
[Fact]
public void GateContinuesAfterValidationFailureAndReportsCountMismatch()
{
var badDimensions = new FakeFrameSource(new FfmpegMovieInfo(8, 16, 100, 20, 1, false),
Frame(8, 16, 1, 0));
var decreasingTimestamps = new FakeFrameSource(new FfmpegMovieInfo(16, 16, 100, 20, 1, false),
Frame(16, 16, 1, 50), Frame(16, 16, 2, 40));
var sources = new Queue<IFfmpegFrameSource>([badDimensions, decreasingTimestamps]);
MovieCorpusInput[] inputs =
[
new(1, "BAD-DIMS.AGF", () => new MoviePayload("BAD-DIMS.AGF", SyntheticPayload(16, 16))),
new(2, "BAD-PTS.AGF", () => new MoviePayload("BAD-PTS.AGF", SyntheticPayload(16, 16))),
];
MovieCorpusReport report = new MovieCorpusGate(_ => sources.Dequeue()).Run(inputs, 3, 1000);
Assert.False(report.Passed);
Assert.Equal(2, report.FailedCount);
Assert.Contains("expected 3", Assert.Single(report.SelectionErrors));
Assert.Contains("differ", report.Movies[0].Error);
Assert.Contains("timestamp", report.Movies[1].Error);
Assert.True(badDimensions.Disposed);
Assert.True(decreasingTimestamps.Disposed);
}
[Fact]
public void GateRejectsInvalidFrameRateMetadata()
{
var source = new FakeFrameSource(new FfmpegMovieInfo(16, 16, 100, 0, 1, false),
Frame(16, 16, 1, 0));
var input = new MovieCorpusInput(1, "BAD-RATE.AGF", () =>
new MoviePayload("BAD-RATE.AGF", SyntheticPayload(16, 16)));
MovieCorpusReport report = new MovieCorpusGate(_ => source).Run([input], 1, 1000);
Assert.False(report.Passed);
Assert.Contains("rate 0/1", Assert.Single(report.Movies).Error);
Assert.True(source.Disposed);
}
private static byte[] SyntheticPayload(int width, int height) =>
[
0, 0, 1, 0xba,
0, 0, 1, 0xb3,
(byte)(width >> 4),
(byte)(((width & 0x0f) << 4) | (height >> 8)),
(byte)height,
0,
];
private static FfmpegVideoFrame Frame(int width, int height, byte value, long timestamp)
=> new(new RgbaImage(width, height,
Enumerable.Repeat(value, checked(width * height * 4)).ToArray()), timestamp);
}

View File

@@ -100,10 +100,11 @@ public class MovieOpcodeTests
var factory = new FakeMovieDecoderFactory(decoder);
var payload = new MoviePayload("TEST.AGF", new byte[] { 0, 0, 1, 0xba });
var runtime = MovieRuntime.Open("TEST.AGF", 7, payload, factory);
var runtime = MovieRuntime.Open("TEST.AGF", 7, 0x123, payload, factory);
Assert.Same(payload, factory.OpenedPayload);
Assert.Same(decoder, runtime.Decoder);
Assert.Equal(0x123, runtime.ResourceId);
Assert.Equal(1876, runtime.Decoder.StopTimeMs);
Assert.Equal(5000, runtime.WatchdogMs);
Assert.True(runtime.Decoder.TryTakeFrame(out var frame));
@@ -120,7 +121,7 @@ public class MovieOpcodeTests
public void MovieRuntimeComputesBoundedWatchdogFromDecoderMetadata(long? stopTimeMs, long expected)
{
var decoder = new FakeMovieDecoder { StopTimeMs = stopTimeMs };
var runtime = MovieRuntime.Open("TEST.AGF", 7,
var runtime = MovieRuntime.Open("TEST.AGF", 7, 0x123,
new MoviePayload("TEST.AGF", new byte[] { 0, 0, 1, 0xba }),
new FakeMovieDecoderFactory(decoder));

View File

@@ -0,0 +1,65 @@
using Age.Engine.Sys4;
public class MovieSurfaceRegistryTests
{
[Fact]
public void SameResourceOnTwoSurfacesHasIndependentFramesCompletionAndRelease()
{
var registry = new MovieSurfaceRegistry();
MovieSurfaceBinding first = registry.Begin(7, 0x2b42, out _);
MovieSurfaceBinding second = registry.Begin(8, 0x2b42, out _);
var firstFrame = Frame(1);
var secondFrame = Frame(2);
Assert.True(registry.PublishFrame(first.PlaybackId, firstFrame, "MVB126.AGF", 0x2b42));
Assert.True(registry.PublishFrame(second.PlaybackId, secondFrame, "MVB126.AGF", 0x2b42));
Assert.True(registry.Complete(first.PlaybackId));
MovieSurfaceRelease released = registry.ReleaseIfCompleted(7);
Assert.Equal(MovieSurfaceReleaseKind.Released, released.Kind);
Assert.False(registry.IsBound(7));
Assert.True(registry.IsActive(8));
Assert.True(registry.TryResolveSurface(8, out MovieSurfaceFrame? remaining));
Assert.Same(secondFrame, remaining!.Image);
Assert.True(registry.Complete(second.PlaybackId));
Assert.Equal(MovieSurfaceReleaseKind.Released, registry.ReleaseIfCompleted(8).Kind);
Assert.False(registry.HasActivePlayback);
}
[Fact]
public void ReplacingSurfaceInvalidatesLateEventsFromPriorPlayback()
{
var registry = new MovieSurfaceRegistry();
MovieSurfaceBinding prior = registry.Begin(7, 0x2bdc, out _);
MovieSurfaceBinding current = registry.Begin(7, 0x2bad, out MovieSurfaceBinding? replaced);
Assert.Equal(prior, replaced);
Assert.False(registry.Complete(prior.PlaybackId));
Assert.False(registry.PublishFrame(prior.PlaybackId, Frame(1), "OLD.AGF", 1));
Assert.True(registry.IsActive(7));
Assert.True(registry.PublishFrame(current.PlaybackId, Frame(2), "NEW.AGF", 2));
Assert.True(registry.TryResolveSurface(7, out MovieSurfaceFrame? frame));
Assert.Equal("NEW.AGF", frame!.Name);
}
[Fact]
public void ReleasedMovieResourceRemainsTypedAsMovieForCleanupFrame()
{
var registry = new MovieSurfaceRegistry();
MovieSurfaceBinding binding = registry.Begin(7, 0x2bde, out _);
Assert.True(registry.IsKnownMovieResource(0x2bde));
Assert.True(registry.Complete(binding.PlaybackId));
Assert.Equal(MovieSurfaceReleaseKind.Released, registry.ReleaseIfCompleted(7).Kind);
Assert.False(registry.IsBound(7));
Assert.False(registry.TryResolveResource(0x2bde, out _));
Assert.True(registry.IsKnownMovieResource(0x2bde));
Assert.False(registry.IsKnownMovieResource(0x2af5));
}
private static RgbaImage Frame(byte value)
=> new(1, 1, new[] { value, value, value, (byte)255 });
}

View File

@@ -39,6 +39,23 @@ public class Sys4AssetStoreTests
Assert.Equal("23F0C104A45C099CEFB7D333362716EDE6F20B9EC53E4C3705A8E3A87063708E", digest);
}
[Fact]
public void CompletePackedAssetEnumerationPreservesBaseThenAppendOrder()
{
var catalog = Sys4AssetCatalog.Load(Paths.Sys4Ini);
var append = Assert.Single(catalog.AppendPacks).Value;
var assets = catalog.EnumerateAssets();
Assert.Equal(catalog.Files.Count + append.Files.Count, assets.Count);
Assert.Equal(0, assets[0].PackedId);
Assert.Same(catalog.Files[0], assets[0].Asset);
Assert.Equal(0x01000000, assets[catalog.Files.Count].PackedId);
Assert.Same(append.Files[0], assets[catalog.Files.Count].Asset);
Assert.Equal(catalog.EnumerateScripts(), assets.Where(entry =>
entry.Asset.Name.EndsWith(".BIN", StringComparison.OrdinalIgnoreCase)));
}
[Fact]
public void CompleteAppendDirectoryAndPayloadsMatchBinExtractAlf()
{

View File

@@ -31,6 +31,22 @@ public readonly record struct NumericGlyphStyle(int SurfaceSlot, int AtlasX, int
public bool Registered => SurfaceSlot != 0;
}
public sealed record BlockingGfxObjectDiagnostic(
long Handle, int SourceSlot, long StartMs, long ControlFlags,
bool ColorEnabled, long ColorDelayMs, long ColorDurationMs,
bool ScaleEnabled, long ScaleDelayMs, long ScaleDurationMs,
bool RotationEnabled, long RotationDelayMs, long RotationDurationMs,
bool TranslationEnabled, long TranslationDelayMs, long TranslationDurationMs);
public sealed record GfxDiagnosticSnapshot(
long NowMs, bool HasActiveTimedPresentation, int ObjectCount, int VisibleObjectCount,
int ActiveSurfaceTransitionCount, long AnimationServiceFlags,
long AnimClockDurationTicks, long AnimClockGeneration,
uint PreviousFrameTimeMilliseconds, uint CurrentFrameTimeMilliseconds,
long RangeTransformFirst, long RangeTransformCount,
BlockingGfxObjectDiagnostic? BlockingRangeTransform,
IReadOnlyList<BlockingGfxObjectDiagnostic> BlockingObjects);
/// <summary>A renderable view of one visible gfx object — the host composites these in ascending-handle order
/// (= the engine's z-order) each frame. Built by <see cref="GfxState.SnapshotVisibleObjects"/>; the surface
/// resId/colorkey are resolved from the object's live source slot at snapshot time (see docs/engine-re.md,
@@ -545,6 +561,51 @@ public sealed class GfxState
o.RotationChannelEnabled || o.TranslationEnabled));
}
/// <summary>Observe-only state for a runtime stall capture. It identifies the exact finite channels
/// which can keep the host's op-0x21c presentation wait active.</summary>
public GfxDiagnosticSnapshot CaptureDiagnosticSnapshot(long nowMs)
{
lock (_lock)
{
BlockingGfxObjectDiagnostic? range = HasBlockingChannels(_rangeTransform)
? DescribeBlockingObject(-1, _rangeTransform)
: null;
var objects = _objects
.Where(pair => pair.Value.Visible
&& (pair.Value.OneShotAnimationControlFlags & 1) == 0
&& HasBlockingChannels(pair.Value))
.OrderBy(pair => pair.Key)
.Select(pair => DescribeBlockingObject(pair.Key, pair.Value))
.ToArray();
return new GfxDiagnosticSnapshot(
nowMs,
_surfaceTransitions.Values.Any(t => TransitionProgress(t, nowMs) < 1.0)
|| range != null || objects.Length != 0,
_objects.Count,
_objects.Values.Count(o => o.Visible),
_surfaceTransitions.Values.Count(t => TransitionProgress(t, nowMs) < 1.0),
AnimationServiceFlags,
AnimClockDurationTicks,
AnimClockGeneration,
PreviousFrameTimeMilliseconds,
CurrentFrameTimeMilliseconds,
_rangeTransformFirst,
_rangeTransformCount,
range,
objects);
}
}
private static bool HasBlockingChannels(GfxObject o)
=> o.OneShotColorEnabled || o.ScaleEnabled || o.RotationChannelEnabled || o.TranslationEnabled;
private static BlockingGfxObjectDiagnostic DescribeBlockingObject(long handle, GfxObject o)
=> new(handle, o.SourceSlot, o.OneShotStartMs, o.OneShotAnimationControlFlags,
o.OneShotColorEnabled, o.ColorDelayMs, o.ColorDurationMs,
o.ScaleEnabled, o.ScaleDelayMs, o.ScaleDurationMs,
o.RotationChannelEnabled, o.RotationDelayMs, o.RotationDurationMs,
o.TranslationEnabled, o.TranslationDelayMs, o.TranslationDurationMs);
/// <summary>Op 0x242: replace the retained object's animation-control word. Native bit 0 makes its
/// finite one-shot channels nonblocking and immune to op 0x243 forced completion.</summary>
public void SetOneShotAnimationControl(long handle, long flags)

View File

@@ -179,23 +179,27 @@ public sealed class Sys4AssetCatalog
.Where(f => f.Name.EndsWith(".BIN", StringComparison.OrdinalIgnoreCase))
.Select(f => f.Name.ToUpperInvariant()).ToArray();
/// <summary>Enumerate every real asset in native packed-id order, including mounted append packs.</summary>
public IReadOnlyList<PackedAssetEntry> EnumerateAssets()
{
var assets = new List<PackedAssetEntry>();
AddAssets(this, assets);
foreach (var append in _appendPacks.OrderBy(pair => pair.Key).Select(pair => pair.Value))
AddAssets(append, assets);
return assets;
}
/// <summary>Enumerate every script in native packed-id order, including mounted append packs.
/// Placeholder slots and non-script assets are excluded without collapsing raw indices.</summary>
public IReadOnlyList<PackedAssetEntry> EnumerateScripts()
{
var scripts = new List<PackedAssetEntry>();
AddScripts(this, scripts);
foreach (var append in _appendPacks.OrderBy(pair => pair.Key).Select(pair => pair.Value))
AddScripts(append, scripts);
return scripts;
}
=> EnumerateAssets().Where(entry =>
entry.Asset.Name.EndsWith(".BIN", StringComparison.OrdinalIgnoreCase)).ToArray();
private static void AddScripts(Sys4AssetCatalog catalog, List<PackedAssetEntry> scripts)
private static void AddAssets(Sys4AssetCatalog catalog, List<PackedAssetEntry> assets)
{
long selector = (long)catalog.PackId << 24;
foreach (var entry in catalog.Files)
if (entry.Name.EndsWith(".BIN", StringComparison.OrdinalIgnoreCase))
scripts.Add(new PackedAssetEntry(selector | (uint)entry.RawIndex, entry));
assets.Add(new PackedAssetEntry(selector | (uint)entry.RawIndex, entry));
}
private static Dictionary<string, (int Start, int End)> BuildSceneRanges(IReadOnlyList<AssetEntry> files)

View File

@@ -4,12 +4,6 @@ using System.IO;
using System.Threading;
using Age.Engine.Sys4;
internal interface IFfmpegFrameSource : IDisposable
{
FfmpegMovieInfo Info { get; }
bool TryDecodeNextVideoFrame(out FfmpegVideoFrame frame);
}
internal interface IMoviePacingClock
{
bool WaitUntil(long elapsedMilliseconds, WaitHandle cancellation);

View File

@@ -14,6 +14,12 @@ internal readonly record struct FfmpegMovieInfo(
internal sealed record FfmpegVideoFrame(RgbaImage Image, long PresentationTimeMs);
internal interface IFfmpegFrameSource : IDisposable
{
FfmpegMovieInfo Info { get; }
bool TryDecodeNextVideoFrame(out FfmpegVideoFrame frame);
}
/// <summary>Sequential, unpaced access to the project-owned FFmpeg C ABI for isolated probes and playback.</summary>
internal sealed class FfmpegMovieSession : IFfmpegFrameSource
{

View File

@@ -22,9 +22,7 @@ public sealed class GodotAdvHost : IHost
private readonly Dictionary<int, RgbaImage> _surfaceImages = new();
private readonly Dictionary<int, long> _surfaceColorKeys = new();
private readonly Dictionary<int, long> _surfaceResources = new(); // surface slot -> packed catalog id
private readonly Dictionary<long, (RgbaImage Image, string Name, int AssetId)> _movieFrames = new();
private readonly Dictionary<int, long> _movieBySurface = new();
private readonly HashSet<long> _completedMovies = new();
private readonly MovieSurfaceRegistry _movieSurfaces = new();
private readonly string?[] _sfxNames = new string?[10]; // SC0000 native channel subset
// slot -> dimensions of the currently allocated surface. Slot 0 begins as the engine's 800x600
// primary surface, but op 0x1fa releases it like any other slot; subsequent size queries must return 0x0.
@@ -869,15 +867,13 @@ public sealed class GodotAdvHost : IHost
/// loose-first asset store.</summary>
public (RgbaImage Image, string Name, int AssetId, bool IsDynamic)? ResolveResIdTexture(long resId)
{
lock (_imageLock)
{
if (_movieFrames.TryGetValue(resId, out var movie))
return (movie.Image, movie.Name, movie.AssetId, true);
// Movie payloads use the same .AGF extension as still images. While the decoder is opening
// (or before its first frame arrives), keep the already-created surface blank
// instead of falling through to AgfDecoder and misclassifying the MPEG program stream.
if (_movieBySurface.Values.Contains(resId)) return null;
}
if (_movieSurfaces.TryResolveResource(resId, out var movie) && movie != null)
return (movie.Image, movie.Name, movie.AssetId, true);
// Movie payloads use the same .AGF extension as still images. Do not misclassify the MPEG program
// stream before its first frame or during the cleanup frame after its surface binding is detached.
// Packed catalog identity is immutable, so a resource which entered the typed movie path remains
// a movie even when it has no live playback.
if (_movieSurfaces.IsKnownMovieResource(resId)) return null;
var asset = _res.ResolveTexture(resId);
var image = asset != null ? Decode(asset) : null;
return asset != null && image != null ? (image, asset.Name, asset.PackedId, false) : null;
@@ -886,27 +882,49 @@ public sealed class GodotAdvHost : IHost
public (RgbaImage Image, string Name, int AssetId, bool IsDynamic)? ResolveSurfaceTexture(
int surfaceSlot, long fallbackResourceId)
{
if (_movieSurfaces.TryResolveSurface(surfaceSlot, out var movie) && movie != null)
return (movie.Image, movie.Name, movie.AssetId, true);
if (_movieSurfaces.IsBound(surfaceSlot)) return null;
lock (_imageLock)
if (_surfaceImages.TryGetValue(surfaceSlot, out var surface))
return (surface, $"<surface:{surfaceSlot}>", int.MinValue + surfaceSlot, true);
return fallbackResourceId != 0 ? ResolveResIdTexture(fallbackResourceId) : null;
}
public bool IsMovieSurfaceBound(int surfaceSlot) => _movieSurfaces.IsBound(surfaceSlot);
public long? PlayMovieToSurface(long resourceId, int surfaceSlot, long movieFlags, long syncMask)
{
string scene = CurrentScene;
var asset = _res.ResolveMovie(resourceId);
if (asset == null) { Godot.GD.Print($"movie unresolved {scene}:0x{resourceId:x}"); return null; }
StartMovie(asset, resourceId, surfaceSlot, movieFlags, syncMask, modal: false,
out long? stopTimeMs);
out long? stopTimeMs, out _);
return stopTimeMs ?? 0;
}
public bool IsMovieSurfaceActive(int surfaceSlot)
=> _movieSurfaces.IsActive(surfaceSlot);
public GodotHostDiagnosticSnapshot CaptureDiagnosticSnapshot()
{
lock (_imageLock)
return _movieBySurface.TryGetValue(surfaceSlot, out long resourceId)
&& !_completedMovies.Contains(resourceId);
IReadOnlyList<MovieSurfaceDiagnostic> movies = _movieSurfaces.Snapshot();
IReadOnlyList<long> completed = _movieSurfaces.CompletedPlaybackIds();
bool screenTransitionActive;
lock (_screenTransitionLock) screenTransitionActive = _screenTransition != null;
return new GodotHostDiagnosticSnapshot(
CurrentScene,
IsWaiting,
IsTransitionWaiting,
IsSleeping,
IsTextRevealing,
_modalMovieWaiting,
_advPagePresentationSuspended,
_messageSkipActive,
screenTransitionActive,
TransitionStartedAtMs,
movies,
completed);
}
public void PlayModalMovieToSurface(long resourceId, int surfaceSlot, long movieFlags)
@@ -923,22 +941,22 @@ public sealed class GodotAdvHost : IHost
try
{
if (!StartMovie(asset, resourceId, surfaceSlot, movieFlags, 0, modal: true,
out _)) return;
out _, out long playbackId)) return;
_timeline?.State("modal-movie-wait", new()
{
["resource"] = resourceId, ["surface"] = surfaceSlot, ["file"] = asset.Name,
["resource"] = resourceId, ["playback"] = playbackId,
["surface"] = surfaceSlot, ["file"] = asset.Name,
});
while (!_stopping && !_modalMovieCancelled)
{
lock (_imageLock)
if (_completedMovies.Contains(resourceId)) break;
if (!_movieSurfaces.IsActive(surfaceSlot)) break;
_frameSignal.WaitOne(50);
}
// Cancellation is a completed modal presentation from the script's perspective. The
// wrapper's following surface-release opcode performs the ordinary decoder teardown.
if (_modalMovieCancelled)
lock (_imageLock) _completedMovies.Add(resourceId);
_movieSurfaces.Complete(playbackId);
_timeline?.State("running", new()
{
["modal_movie_complete"] = !_modalMovieCancelled,
@@ -953,16 +971,18 @@ public sealed class GodotAdvHost : IHost
}
private bool StartMovie(AssetEntry asset, long resourceId, int surfaceSlot, long movieFlags,
long syncMask, bool modal, out long? stopTimeMs)
long syncMask, bool modal, out long? stopTimeMs, out long playbackId)
{
stopTimeMs = null;
// Publish the movie identity before the potentially long VFS read and synchronous decoder setup.
// The compositor can therefore distinguish a legitimate blank pre-roll surface from a still AGF.
ReleaseSurface(surfaceSlot);
// A playback is a surface-owned instance, not the shared resource id. BTL can schedule the same
// asset on multiple surfaces; replacing one binding must not erase another binding's completion.
MovieSurfaceBinding binding = _movieSurfaces.Begin(surfaceSlot, resourceId, out var replaced);
playbackId = binding.PlaybackId;
if (replaced is { } prior) _main.CallDeferred("StopMovie", prior.PlaybackId);
lock (_imageLock)
{
_movieBySurface[surfaceSlot] = resourceId;
_completedMovies.Remove(resourceId);
_surfaceImages.Remove(surfaceSlot);
_surfaceColorKeys.Remove(surfaceSlot);
}
_slotDims[surfaceSlot] = (800, 600); // SC0000 creates this native-sized surface immediately beforehand.
try
@@ -970,30 +990,24 @@ public sealed class GodotAdvHost : IHost
var movie = _res.ReadMovie(asset);
_timeline?.Event("movie-start", new()
{
["resource"] = resourceId, ["surface"] = surfaceSlot, ["file"] = movie.Name,
["resource"] = resourceId, ["playback"] = playbackId,
["surface"] = surfaceSlot, ["file"] = movie.Name,
["flags"] = movieFlags, ["sync_mask"] = syncMask, ["modal"] = modal,
});
bool started = _main.TryPlayMovie(movie.Bytes, movie.Name, resourceId, asset.PackedId,
bool started = _main.TryPlayMovie(movie.Bytes, movie.Name, playbackId, resourceId, asset.PackedId,
out stopTimeMs);
if (!started)
{
stopTimeMs = 0;
NotifyMovieCompleted(resourceId);
NotifyMovieCompleted(playbackId);
}
return started;
}
catch (System.Exception e)
{
lock (_imageLock)
{
if (_movieBySurface.TryGetValue(surfaceSlot, out long registered) && registered == resourceId)
_movieBySurface.Remove(surfaceSlot);
_movieFrames.Remove(resourceId);
_completedMovies.Remove(resourceId);
}
_movieSurfaces.Abandon(playbackId, out _);
_slotDims.Remove(surfaceSlot);
stopTimeMs = 0;
NotifyMovieCompleted(resourceId);
Godot.GD.Print($"movie read failed {asset.Name}: {e.Message}");
return false;
}
@@ -1002,28 +1016,11 @@ public sealed class GodotAdvHost : IHost
public void ReleaseSurface(int slot)
{
lock (_screenTransitionLock) _renderTargetSnapshots.Remove(slot);
long resourceId;
MovieSurfaceRelease movieRelease = _movieSurfaces.ReleaseIfCompleted(slot);
if (movieRelease.Kind == MovieSurfaceReleaseKind.Active)
return; // Static surface setup before 0x21c must not evict an active movie playback.
lock (_imageLock)
{
if (!_movieBySurface.Remove(slot, out resourceId))
{
_surfaceImages.Remove(slot);
_surfaceColorKeys.Remove(slot);
lock (_textLock)
{
_surfaceText.Remove(slot);
_surfaceResources.Remove(slot);
}
_slotDims.Remove(slot);
return;
}
if (!_completedMovies.Contains(resourceId))
{
_movieBySurface[slot] = resourceId;
return; // SC0000 prepares following static surfaces before 0x21c; the movie remains retained.
}
_movieFrames.Remove(resourceId);
_completedMovies.Remove(resourceId);
_surfaceImages.Remove(slot);
_surfaceColorKeys.Remove(slot);
}
@@ -1033,8 +1030,17 @@ public sealed class GodotAdvHost : IHost
_surfaceResources.Remove(slot);
}
_slotDims.Remove(slot);
_timeline?.Event("movie-stop", new() { ["resource"] = resourceId, ["surface"] = slot });
_main.CallDeferred("StopMovie", resourceId);
if (movieRelease.Kind == MovieSurfaceReleaseKind.Released)
{
var binding = movieRelease.Binding;
_timeline?.Event("movie-stop", new()
{
["resource"] = binding.ResourceId,
["playback"] = binding.PlaybackId,
["surface"] = slot,
});
_main.CallDeferred("StopMovie", binding.PlaybackId);
}
}
public void ClearRenderTarget(int surfaceSlot)
@@ -1049,7 +1055,7 @@ public sealed class GodotAdvHost : IHost
public void ReleaseSurfaceRange(int firstSlot, int count)
{
var stoppedMovies = new System.Collections.Generic.HashSet<long>();
IReadOnlyList<MovieSurfaceBinding> stoppedMovies = _movieSurfaces.ReleaseRange(firstSlot, count);
int end = checked(firstSlot + count);
lock (_screenTransitionLock)
for (int slot = firstSlot; slot < end; slot++) _renderTargetSnapshots.Remove(slot);
@@ -1057,12 +1063,6 @@ public sealed class GodotAdvHost : IHost
{
for (int slot = firstSlot; slot < end; slot++)
{
if (_movieBySurface.Remove(slot, out long resourceId))
{
stoppedMovies.Add(resourceId);
_movieFrames.Remove(resourceId);
_completedMovies.Remove(resourceId);
}
_surfaceImages.Remove(slot);
_surfaceColorKeys.Remove(slot);
_slotDims.Remove(slot);
@@ -1076,37 +1076,36 @@ public sealed class GodotAdvHost : IHost
_surfaceResources.Remove(slot);
}
}
foreach (long resourceId in stoppedMovies)
foreach (MovieSurfaceBinding binding in stoppedMovies)
{
_timeline?.Event("movie-stop", new() { ["resource"] = resourceId, ["range_release"] = true });
_main.CallDeferred("StopMovie", resourceId);
_timeline?.Event("movie-stop", new()
{
["resource"] = binding.ResourceId,
["playback"] = binding.PlaybackId,
["range_release"] = true,
});
_main.CallDeferred("StopMovie", binding.PlaybackId);
}
_timeline?.Event("surface-range-release", new() { ["first"] = firstSlot, ["count"] = count });
}
// Main-thread decoder handoff. Replacing the newest frame mirrors the native texture renderer's
// sample callback: the retained object keeps its surface binding while only the surface pixels change.
public void PublishMovieFrame(long resourceId, string name, int assetId, RgbaImage frame)
public void PublishMovieFrame(long playbackId, string name, int assetId, RgbaImage frame)
{
lock (_imageLock) _movieFrames[resourceId] = (frame, name, assetId);
System.Threading.Interlocked.Exchange(ref _presentRequested, 1);
if (_movieSurfaces.PublishFrame(playbackId, frame, name, assetId))
System.Threading.Interlocked.Exchange(ref _presentRequested, 1);
}
public void NotifyMovieCompleted(long resourceId)
public void NotifyMovieCompleted(long playbackId)
{
lock (_imageLock)
if (!_completedMovies.Add(resourceId)) return;
_timeline?.Event("movie-complete", new() { ["resource"] = resourceId });
if (!_movieSurfaces.Complete(playbackId)) return;
_timeline?.Event("movie-complete", new() { ["playback"] = playbackId });
_frameSignal.Set();
}
private bool HasActiveMoviePresentation()
{
lock (_imageLock)
foreach (long resourceId in _movieBySurface.Values)
if (!_completedMovies.Contains(resourceId)) return true;
return false;
}
=> _movieSurfaces.HasActivePlayback;
private RgbaImage? Decode(AssetEntry asset)
{
@@ -1254,6 +1253,11 @@ public sealed class GodotAdvHost : IHost
}
public readonly record struct SurfaceTextDraw(int X, int Y, string Text, AdvTextStyle Style);
public sealed record GodotHostDiagnosticSnapshot(
string CurrentScene, bool IsInputWaiting, bool IsTransitionWaiting, bool IsSleeping,
bool IsTextRevealing, bool IsModalMovieWaiting, bool IsAdvPagePresentationSuspended,
bool IsMessageSkipActive, bool IsScreenTransitionActive, long TransitionStartedAtMs,
IReadOnlyList<MovieSurfaceDiagnostic> MovieSurfaces, IReadOnlyList<long> CompletedMoviePlaybackIds);
public readonly record struct LegacyScreenTransitionSnapshot(
IReadOnlyList<RenderObject> Source, IReadOnlyList<RenderObject> Target, double Progress);

View File

@@ -8,9 +8,13 @@ using Age.Engine.Diagnostics;
// engine fact delivered over the trace seam.
public sealed class GodotTraceSink : ITraceSink
{
private const int RecentStepCapacity = 128;
private readonly GodotTimelineLog? _timeline;
private readonly PageLocatorState _locator;
private readonly object _snapshotLock = new();
private readonly Stack<string> _scripts = new();
private readonly Queue<GodotTraceStepSnapshot> _recentSteps = new();
private GodotTraceStepSnapshot? _latestStep;
public GodotTraceSink(PageLocatorState locator, GodotTimelineLog? timeline = null)
{ _locator = locator; _timeline = timeline; }
// The page locator needs the exact script/offset even when the heavier timeline log is disabled.
@@ -28,8 +32,13 @@ public sealed class GodotTraceSink : ITraceSink
}
if (e.Kind == TraceEventKind.FrameEnter && e.Name != null)
{
_scripts.Push(e.Name);
PublishCallStack();
string[] callStack;
lock (_snapshotLock)
{
_scripts.Push(e.Name);
callStack = CurrentCallStackLocked();
}
_locator.CallStack(callStack);
_timeline?.Event("frame-enter", new()
{
["name"] = e.Name, ["depth"] = e.Depth,
@@ -42,12 +51,25 @@ public sealed class GodotTraceSink : ITraceSink
{
["name"] = e.Name, ["depth"] = e.Depth, ["outcome"] = e.Text,
});
_scripts.Pop();
PublishCallStack();
string[] callStack;
lock (_snapshotLock)
{
if (_scripts.Count > 0) _scripts.Pop();
callStack = CurrentCallStackLocked();
}
_locator.CallStack(callStack);
}
else if (e.Kind == TraceEventKind.Step && e.Ins != null)
{
string script = _scripts.Count > 0 ? _scripts.Peek() : "<unknown>";
string script;
lock (_snapshotLock)
{
script = _scripts.Count > 0 ? _scripts.Peek() : "<unknown>";
if (_recentSteps.Count == RecentStepCapacity) _recentSteps.Dequeue();
var step = new GodotTraceStepSnapshot(script, e.Ins.Offset, e.Opcode, e.Depth);
_latestStep = step;
_recentSteps.Enqueue(step);
}
_locator.Step(script, e.Ins.Offset);
_timeline?.Step(script, e.Ins.Offset, e.Opcode, e.Depth);
}
@@ -60,10 +82,31 @@ public sealed class GodotTraceSink : ITraceSink
_timeline?.State("halted", new() { ["reason"] = e.Text, ["steps"] = e.Steps });
}
private void PublishCallStack()
public GodotTraceSnapshot Snapshot()
{
lock (_snapshotLock)
{
GodotTraceStepSnapshot? current = _latestStep;
return new GodotTraceSnapshot(
current?.Script ?? (_scripts.Count > 0 ? _scripts.Peek() : "<unknown>"),
current?.Offset ?? -1,
current?.Opcode ?? -1,
current?.Depth ?? System.Math.Max(0, _scripts.Count - 1),
CurrentCallStackLocked(),
_recentSteps.ToArray());
}
}
private string[] CurrentCallStackLocked()
{
var stack = _scripts.ToArray();
System.Array.Reverse(stack);
_locator.CallStack(stack);
return stack;
}
}
public sealed record GodotTraceStepSnapshot(string Script, int Offset, int Opcode, int Depth);
public sealed record GodotTraceSnapshot(string CurrentScript, int CurrentOffset, int CurrentOpcode,
int CurrentDepth, IReadOnlyList<string> CallStack,
IReadOnlyList<GodotTraceStepSnapshot> RecentSteps);

View File

@@ -393,6 +393,12 @@ public partial class Main : Godot.Control
GetViewport().SetInputAsHandled();
return;
}
if (e is InputEventKey diagnosticKey && diagnosticKey.Keycode == Key.F6)
{
if (diagnosticKey.Pressed && !diagnosticKey.Echo) CaptureStallDiagnostic();
GetViewport().SetInputAsHandled();
return;
}
if (_debugSceneLauncher?.Visible == true)
{
if (e is InputEventKey escape && escape.Pressed && !escape.Echo && escape.Keycode == Key.Escape)
@@ -501,6 +507,86 @@ public partial class Main : Godot.Control
private static bool IsAdvanceAction(int action) => action is 4 or 5;
private static bool HasAdvanceAction(int mask) => (mask & ((1 << 4) | (1 << 5))) != 0;
private void CaptureStallDiagnostic()
{
try
{
long nowMs = _clock.NowMs;
GodotTraceSnapshot trace = _trace.Snapshot();
var activeMovies = _movies
.OrderBy(pair => pair.Key)
.Select(pair => new
{
playback_id = pair.Key,
resource_id = pair.Value.ResourceId,
name = pair.Value.Name,
asset_id = pair.Value.AssetId,
stop_time_ms = pair.Value.Decoder.StopTimeMs,
decoder_completed = pair.Value.Decoder.IsCompleted,
decoder_failure = pair.Value.Decoder.Failure,
frame_seen = _movieFrameSeen.Contains(pair.Key),
completion_notified = _movieCompletionNotified.Contains(pair.Key),
watchdog_ms = pair.Value.WatchdogMs,
elapsed_ms = (long)Stopwatch.GetElapsedTime(pair.Value.StartedAtTimestamp).TotalMilliseconds,
})
.ToArray();
var pendingMovies = _pendingMovies
.OrderBy(pair => pair.Key)
.Select(pair => new
{
playback_id = pair.Key,
resource_id = pair.Value.ResourceId,
name = pair.Value.Name,
asset_id = pair.Value.AssetId,
stop_time_ms = pair.Value.Decoder.StopTimeMs,
decoder_completed = pair.Value.Decoder.IsCompleted,
decoder_failure = pair.Value.Decoder.Failure,
})
.ToArray();
var snapshot = new
{
format_version = 1,
captured_utc = System.DateTimeOffset.UtcNow.ToString("O"),
render_frame = _timelineFrame,
clock_ms = nowMs,
vm = new
{
steps = _vm.Steps,
halt_reason = _vm.HaltReason,
done = _done,
trace,
},
host = _host.CaptureDiagnosticSnapshot(),
gfx = _vm.Gfx.CaptureDiagnosticSnapshot(nowMs),
active_movies = activeMovies,
pending_movies = pendingMovies,
};
string directory = ProjectSettings.GlobalizePath("user://diagnostics");
System.IO.Directory.CreateDirectory(directory);
string path = System.IO.Path.Combine(directory,
$"stall-{System.DateTimeOffset.Now:yyyyMMdd-HHmmss-fff}.json");
var jsonOptions = new JsonSerializerOptions
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
};
System.IO.File.WriteAllText(path, JsonSerializer.Serialize(snapshot, jsonOptions));
string coordinate = trace.CurrentOffset >= 0
? $"{System.IO.Path.GetFileNameWithoutExtension(trace.CurrentScript).ToUpperInvariant()}@0x{trace.CurrentOffset:x}"
: trace.CurrentScript;
string clipboard = $"{coordinate} · stall snapshot {path}";
DisplayServer.ClipboardSet(clipboard);
_status.Text = $"Diagnostic saved: {coordinate} (path copied)";
GD.Print($"[diagnostic] stall snapshot {coordinate} -> {path}");
}
catch (System.Exception exception)
{
_status.Text = "Diagnostic capture failed; see Godot log.";
GD.Print($"[diagnostic] stall snapshot failed: {exception}");
}
}
private void ToggleDebugSceneLauncher()
{
if (_debugSceneLauncher == null) return;
@@ -680,6 +766,7 @@ public partial class Main : Godot.Control
var surfaceTexture = rawObject != null
? _host.ResolveSurfaceTexture(rawObject.SourceSlot, v.SurfaceResId)
: null;
bool movieSurfaceBound = rawObject != null && _host.IsMovieSurfaceBound(rawObject.SourceSlot);
string outcome;
if (v.SurfaceTransition is { } transition)
{
@@ -709,7 +796,8 @@ public partial class Main : Godot.Control
}
else
{
var texture = surfaceTexture ?? _host.ResolveResIdTexture(v.SurfaceResId);
var texture = surfaceTexture
?? (movieSurfaceBound ? null : _host.ResolveResIdTexture(v.SurfaceResId));
if (texture == null) outcome = $"SKIP(resId=0x{v.SurfaceResId:x} UNRESOLVED)";
else
{
@@ -889,6 +977,7 @@ public partial class Main : Godot.Control
var texture = rawObject != null
? _host.ResolveSurfaceTexture(rawObject.SourceSlot, source.SurfaceResId)
: null;
bool movieSurfaceBound = rawObject != null && _host.IsMovieSurfaceBound(rawObject.SourceSlot);
if (source.SurfaceResId == 0 && texture == null)
{
if (source.Blend == BlendKind.Opaque) continue;
@@ -897,7 +986,7 @@ public partial class Main : Godot.Control
}
else
{
texture ??= _host.ResolveResIdTexture(source.SurfaceResId);
if (!movieSurfaceBound) texture ??= _host.ResolveResIdTexture(source.SurfaceResId);
if (texture == null) continue;
BlitLayer(texture.Value.Image, texture.Value.AssetId, source.ColorKey, source.Tint, source.TintStrength / 255f,
source.SrcX, source.SrcY, source.W, source.H, affine, opacity, source.MultiplyTint,
@@ -1177,17 +1266,18 @@ public partial class Main : Godot.Control
CreateTween().TweenProperty(_bgm, "volume_db", targetDb, realDurationSeconds);
}
public bool TryPlayMovie(byte[] mpegBytes, string assetName, long resourceId, int assetId,
public bool TryPlayMovie(byte[] mpegBytes, string assetName, long playbackId,
long resourceId, int assetId,
out long? stopTimeMs)
{
stopTimeMs = null;
try
{
var payload = new Age.Engine.Sys4.MoviePayload(assetName, mpegBytes);
var runtime = MovieRuntime.Open(assetName, assetId, payload, _movieDecoderFactory);
var runtime = MovieRuntime.Open(assetName, assetId, resourceId, payload, _movieDecoderFactory);
stopTimeMs = runtime.Decoder.StopTimeMs;
while (!_pendingMovies.TryAdd(resourceId, runtime))
if (_pendingMovies.TryRemove(resourceId, out var prior)) prior.Decoder.Dispose();
while (!_pendingMovies.TryAdd(playbackId, runtime))
if (_pendingMovies.TryRemove(playbackId, out var prior)) prior.Decoder.Dispose();
return true;
}
catch (System.Exception e)
@@ -1199,50 +1289,52 @@ public partial class Main : Godot.Control
private void AdoptPendingMovies()
{
foreach (var (resourceId, _) in _pendingMovies)
foreach (var (playbackId, _) in _pendingMovies)
{
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)");
if (!_pendingMovies.TryRemove(playbackId, out var movie)) continue;
if (_movies.Remove(playbackId, out var prior)) prior.Decoder.Dispose();
_movies[playbackId] = movie;
_movieCompletionNotified.Remove(playbackId);
GD.Print($"movie started {movie.Name} playback={playbackId} " +
$"({movie.Decoder.StopTimeMs?.ToString() ?? "unknown"} ms from VFS)");
}
}
private void UpdateMovieFrames()
{
if (_host == null) return;
foreach (var (resourceId, movie) in _movies)
foreach (var (playbackId, movie) in _movies)
{
if (movie.Decoder.TryTakeFrame(out var frame))
{
_host.PublishMovieFrame(resourceId, movie.Name, movie.AssetId, frame);
if (_movieFrameSeen.Add(resourceId))
GD.Print($"movie first frame {movie.Name}: {frame.Width}x{frame.Height} RGBA8 at render frame {_timelineFrame}");
_host.PublishMovieFrame(playbackId, movie.Name, movie.AssetId, frame);
if (_movieFrameSeen.Add(playbackId))
GD.Print($"movie first frame {movie.Name} playback={playbackId}: " +
$"{frame.Width}x{frame.Height} RGBA8 at render frame {_timelineFrame}");
}
bool watchdogExpired = Stopwatch.GetElapsedTime(movie.StartedAtTimestamp).TotalMilliseconds
>= movie.WatchdogMs;
if ((movie.Decoder.IsCompleted || watchdogExpired) && _movieCompletionNotified.Add(resourceId))
if ((movie.Decoder.IsCompleted || watchdogExpired) && _movieCompletionNotified.Add(playbackId))
{
if (movie.Decoder.Failure is { } failure)
GD.Print($"movie decode failed {movie.Name}: {failure}");
if (watchdogExpired && !movie.Decoder.IsCompleted)
GD.Print($"movie completion watchdog {movie.Name}: forcing completion after {movie.WatchdogMs} ms");
_host.NotifyMovieCompleted(resourceId);
_host.NotifyMovieCompleted(playbackId);
}
}
}
public void StopMovie(long resourceId)
public void StopMovie(long playbackId)
{
if (_pendingMovies.TryRemove(resourceId, out var pending)) pending.Decoder.Dispose();
if (_movies.Remove(resourceId, out var movie))
if (_pendingMovies.TryRemove(playbackId, out var pending)) pending.Decoder.Dispose();
if (_movies.Remove(playbackId, out var movie))
{
movie.Decoder.Dispose();
GD.Print($"movie stopped {movie.Name} at render frame {_timelineFrame}");
GD.Print($"movie stopped {movie.Name} playback={playbackId} at render frame {_timelineFrame}");
}
_movieFrameSeen.Remove(resourceId);
_movieCompletionNotified.Remove(resourceId);
_movieFrameSeen.Remove(playbackId);
_movieCompletionNotified.Remove(playbackId);
}
public void AppendLine(string text) => _text.Text += text + "\n";

View File

@@ -3,15 +3,15 @@ using System.Diagnostics;
using Age.Engine.Sys4;
/// <summary>Presentation-side ownership for one decoder plus its fail-safe completion deadline.</summary>
internal sealed record MovieRuntime(string Name, int AssetId, IMovieDecoder Decoder,
internal sealed record MovieRuntime(string Name, int AssetId, long ResourceId, IMovieDecoder Decoder,
long StartedAtTimestamp, long WatchdogMs)
{
public static MovieRuntime Open(string name, int assetId, MoviePayload payload,
public static MovieRuntime Open(string name, int assetId, long resourceId, MoviePayload payload,
IMovieDecoderFactory factory)
{
ArgumentNullException.ThrowIfNull(factory);
IMovieDecoder decoder = factory.Open(payload);
return new MovieRuntime(name, assetId, decoder, Stopwatch.GetTimestamp(),
return new MovieRuntime(name, assetId, resourceId, decoder, Stopwatch.GetTimestamp(),
decoder.StopTimeMs is >= 0 and var stopTime
? Math.Clamp(stopTime + 2000, 5000, 300000)
: 30000);

View File

@@ -0,0 +1,182 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Age.Engine.Sys4;
internal readonly record struct MovieSurfaceBinding(long PlaybackId, long ResourceId, int SurfaceSlot);
internal sealed record MovieSurfaceFrame(RgbaImage Image, string Name, int AssetId);
public sealed record MovieSurfaceDiagnostic(int SurfaceSlot, long PlaybackId, long ResourceId,
bool Completed, bool HasFrame, string? Name);
internal enum MovieSurfaceReleaseKind { NotBound, Active, Released }
internal readonly record struct MovieSurfaceRelease(MovieSurfaceReleaseKind Kind,
MovieSurfaceBinding Binding);
/// <summary>Instance-keyed movie/surface ownership. A resource may be played more than once concurrently;
/// completion and frames therefore belong to a playback, never to the shared asset id.</summary>
internal sealed class MovieSurfaceRegistry
{
private readonly object _lock = new();
private readonly Dictionary<int, MovieSurfaceBinding> _bySurface = new();
private readonly Dictionary<long, MovieSurfaceBinding> _byPlayback = new();
private readonly Dictionary<long, MovieSurfaceFrame> _frames = new();
private readonly HashSet<long> _completed = new();
private readonly HashSet<long> _knownMovieResources = new();
private long _nextPlaybackId;
public MovieSurfaceBinding Begin(int surfaceSlot, long resourceId,
out MovieSurfaceBinding? replaced)
{
lock (_lock)
{
replaced = _bySurface.TryGetValue(surfaceSlot, out var prior) ? prior : null;
if (replaced.HasValue) RemoveLocked(prior);
var binding = new MovieSurfaceBinding(++_nextPlaybackId, resourceId, surfaceSlot);
_knownMovieResources.Add(resourceId);
_bySurface[surfaceSlot] = binding;
_byPlayback[binding.PlaybackId] = binding;
return binding;
}
}
public bool PublishFrame(long playbackId, RgbaImage image, string name, int assetId)
{
lock (_lock)
{
if (!_byPlayback.ContainsKey(playbackId)) return false;
_frames[playbackId] = new MovieSurfaceFrame(image, name, assetId);
return true;
}
}
public bool Complete(long playbackId)
{
lock (_lock)
return _byPlayback.ContainsKey(playbackId) && _completed.Add(playbackId);
}
public bool IsActive(int surfaceSlot)
{
lock (_lock)
return _bySurface.TryGetValue(surfaceSlot, out var binding)
&& !_completed.Contains(binding.PlaybackId);
}
public bool IsBound(int surfaceSlot)
{
lock (_lock) return _bySurface.ContainsKey(surfaceSlot);
}
public bool HasActivePlayback
{
get
{
lock (_lock)
return _byPlayback.Keys.Any(playbackId => !_completed.Contains(playbackId));
}
}
public bool TryResolveSurface(int surfaceSlot, out MovieSurfaceFrame? frame)
{
lock (_lock)
{
if (_bySurface.TryGetValue(surfaceSlot, out var binding)
&& _frames.TryGetValue(binding.PlaybackId, out var found))
{
frame = found;
return true;
}
frame = null;
return false;
}
}
public bool TryResolveResource(long resourceId, out MovieSurfaceFrame? frame)
{
lock (_lock)
{
foreach (var binding in _byPlayback.Values
.Where(binding => binding.ResourceId == resourceId)
.OrderByDescending(binding => binding.PlaybackId))
{
if (!_frames.TryGetValue(binding.PlaybackId, out var found)) continue;
frame = found;
return true;
}
frame = null;
return false;
}
}
/// <summary>Catalog ids are immutable within a mounted resource set. Remembering that an id entered
/// the typed movie path prevents a cleanup-frame render snapshot from treating its .AGF-named MPEG
/// payload as a still image after the last live surface binding has been detached.</summary>
public bool IsKnownMovieResource(long resourceId)
{
lock (_lock) return _knownMovieResources.Contains(resourceId);
}
public MovieSurfaceRelease ReleaseIfCompleted(int surfaceSlot)
{
lock (_lock)
{
if (!_bySurface.TryGetValue(surfaceSlot, out var binding))
return new MovieSurfaceRelease(MovieSurfaceReleaseKind.NotBound, default);
if (!_completed.Contains(binding.PlaybackId))
return new MovieSurfaceRelease(MovieSurfaceReleaseKind.Active, binding);
RemoveLocked(binding);
return new MovieSurfaceRelease(MovieSurfaceReleaseKind.Released, binding);
}
}
public bool Abandon(long playbackId, out MovieSurfaceBinding binding)
{
lock (_lock)
{
if (!_byPlayback.TryGetValue(playbackId, out binding)) return false;
RemoveLocked(binding);
return true;
}
}
public IReadOnlyList<MovieSurfaceBinding> ReleaseRange(int firstSlot, int count)
{
int end = checked(firstSlot + count);
lock (_lock)
{
var released = _bySurface.Values
.Where(binding => binding.SurfaceSlot >= firstSlot && binding.SurfaceSlot < end)
.OrderBy(binding => binding.SurfaceSlot)
.ToArray();
foreach (var binding in released) RemoveLocked(binding);
return released;
}
}
public IReadOnlyList<MovieSurfaceDiagnostic> Snapshot()
{
lock (_lock)
return _bySurface.Values
.OrderBy(binding => binding.SurfaceSlot)
.Select(binding => new MovieSurfaceDiagnostic(
binding.SurfaceSlot,
binding.PlaybackId,
binding.ResourceId,
_completed.Contains(binding.PlaybackId),
_frames.ContainsKey(binding.PlaybackId),
_frames.TryGetValue(binding.PlaybackId, out var frame) ? frame.Name : null))
.ToArray();
}
public IReadOnlyList<long> CompletedPlaybackIds()
{
lock (_lock) return _completed.OrderBy(id => id).ToArray();
}
private void RemoveLocked(MovieSurfaceBinding binding)
{
_bySurface.Remove(binding.SurfaceSlot);
_byPlayback.Remove(binding.PlaybackId);
_frames.Remove(binding.PlaybackId);
_completed.Remove(binding.PlaybackId);
}
}

View File

@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<NuGetAudit>false</NuGetAudit>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\engine\Age.Engine\Age.Engine.csproj" />
<Compile Include="..\..\godot\FfmpegMovieNative.cs" Link="FfmpegMovieNative.cs" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,228 @@
using System.Diagnostics;
using Age.Engine.Sys4;
internal sealed record MovieCorpusInput(long PackedId, string Name, Func<MoviePayload> ReadPayload);
internal sealed record MovieCorpusItemResult(
long PackedId,
string PackedIdHex,
string Name,
long PayloadBytes,
int ExpectedWidth,
int ExpectedHeight,
int Width,
int Height,
long StopTimeMs,
int FrameRateNumerator,
int FrameRateDenominator,
bool HasAudio,
long FrameCount,
long FirstPresentationTimeMs,
long LastPresentationTimeMs,
bool FramesChanged,
long ReadMilliseconds,
long OpenMilliseconds,
long DecodeMilliseconds,
long DisposeMilliseconds,
bool Passed,
string? Error);
internal sealed record MovieCorpusReport(
string StartedUtc,
string CompletedUtc,
int ExpectedCount,
int CandidateCount,
int PassedCount,
int FailedCount,
long ElapsedMilliseconds,
bool Passed,
IReadOnlyList<string> SelectionErrors,
IReadOnlyList<MovieCorpusItemResult> Movies);
internal static class MovieCorpusDiscovery
{
private static ReadOnlySpan<byte> MpegPackStart => [0, 0, 1, 0xba];
public static IReadOnlyList<PackedAssetEntry> DiscoverMpegMovies(
Sys4AssetCatalog catalog, IAssetStore store)
{
var movies = new List<PackedAssetEntry>();
Span<byte> signature = stackalloc byte[4];
foreach (var packed in catalog.EnumerateAssets())
{
if (!packed.Asset.Name.EndsWith(".AGF", StringComparison.OrdinalIgnoreCase)) continue;
using Stream stream = store.Open(packed.Asset);
int length = 0;
while (length < signature.Length)
{
int read = stream.Read(signature[length..]);
if (read == 0) break;
length += read;
}
if (length == signature.Length && signature.SequenceEqual(MpegPackStart)) movies.Add(packed);
}
return movies;
}
public static bool TryReadSequenceDimensions(ReadOnlySpan<byte> payload, out int width, out int height)
{
for (int offset = 0; offset <= payload.Length - 7; offset++)
{
if (payload[offset] != 0 || payload[offset + 1] != 0
|| payload[offset + 2] != 1 || payload[offset + 3] != 0xb3) continue;
width = (payload[offset + 4] << 4) | (payload[offset + 5] >> 4);
height = ((payload[offset + 5] & 0x0f) << 8) | payload[offset + 6];
return width > 0 && height > 0;
}
width = 0;
height = 0;
return false;
}
}
internal sealed class MovieCorpusGate
{
private readonly Func<MoviePayload, IFfmpegFrameSource> _open;
public MovieCorpusGate(Func<MoviePayload, IFfmpegFrameSource> open)
=> _open = open ?? throw new ArgumentNullException(nameof(open));
public MovieCorpusReport Run(IReadOnlyList<MovieCorpusInput> inputs, int expectedCount,
long maximumItemMilliseconds,
Action<int, int, MovieCorpusItemResult>? progress = null)
{
if (expectedCount < 0) throw new ArgumentOutOfRangeException(nameof(expectedCount));
if (maximumItemMilliseconds <= 0) throw new ArgumentOutOfRangeException(nameof(maximumItemMilliseconds));
DateTimeOffset started = DateTimeOffset.UtcNow;
var total = Stopwatch.StartNew();
var selectionErrors = new List<string>();
if (inputs.Count != expectedCount)
selectionErrors.Add($"expected {expectedCount} MPEG movies, discovered {inputs.Count}");
var results = new List<MovieCorpusItemResult>(inputs.Count);
for (int index = 0; index < inputs.Count; index++)
{
MovieCorpusItemResult result = RunOne(inputs[index], maximumItemMilliseconds);
results.Add(result);
progress?.Invoke(index + 1, inputs.Count, result);
}
total.Stop();
int passed = results.Count(result => result.Passed);
int failed = results.Count - passed;
return new MovieCorpusReport(
started.ToString("O"), DateTimeOffset.UtcNow.ToString("O"), expectedCount, inputs.Count,
passed, failed, total.ElapsedMilliseconds, selectionErrors.Count == 0 && failed == 0,
selectionErrors, results);
}
private MovieCorpusItemResult RunOne(MovieCorpusInput input, long maximumItemMilliseconds)
{
long payloadBytes = 0;
int expectedWidth = 0, expectedHeight = 0, width = 0, height = 0;
long stopTimeMs = 0, frameCount = 0, firstPts = -1, lastPts = -1;
int frameRateNumerator = 0, frameRateDenominator = 0;
bool hasAudio = false, framesChanged = false;
long readMs = 0, openMs = 0, decodeMs = 0, disposeMs = 0;
string? error = null;
IFfmpegFrameSource? source = null;
var itemTime = Stopwatch.StartNew();
try
{
var phase = Stopwatch.StartNew();
MoviePayload payload = input.ReadPayload();
phase.Stop();
readMs = phase.ElapsedMilliseconds;
payloadBytes = payload.Bytes.LongLength;
if (!MovieCorpusDiscovery.TryReadSequenceDimensions(payload.Bytes, out expectedWidth, out expectedHeight))
throw new InvalidDataException("MPEG sequence header was not found");
phase.Restart();
source = _open(payload);
phase.Stop();
openMs = phase.ElapsedMilliseconds;
FfmpegMovieInfo info = source.Info;
width = info.Width;
height = info.Height;
stopTimeMs = info.StopTimeMs;
frameRateNumerator = info.FrameRateNumerator;
frameRateDenominator = info.FrameRateDenominator;
hasAudio = info.HasAudio;
if (width != expectedWidth || height != expectedHeight)
throw new InvalidDataException(
$"decoder dimensions {width}x{height} differ from MPEG sequence {expectedWidth}x{expectedHeight}");
if (width <= 0 || height <= 0 || stopTimeMs <= 0
|| frameRateNumerator <= 0 || frameRateDenominator <= 0)
throw new InvalidDataException(
$"invalid metadata {width}x{height}, {stopTimeMs} ms, " +
$"rate {frameRateNumerator}/{frameRateDenominator}");
var decodeTime = Stopwatch.StartNew();
byte[]? firstPixels = null;
try
{
while (source.TryDecodeNextVideoFrame(out FfmpegVideoFrame frame))
{
if (frame.Image.Width != width || frame.Image.Height != height)
throw new InvalidDataException(
$"frame {frameCount} dimensions are {frame.Image.Width}x{frame.Image.Height}, expected {width}x{height}");
int expectedBytes = checked(width * height * 4);
if (frame.Image.Pixels.Length != expectedBytes)
throw new InvalidDataException(
$"frame {frameCount} has {frame.Image.Pixels.Length} RGBA bytes, expected {expectedBytes}");
if (frame.PresentationTimeMs < 0 || frame.PresentationTimeMs < lastPts)
throw new InvalidDataException(
$"frame {frameCount} timestamp {frame.PresentationTimeMs} follows {lastPts}");
if (frameCount == 0)
{
firstPts = frame.PresentationTimeMs;
firstPixels = frame.Image.Pixels;
}
else if (!framesChanged && !frame.Image.Pixels.AsSpan().SequenceEqual(firstPixels))
framesChanged = true;
lastPts = frame.PresentationTimeMs;
frameCount++;
if (itemTime.ElapsedMilliseconds > maximumItemMilliseconds)
throw new TimeoutException($"item exceeded {maximumItemMilliseconds} ms before EOF");
}
}
finally
{
decodeTime.Stop();
decodeMs = decodeTime.ElapsedMilliseconds;
}
if (frameCount == 0) throw new InvalidDataException("decoder reached EOF without a video frame");
}
catch (Exception exception)
{
error = exception.Message;
}
finally
{
if (source != null)
{
var disposeTime = Stopwatch.StartNew();
try { source.Dispose(); }
catch (Exception exception)
{
error = error == null ? $"dispose failed: {exception.Message}"
: $"{error}; dispose failed: {exception.Message}";
}
disposeTime.Stop();
disposeMs = disposeTime.ElapsedMilliseconds;
}
}
itemTime.Stop();
if (error == null && itemTime.ElapsedMilliseconds > maximumItemMilliseconds)
error = $"item took {itemTime.ElapsedMilliseconds} ms, limit is {maximumItemMilliseconds} ms";
return new MovieCorpusItemResult(
input.PackedId, $"0x{input.PackedId:x}", input.Name, payloadBytes,
expectedWidth, expectedHeight, width, height, stopTimeMs,
frameRateNumerator, frameRateDenominator, hasAudio, frameCount, firstPts, lastPts,
framesChanged, readMs, openMs, decodeMs, disposeMs, error == null, error);
}
}

View File

@@ -0,0 +1,60 @@
using System.Text.Json;
using Age.Engine.Sys4;
static string? Option(string[] arguments, string name)
{
int index = Array.IndexOf(arguments, name);
return index >= 0 && index + 1 < arguments.Length ? arguments[index + 1] : null;
}
if (args.Contains("--help"))
{
Console.WriteLine("usage: dotnet run --project tools/movie-corpus-gate -- [--output <json>] [--native-dir <dir>] [--expected-count 213] [--max-item-ms 30000]");
return 0;
}
string output = Path.GetFullPath(Option(args, "--output")
?? Path.Combine(Paths.Build, "movie-corpus-ffmpeg.json"));
string nativeDirectory = Path.GetFullPath(Option(args, "--native-dir")
?? Path.Combine(Paths.Build, "native", "win-x64"));
int expectedCount = int.Parse(Option(args, "--expected-count") ?? "213");
long maximumItemMilliseconds = long.Parse(Option(args, "--max-item-ms") ?? "30000");
string nativeLibrary = Path.Combine(nativeDirectory, OperatingSystem.IsWindows()
? "age_movie_ffmpeg.dll" : OperatingSystem.IsMacOS()
? "libage_movie_ffmpeg.dylib" : "libage_movie_ffmpeg.so");
if (!File.Exists(nativeLibrary))
{
Console.Error.WriteLine($"native movie shim not found: {nativeLibrary}");
return 2;
}
Environment.SetEnvironmentVariable("AGE_FFMPEG_NATIVE_DIR", nativeDirectory);
var catalog = Sys4AssetCatalog.Load(Paths.Sys4Ini);
var store = new Sys4AssetStore(catalog, Paths.GameDir, Paths.GameDir);
var resources = new ResourceMap(catalog, store);
Console.WriteLine("discovering MPEG program streams from .AGF catalog entries...");
var discovered = MovieCorpusDiscovery.DiscoverMpegMovies(catalog, store);
var inputs = discovered.Select(packed => new MovieCorpusInput(
packed.PackedId, packed.Asset.Name, () => resources.ReadMovie(packed.Asset))).ToArray();
Console.WriteLine($"discovered {inputs.Length} movies; decoding every frame without presentation waits");
var gate = new MovieCorpusGate(payload => new FfmpegMovieSession(payload));
MovieCorpusReport report = gate.Run(inputs, expectedCount, maximumItemMilliseconds,
(index, count, item) => Console.WriteLine(
$"[{index,3}/{count}] {(item.Passed ? "PASS" : "FAIL")} {item.PackedIdHex,-10} {item.Name,-16} " +
$"{item.Width}x{item.Height} {item.FrameCount}f {item.StopTimeMs}ms decode={item.DecodeMilliseconds}ms" +
(item.Error == null ? "" : $" :: {item.Error}")));
string? parent = Path.GetDirectoryName(output);
if (!string.IsNullOrEmpty(parent)) Directory.CreateDirectory(parent);
var jsonOptions = new JsonSerializerOptions
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
};
File.WriteAllText(output, JsonSerializer.Serialize(report, jsonOptions));
Console.WriteLine($"summary: {report.PassedCount}/{report.CandidateCount} passed, " +
$"{report.FailedCount} failed in {report.ElapsedMilliseconds} ms");
foreach (string error in report.SelectionErrors) Console.Error.WriteLine("selection: " + error);
Console.WriteLine("report: " + output);
return report.Passed ? 0 : 1;