Harden movie playback lifecycle and diagnostics
This commit is contained in:
@@ -26,6 +26,11 @@
|
||||
<Compile Include="..\..\godot\MovieRuntime.cs" Link="MovieRuntime.cs" />
|
||||
<Compile Include="..\..\godot\FfmpegMovieNative.cs" Link="FfmpegMovieNative.cs" />
|
||||
<Compile Include="..\..\godot\FfmpegMovieDecoder.cs" Link="FfmpegMovieDecoder.cs" />
|
||||
<Compile Include="..\..\godot\PageLocatorState.cs" Link="PageLocatorState.cs" />
|
||||
<Compile Include="..\..\godot\GodotTimelineLog.cs" Link="GodotTimelineLog.cs" />
|
||||
<Compile Include="..\..\godot\GodotTraceSink.cs" Link="GodotTraceSink.cs" />
|
||||
<Compile Include="..\..\godot\MovieSurfaceRegistry.cs" Link="MovieSurfaceRegistry.cs" />
|
||||
<Compile Include="..\..\tools\movie-corpus-gate\MovieCorpusGate.cs" Link="MovieCorpusGate.cs" />
|
||||
<Compile Include="..\..\godot\DirectShowMovieDecoder.cs" Link="DirectShowMovieDecoder.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
24
engine/Age.Engine.Tests/GfxDiagnosticSnapshotTests.cs
Normal file
24
engine/Age.Engine.Tests/GfxDiagnosticSnapshotTests.cs
Normal file
@@ -0,0 +1,24 @@
|
||||
using Age.Engine.Model;
|
||||
|
||||
public class GfxDiagnosticSnapshotTests
|
||||
{
|
||||
[Fact]
|
||||
public void SnapshotIdentifiesOnlyChannelsThatCanBlockPresentationWait()
|
||||
{
|
||||
var gfx = new GfxState();
|
||||
gfx.BindDraw(0x100, 7, 0, 0, 32, 32, 0, 0);
|
||||
gfx.SetTranslationChannel(0x100, 25, 300, (20, 30, 0));
|
||||
gfx.BindDraw(0x200, 8, 0, 0, 32, 32, 0, 0);
|
||||
gfx.SetScaleChannel(0x200, 0, 500, (200, 200, 100));
|
||||
gfx.SetOneShotAnimationControl(0x200, 1);
|
||||
|
||||
GfxDiagnosticSnapshot snapshot = gfx.CaptureDiagnosticSnapshot(1000);
|
||||
|
||||
Assert.True(snapshot.HasActiveTimedPresentation);
|
||||
var blocking = Assert.Single(snapshot.BlockingObjects);
|
||||
Assert.Equal(0x100, blocking.Handle);
|
||||
Assert.True(blocking.TranslationEnabled);
|
||||
Assert.Equal(25, blocking.TranslationDelayMs);
|
||||
Assert.Equal(300, blocking.TranslationDurationMs);
|
||||
}
|
||||
}
|
||||
31
engine/Age.Engine.Tests/GodotTraceSinkTests.cs
Normal file
31
engine/Age.Engine.Tests/GodotTraceSinkTests.cs
Normal file
@@ -0,0 +1,31 @@
|
||||
using Age.Engine.Diagnostics;
|
||||
using Age.Engine.Model;
|
||||
|
||||
public class GodotTraceSinkTests
|
||||
{
|
||||
[Fact]
|
||||
public void SnapshotRetainsCurrentStackAndBoundedRecentSteps()
|
||||
{
|
||||
using var locator = new PageLocatorState("SYSTEM4", null);
|
||||
var sink = new GodotTraceSink(locator);
|
||||
sink.Emit(TraceEvent.FrameEnter("SYSTEM4.BIN", 0, FrameCause.TopScene));
|
||||
sink.Emit(TraceEvent.FrameEnter("BTL.BIN", 1, FrameCause.CallScript, 0x2b10));
|
||||
|
||||
for (int index = 0; index < 140; index++)
|
||||
{
|
||||
var instruction = new Instruction(0x2400 + index, 0x100 + index,
|
||||
Array.Empty<Operand>());
|
||||
sink.Emit(TraceEvent.Step(index, instruction, 1));
|
||||
}
|
||||
|
||||
GodotTraceSnapshot snapshot = sink.Snapshot();
|
||||
|
||||
Assert.Equal("BTL.BIN", snapshot.CurrentScript);
|
||||
Assert.Equal(0x2400 + 139, snapshot.CurrentOffset);
|
||||
Assert.Equal(0x100 + 139, snapshot.CurrentOpcode);
|
||||
Assert.Equal(new[] { "SYSTEM4.BIN", "BTL.BIN" }, snapshot.CallStack);
|
||||
Assert.Equal(128, snapshot.RecentSteps.Count);
|
||||
Assert.Equal(0x2400 + 12, snapshot.RecentSteps[0].Offset);
|
||||
Assert.Equal(0x2400 + 139, snapshot.RecentSteps[^1].Offset);
|
||||
}
|
||||
}
|
||||
102
engine/Age.Engine.Tests/MovieCorpusGateTests.cs
Normal file
102
engine/Age.Engine.Tests/MovieCorpusGateTests.cs
Normal file
@@ -0,0 +1,102 @@
|
||||
using Age.Engine.Sys4;
|
||||
|
||||
public class MovieCorpusGateTests
|
||||
{
|
||||
private sealed class FakeFrameSource(FfmpegMovieInfo info,
|
||||
params FfmpegVideoFrame[] frames) : IFfmpegFrameSource
|
||||
{
|
||||
private readonly Queue<FfmpegVideoFrame> _frames = new(frames);
|
||||
public FfmpegMovieInfo Info { get; } = info;
|
||||
public bool Disposed { get; private set; }
|
||||
public bool TryDecodeNextVideoFrame(out FfmpegVideoFrame frame)
|
||||
=> _frames.TryDequeue(out frame!);
|
||||
public void Dispose() => Disposed = true;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SequenceHeaderProbeReadsIndependentDimensions()
|
||||
{
|
||||
byte[] payload = SyntheticPayload(320, 240);
|
||||
|
||||
Assert.True(MovieCorpusDiscovery.TryReadSequenceDimensions(payload, out int width, out int height));
|
||||
Assert.Equal(320, width);
|
||||
Assert.Equal(240, height);
|
||||
Assert.False(MovieCorpusDiscovery.TryReadSequenceDimensions(new byte[] { 0, 0, 1, 0xba }, out _, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GateReportsFramesMetadataTimingAndTeardown()
|
||||
{
|
||||
var source = new FakeFrameSource(new FfmpegMovieInfo(16, 16, 100, 20, 1, false),
|
||||
Frame(16, 16, 1, 0), Frame(16, 16, 2, 50));
|
||||
var input = new MovieCorpusInput(0x2bc2, "SYNTH.AGF", () =>
|
||||
new MoviePayload("SYNTH.AGF", SyntheticPayload(16, 16)));
|
||||
|
||||
MovieCorpusReport report = new MovieCorpusGate(_ => source).Run([input], 1, 1000);
|
||||
|
||||
Assert.True(report.Passed);
|
||||
var item = Assert.Single(report.Movies);
|
||||
Assert.True(item.Passed);
|
||||
Assert.Equal("0x2bc2", item.PackedIdHex);
|
||||
Assert.Equal(16, item.ExpectedWidth);
|
||||
Assert.Equal(2, item.FrameCount);
|
||||
Assert.Equal(0, item.FirstPresentationTimeMs);
|
||||
Assert.Equal(50, item.LastPresentationTimeMs);
|
||||
Assert.True(item.FramesChanged);
|
||||
Assert.True(source.Disposed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GateContinuesAfterValidationFailureAndReportsCountMismatch()
|
||||
{
|
||||
var badDimensions = new FakeFrameSource(new FfmpegMovieInfo(8, 16, 100, 20, 1, false),
|
||||
Frame(8, 16, 1, 0));
|
||||
var decreasingTimestamps = new FakeFrameSource(new FfmpegMovieInfo(16, 16, 100, 20, 1, false),
|
||||
Frame(16, 16, 1, 50), Frame(16, 16, 2, 40));
|
||||
var sources = new Queue<IFfmpegFrameSource>([badDimensions, decreasingTimestamps]);
|
||||
MovieCorpusInput[] inputs =
|
||||
[
|
||||
new(1, "BAD-DIMS.AGF", () => new MoviePayload("BAD-DIMS.AGF", SyntheticPayload(16, 16))),
|
||||
new(2, "BAD-PTS.AGF", () => new MoviePayload("BAD-PTS.AGF", SyntheticPayload(16, 16))),
|
||||
];
|
||||
|
||||
MovieCorpusReport report = new MovieCorpusGate(_ => sources.Dequeue()).Run(inputs, 3, 1000);
|
||||
|
||||
Assert.False(report.Passed);
|
||||
Assert.Equal(2, report.FailedCount);
|
||||
Assert.Contains("expected 3", Assert.Single(report.SelectionErrors));
|
||||
Assert.Contains("differ", report.Movies[0].Error);
|
||||
Assert.Contains("timestamp", report.Movies[1].Error);
|
||||
Assert.True(badDimensions.Disposed);
|
||||
Assert.True(decreasingTimestamps.Disposed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GateRejectsInvalidFrameRateMetadata()
|
||||
{
|
||||
var source = new FakeFrameSource(new FfmpegMovieInfo(16, 16, 100, 0, 1, false),
|
||||
Frame(16, 16, 1, 0));
|
||||
var input = new MovieCorpusInput(1, "BAD-RATE.AGF", () =>
|
||||
new MoviePayload("BAD-RATE.AGF", SyntheticPayload(16, 16)));
|
||||
|
||||
MovieCorpusReport report = new MovieCorpusGate(_ => source).Run([input], 1, 1000);
|
||||
|
||||
Assert.False(report.Passed);
|
||||
Assert.Contains("rate 0/1", Assert.Single(report.Movies).Error);
|
||||
Assert.True(source.Disposed);
|
||||
}
|
||||
|
||||
private static byte[] SyntheticPayload(int width, int height) =>
|
||||
[
|
||||
0, 0, 1, 0xba,
|
||||
0, 0, 1, 0xb3,
|
||||
(byte)(width >> 4),
|
||||
(byte)(((width & 0x0f) << 4) | (height >> 8)),
|
||||
(byte)height,
|
||||
0,
|
||||
];
|
||||
|
||||
private static FfmpegVideoFrame Frame(int width, int height, byte value, long timestamp)
|
||||
=> new(new RgbaImage(width, height,
|
||||
Enumerable.Repeat(value, checked(width * height * 4)).ToArray()), timestamp);
|
||||
}
|
||||
@@ -100,10 +100,11 @@ public class MovieOpcodeTests
|
||||
var factory = new FakeMovieDecoderFactory(decoder);
|
||||
var payload = new MoviePayload("TEST.AGF", new byte[] { 0, 0, 1, 0xba });
|
||||
|
||||
var runtime = MovieRuntime.Open("TEST.AGF", 7, payload, factory);
|
||||
var runtime = MovieRuntime.Open("TEST.AGF", 7, 0x123, payload, factory);
|
||||
|
||||
Assert.Same(payload, factory.OpenedPayload);
|
||||
Assert.Same(decoder, runtime.Decoder);
|
||||
Assert.Equal(0x123, runtime.ResourceId);
|
||||
Assert.Equal(1876, runtime.Decoder.StopTimeMs);
|
||||
Assert.Equal(5000, runtime.WatchdogMs);
|
||||
Assert.True(runtime.Decoder.TryTakeFrame(out var frame));
|
||||
@@ -120,7 +121,7 @@ public class MovieOpcodeTests
|
||||
public void MovieRuntimeComputesBoundedWatchdogFromDecoderMetadata(long? stopTimeMs, long expected)
|
||||
{
|
||||
var decoder = new FakeMovieDecoder { StopTimeMs = stopTimeMs };
|
||||
var runtime = MovieRuntime.Open("TEST.AGF", 7,
|
||||
var runtime = MovieRuntime.Open("TEST.AGF", 7, 0x123,
|
||||
new MoviePayload("TEST.AGF", new byte[] { 0, 0, 1, 0xba }),
|
||||
new FakeMovieDecoderFactory(decoder));
|
||||
|
||||
|
||||
65
engine/Age.Engine.Tests/MovieSurfaceRegistryTests.cs
Normal file
65
engine/Age.Engine.Tests/MovieSurfaceRegistryTests.cs
Normal file
@@ -0,0 +1,65 @@
|
||||
using Age.Engine.Sys4;
|
||||
|
||||
public class MovieSurfaceRegistryTests
|
||||
{
|
||||
[Fact]
|
||||
public void SameResourceOnTwoSurfacesHasIndependentFramesCompletionAndRelease()
|
||||
{
|
||||
var registry = new MovieSurfaceRegistry();
|
||||
MovieSurfaceBinding first = registry.Begin(7, 0x2b42, out _);
|
||||
MovieSurfaceBinding second = registry.Begin(8, 0x2b42, out _);
|
||||
var firstFrame = Frame(1);
|
||||
var secondFrame = Frame(2);
|
||||
|
||||
Assert.True(registry.PublishFrame(first.PlaybackId, firstFrame, "MVB126.AGF", 0x2b42));
|
||||
Assert.True(registry.PublishFrame(second.PlaybackId, secondFrame, "MVB126.AGF", 0x2b42));
|
||||
Assert.True(registry.Complete(first.PlaybackId));
|
||||
|
||||
MovieSurfaceRelease released = registry.ReleaseIfCompleted(7);
|
||||
|
||||
Assert.Equal(MovieSurfaceReleaseKind.Released, released.Kind);
|
||||
Assert.False(registry.IsBound(7));
|
||||
Assert.True(registry.IsActive(8));
|
||||
Assert.True(registry.TryResolveSurface(8, out MovieSurfaceFrame? remaining));
|
||||
Assert.Same(secondFrame, remaining!.Image);
|
||||
Assert.True(registry.Complete(second.PlaybackId));
|
||||
Assert.Equal(MovieSurfaceReleaseKind.Released, registry.ReleaseIfCompleted(8).Kind);
|
||||
Assert.False(registry.HasActivePlayback);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReplacingSurfaceInvalidatesLateEventsFromPriorPlayback()
|
||||
{
|
||||
var registry = new MovieSurfaceRegistry();
|
||||
MovieSurfaceBinding prior = registry.Begin(7, 0x2bdc, out _);
|
||||
|
||||
MovieSurfaceBinding current = registry.Begin(7, 0x2bad, out MovieSurfaceBinding? replaced);
|
||||
|
||||
Assert.Equal(prior, replaced);
|
||||
Assert.False(registry.Complete(prior.PlaybackId));
|
||||
Assert.False(registry.PublishFrame(prior.PlaybackId, Frame(1), "OLD.AGF", 1));
|
||||
Assert.True(registry.IsActive(7));
|
||||
Assert.True(registry.PublishFrame(current.PlaybackId, Frame(2), "NEW.AGF", 2));
|
||||
Assert.True(registry.TryResolveSurface(7, out MovieSurfaceFrame? frame));
|
||||
Assert.Equal("NEW.AGF", frame!.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReleasedMovieResourceRemainsTypedAsMovieForCleanupFrame()
|
||||
{
|
||||
var registry = new MovieSurfaceRegistry();
|
||||
MovieSurfaceBinding binding = registry.Begin(7, 0x2bde, out _);
|
||||
|
||||
Assert.True(registry.IsKnownMovieResource(0x2bde));
|
||||
Assert.True(registry.Complete(binding.PlaybackId));
|
||||
Assert.Equal(MovieSurfaceReleaseKind.Released, registry.ReleaseIfCompleted(7).Kind);
|
||||
|
||||
Assert.False(registry.IsBound(7));
|
||||
Assert.False(registry.TryResolveResource(0x2bde, out _));
|
||||
Assert.True(registry.IsKnownMovieResource(0x2bde));
|
||||
Assert.False(registry.IsKnownMovieResource(0x2af5));
|
||||
}
|
||||
|
||||
private static RgbaImage Frame(byte value)
|
||||
=> new(1, 1, new[] { value, value, value, (byte)255 });
|
||||
}
|
||||
@@ -39,6 +39,23 @@ public class Sys4AssetStoreTests
|
||||
Assert.Equal("23F0C104A45C099CEFB7D333362716EDE6F20B9EC53E4C3705A8E3A87063708E", digest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompletePackedAssetEnumerationPreservesBaseThenAppendOrder()
|
||||
{
|
||||
var catalog = Sys4AssetCatalog.Load(Paths.Sys4Ini);
|
||||
var append = Assert.Single(catalog.AppendPacks).Value;
|
||||
|
||||
var assets = catalog.EnumerateAssets();
|
||||
|
||||
Assert.Equal(catalog.Files.Count + append.Files.Count, assets.Count);
|
||||
Assert.Equal(0, assets[0].PackedId);
|
||||
Assert.Same(catalog.Files[0], assets[0].Asset);
|
||||
Assert.Equal(0x01000000, assets[catalog.Files.Count].PackedId);
|
||||
Assert.Same(append.Files[0], assets[catalog.Files.Count].Asset);
|
||||
Assert.Equal(catalog.EnumerateScripts(), assets.Where(entry =>
|
||||
entry.Asset.Name.EndsWith(".BIN", StringComparison.OrdinalIgnoreCase)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompleteAppendDirectoryAndPayloadsMatchBinExtractAlf()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user