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()
{

View File

@@ -31,6 +31,22 @@ public readonly record struct NumericGlyphStyle(int SurfaceSlot, int AtlasX, int
public bool Registered => SurfaceSlot != 0;
}
public sealed record BlockingGfxObjectDiagnostic(
long Handle, int SourceSlot, long StartMs, long ControlFlags,
bool ColorEnabled, long ColorDelayMs, long ColorDurationMs,
bool ScaleEnabled, long ScaleDelayMs, long ScaleDurationMs,
bool RotationEnabled, long RotationDelayMs, long RotationDurationMs,
bool TranslationEnabled, long TranslationDelayMs, long TranslationDurationMs);
public sealed record GfxDiagnosticSnapshot(
long NowMs, bool HasActiveTimedPresentation, int ObjectCount, int VisibleObjectCount,
int ActiveSurfaceTransitionCount, long AnimationServiceFlags,
long AnimClockDurationTicks, long AnimClockGeneration,
uint PreviousFrameTimeMilliseconds, uint CurrentFrameTimeMilliseconds,
long RangeTransformFirst, long RangeTransformCount,
BlockingGfxObjectDiagnostic? BlockingRangeTransform,
IReadOnlyList<BlockingGfxObjectDiagnostic> BlockingObjects);
/// <summary>A renderable view of one visible gfx object — the host composites these in ascending-handle order
/// (= the engine's z-order) each frame. Built by <see cref="GfxState.SnapshotVisibleObjects"/>; the surface
/// resId/colorkey are resolved from the object's live source slot at snapshot time (see docs/engine-re.md,
@@ -545,6 +561,51 @@ public sealed class GfxState
o.RotationChannelEnabled || o.TranslationEnabled));
}
/// <summary>Observe-only state for a runtime stall capture. It identifies the exact finite channels
/// which can keep the host's op-0x21c presentation wait active.</summary>
public GfxDiagnosticSnapshot CaptureDiagnosticSnapshot(long nowMs)
{
lock (_lock)
{
BlockingGfxObjectDiagnostic? range = HasBlockingChannels(_rangeTransform)
? DescribeBlockingObject(-1, _rangeTransform)
: null;
var objects = _objects
.Where(pair => pair.Value.Visible
&& (pair.Value.OneShotAnimationControlFlags & 1) == 0
&& HasBlockingChannels(pair.Value))
.OrderBy(pair => pair.Key)
.Select(pair => DescribeBlockingObject(pair.Key, pair.Value))
.ToArray();
return new GfxDiagnosticSnapshot(
nowMs,
_surfaceTransitions.Values.Any(t => TransitionProgress(t, nowMs) < 1.0)
|| range != null || objects.Length != 0,
_objects.Count,
_objects.Values.Count(o => o.Visible),
_surfaceTransitions.Values.Count(t => TransitionProgress(t, nowMs) < 1.0),
AnimationServiceFlags,
AnimClockDurationTicks,
AnimClockGeneration,
PreviousFrameTimeMilliseconds,
CurrentFrameTimeMilliseconds,
_rangeTransformFirst,
_rangeTransformCount,
range,
objects);
}
}
private static bool HasBlockingChannels(GfxObject o)
=> o.OneShotColorEnabled || o.ScaleEnabled || o.RotationChannelEnabled || o.TranslationEnabled;
private static BlockingGfxObjectDiagnostic DescribeBlockingObject(long handle, GfxObject o)
=> new(handle, o.SourceSlot, o.OneShotStartMs, o.OneShotAnimationControlFlags,
o.OneShotColorEnabled, o.ColorDelayMs, o.ColorDurationMs,
o.ScaleEnabled, o.ScaleDelayMs, o.ScaleDurationMs,
o.RotationChannelEnabled, o.RotationDelayMs, o.RotationDurationMs,
o.TranslationEnabled, o.TranslationDelayMs, o.TranslationDurationMs);
/// <summary>Op 0x242: replace the retained object's animation-control word. Native bit 0 makes its
/// finite one-shot channels nonblocking and immune to op 0x243 forced completion.</summary>
public void SetOneShotAnimationControl(long handle, long flags)

View File

@@ -179,23 +179,27 @@ public sealed class Sys4AssetCatalog
.Where(f => f.Name.EndsWith(".BIN", StringComparison.OrdinalIgnoreCase))
.Select(f => f.Name.ToUpperInvariant()).ToArray();
/// <summary>Enumerate every real asset in native packed-id order, including mounted append packs.</summary>
public IReadOnlyList<PackedAssetEntry> EnumerateAssets()
{
var assets = new List<PackedAssetEntry>();
AddAssets(this, assets);
foreach (var append in _appendPacks.OrderBy(pair => pair.Key).Select(pair => pair.Value))
AddAssets(append, assets);
return assets;
}
/// <summary>Enumerate every script in native packed-id order, including mounted append packs.
/// Placeholder slots and non-script assets are excluded without collapsing raw indices.</summary>
public IReadOnlyList<PackedAssetEntry> EnumerateScripts()
{
var scripts = new List<PackedAssetEntry>();
AddScripts(this, scripts);
foreach (var append in _appendPacks.OrderBy(pair => pair.Key).Select(pair => pair.Value))
AddScripts(append, scripts);
return scripts;
}
=> EnumerateAssets().Where(entry =>
entry.Asset.Name.EndsWith(".BIN", StringComparison.OrdinalIgnoreCase)).ToArray();
private static void AddScripts(Sys4AssetCatalog catalog, List<PackedAssetEntry> scripts)
private static void AddAssets(Sys4AssetCatalog catalog, List<PackedAssetEntry> assets)
{
long selector = (long)catalog.PackId << 24;
foreach (var entry in catalog.Files)
if (entry.Name.EndsWith(".BIN", StringComparison.OrdinalIgnoreCase))
scripts.Add(new PackedAssetEntry(selector | (uint)entry.RawIndex, entry));
assets.Add(new PackedAssetEntry(selector | (uint)entry.RawIndex, entry));
}
private static Dictionary<string, (int Start, int End)> BuildSceneRanges(IReadOnlyList<AssetEntry> files)