diff --git a/.gitignore b/.gitignore index 154bc36..64b1914 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,8 @@ __pycache__/ # .NET (engine/) build output engine/**/bin/ engine/**/obj/ +tools/**/bin/ +tools/**/obj/ .vs/ # Godot / C# (future) diff --git a/docs/PROJECT-STRUCTURE.md b/docs/PROJECT-STRUCTURE.md index 6f8341e..5a4adb6 100644 --- a/docs/PROJECT-STRUCTURE.md +++ b/docs/PROJECT-STRUCTURE.md @@ -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) diff --git a/docs/asset-resolution-re.md b/docs/asset-resolution-re.md index f1d9a01..c1172cf 100644 --- a/docs/asset-resolution-re.md +++ b/docs/asset-resolution-re.md @@ -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) diff --git a/docs/phase-b-framework.md b/docs/phase-b-framework.md index 87e6295..96676fc 100644 --- a/docs/phase-b-framework.md +++ b/docs/phase-b-framework.md @@ -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 diff --git a/docs/platform-portability.md b/docs/platform-portability.md index 7f1e06d..d04086c 100644 --- a/docs/platform-portability.md +++ b/docs/platform-portability.md @@ -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: diff --git a/docs/tools-reference.md b/docs/tools-reference.md index edf3006..231c770 100644 --- a/docs/tools-reference.md +++ b/docs/tools-reference.md @@ -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 ] [--context N]` | `build/page-map-.jsonl` + script corpus → stdout | +In a live Godot run, **F6** writes an observe-only stall snapshot under +`user://diagnostics/stall-.json`, prints the absolute path, and copies +`SCRIPT@offset · stall snapshot ` 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 ]` | `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 [-OutputDirectory ]` | 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 ` | `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 diff --git a/engine/Age.Engine.Tests/Age.Engine.Tests.csproj b/engine/Age.Engine.Tests/Age.Engine.Tests.csproj index 1ac2eab..621fcc9 100644 --- a/engine/Age.Engine.Tests/Age.Engine.Tests.csproj +++ b/engine/Age.Engine.Tests/Age.Engine.Tests.csproj @@ -26,6 +26,11 @@ + + + + + diff --git a/engine/Age.Engine.Tests/GfxDiagnosticSnapshotTests.cs b/engine/Age.Engine.Tests/GfxDiagnosticSnapshotTests.cs new file mode 100644 index 0000000..838d086 --- /dev/null +++ b/engine/Age.Engine.Tests/GfxDiagnosticSnapshotTests.cs @@ -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); + } +} diff --git a/engine/Age.Engine.Tests/GodotTraceSinkTests.cs b/engine/Age.Engine.Tests/GodotTraceSinkTests.cs new file mode 100644 index 0000000..fd2a4ac --- /dev/null +++ b/engine/Age.Engine.Tests/GodotTraceSinkTests.cs @@ -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()); + 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); + } +} diff --git a/engine/Age.Engine.Tests/MovieCorpusGateTests.cs b/engine/Age.Engine.Tests/MovieCorpusGateTests.cs new file mode 100644 index 0000000..f9b3059 --- /dev/null +++ b/engine/Age.Engine.Tests/MovieCorpusGateTests.cs @@ -0,0 +1,102 @@ +using Age.Engine.Sys4; + +public class MovieCorpusGateTests +{ + private sealed class FakeFrameSource(FfmpegMovieInfo info, + params FfmpegVideoFrame[] frames) : IFfmpegFrameSource + { + private readonly Queue _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([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); +} diff --git a/engine/Age.Engine.Tests/MovieOpcodeTests.cs b/engine/Age.Engine.Tests/MovieOpcodeTests.cs index 09558d0..d562c16 100644 --- a/engine/Age.Engine.Tests/MovieOpcodeTests.cs +++ b/engine/Age.Engine.Tests/MovieOpcodeTests.cs @@ -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)); diff --git a/engine/Age.Engine.Tests/MovieSurfaceRegistryTests.cs b/engine/Age.Engine.Tests/MovieSurfaceRegistryTests.cs new file mode 100644 index 0000000..53e370c --- /dev/null +++ b/engine/Age.Engine.Tests/MovieSurfaceRegistryTests.cs @@ -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 }); +} diff --git a/engine/Age.Engine.Tests/Sys4AssetStoreTests.cs b/engine/Age.Engine.Tests/Sys4AssetStoreTests.cs index 7fa3b04..ff1e6a0 100644 --- a/engine/Age.Engine.Tests/Sys4AssetStoreTests.cs +++ b/engine/Age.Engine.Tests/Sys4AssetStoreTests.cs @@ -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() { diff --git a/engine/Age.Engine/Model/GfxState.cs b/engine/Age.Engine/Model/GfxState.cs index a1a2cdc..20e272e 100644 --- a/engine/Age.Engine/Model/GfxState.cs +++ b/engine/Age.Engine/Model/GfxState.cs @@ -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 BlockingObjects); + /// 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 ; 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)); } + /// 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. + 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); + /// 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. public void SetOneShotAnimationControl(long handle, long flags) diff --git a/engine/Age.Engine/Sys4/Sys4AssetCatalog.cs b/engine/Age.Engine/Sys4/Sys4AssetCatalog.cs index c5dfda3..73bf41b 100644 --- a/engine/Age.Engine/Sys4/Sys4AssetCatalog.cs +++ b/engine/Age.Engine/Sys4/Sys4AssetCatalog.cs @@ -179,23 +179,27 @@ public sealed class Sys4AssetCatalog .Where(f => f.Name.EndsWith(".BIN", StringComparison.OrdinalIgnoreCase)) .Select(f => f.Name.ToUpperInvariant()).ToArray(); + /// Enumerate every real asset in native packed-id order, including mounted append packs. + public IReadOnlyList EnumerateAssets() + { + var assets = new List(); + AddAssets(this, assets); + foreach (var append in _appendPacks.OrderBy(pair => pair.Key).Select(pair => pair.Value)) + AddAssets(append, assets); + return assets; + } + /// Enumerate every script in native packed-id order, including mounted append packs. /// Placeholder slots and non-script assets are excluded without collapsing raw indices. public IReadOnlyList EnumerateScripts() - { - var scripts = new List(); - 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 scripts) + private static void AddAssets(Sys4AssetCatalog catalog, List 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 BuildSceneRanges(IReadOnlyList files) diff --git a/godot/FfmpegMovieDecoder.cs b/godot/FfmpegMovieDecoder.cs index 132df52..118b358 100644 --- a/godot/FfmpegMovieDecoder.cs +++ b/godot/FfmpegMovieDecoder.cs @@ -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); diff --git a/godot/FfmpegMovieNative.cs b/godot/FfmpegMovieNative.cs index 076b1c3..78fedc8 100644 --- a/godot/FfmpegMovieNative.cs +++ b/godot/FfmpegMovieNative.cs @@ -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); +} + /// Sequential, unpaced access to the project-owned FFmpeg C ABI for isolated probes and playback. internal sealed class FfmpegMovieSession : IFfmpegFrameSource { diff --git a/godot/GodotAdvHost.cs b/godot/GodotAdvHost.cs index 9754b25..f262e79 100644 --- a/godot/GodotAdvHost.cs +++ b/godot/GodotAdvHost.cs @@ -22,9 +22,7 @@ public sealed class GodotAdvHost : IHost private readonly Dictionary _surfaceImages = new(); private readonly Dictionary _surfaceColorKeys = new(); private readonly Dictionary _surfaceResources = new(); // surface slot -> packed catalog id - private readonly Dictionary _movieFrames = new(); - private readonly Dictionary _movieBySurface = new(); - private readonly HashSet _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. 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, $"", 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 movies = _movieSurfaces.Snapshot(); + IReadOnlyList 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(); + IReadOnlyList 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 MovieSurfaces, IReadOnlyList CompletedMoviePlaybackIds); public readonly record struct LegacyScreenTransitionSnapshot( IReadOnlyList Source, IReadOnlyList Target, double Progress); diff --git a/godot/GodotTraceSink.cs b/godot/GodotTraceSink.cs index 36a98a2..5ce895b 100644 --- a/godot/GodotTraceSink.cs +++ b/godot/GodotTraceSink.cs @@ -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 _scripts = new(); + private readonly Queue _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() : ""; + string script; + lock (_snapshotLock) + { + script = _scripts.Count > 0 ? _scripts.Peek() : ""; + 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() : ""), + 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 CallStack, + IReadOnlyList RecentSteps); diff --git a/godot/Main.cs b/godot/Main.cs index 935643c..05e26d9 100644 --- a/godot/Main.cs +++ b/godot/Main.cs @@ -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"; diff --git a/godot/MovieRuntime.cs b/godot/MovieRuntime.cs index c6c7f12..da165c1 100644 --- a/godot/MovieRuntime.cs +++ b/godot/MovieRuntime.cs @@ -3,15 +3,15 @@ using System.Diagnostics; using Age.Engine.Sys4; /// Presentation-side ownership for one decoder plus its fail-safe completion deadline. -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); diff --git a/godot/MovieSurfaceRegistry.cs b/godot/MovieSurfaceRegistry.cs new file mode 100644 index 0000000..bc34300 --- /dev/null +++ b/godot/MovieSurfaceRegistry.cs @@ -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); + +/// 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. +internal sealed class MovieSurfaceRegistry +{ + private readonly object _lock = new(); + private readonly Dictionary _bySurface = new(); + private readonly Dictionary _byPlayback = new(); + private readonly Dictionary _frames = new(); + private readonly HashSet _completed = new(); + private readonly HashSet _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; + } + } + + /// 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. + 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 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 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 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); + } +} diff --git a/tools/movie-corpus-gate/Age.MovieCorpusGate.csproj b/tools/movie-corpus-gate/Age.MovieCorpusGate.csproj new file mode 100644 index 0000000..63b46a3 --- /dev/null +++ b/tools/movie-corpus-gate/Age.MovieCorpusGate.csproj @@ -0,0 +1,13 @@ + + + Exe + net8.0 + enable + enable + false + + + + + + diff --git a/tools/movie-corpus-gate/MovieCorpusGate.cs b/tools/movie-corpus-gate/MovieCorpusGate.cs new file mode 100644 index 0000000..21420b5 --- /dev/null +++ b/tools/movie-corpus-gate/MovieCorpusGate.cs @@ -0,0 +1,228 @@ +using System.Diagnostics; +using Age.Engine.Sys4; + +internal sealed record MovieCorpusInput(long PackedId, string Name, Func 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 SelectionErrors, + IReadOnlyList Movies); + +internal static class MovieCorpusDiscovery +{ + private static ReadOnlySpan MpegPackStart => [0, 0, 1, 0xba]; + + public static IReadOnlyList DiscoverMpegMovies( + Sys4AssetCatalog catalog, IAssetStore store) + { + var movies = new List(); + Span 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 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 _open; + + public MovieCorpusGate(Func open) + => _open = open ?? throw new ArgumentNullException(nameof(open)); + + public MovieCorpusReport Run(IReadOnlyList inputs, int expectedCount, + long maximumItemMilliseconds, + Action? 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(); + if (inputs.Count != expectedCount) + selectionErrors.Add($"expected {expectedCount} MPEG movies, discovered {inputs.Count}"); + + var results = new List(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); + } + +} diff --git a/tools/movie-corpus-gate/Program.cs b/tools/movie-corpus-gate/Program.cs new file mode 100644 index 0000000..b884acb --- /dev/null +++ b/tools/movie-corpus-gate/Program.cs @@ -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 ] [--native-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;