Harden movie playback lifecycle and diagnostics

This commit is contained in:
gamer147
2026-07-22 09:44:57 -04:00
parent 816673cf6a
commit 4f5137a98f
25 changed files with 1145 additions and 147 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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