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
|
# .NET (engine/) build output
|
||||||
engine/**/bin/
|
engine/**/bin/
|
||||||
engine/**/obj/
|
engine/**/obj/
|
||||||
|
tools/**/bin/
|
||||||
|
tools/**/obj/
|
||||||
.vs/
|
.vs/
|
||||||
|
|
||||||
# Godot / C# (future)
|
# 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_phase2.py batch: disasm + text + data extraction
|
||||||
│ ├── extract_init.py, global_map.py … *INIT parsers, global-var map builder
|
│ ├── extract_init.py, global_map.py … *INIT parsers, global-var map builder
|
||||||
│ ├── validate_opcode_table*.py decode-coverage validators
|
│ ├── 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)
|
│ └── probe_*.py format reverse-engineering probes (historical)
|
||||||
│
|
│
|
||||||
├── bin/ 3rd-party binaries we use (not ours, not the game's)
|
├── 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
|
reported combat stall survives this guard, capture the VM/service coordinate after `0x2518` and treat it as
|
||||||
a separate BTL timed-presentation bug.
|
a separate BTL timed-presentation bug.
|
||||||
|
|
||||||
The remaining implementation boundary is the already-planned decoder interface/factory plus a software
|
The FFmpeg replacement and destination-surface work are recorded in `docs/platform-portability.md` and
|
||||||
MPEG backend that handles the installed non-16-aligned effects, and preservation of the destination
|
`docs/phase-b-framework.md`. Movie identity also remains typed across the entire retained-surface lifecycle.
|
||||||
surface's created dimensions instead of replacing every movie surface with the SC0000-specific 800x600
|
The packed catalog is immutable while mounted, so once an id enters the movie path its `.AGF`-named MPEG
|
||||||
value. Failure is now nonblocking and suppresses bogus still-AGF fallback. Regressions must cover at
|
payload cannot fall through to the ACGF still decoder before the first frame or after the last live binding
|
||||||
least a 280x352 effect (`MVB001`), a 400x400 effect (`MVB914`), immediate `0x23f` stop time, RGBA frame
|
is detached. The latter guard closes a real cross-thread cleanup window: BTL clears `GfxState` and releases
|
||||||
publication, completion, failure completion, and release.
|
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)
|
### 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 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
|
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.
|
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,
|
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
|
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
|
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
|
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
|
`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
|
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
|
is 318 tests, the Godot build is warning-free, and threaded selftest passes. That established the prerequisite
|
||||||
installed-corpus gate, then validate SC0000 CHAPTER and the original battle exchange before removing DirectShow
|
for the installed-corpus gate recorded below.
|
||||||
or judging the tabled combat stall.
|
|
||||||
|
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
|
**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
|
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 |
|
| 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 |
|
| 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 | `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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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
|
DirectShow; the user subsequently confirmed both opening movies work in normal windowed playback. Full
|
||||||
export/source-offer packaging and non-Windows builds remain outstanding.
|
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
|
## Cross-platform validation gates
|
||||||
|
|
||||||
Before advertising a platform as supported:
|
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 |
|
| `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)
|
## Native FFmpeg movie shim (Windows x64)
|
||||||
|
|
||||||
These PowerShell tools build the selected Windows-x64 live movie backend. The dependency manifest pins an
|
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/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 |
|
| `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
|
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
|
`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`
|
`dotnet build godot/Himegari.csproj` also stages the shim, its five DLL dependencies, and `FFmpeg-LICENSE.txt`
|
||||||
beside `Himegari.dll` for development playback.
|
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
|
## Engine (C#) — VM core, CLI, Godot frontend
|
||||||
|
|
||||||
The `engine/` .NET solution (`AgeEngine.sln`) is the runtime VM; `godot/` is the ADV frontend. Not
|
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\MovieRuntime.cs" Link="MovieRuntime.cs" />
|
||||||
<Compile Include="..\..\godot\FfmpegMovieNative.cs" Link="FfmpegMovieNative.cs" />
|
<Compile Include="..\..\godot\FfmpegMovieNative.cs" Link="FfmpegMovieNative.cs" />
|
||||||
<Compile Include="..\..\godot\FfmpegMovieDecoder.cs" Link="FfmpegMovieDecoder.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" />
|
<Compile Include="..\..\godot\DirectShowMovieDecoder.cs" Link="DirectShowMovieDecoder.cs" />
|
||||||
</ItemGroup>
|
</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 factory = new FakeMovieDecoderFactory(decoder);
|
||||||
var payload = new MoviePayload("TEST.AGF", new byte[] { 0, 0, 1, 0xba });
|
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(payload, factory.OpenedPayload);
|
||||||
Assert.Same(decoder, runtime.Decoder);
|
Assert.Same(decoder, runtime.Decoder);
|
||||||
|
Assert.Equal(0x123, runtime.ResourceId);
|
||||||
Assert.Equal(1876, runtime.Decoder.StopTimeMs);
|
Assert.Equal(1876, runtime.Decoder.StopTimeMs);
|
||||||
Assert.Equal(5000, runtime.WatchdogMs);
|
Assert.Equal(5000, runtime.WatchdogMs);
|
||||||
Assert.True(runtime.Decoder.TryTakeFrame(out var frame));
|
Assert.True(runtime.Decoder.TryTakeFrame(out var frame));
|
||||||
@@ -120,7 +121,7 @@ public class MovieOpcodeTests
|
|||||||
public void MovieRuntimeComputesBoundedWatchdogFromDecoderMetadata(long? stopTimeMs, long expected)
|
public void MovieRuntimeComputesBoundedWatchdogFromDecoderMetadata(long? stopTimeMs, long expected)
|
||||||
{
|
{
|
||||||
var decoder = new FakeMovieDecoder { StopTimeMs = stopTimeMs };
|
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 MoviePayload("TEST.AGF", new byte[] { 0, 0, 1, 0xba }),
|
||||||
new FakeMovieDecoderFactory(decoder));
|
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);
|
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]
|
[Fact]
|
||||||
public void CompleteAppendDirectoryAndPayloadsMatchBinExtractAlf()
|
public void CompleteAppendDirectoryAndPayloadsMatchBinExtractAlf()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -31,6 +31,22 @@ public readonly record struct NumericGlyphStyle(int SurfaceSlot, int AtlasX, int
|
|||||||
public bool Registered => SurfaceSlot != 0;
|
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
|
/// <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
|
/// (= 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,
|
/// 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));
|
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
|
/// <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>
|
/// finite one-shot channels nonblocking and immune to op 0x243 forced completion.</summary>
|
||||||
public void SetOneShotAnimationControl(long handle, long flags)
|
public void SetOneShotAnimationControl(long handle, long flags)
|
||||||
|
|||||||
@@ -179,23 +179,27 @@ public sealed class Sys4AssetCatalog
|
|||||||
.Where(f => f.Name.EndsWith(".BIN", StringComparison.OrdinalIgnoreCase))
|
.Where(f => f.Name.EndsWith(".BIN", StringComparison.OrdinalIgnoreCase))
|
||||||
.Select(f => f.Name.ToUpperInvariant()).ToArray();
|
.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.
|
/// <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>
|
/// Placeholder slots and non-script assets are excluded without collapsing raw indices.</summary>
|
||||||
public IReadOnlyList<PackedAssetEntry> EnumerateScripts()
|
public IReadOnlyList<PackedAssetEntry> EnumerateScripts()
|
||||||
{
|
=> EnumerateAssets().Where(entry =>
|
||||||
var scripts = new List<PackedAssetEntry>();
|
entry.Asset.Name.EndsWith(".BIN", StringComparison.OrdinalIgnoreCase)).ToArray();
|
||||||
AddScripts(this, scripts);
|
|
||||||
foreach (var append in _appendPacks.OrderBy(pair => pair.Key).Select(pair => pair.Value))
|
|
||||||
AddScripts(append, scripts);
|
|
||||||
return scripts;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void AddScripts(Sys4AssetCatalog catalog, List<PackedAssetEntry> scripts)
|
private static void AddAssets(Sys4AssetCatalog catalog, List<PackedAssetEntry> assets)
|
||||||
{
|
{
|
||||||
long selector = (long)catalog.PackId << 24;
|
long selector = (long)catalog.PackId << 24;
|
||||||
foreach (var entry in catalog.Files)
|
foreach (var entry in catalog.Files)
|
||||||
if (entry.Name.EndsWith(".BIN", StringComparison.OrdinalIgnoreCase))
|
assets.Add(new PackedAssetEntry(selector | (uint)entry.RawIndex, entry));
|
||||||
scripts.Add(new PackedAssetEntry(selector | (uint)entry.RawIndex, entry));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Dictionary<string, (int Start, int End)> BuildSceneRanges(IReadOnlyList<AssetEntry> files)
|
private static Dictionary<string, (int Start, int End)> BuildSceneRanges(IReadOnlyList<AssetEntry> files)
|
||||||
|
|||||||
@@ -4,12 +4,6 @@ using System.IO;
|
|||||||
using System.Threading;
|
using System.Threading;
|
||||||
using Age.Engine.Sys4;
|
using Age.Engine.Sys4;
|
||||||
|
|
||||||
internal interface IFfmpegFrameSource : IDisposable
|
|
||||||
{
|
|
||||||
FfmpegMovieInfo Info { get; }
|
|
||||||
bool TryDecodeNextVideoFrame(out FfmpegVideoFrame frame);
|
|
||||||
}
|
|
||||||
|
|
||||||
internal interface IMoviePacingClock
|
internal interface IMoviePacingClock
|
||||||
{
|
{
|
||||||
bool WaitUntil(long elapsedMilliseconds, WaitHandle cancellation);
|
bool WaitUntil(long elapsedMilliseconds, WaitHandle cancellation);
|
||||||
|
|||||||
@@ -14,6 +14,12 @@ internal readonly record struct FfmpegMovieInfo(
|
|||||||
|
|
||||||
internal sealed record FfmpegVideoFrame(RgbaImage Image, long PresentationTimeMs);
|
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>
|
/// <summary>Sequential, unpaced access to the project-owned FFmpeg C ABI for isolated probes and playback.</summary>
|
||||||
internal sealed class FfmpegMovieSession : IFfmpegFrameSource
|
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, RgbaImage> _surfaceImages = new();
|
||||||
private readonly Dictionary<int, long> _surfaceColorKeys = new();
|
private readonly Dictionary<int, long> _surfaceColorKeys = new();
|
||||||
private readonly Dictionary<int, long> _surfaceResources = new(); // surface slot -> packed catalog id
|
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 MovieSurfaceRegistry _movieSurfaces = new();
|
||||||
private readonly Dictionary<int, long> _movieBySurface = new();
|
|
||||||
private readonly HashSet<long> _completedMovies = new();
|
|
||||||
private readonly string?[] _sfxNames = new string?[10]; // SC0000 native channel subset
|
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
|
// 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.
|
// 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>
|
/// loose-first asset store.</summary>
|
||||||
public (RgbaImage Image, string Name, int AssetId, bool IsDynamic)? ResolveResIdTexture(long resId)
|
public (RgbaImage Image, string Name, int AssetId, bool IsDynamic)? ResolveResIdTexture(long resId)
|
||||||
{
|
{
|
||||||
lock (_imageLock)
|
if (_movieSurfaces.TryResolveResource(resId, out var movie) && movie != null)
|
||||||
{
|
|
||||||
if (_movieFrames.TryGetValue(resId, out var movie))
|
|
||||||
return (movie.Image, movie.Name, movie.AssetId, true);
|
return (movie.Image, movie.Name, movie.AssetId, true);
|
||||||
// Movie payloads use the same .AGF extension as still images. While the decoder is opening
|
// Movie payloads use the same .AGF extension as still images. Do not misclassify the MPEG program
|
||||||
// (or before its first frame arrives), keep the already-created surface blank
|
// stream before its first frame or during the cleanup frame after its surface binding is detached.
|
||||||
// instead of falling through to AgfDecoder and misclassifying the MPEG program stream.
|
// Packed catalog identity is immutable, so a resource which entered the typed movie path remains
|
||||||
if (_movieBySurface.Values.Contains(resId)) return null;
|
// a movie even when it has no live playback.
|
||||||
}
|
if (_movieSurfaces.IsKnownMovieResource(resId)) return null;
|
||||||
var asset = _res.ResolveTexture(resId);
|
var asset = _res.ResolveTexture(resId);
|
||||||
var image = asset != null ? Decode(asset) : null;
|
var image = asset != null ? Decode(asset) : null;
|
||||||
return asset != null && image != null ? (image, asset.Name, asset.PackedId, false) : 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(
|
public (RgbaImage Image, string Name, int AssetId, bool IsDynamic)? ResolveSurfaceTexture(
|
||||||
int surfaceSlot, long fallbackResourceId)
|
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)
|
lock (_imageLock)
|
||||||
if (_surfaceImages.TryGetValue(surfaceSlot, out var surface))
|
if (_surfaceImages.TryGetValue(surfaceSlot, out var surface))
|
||||||
return (surface, $"<surface:{surfaceSlot}>", int.MinValue + surfaceSlot, true);
|
return (surface, $"<surface:{surfaceSlot}>", int.MinValue + surfaceSlot, true);
|
||||||
return fallbackResourceId != 0 ? ResolveResIdTexture(fallbackResourceId) : null;
|
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)
|
public long? PlayMovieToSurface(long resourceId, int surfaceSlot, long movieFlags, long syncMask)
|
||||||
{
|
{
|
||||||
string scene = CurrentScene;
|
string scene = CurrentScene;
|
||||||
var asset = _res.ResolveMovie(resourceId);
|
var asset = _res.ResolveMovie(resourceId);
|
||||||
if (asset == null) { Godot.GD.Print($"movie unresolved {scene}:0x{resourceId:x}"); return null; }
|
if (asset == null) { Godot.GD.Print($"movie unresolved {scene}:0x{resourceId:x}"); return null; }
|
||||||
StartMovie(asset, resourceId, surfaceSlot, movieFlags, syncMask, modal: false,
|
StartMovie(asset, resourceId, surfaceSlot, movieFlags, syncMask, modal: false,
|
||||||
out long? stopTimeMs);
|
out long? stopTimeMs, out _);
|
||||||
return stopTimeMs ?? 0;
|
return stopTimeMs ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool IsMovieSurfaceActive(int surfaceSlot)
|
public bool IsMovieSurfaceActive(int surfaceSlot)
|
||||||
|
=> _movieSurfaces.IsActive(surfaceSlot);
|
||||||
|
|
||||||
|
public GodotHostDiagnosticSnapshot CaptureDiagnosticSnapshot()
|
||||||
{
|
{
|
||||||
lock (_imageLock)
|
IReadOnlyList<MovieSurfaceDiagnostic> movies = _movieSurfaces.Snapshot();
|
||||||
return _movieBySurface.TryGetValue(surfaceSlot, out long resourceId)
|
IReadOnlyList<long> completed = _movieSurfaces.CompletedPlaybackIds();
|
||||||
&& !_completedMovies.Contains(resourceId);
|
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)
|
public void PlayModalMovieToSurface(long resourceId, int surfaceSlot, long movieFlags)
|
||||||
@@ -923,22 +941,22 @@ public sealed class GodotAdvHost : IHost
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (!StartMovie(asset, resourceId, surfaceSlot, movieFlags, 0, modal: true,
|
if (!StartMovie(asset, resourceId, surfaceSlot, movieFlags, 0, modal: true,
|
||||||
out _)) return;
|
out _, out long playbackId)) return;
|
||||||
_timeline?.State("modal-movie-wait", new()
|
_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)
|
while (!_stopping && !_modalMovieCancelled)
|
||||||
{
|
{
|
||||||
lock (_imageLock)
|
if (!_movieSurfaces.IsActive(surfaceSlot)) break;
|
||||||
if (_completedMovies.Contains(resourceId)) break;
|
|
||||||
_frameSignal.WaitOne(50);
|
_frameSignal.WaitOne(50);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cancellation is a completed modal presentation from the script's perspective. The
|
// Cancellation is a completed modal presentation from the script's perspective. The
|
||||||
// wrapper's following surface-release opcode performs the ordinary decoder teardown.
|
// wrapper's following surface-release opcode performs the ordinary decoder teardown.
|
||||||
if (_modalMovieCancelled)
|
if (_modalMovieCancelled)
|
||||||
lock (_imageLock) _completedMovies.Add(resourceId);
|
_movieSurfaces.Complete(playbackId);
|
||||||
_timeline?.State("running", new()
|
_timeline?.State("running", new()
|
||||||
{
|
{
|
||||||
["modal_movie_complete"] = !_modalMovieCancelled,
|
["modal_movie_complete"] = !_modalMovieCancelled,
|
||||||
@@ -953,16 +971,18 @@ public sealed class GodotAdvHost : IHost
|
|||||||
}
|
}
|
||||||
|
|
||||||
private bool StartMovie(AssetEntry asset, long resourceId, int surfaceSlot, long movieFlags,
|
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;
|
stopTimeMs = null;
|
||||||
// Publish the movie identity before the potentially long VFS read and synchronous decoder setup.
|
// A playback is a surface-owned instance, not the shared resource id. BTL can schedule the same
|
||||||
// The compositor can therefore distinguish a legitimate blank pre-roll surface from a still AGF.
|
// asset on multiple surfaces; replacing one binding must not erase another binding's completion.
|
||||||
ReleaseSurface(surfaceSlot);
|
MovieSurfaceBinding binding = _movieSurfaces.Begin(surfaceSlot, resourceId, out var replaced);
|
||||||
|
playbackId = binding.PlaybackId;
|
||||||
|
if (replaced is { } prior) _main.CallDeferred("StopMovie", prior.PlaybackId);
|
||||||
lock (_imageLock)
|
lock (_imageLock)
|
||||||
{
|
{
|
||||||
_movieBySurface[surfaceSlot] = resourceId;
|
_surfaceImages.Remove(surfaceSlot);
|
||||||
_completedMovies.Remove(resourceId);
|
_surfaceColorKeys.Remove(surfaceSlot);
|
||||||
}
|
}
|
||||||
_slotDims[surfaceSlot] = (800, 600); // SC0000 creates this native-sized surface immediately beforehand.
|
_slotDims[surfaceSlot] = (800, 600); // SC0000 creates this native-sized surface immediately beforehand.
|
||||||
try
|
try
|
||||||
@@ -970,30 +990,24 @@ public sealed class GodotAdvHost : IHost
|
|||||||
var movie = _res.ReadMovie(asset);
|
var movie = _res.ReadMovie(asset);
|
||||||
_timeline?.Event("movie-start", new()
|
_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,
|
["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);
|
out stopTimeMs);
|
||||||
if (!started)
|
if (!started)
|
||||||
{
|
{
|
||||||
stopTimeMs = 0;
|
stopTimeMs = 0;
|
||||||
NotifyMovieCompleted(resourceId);
|
NotifyMovieCompleted(playbackId);
|
||||||
}
|
}
|
||||||
return started;
|
return started;
|
||||||
}
|
}
|
||||||
catch (System.Exception e)
|
catch (System.Exception e)
|
||||||
{
|
{
|
||||||
lock (_imageLock)
|
_movieSurfaces.Abandon(playbackId, out _);
|
||||||
{
|
|
||||||
if (_movieBySurface.TryGetValue(surfaceSlot, out long registered) && registered == resourceId)
|
|
||||||
_movieBySurface.Remove(surfaceSlot);
|
|
||||||
_movieFrames.Remove(resourceId);
|
|
||||||
_completedMovies.Remove(resourceId);
|
|
||||||
}
|
|
||||||
_slotDims.Remove(surfaceSlot);
|
_slotDims.Remove(surfaceSlot);
|
||||||
stopTimeMs = 0;
|
stopTimeMs = 0;
|
||||||
NotifyMovieCompleted(resourceId);
|
|
||||||
Godot.GD.Print($"movie read failed {asset.Name}: {e.Message}");
|
Godot.GD.Print($"movie read failed {asset.Name}: {e.Message}");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -1002,28 +1016,11 @@ public sealed class GodotAdvHost : IHost
|
|||||||
public void ReleaseSurface(int slot)
|
public void ReleaseSurface(int slot)
|
||||||
{
|
{
|
||||||
lock (_screenTransitionLock) _renderTargetSnapshots.Remove(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)
|
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);
|
_surfaceImages.Remove(slot);
|
||||||
_surfaceColorKeys.Remove(slot);
|
_surfaceColorKeys.Remove(slot);
|
||||||
}
|
}
|
||||||
@@ -1033,8 +1030,17 @@ public sealed class GodotAdvHost : IHost
|
|||||||
_surfaceResources.Remove(slot);
|
_surfaceResources.Remove(slot);
|
||||||
}
|
}
|
||||||
_slotDims.Remove(slot);
|
_slotDims.Remove(slot);
|
||||||
_timeline?.Event("movie-stop", new() { ["resource"] = resourceId, ["surface"] = slot });
|
if (movieRelease.Kind == MovieSurfaceReleaseKind.Released)
|
||||||
_main.CallDeferred("StopMovie", resourceId);
|
{
|
||||||
|
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)
|
public void ClearRenderTarget(int surfaceSlot)
|
||||||
@@ -1049,7 +1055,7 @@ public sealed class GodotAdvHost : IHost
|
|||||||
|
|
||||||
public void ReleaseSurfaceRange(int firstSlot, int count)
|
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);
|
int end = checked(firstSlot + count);
|
||||||
lock (_screenTransitionLock)
|
lock (_screenTransitionLock)
|
||||||
for (int slot = firstSlot; slot < end; slot++) _renderTargetSnapshots.Remove(slot);
|
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++)
|
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);
|
_surfaceImages.Remove(slot);
|
||||||
_surfaceColorKeys.Remove(slot);
|
_surfaceColorKeys.Remove(slot);
|
||||||
_slotDims.Remove(slot);
|
_slotDims.Remove(slot);
|
||||||
@@ -1076,37 +1076,36 @@ public sealed class GodotAdvHost : IHost
|
|||||||
_surfaceResources.Remove(slot);
|
_surfaceResources.Remove(slot);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
foreach (long resourceId in stoppedMovies)
|
foreach (MovieSurfaceBinding binding in stoppedMovies)
|
||||||
{
|
{
|
||||||
_timeline?.Event("movie-stop", new() { ["resource"] = resourceId, ["range_release"] = true });
|
_timeline?.Event("movie-stop", new()
|
||||||
_main.CallDeferred("StopMovie", resourceId);
|
{
|
||||||
|
["resource"] = binding.ResourceId,
|
||||||
|
["playback"] = binding.PlaybackId,
|
||||||
|
["range_release"] = true,
|
||||||
|
});
|
||||||
|
_main.CallDeferred("StopMovie", binding.PlaybackId);
|
||||||
}
|
}
|
||||||
_timeline?.Event("surface-range-release", new() { ["first"] = firstSlot, ["count"] = count });
|
_timeline?.Event("surface-range-release", new() { ["first"] = firstSlot, ["count"] = count });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Main-thread decoder handoff. Replacing the newest frame mirrors the native texture renderer's
|
// 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.
|
// 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);
|
if (_movieSurfaces.PublishFrame(playbackId, frame, name, assetId))
|
||||||
System.Threading.Interlocked.Exchange(ref _presentRequested, 1);
|
System.Threading.Interlocked.Exchange(ref _presentRequested, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void NotifyMovieCompleted(long resourceId)
|
public void NotifyMovieCompleted(long playbackId)
|
||||||
{
|
{
|
||||||
lock (_imageLock)
|
if (!_movieSurfaces.Complete(playbackId)) return;
|
||||||
if (!_completedMovies.Add(resourceId)) return;
|
_timeline?.Event("movie-complete", new() { ["playback"] = playbackId });
|
||||||
_timeline?.Event("movie-complete", new() { ["resource"] = resourceId });
|
|
||||||
_frameSignal.Set();
|
_frameSignal.Set();
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool HasActiveMoviePresentation()
|
private bool HasActiveMoviePresentation()
|
||||||
{
|
=> _movieSurfaces.HasActivePlayback;
|
||||||
lock (_imageLock)
|
|
||||||
foreach (long resourceId in _movieBySurface.Values)
|
|
||||||
if (!_completedMovies.Contains(resourceId)) return true;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private RgbaImage? Decode(AssetEntry asset)
|
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 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(
|
public readonly record struct LegacyScreenTransitionSnapshot(
|
||||||
IReadOnlyList<RenderObject> Source, IReadOnlyList<RenderObject> Target, double Progress);
|
IReadOnlyList<RenderObject> Source, IReadOnlyList<RenderObject> Target, double Progress);
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,13 @@ using Age.Engine.Diagnostics;
|
|||||||
// engine fact delivered over the trace seam.
|
// engine fact delivered over the trace seam.
|
||||||
public sealed class GodotTraceSink : ITraceSink
|
public sealed class GodotTraceSink : ITraceSink
|
||||||
{
|
{
|
||||||
|
private const int RecentStepCapacity = 128;
|
||||||
private readonly GodotTimelineLog? _timeline;
|
private readonly GodotTimelineLog? _timeline;
|
||||||
private readonly PageLocatorState _locator;
|
private readonly PageLocatorState _locator;
|
||||||
|
private readonly object _snapshotLock = new();
|
||||||
private readonly Stack<string> _scripts = new();
|
private readonly Stack<string> _scripts = new();
|
||||||
|
private readonly Queue<GodotTraceStepSnapshot> _recentSteps = new();
|
||||||
|
private GodotTraceStepSnapshot? _latestStep;
|
||||||
public GodotTraceSink(PageLocatorState locator, GodotTimelineLog? timeline = null)
|
public GodotTraceSink(PageLocatorState locator, GodotTimelineLog? timeline = null)
|
||||||
{ _locator = locator; _timeline = timeline; }
|
{ _locator = locator; _timeline = timeline; }
|
||||||
// The page locator needs the exact script/offset even when the heavier timeline log is disabled.
|
// The page locator needs the exact script/offset even when the heavier timeline log is disabled.
|
||||||
@@ -27,9 +31,14 @@ public sealed class GodotTraceSink : ITraceSink
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (e.Kind == TraceEventKind.FrameEnter && e.Name != null)
|
if (e.Kind == TraceEventKind.FrameEnter && e.Name != null)
|
||||||
|
{
|
||||||
|
string[] callStack;
|
||||||
|
lock (_snapshotLock)
|
||||||
{
|
{
|
||||||
_scripts.Push(e.Name);
|
_scripts.Push(e.Name);
|
||||||
PublishCallStack();
|
callStack = CurrentCallStackLocked();
|
||||||
|
}
|
||||||
|
_locator.CallStack(callStack);
|
||||||
_timeline?.Event("frame-enter", new()
|
_timeline?.Event("frame-enter", new()
|
||||||
{
|
{
|
||||||
["name"] = e.Name, ["depth"] = e.Depth,
|
["name"] = e.Name, ["depth"] = e.Depth,
|
||||||
@@ -42,12 +51,25 @@ public sealed class GodotTraceSink : ITraceSink
|
|||||||
{
|
{
|
||||||
["name"] = e.Name, ["depth"] = e.Depth, ["outcome"] = e.Text,
|
["name"] = e.Name, ["depth"] = e.Depth, ["outcome"] = e.Text,
|
||||||
});
|
});
|
||||||
_scripts.Pop();
|
string[] callStack;
|
||||||
PublishCallStack();
|
lock (_snapshotLock)
|
||||||
|
{
|
||||||
|
if (_scripts.Count > 0) _scripts.Pop();
|
||||||
|
callStack = CurrentCallStackLocked();
|
||||||
|
}
|
||||||
|
_locator.CallStack(callStack);
|
||||||
}
|
}
|
||||||
else if (e.Kind == TraceEventKind.Step && e.Ins != null)
|
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);
|
_locator.Step(script, e.Ins.Offset);
|
||||||
_timeline?.Step(script, e.Ins.Offset, e.Opcode, e.Depth);
|
_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 });
|
_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();
|
var stack = _scripts.ToArray();
|
||||||
System.Array.Reverse(stack);
|
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();
|
GetViewport().SetInputAsHandled();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (e is InputEventKey diagnosticKey && diagnosticKey.Keycode == Key.F6)
|
||||||
|
{
|
||||||
|
if (diagnosticKey.Pressed && !diagnosticKey.Echo) CaptureStallDiagnostic();
|
||||||
|
GetViewport().SetInputAsHandled();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (_debugSceneLauncher?.Visible == true)
|
if (_debugSceneLauncher?.Visible == true)
|
||||||
{
|
{
|
||||||
if (e is InputEventKey escape && escape.Pressed && !escape.Echo && escape.Keycode == Key.Escape)
|
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 IsAdvanceAction(int action) => action is 4 or 5;
|
||||||
private static bool HasAdvanceAction(int mask) => (mask & ((1 << 4) | (1 << 5))) != 0;
|
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()
|
private void ToggleDebugSceneLauncher()
|
||||||
{
|
{
|
||||||
if (_debugSceneLauncher == null) return;
|
if (_debugSceneLauncher == null) return;
|
||||||
@@ -680,6 +766,7 @@ public partial class Main : Godot.Control
|
|||||||
var surfaceTexture = rawObject != null
|
var surfaceTexture = rawObject != null
|
||||||
? _host.ResolveSurfaceTexture(rawObject.SourceSlot, v.SurfaceResId)
|
? _host.ResolveSurfaceTexture(rawObject.SourceSlot, v.SurfaceResId)
|
||||||
: null;
|
: null;
|
||||||
|
bool movieSurfaceBound = rawObject != null && _host.IsMovieSurfaceBound(rawObject.SourceSlot);
|
||||||
string outcome;
|
string outcome;
|
||||||
if (v.SurfaceTransition is { } transition)
|
if (v.SurfaceTransition is { } transition)
|
||||||
{
|
{
|
||||||
@@ -709,7 +796,8 @@ public partial class Main : Godot.Control
|
|||||||
}
|
}
|
||||||
else
|
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)";
|
if (texture == null) outcome = $"SKIP(resId=0x{v.SurfaceResId:x} UNRESOLVED)";
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -889,6 +977,7 @@ public partial class Main : Godot.Control
|
|||||||
var texture = rawObject != null
|
var texture = rawObject != null
|
||||||
? _host.ResolveSurfaceTexture(rawObject.SourceSlot, source.SurfaceResId)
|
? _host.ResolveSurfaceTexture(rawObject.SourceSlot, source.SurfaceResId)
|
||||||
: null;
|
: null;
|
||||||
|
bool movieSurfaceBound = rawObject != null && _host.IsMovieSurfaceBound(rawObject.SourceSlot);
|
||||||
if (source.SurfaceResId == 0 && texture == null)
|
if (source.SurfaceResId == 0 && texture == null)
|
||||||
{
|
{
|
||||||
if (source.Blend == BlendKind.Opaque) continue;
|
if (source.Blend == BlendKind.Opaque) continue;
|
||||||
@@ -897,7 +986,7 @@ public partial class Main : Godot.Control
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
texture ??= _host.ResolveResIdTexture(source.SurfaceResId);
|
if (!movieSurfaceBound) texture ??= _host.ResolveResIdTexture(source.SurfaceResId);
|
||||||
if (texture == null) continue;
|
if (texture == null) continue;
|
||||||
BlitLayer(texture.Value.Image, texture.Value.AssetId, source.ColorKey, source.Tint, source.TintStrength / 255f,
|
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,
|
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);
|
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)
|
out long? stopTimeMs)
|
||||||
{
|
{
|
||||||
stopTimeMs = null;
|
stopTimeMs = null;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var payload = new Age.Engine.Sys4.MoviePayload(assetName, mpegBytes);
|
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;
|
stopTimeMs = runtime.Decoder.StopTimeMs;
|
||||||
while (!_pendingMovies.TryAdd(resourceId, runtime))
|
while (!_pendingMovies.TryAdd(playbackId, runtime))
|
||||||
if (_pendingMovies.TryRemove(resourceId, out var prior)) prior.Decoder.Dispose();
|
if (_pendingMovies.TryRemove(playbackId, out var prior)) prior.Decoder.Dispose();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
catch (System.Exception e)
|
catch (System.Exception e)
|
||||||
@@ -1199,50 +1289,52 @@ public partial class Main : Godot.Control
|
|||||||
|
|
||||||
private void AdoptPendingMovies()
|
private void AdoptPendingMovies()
|
||||||
{
|
{
|
||||||
foreach (var (resourceId, _) in _pendingMovies)
|
foreach (var (playbackId, _) in _pendingMovies)
|
||||||
{
|
{
|
||||||
if (!_pendingMovies.TryRemove(resourceId, out var movie)) continue;
|
if (!_pendingMovies.TryRemove(playbackId, out var movie)) continue;
|
||||||
if (_movies.Remove(resourceId, out var prior)) prior.Decoder.Dispose();
|
if (_movies.Remove(playbackId, out var prior)) prior.Decoder.Dispose();
|
||||||
_movies[resourceId] = movie;
|
_movies[playbackId] = movie;
|
||||||
_movieCompletionNotified.Remove(resourceId);
|
_movieCompletionNotified.Remove(playbackId);
|
||||||
GD.Print($"movie started {movie.Name} ({movie.Decoder.StopTimeMs?.ToString() ?? "unknown"} ms from VFS)");
|
GD.Print($"movie started {movie.Name} playback={playbackId} " +
|
||||||
|
$"({movie.Decoder.StopTimeMs?.ToString() ?? "unknown"} ms from VFS)");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void UpdateMovieFrames()
|
private void UpdateMovieFrames()
|
||||||
{
|
{
|
||||||
if (_host == null) return;
|
if (_host == null) return;
|
||||||
foreach (var (resourceId, movie) in _movies)
|
foreach (var (playbackId, movie) in _movies)
|
||||||
{
|
{
|
||||||
if (movie.Decoder.TryTakeFrame(out var frame))
|
if (movie.Decoder.TryTakeFrame(out var frame))
|
||||||
{
|
{
|
||||||
_host.PublishMovieFrame(resourceId, movie.Name, movie.AssetId, frame);
|
_host.PublishMovieFrame(playbackId, movie.Name, movie.AssetId, frame);
|
||||||
if (_movieFrameSeen.Add(resourceId))
|
if (_movieFrameSeen.Add(playbackId))
|
||||||
GD.Print($"movie first frame {movie.Name}: {frame.Width}x{frame.Height} RGBA8 at render frame {_timelineFrame}");
|
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
|
bool watchdogExpired = Stopwatch.GetElapsedTime(movie.StartedAtTimestamp).TotalMilliseconds
|
||||||
>= movie.WatchdogMs;
|
>= movie.WatchdogMs;
|
||||||
if ((movie.Decoder.IsCompleted || watchdogExpired) && _movieCompletionNotified.Add(resourceId))
|
if ((movie.Decoder.IsCompleted || watchdogExpired) && _movieCompletionNotified.Add(playbackId))
|
||||||
{
|
{
|
||||||
if (movie.Decoder.Failure is { } failure)
|
if (movie.Decoder.Failure is { } failure)
|
||||||
GD.Print($"movie decode failed {movie.Name}: {failure}");
|
GD.Print($"movie decode failed {movie.Name}: {failure}");
|
||||||
if (watchdogExpired && !movie.Decoder.IsCompleted)
|
if (watchdogExpired && !movie.Decoder.IsCompleted)
|
||||||
GD.Print($"movie completion watchdog {movie.Name}: forcing completion after {movie.WatchdogMs} ms");
|
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 (_pendingMovies.TryRemove(playbackId, out var pending)) pending.Decoder.Dispose();
|
||||||
if (_movies.Remove(resourceId, out var movie))
|
if (_movies.Remove(playbackId, out var movie))
|
||||||
{
|
{
|
||||||
movie.Decoder.Dispose();
|
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);
|
_movieFrameSeen.Remove(playbackId);
|
||||||
_movieCompletionNotified.Remove(resourceId);
|
_movieCompletionNotified.Remove(playbackId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void AppendLine(string text) => _text.Text += text + "\n";
|
public void AppendLine(string text) => _text.Text += text + "\n";
|
||||||
|
|||||||
@@ -3,15 +3,15 @@ using System.Diagnostics;
|
|||||||
using Age.Engine.Sys4;
|
using Age.Engine.Sys4;
|
||||||
|
|
||||||
/// <summary>Presentation-side ownership for one decoder plus its fail-safe completion deadline.</summary>
|
/// <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)
|
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)
|
IMovieDecoderFactory factory)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(factory);
|
ArgumentNullException.ThrowIfNull(factory);
|
||||||
IMovieDecoder decoder = factory.Open(payload);
|
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
|
decoder.StopTimeMs is >= 0 and var stopTime
|
||||||
? Math.Clamp(stopTime + 2000, 5000, 300000)
|
? Math.Clamp(stopTime + 2000, 5000, 300000)
|
||||||
: 30000);
|
: 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