Harden movie playback lifecycle and diagnostics
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -16,6 +16,8 @@ __pycache__/
|
||||
# .NET (engine/) build output
|
||||
engine/**/bin/
|
||||
engine/**/obj/
|
||||
tools/**/bin/
|
||||
tools/**/obj/
|
||||
.vs/
|
||||
|
||||
# Godot / C# (future)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
24
engine/Age.Engine.Tests/GfxDiagnosticSnapshotTests.cs
Normal file
24
engine/Age.Engine.Tests/GfxDiagnosticSnapshotTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
31
engine/Age.Engine.Tests/GodotTraceSinkTests.cs
Normal file
31
engine/Age.Engine.Tests/GodotTraceSinkTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
102
engine/Age.Engine.Tests/MovieCorpusGateTests.cs
Normal file
102
engine/Age.Engine.Tests/MovieCorpusGateTests.cs
Normal 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);
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
|
||||
65
engine/Age.Engine.Tests/MovieSurfaceRegistryTests.cs
Normal file
65
engine/Age.Engine.Tests/MovieSurfaceRegistryTests.cs
Normal 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 });
|
||||
}
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
140
godot/Main.cs
140
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";
|
||||
|
||||
@@ -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);
|
||||
|
||||
182
godot/MovieSurfaceRegistry.cs
Normal file
182
godot/MovieSurfaceRegistry.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
13
tools/movie-corpus-gate/Age.MovieCorpusGate.csproj
Normal file
13
tools/movie-corpus-gate/Age.MovieCorpusGate.csproj
Normal 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>
|
||||
228
tools/movie-corpus-gate/MovieCorpusGate.cs
Normal file
228
tools/movie-corpus-gate/MovieCorpusGate.cs
Normal 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);
|
||||
}
|
||||
|
||||
}
|
||||
60
tools/movie-corpus-gate/Program.cs
Normal file
60
tools/movie-corpus-gate/Program.cs
Normal 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;
|
||||
Reference in New Issue
Block a user