Harden movie playback lifecycle and diagnostics
This commit is contained in:
@@ -4,12 +4,6 @@ using System.IO;
|
||||
using System.Threading;
|
||||
using Age.Engine.Sys4;
|
||||
|
||||
internal interface IFfmpegFrameSource : IDisposable
|
||||
{
|
||||
FfmpegMovieInfo Info { get; }
|
||||
bool TryDecodeNextVideoFrame(out FfmpegVideoFrame frame);
|
||||
}
|
||||
|
||||
internal interface IMoviePacingClock
|
||||
{
|
||||
bool WaitUntil(long elapsedMilliseconds, WaitHandle cancellation);
|
||||
|
||||
@@ -14,6 +14,12 @@ internal readonly record struct FfmpegMovieInfo(
|
||||
|
||||
internal sealed record FfmpegVideoFrame(RgbaImage Image, long PresentationTimeMs);
|
||||
|
||||
internal interface IFfmpegFrameSource : IDisposable
|
||||
{
|
||||
FfmpegMovieInfo Info { get; }
|
||||
bool TryDecodeNextVideoFrame(out FfmpegVideoFrame frame);
|
||||
}
|
||||
|
||||
/// <summary>Sequential, unpaced access to the project-owned FFmpeg C ABI for isolated probes and playback.</summary>
|
||||
internal sealed class FfmpegMovieSession : IFfmpegFrameSource
|
||||
{
|
||||
|
||||
@@ -22,9 +22,7 @@ public sealed class GodotAdvHost : IHost
|
||||
private readonly Dictionary<int, RgbaImage> _surfaceImages = new();
|
||||
private readonly Dictionary<int, long> _surfaceColorKeys = new();
|
||||
private readonly Dictionary<int, long> _surfaceResources = new(); // surface slot -> packed catalog id
|
||||
private readonly Dictionary<long, (RgbaImage Image, string Name, int AssetId)> _movieFrames = new();
|
||||
private readonly Dictionary<int, long> _movieBySurface = new();
|
||||
private readonly HashSet<long> _completedMovies = new();
|
||||
private readonly MovieSurfaceRegistry _movieSurfaces = new();
|
||||
private readonly string?[] _sfxNames = new string?[10]; // SC0000 native channel subset
|
||||
// slot -> dimensions of the currently allocated surface. Slot 0 begins as the engine's 800x600
|
||||
// primary surface, but op 0x1fa releases it like any other slot; subsequent size queries must return 0x0.
|
||||
@@ -869,15 +867,13 @@ public sealed class GodotAdvHost : IHost
|
||||
/// loose-first asset store.</summary>
|
||||
public (RgbaImage Image, string Name, int AssetId, bool IsDynamic)? ResolveResIdTexture(long resId)
|
||||
{
|
||||
lock (_imageLock)
|
||||
{
|
||||
if (_movieFrames.TryGetValue(resId, out var movie))
|
||||
return (movie.Image, movie.Name, movie.AssetId, true);
|
||||
// Movie payloads use the same .AGF extension as still images. While the decoder is opening
|
||||
// (or before its first frame arrives), keep the already-created surface blank
|
||||
// instead of falling through to AgfDecoder and misclassifying the MPEG program stream.
|
||||
if (_movieBySurface.Values.Contains(resId)) return null;
|
||||
}
|
||||
if (_movieSurfaces.TryResolveResource(resId, out var movie) && movie != null)
|
||||
return (movie.Image, movie.Name, movie.AssetId, true);
|
||||
// Movie payloads use the same .AGF extension as still images. Do not misclassify the MPEG program
|
||||
// stream before its first frame or during the cleanup frame after its surface binding is detached.
|
||||
// Packed catalog identity is immutable, so a resource which entered the typed movie path remains
|
||||
// a movie even when it has no live playback.
|
||||
if (_movieSurfaces.IsKnownMovieResource(resId)) return null;
|
||||
var asset = _res.ResolveTexture(resId);
|
||||
var image = asset != null ? Decode(asset) : null;
|
||||
return asset != null && image != null ? (image, asset.Name, asset.PackedId, false) : null;
|
||||
@@ -886,27 +882,49 @@ public sealed class GodotAdvHost : IHost
|
||||
public (RgbaImage Image, string Name, int AssetId, bool IsDynamic)? ResolveSurfaceTexture(
|
||||
int surfaceSlot, long fallbackResourceId)
|
||||
{
|
||||
if (_movieSurfaces.TryResolveSurface(surfaceSlot, out var movie) && movie != null)
|
||||
return (movie.Image, movie.Name, movie.AssetId, true);
|
||||
if (_movieSurfaces.IsBound(surfaceSlot)) return null;
|
||||
lock (_imageLock)
|
||||
if (_surfaceImages.TryGetValue(surfaceSlot, out var surface))
|
||||
return (surface, $"<surface:{surfaceSlot}>", int.MinValue + surfaceSlot, true);
|
||||
return fallbackResourceId != 0 ? ResolveResIdTexture(fallbackResourceId) : null;
|
||||
}
|
||||
|
||||
public bool IsMovieSurfaceBound(int surfaceSlot) => _movieSurfaces.IsBound(surfaceSlot);
|
||||
|
||||
public long? PlayMovieToSurface(long resourceId, int surfaceSlot, long movieFlags, long syncMask)
|
||||
{
|
||||
string scene = CurrentScene;
|
||||
var asset = _res.ResolveMovie(resourceId);
|
||||
if (asset == null) { Godot.GD.Print($"movie unresolved {scene}:0x{resourceId:x}"); return null; }
|
||||
StartMovie(asset, resourceId, surfaceSlot, movieFlags, syncMask, modal: false,
|
||||
out long? stopTimeMs);
|
||||
out long? stopTimeMs, out _);
|
||||
return stopTimeMs ?? 0;
|
||||
}
|
||||
|
||||
public bool IsMovieSurfaceActive(int surfaceSlot)
|
||||
=> _movieSurfaces.IsActive(surfaceSlot);
|
||||
|
||||
public GodotHostDiagnosticSnapshot CaptureDiagnosticSnapshot()
|
||||
{
|
||||
lock (_imageLock)
|
||||
return _movieBySurface.TryGetValue(surfaceSlot, out long resourceId)
|
||||
&& !_completedMovies.Contains(resourceId);
|
||||
IReadOnlyList<MovieSurfaceDiagnostic> movies = _movieSurfaces.Snapshot();
|
||||
IReadOnlyList<long> completed = _movieSurfaces.CompletedPlaybackIds();
|
||||
bool screenTransitionActive;
|
||||
lock (_screenTransitionLock) screenTransitionActive = _screenTransition != null;
|
||||
return new GodotHostDiagnosticSnapshot(
|
||||
CurrentScene,
|
||||
IsWaiting,
|
||||
IsTransitionWaiting,
|
||||
IsSleeping,
|
||||
IsTextRevealing,
|
||||
_modalMovieWaiting,
|
||||
_advPagePresentationSuspended,
|
||||
_messageSkipActive,
|
||||
screenTransitionActive,
|
||||
TransitionStartedAtMs,
|
||||
movies,
|
||||
completed);
|
||||
}
|
||||
|
||||
public void PlayModalMovieToSurface(long resourceId, int surfaceSlot, long movieFlags)
|
||||
@@ -923,22 +941,22 @@ public sealed class GodotAdvHost : IHost
|
||||
try
|
||||
{
|
||||
if (!StartMovie(asset, resourceId, surfaceSlot, movieFlags, 0, modal: true,
|
||||
out _)) return;
|
||||
out _, out long playbackId)) return;
|
||||
_timeline?.State("modal-movie-wait", new()
|
||||
{
|
||||
["resource"] = resourceId, ["surface"] = surfaceSlot, ["file"] = asset.Name,
|
||||
["resource"] = resourceId, ["playback"] = playbackId,
|
||||
["surface"] = surfaceSlot, ["file"] = asset.Name,
|
||||
});
|
||||
while (!_stopping && !_modalMovieCancelled)
|
||||
{
|
||||
lock (_imageLock)
|
||||
if (_completedMovies.Contains(resourceId)) break;
|
||||
if (!_movieSurfaces.IsActive(surfaceSlot)) break;
|
||||
_frameSignal.WaitOne(50);
|
||||
}
|
||||
|
||||
// Cancellation is a completed modal presentation from the script's perspective. The
|
||||
// wrapper's following surface-release opcode performs the ordinary decoder teardown.
|
||||
if (_modalMovieCancelled)
|
||||
lock (_imageLock) _completedMovies.Add(resourceId);
|
||||
_movieSurfaces.Complete(playbackId);
|
||||
_timeline?.State("running", new()
|
||||
{
|
||||
["modal_movie_complete"] = !_modalMovieCancelled,
|
||||
@@ -953,16 +971,18 @@ public sealed class GodotAdvHost : IHost
|
||||
}
|
||||
|
||||
private bool StartMovie(AssetEntry asset, long resourceId, int surfaceSlot, long movieFlags,
|
||||
long syncMask, bool modal, out long? stopTimeMs)
|
||||
long syncMask, bool modal, out long? stopTimeMs, out long playbackId)
|
||||
{
|
||||
stopTimeMs = null;
|
||||
// Publish the movie identity before the potentially long VFS read and synchronous decoder setup.
|
||||
// The compositor can therefore distinguish a legitimate blank pre-roll surface from a still AGF.
|
||||
ReleaseSurface(surfaceSlot);
|
||||
// A playback is a surface-owned instance, not the shared resource id. BTL can schedule the same
|
||||
// asset on multiple surfaces; replacing one binding must not erase another binding's completion.
|
||||
MovieSurfaceBinding binding = _movieSurfaces.Begin(surfaceSlot, resourceId, out var replaced);
|
||||
playbackId = binding.PlaybackId;
|
||||
if (replaced is { } prior) _main.CallDeferred("StopMovie", prior.PlaybackId);
|
||||
lock (_imageLock)
|
||||
{
|
||||
_movieBySurface[surfaceSlot] = resourceId;
|
||||
_completedMovies.Remove(resourceId);
|
||||
_surfaceImages.Remove(surfaceSlot);
|
||||
_surfaceColorKeys.Remove(surfaceSlot);
|
||||
}
|
||||
_slotDims[surfaceSlot] = (800, 600); // SC0000 creates this native-sized surface immediately beforehand.
|
||||
try
|
||||
@@ -970,30 +990,24 @@ public sealed class GodotAdvHost : IHost
|
||||
var movie = _res.ReadMovie(asset);
|
||||
_timeline?.Event("movie-start", new()
|
||||
{
|
||||
["resource"] = resourceId, ["surface"] = surfaceSlot, ["file"] = movie.Name,
|
||||
["resource"] = resourceId, ["playback"] = playbackId,
|
||||
["surface"] = surfaceSlot, ["file"] = movie.Name,
|
||||
["flags"] = movieFlags, ["sync_mask"] = syncMask, ["modal"] = modal,
|
||||
});
|
||||
bool started = _main.TryPlayMovie(movie.Bytes, movie.Name, resourceId, asset.PackedId,
|
||||
bool started = _main.TryPlayMovie(movie.Bytes, movie.Name, playbackId, resourceId, asset.PackedId,
|
||||
out stopTimeMs);
|
||||
if (!started)
|
||||
{
|
||||
stopTimeMs = 0;
|
||||
NotifyMovieCompleted(resourceId);
|
||||
NotifyMovieCompleted(playbackId);
|
||||
}
|
||||
return started;
|
||||
}
|
||||
catch (System.Exception e)
|
||||
{
|
||||
lock (_imageLock)
|
||||
{
|
||||
if (_movieBySurface.TryGetValue(surfaceSlot, out long registered) && registered == resourceId)
|
||||
_movieBySurface.Remove(surfaceSlot);
|
||||
_movieFrames.Remove(resourceId);
|
||||
_completedMovies.Remove(resourceId);
|
||||
}
|
||||
_movieSurfaces.Abandon(playbackId, out _);
|
||||
_slotDims.Remove(surfaceSlot);
|
||||
stopTimeMs = 0;
|
||||
NotifyMovieCompleted(resourceId);
|
||||
Godot.GD.Print($"movie read failed {asset.Name}: {e.Message}");
|
||||
return false;
|
||||
}
|
||||
@@ -1002,28 +1016,11 @@ public sealed class GodotAdvHost : IHost
|
||||
public void ReleaseSurface(int slot)
|
||||
{
|
||||
lock (_screenTransitionLock) _renderTargetSnapshots.Remove(slot);
|
||||
long resourceId;
|
||||
MovieSurfaceRelease movieRelease = _movieSurfaces.ReleaseIfCompleted(slot);
|
||||
if (movieRelease.Kind == MovieSurfaceReleaseKind.Active)
|
||||
return; // Static surface setup before 0x21c must not evict an active movie playback.
|
||||
lock (_imageLock)
|
||||
{
|
||||
if (!_movieBySurface.Remove(slot, out resourceId))
|
||||
{
|
||||
_surfaceImages.Remove(slot);
|
||||
_surfaceColorKeys.Remove(slot);
|
||||
lock (_textLock)
|
||||
{
|
||||
_surfaceText.Remove(slot);
|
||||
_surfaceResources.Remove(slot);
|
||||
}
|
||||
_slotDims.Remove(slot);
|
||||
return;
|
||||
}
|
||||
if (!_completedMovies.Contains(resourceId))
|
||||
{
|
||||
_movieBySurface[slot] = resourceId;
|
||||
return; // SC0000 prepares following static surfaces before 0x21c; the movie remains retained.
|
||||
}
|
||||
_movieFrames.Remove(resourceId);
|
||||
_completedMovies.Remove(resourceId);
|
||||
_surfaceImages.Remove(slot);
|
||||
_surfaceColorKeys.Remove(slot);
|
||||
}
|
||||
@@ -1033,8 +1030,17 @@ public sealed class GodotAdvHost : IHost
|
||||
_surfaceResources.Remove(slot);
|
||||
}
|
||||
_slotDims.Remove(slot);
|
||||
_timeline?.Event("movie-stop", new() { ["resource"] = resourceId, ["surface"] = slot });
|
||||
_main.CallDeferred("StopMovie", resourceId);
|
||||
if (movieRelease.Kind == MovieSurfaceReleaseKind.Released)
|
||||
{
|
||||
var binding = movieRelease.Binding;
|
||||
_timeline?.Event("movie-stop", new()
|
||||
{
|
||||
["resource"] = binding.ResourceId,
|
||||
["playback"] = binding.PlaybackId,
|
||||
["surface"] = slot,
|
||||
});
|
||||
_main.CallDeferred("StopMovie", binding.PlaybackId);
|
||||
}
|
||||
}
|
||||
|
||||
public void ClearRenderTarget(int surfaceSlot)
|
||||
@@ -1049,7 +1055,7 @@ public sealed class GodotAdvHost : IHost
|
||||
|
||||
public void ReleaseSurfaceRange(int firstSlot, int count)
|
||||
{
|
||||
var stoppedMovies = new System.Collections.Generic.HashSet<long>();
|
||||
IReadOnlyList<MovieSurfaceBinding> stoppedMovies = _movieSurfaces.ReleaseRange(firstSlot, count);
|
||||
int end = checked(firstSlot + count);
|
||||
lock (_screenTransitionLock)
|
||||
for (int slot = firstSlot; slot < end; slot++) _renderTargetSnapshots.Remove(slot);
|
||||
@@ -1057,12 +1063,6 @@ public sealed class GodotAdvHost : IHost
|
||||
{
|
||||
for (int slot = firstSlot; slot < end; slot++)
|
||||
{
|
||||
if (_movieBySurface.Remove(slot, out long resourceId))
|
||||
{
|
||||
stoppedMovies.Add(resourceId);
|
||||
_movieFrames.Remove(resourceId);
|
||||
_completedMovies.Remove(resourceId);
|
||||
}
|
||||
_surfaceImages.Remove(slot);
|
||||
_surfaceColorKeys.Remove(slot);
|
||||
_slotDims.Remove(slot);
|
||||
@@ -1076,37 +1076,36 @@ public sealed class GodotAdvHost : IHost
|
||||
_surfaceResources.Remove(slot);
|
||||
}
|
||||
}
|
||||
foreach (long resourceId in stoppedMovies)
|
||||
foreach (MovieSurfaceBinding binding in stoppedMovies)
|
||||
{
|
||||
_timeline?.Event("movie-stop", new() { ["resource"] = resourceId, ["range_release"] = true });
|
||||
_main.CallDeferred("StopMovie", resourceId);
|
||||
_timeline?.Event("movie-stop", new()
|
||||
{
|
||||
["resource"] = binding.ResourceId,
|
||||
["playback"] = binding.PlaybackId,
|
||||
["range_release"] = true,
|
||||
});
|
||||
_main.CallDeferred("StopMovie", binding.PlaybackId);
|
||||
}
|
||||
_timeline?.Event("surface-range-release", new() { ["first"] = firstSlot, ["count"] = count });
|
||||
}
|
||||
|
||||
// Main-thread decoder handoff. Replacing the newest frame mirrors the native texture renderer's
|
||||
// sample callback: the retained object keeps its surface binding while only the surface pixels change.
|
||||
public void PublishMovieFrame(long resourceId, string name, int assetId, RgbaImage frame)
|
||||
public void PublishMovieFrame(long playbackId, string name, int assetId, RgbaImage frame)
|
||||
{
|
||||
lock (_imageLock) _movieFrames[resourceId] = (frame, name, assetId);
|
||||
System.Threading.Interlocked.Exchange(ref _presentRequested, 1);
|
||||
if (_movieSurfaces.PublishFrame(playbackId, frame, name, assetId))
|
||||
System.Threading.Interlocked.Exchange(ref _presentRequested, 1);
|
||||
}
|
||||
|
||||
public void NotifyMovieCompleted(long resourceId)
|
||||
public void NotifyMovieCompleted(long playbackId)
|
||||
{
|
||||
lock (_imageLock)
|
||||
if (!_completedMovies.Add(resourceId)) return;
|
||||
_timeline?.Event("movie-complete", new() { ["resource"] = resourceId });
|
||||
if (!_movieSurfaces.Complete(playbackId)) return;
|
||||
_timeline?.Event("movie-complete", new() { ["playback"] = playbackId });
|
||||
_frameSignal.Set();
|
||||
}
|
||||
|
||||
private bool HasActiveMoviePresentation()
|
||||
{
|
||||
lock (_imageLock)
|
||||
foreach (long resourceId in _movieBySurface.Values)
|
||||
if (!_completedMovies.Contains(resourceId)) return true;
|
||||
return false;
|
||||
}
|
||||
=> _movieSurfaces.HasActivePlayback;
|
||||
|
||||
private RgbaImage? Decode(AssetEntry asset)
|
||||
{
|
||||
@@ -1254,6 +1253,11 @@ public sealed class GodotAdvHost : IHost
|
||||
}
|
||||
|
||||
public readonly record struct SurfaceTextDraw(int X, int Y, string Text, AdvTextStyle Style);
|
||||
public sealed record GodotHostDiagnosticSnapshot(
|
||||
string CurrentScene, bool IsInputWaiting, bool IsTransitionWaiting, bool IsSleeping,
|
||||
bool IsTextRevealing, bool IsModalMovieWaiting, bool IsAdvPagePresentationSuspended,
|
||||
bool IsMessageSkipActive, bool IsScreenTransitionActive, long TransitionStartedAtMs,
|
||||
IReadOnlyList<MovieSurfaceDiagnostic> MovieSurfaces, IReadOnlyList<long> CompletedMoviePlaybackIds);
|
||||
public readonly record struct LegacyScreenTransitionSnapshot(
|
||||
IReadOnlyList<RenderObject> Source, IReadOnlyList<RenderObject> Target, double Progress);
|
||||
|
||||
|
||||
@@ -8,9 +8,13 @@ using Age.Engine.Diagnostics;
|
||||
// engine fact delivered over the trace seam.
|
||||
public sealed class GodotTraceSink : ITraceSink
|
||||
{
|
||||
private const int RecentStepCapacity = 128;
|
||||
private readonly GodotTimelineLog? _timeline;
|
||||
private readonly PageLocatorState _locator;
|
||||
private readonly object _snapshotLock = new();
|
||||
private readonly Stack<string> _scripts = new();
|
||||
private readonly Queue<GodotTraceStepSnapshot> _recentSteps = new();
|
||||
private GodotTraceStepSnapshot? _latestStep;
|
||||
public GodotTraceSink(PageLocatorState locator, GodotTimelineLog? timeline = null)
|
||||
{ _locator = locator; _timeline = timeline; }
|
||||
// The page locator needs the exact script/offset even when the heavier timeline log is disabled.
|
||||
@@ -28,8 +32,13 @@ public sealed class GodotTraceSink : ITraceSink
|
||||
}
|
||||
if (e.Kind == TraceEventKind.FrameEnter && e.Name != null)
|
||||
{
|
||||
_scripts.Push(e.Name);
|
||||
PublishCallStack();
|
||||
string[] callStack;
|
||||
lock (_snapshotLock)
|
||||
{
|
||||
_scripts.Push(e.Name);
|
||||
callStack = CurrentCallStackLocked();
|
||||
}
|
||||
_locator.CallStack(callStack);
|
||||
_timeline?.Event("frame-enter", new()
|
||||
{
|
||||
["name"] = e.Name, ["depth"] = e.Depth,
|
||||
@@ -42,12 +51,25 @@ public sealed class GodotTraceSink : ITraceSink
|
||||
{
|
||||
["name"] = e.Name, ["depth"] = e.Depth, ["outcome"] = e.Text,
|
||||
});
|
||||
_scripts.Pop();
|
||||
PublishCallStack();
|
||||
string[] callStack;
|
||||
lock (_snapshotLock)
|
||||
{
|
||||
if (_scripts.Count > 0) _scripts.Pop();
|
||||
callStack = CurrentCallStackLocked();
|
||||
}
|
||||
_locator.CallStack(callStack);
|
||||
}
|
||||
else if (e.Kind == TraceEventKind.Step && e.Ins != null)
|
||||
{
|
||||
string script = _scripts.Count > 0 ? _scripts.Peek() : "<unknown>";
|
||||
string script;
|
||||
lock (_snapshotLock)
|
||||
{
|
||||
script = _scripts.Count > 0 ? _scripts.Peek() : "<unknown>";
|
||||
if (_recentSteps.Count == RecentStepCapacity) _recentSteps.Dequeue();
|
||||
var step = new GodotTraceStepSnapshot(script, e.Ins.Offset, e.Opcode, e.Depth);
|
||||
_latestStep = step;
|
||||
_recentSteps.Enqueue(step);
|
||||
}
|
||||
_locator.Step(script, e.Ins.Offset);
|
||||
_timeline?.Step(script, e.Ins.Offset, e.Opcode, e.Depth);
|
||||
}
|
||||
@@ -60,10 +82,31 @@ public sealed class GodotTraceSink : ITraceSink
|
||||
_timeline?.State("halted", new() { ["reason"] = e.Text, ["steps"] = e.Steps });
|
||||
}
|
||||
|
||||
private void PublishCallStack()
|
||||
public GodotTraceSnapshot Snapshot()
|
||||
{
|
||||
lock (_snapshotLock)
|
||||
{
|
||||
GodotTraceStepSnapshot? current = _latestStep;
|
||||
return new GodotTraceSnapshot(
|
||||
current?.Script ?? (_scripts.Count > 0 ? _scripts.Peek() : "<unknown>"),
|
||||
current?.Offset ?? -1,
|
||||
current?.Opcode ?? -1,
|
||||
current?.Depth ?? System.Math.Max(0, _scripts.Count - 1),
|
||||
CurrentCallStackLocked(),
|
||||
_recentSteps.ToArray());
|
||||
}
|
||||
}
|
||||
|
||||
private string[] CurrentCallStackLocked()
|
||||
{
|
||||
var stack = _scripts.ToArray();
|
||||
System.Array.Reverse(stack);
|
||||
_locator.CallStack(stack);
|
||||
return stack;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record GodotTraceStepSnapshot(string Script, int Offset, int Opcode, int Depth);
|
||||
|
||||
public sealed record GodotTraceSnapshot(string CurrentScript, int CurrentOffset, int CurrentOpcode,
|
||||
int CurrentDepth, IReadOnlyList<string> CallStack,
|
||||
IReadOnlyList<GodotTraceStepSnapshot> RecentSteps);
|
||||
|
||||
140
godot/Main.cs
140
godot/Main.cs
@@ -393,6 +393,12 @@ public partial class Main : Godot.Control
|
||||
GetViewport().SetInputAsHandled();
|
||||
return;
|
||||
}
|
||||
if (e is InputEventKey diagnosticKey && diagnosticKey.Keycode == Key.F6)
|
||||
{
|
||||
if (diagnosticKey.Pressed && !diagnosticKey.Echo) CaptureStallDiagnostic();
|
||||
GetViewport().SetInputAsHandled();
|
||||
return;
|
||||
}
|
||||
if (_debugSceneLauncher?.Visible == true)
|
||||
{
|
||||
if (e is InputEventKey escape && escape.Pressed && !escape.Echo && escape.Keycode == Key.Escape)
|
||||
@@ -501,6 +507,86 @@ public partial class Main : Godot.Control
|
||||
private static bool IsAdvanceAction(int action) => action is 4 or 5;
|
||||
private static bool HasAdvanceAction(int mask) => (mask & ((1 << 4) | (1 << 5))) != 0;
|
||||
|
||||
private void CaptureStallDiagnostic()
|
||||
{
|
||||
try
|
||||
{
|
||||
long nowMs = _clock.NowMs;
|
||||
GodotTraceSnapshot trace = _trace.Snapshot();
|
||||
var activeMovies = _movies
|
||||
.OrderBy(pair => pair.Key)
|
||||
.Select(pair => new
|
||||
{
|
||||
playback_id = pair.Key,
|
||||
resource_id = pair.Value.ResourceId,
|
||||
name = pair.Value.Name,
|
||||
asset_id = pair.Value.AssetId,
|
||||
stop_time_ms = pair.Value.Decoder.StopTimeMs,
|
||||
decoder_completed = pair.Value.Decoder.IsCompleted,
|
||||
decoder_failure = pair.Value.Decoder.Failure,
|
||||
frame_seen = _movieFrameSeen.Contains(pair.Key),
|
||||
completion_notified = _movieCompletionNotified.Contains(pair.Key),
|
||||
watchdog_ms = pair.Value.WatchdogMs,
|
||||
elapsed_ms = (long)Stopwatch.GetElapsedTime(pair.Value.StartedAtTimestamp).TotalMilliseconds,
|
||||
})
|
||||
.ToArray();
|
||||
var pendingMovies = _pendingMovies
|
||||
.OrderBy(pair => pair.Key)
|
||||
.Select(pair => new
|
||||
{
|
||||
playback_id = pair.Key,
|
||||
resource_id = pair.Value.ResourceId,
|
||||
name = pair.Value.Name,
|
||||
asset_id = pair.Value.AssetId,
|
||||
stop_time_ms = pair.Value.Decoder.StopTimeMs,
|
||||
decoder_completed = pair.Value.Decoder.IsCompleted,
|
||||
decoder_failure = pair.Value.Decoder.Failure,
|
||||
})
|
||||
.ToArray();
|
||||
var snapshot = new
|
||||
{
|
||||
format_version = 1,
|
||||
captured_utc = System.DateTimeOffset.UtcNow.ToString("O"),
|
||||
render_frame = _timelineFrame,
|
||||
clock_ms = nowMs,
|
||||
vm = new
|
||||
{
|
||||
steps = _vm.Steps,
|
||||
halt_reason = _vm.HaltReason,
|
||||
done = _done,
|
||||
trace,
|
||||
},
|
||||
host = _host.CaptureDiagnosticSnapshot(),
|
||||
gfx = _vm.Gfx.CaptureDiagnosticSnapshot(nowMs),
|
||||
active_movies = activeMovies,
|
||||
pending_movies = pendingMovies,
|
||||
};
|
||||
|
||||
string directory = ProjectSettings.GlobalizePath("user://diagnostics");
|
||||
System.IO.Directory.CreateDirectory(directory);
|
||||
string path = System.IO.Path.Combine(directory,
|
||||
$"stall-{System.DateTimeOffset.Now:yyyyMMdd-HHmmss-fff}.json");
|
||||
var jsonOptions = new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
};
|
||||
System.IO.File.WriteAllText(path, JsonSerializer.Serialize(snapshot, jsonOptions));
|
||||
string coordinate = trace.CurrentOffset >= 0
|
||||
? $"{System.IO.Path.GetFileNameWithoutExtension(trace.CurrentScript).ToUpperInvariant()}@0x{trace.CurrentOffset:x}"
|
||||
: trace.CurrentScript;
|
||||
string clipboard = $"{coordinate} · stall snapshot {path}";
|
||||
DisplayServer.ClipboardSet(clipboard);
|
||||
_status.Text = $"Diagnostic saved: {coordinate} (path copied)";
|
||||
GD.Print($"[diagnostic] stall snapshot {coordinate} -> {path}");
|
||||
}
|
||||
catch (System.Exception exception)
|
||||
{
|
||||
_status.Text = "Diagnostic capture failed; see Godot log.";
|
||||
GD.Print($"[diagnostic] stall snapshot failed: {exception}");
|
||||
}
|
||||
}
|
||||
|
||||
private void ToggleDebugSceneLauncher()
|
||||
{
|
||||
if (_debugSceneLauncher == null) return;
|
||||
@@ -680,6 +766,7 @@ public partial class Main : Godot.Control
|
||||
var surfaceTexture = rawObject != null
|
||||
? _host.ResolveSurfaceTexture(rawObject.SourceSlot, v.SurfaceResId)
|
||||
: null;
|
||||
bool movieSurfaceBound = rawObject != null && _host.IsMovieSurfaceBound(rawObject.SourceSlot);
|
||||
string outcome;
|
||||
if (v.SurfaceTransition is { } transition)
|
||||
{
|
||||
@@ -709,7 +796,8 @@ public partial class Main : Godot.Control
|
||||
}
|
||||
else
|
||||
{
|
||||
var texture = surfaceTexture ?? _host.ResolveResIdTexture(v.SurfaceResId);
|
||||
var texture = surfaceTexture
|
||||
?? (movieSurfaceBound ? null : _host.ResolveResIdTexture(v.SurfaceResId));
|
||||
if (texture == null) outcome = $"SKIP(resId=0x{v.SurfaceResId:x} UNRESOLVED)";
|
||||
else
|
||||
{
|
||||
@@ -889,6 +977,7 @@ public partial class Main : Godot.Control
|
||||
var texture = rawObject != null
|
||||
? _host.ResolveSurfaceTexture(rawObject.SourceSlot, source.SurfaceResId)
|
||||
: null;
|
||||
bool movieSurfaceBound = rawObject != null && _host.IsMovieSurfaceBound(rawObject.SourceSlot);
|
||||
if (source.SurfaceResId == 0 && texture == null)
|
||||
{
|
||||
if (source.Blend == BlendKind.Opaque) continue;
|
||||
@@ -897,7 +986,7 @@ public partial class Main : Godot.Control
|
||||
}
|
||||
else
|
||||
{
|
||||
texture ??= _host.ResolveResIdTexture(source.SurfaceResId);
|
||||
if (!movieSurfaceBound) texture ??= _host.ResolveResIdTexture(source.SurfaceResId);
|
||||
if (texture == null) continue;
|
||||
BlitLayer(texture.Value.Image, texture.Value.AssetId, source.ColorKey, source.Tint, source.TintStrength / 255f,
|
||||
source.SrcX, source.SrcY, source.W, source.H, affine, opacity, source.MultiplyTint,
|
||||
@@ -1177,17 +1266,18 @@ public partial class Main : Godot.Control
|
||||
CreateTween().TweenProperty(_bgm, "volume_db", targetDb, realDurationSeconds);
|
||||
}
|
||||
|
||||
public bool TryPlayMovie(byte[] mpegBytes, string assetName, long resourceId, int assetId,
|
||||
public bool TryPlayMovie(byte[] mpegBytes, string assetName, long playbackId,
|
||||
long resourceId, int assetId,
|
||||
out long? stopTimeMs)
|
||||
{
|
||||
stopTimeMs = null;
|
||||
try
|
||||
{
|
||||
var payload = new Age.Engine.Sys4.MoviePayload(assetName, mpegBytes);
|
||||
var runtime = MovieRuntime.Open(assetName, assetId, payload, _movieDecoderFactory);
|
||||
var runtime = MovieRuntime.Open(assetName, assetId, resourceId, payload, _movieDecoderFactory);
|
||||
stopTimeMs = runtime.Decoder.StopTimeMs;
|
||||
while (!_pendingMovies.TryAdd(resourceId, runtime))
|
||||
if (_pendingMovies.TryRemove(resourceId, out var prior)) prior.Decoder.Dispose();
|
||||
while (!_pendingMovies.TryAdd(playbackId, runtime))
|
||||
if (_pendingMovies.TryRemove(playbackId, out var prior)) prior.Decoder.Dispose();
|
||||
return true;
|
||||
}
|
||||
catch (System.Exception e)
|
||||
@@ -1199,50 +1289,52 @@ public partial class Main : Godot.Control
|
||||
|
||||
private void AdoptPendingMovies()
|
||||
{
|
||||
foreach (var (resourceId, _) in _pendingMovies)
|
||||
foreach (var (playbackId, _) in _pendingMovies)
|
||||
{
|
||||
if (!_pendingMovies.TryRemove(resourceId, out var movie)) continue;
|
||||
if (_movies.Remove(resourceId, out var prior)) prior.Decoder.Dispose();
|
||||
_movies[resourceId] = movie;
|
||||
_movieCompletionNotified.Remove(resourceId);
|
||||
GD.Print($"movie started {movie.Name} ({movie.Decoder.StopTimeMs?.ToString() ?? "unknown"} ms from VFS)");
|
||||
if (!_pendingMovies.TryRemove(playbackId, out var movie)) continue;
|
||||
if (_movies.Remove(playbackId, out var prior)) prior.Decoder.Dispose();
|
||||
_movies[playbackId] = movie;
|
||||
_movieCompletionNotified.Remove(playbackId);
|
||||
GD.Print($"movie started {movie.Name} playback={playbackId} " +
|
||||
$"({movie.Decoder.StopTimeMs?.ToString() ?? "unknown"} ms from VFS)");
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateMovieFrames()
|
||||
{
|
||||
if (_host == null) return;
|
||||
foreach (var (resourceId, movie) in _movies)
|
||||
foreach (var (playbackId, movie) in _movies)
|
||||
{
|
||||
if (movie.Decoder.TryTakeFrame(out var frame))
|
||||
{
|
||||
_host.PublishMovieFrame(resourceId, movie.Name, movie.AssetId, frame);
|
||||
if (_movieFrameSeen.Add(resourceId))
|
||||
GD.Print($"movie first frame {movie.Name}: {frame.Width}x{frame.Height} RGBA8 at render frame {_timelineFrame}");
|
||||
_host.PublishMovieFrame(playbackId, movie.Name, movie.AssetId, frame);
|
||||
if (_movieFrameSeen.Add(playbackId))
|
||||
GD.Print($"movie first frame {movie.Name} playback={playbackId}: " +
|
||||
$"{frame.Width}x{frame.Height} RGBA8 at render frame {_timelineFrame}");
|
||||
}
|
||||
bool watchdogExpired = Stopwatch.GetElapsedTime(movie.StartedAtTimestamp).TotalMilliseconds
|
||||
>= movie.WatchdogMs;
|
||||
if ((movie.Decoder.IsCompleted || watchdogExpired) && _movieCompletionNotified.Add(resourceId))
|
||||
if ((movie.Decoder.IsCompleted || watchdogExpired) && _movieCompletionNotified.Add(playbackId))
|
||||
{
|
||||
if (movie.Decoder.Failure is { } failure)
|
||||
GD.Print($"movie decode failed {movie.Name}: {failure}");
|
||||
if (watchdogExpired && !movie.Decoder.IsCompleted)
|
||||
GD.Print($"movie completion watchdog {movie.Name}: forcing completion after {movie.WatchdogMs} ms");
|
||||
_host.NotifyMovieCompleted(resourceId);
|
||||
_host.NotifyMovieCompleted(playbackId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void StopMovie(long resourceId)
|
||||
public void StopMovie(long playbackId)
|
||||
{
|
||||
if (_pendingMovies.TryRemove(resourceId, out var pending)) pending.Decoder.Dispose();
|
||||
if (_movies.Remove(resourceId, out var movie))
|
||||
if (_pendingMovies.TryRemove(playbackId, out var pending)) pending.Decoder.Dispose();
|
||||
if (_movies.Remove(playbackId, out var movie))
|
||||
{
|
||||
movie.Decoder.Dispose();
|
||||
GD.Print($"movie stopped {movie.Name} at render frame {_timelineFrame}");
|
||||
GD.Print($"movie stopped {movie.Name} playback={playbackId} at render frame {_timelineFrame}");
|
||||
}
|
||||
_movieFrameSeen.Remove(resourceId);
|
||||
_movieCompletionNotified.Remove(resourceId);
|
||||
_movieFrameSeen.Remove(playbackId);
|
||||
_movieCompletionNotified.Remove(playbackId);
|
||||
}
|
||||
|
||||
public void AppendLine(string text) => _text.Text += text + "\n";
|
||||
|
||||
@@ -3,15 +3,15 @@ using System.Diagnostics;
|
||||
using Age.Engine.Sys4;
|
||||
|
||||
/// <summary>Presentation-side ownership for one decoder plus its fail-safe completion deadline.</summary>
|
||||
internal sealed record MovieRuntime(string Name, int AssetId, IMovieDecoder Decoder,
|
||||
internal sealed record MovieRuntime(string Name, int AssetId, long ResourceId, IMovieDecoder Decoder,
|
||||
long StartedAtTimestamp, long WatchdogMs)
|
||||
{
|
||||
public static MovieRuntime Open(string name, int assetId, MoviePayload payload,
|
||||
public static MovieRuntime Open(string name, int assetId, long resourceId, MoviePayload payload,
|
||||
IMovieDecoderFactory factory)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(factory);
|
||||
IMovieDecoder decoder = factory.Open(payload);
|
||||
return new MovieRuntime(name, assetId, decoder, Stopwatch.GetTimestamp(),
|
||||
return new MovieRuntime(name, assetId, resourceId, decoder, Stopwatch.GetTimestamp(),
|
||||
decoder.StopTimeMs is >= 0 and var stopTime
|
||||
? Math.Clamp(stopTime + 2000, 5000, 300000)
|
||||
: 30000);
|
||||
|
||||
182
godot/MovieSurfaceRegistry.cs
Normal file
182
godot/MovieSurfaceRegistry.cs
Normal file
@@ -0,0 +1,182 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Age.Engine.Sys4;
|
||||
|
||||
internal readonly record struct MovieSurfaceBinding(long PlaybackId, long ResourceId, int SurfaceSlot);
|
||||
internal sealed record MovieSurfaceFrame(RgbaImage Image, string Name, int AssetId);
|
||||
public sealed record MovieSurfaceDiagnostic(int SurfaceSlot, long PlaybackId, long ResourceId,
|
||||
bool Completed, bool HasFrame, string? Name);
|
||||
internal enum MovieSurfaceReleaseKind { NotBound, Active, Released }
|
||||
internal readonly record struct MovieSurfaceRelease(MovieSurfaceReleaseKind Kind,
|
||||
MovieSurfaceBinding Binding);
|
||||
|
||||
/// <summary>Instance-keyed movie/surface ownership. A resource may be played more than once concurrently;
|
||||
/// completion and frames therefore belong to a playback, never to the shared asset id.</summary>
|
||||
internal sealed class MovieSurfaceRegistry
|
||||
{
|
||||
private readonly object _lock = new();
|
||||
private readonly Dictionary<int, MovieSurfaceBinding> _bySurface = new();
|
||||
private readonly Dictionary<long, MovieSurfaceBinding> _byPlayback = new();
|
||||
private readonly Dictionary<long, MovieSurfaceFrame> _frames = new();
|
||||
private readonly HashSet<long> _completed = new();
|
||||
private readonly HashSet<long> _knownMovieResources = new();
|
||||
private long _nextPlaybackId;
|
||||
|
||||
public MovieSurfaceBinding Begin(int surfaceSlot, long resourceId,
|
||||
out MovieSurfaceBinding? replaced)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
replaced = _bySurface.TryGetValue(surfaceSlot, out var prior) ? prior : null;
|
||||
if (replaced.HasValue) RemoveLocked(prior);
|
||||
var binding = new MovieSurfaceBinding(++_nextPlaybackId, resourceId, surfaceSlot);
|
||||
_knownMovieResources.Add(resourceId);
|
||||
_bySurface[surfaceSlot] = binding;
|
||||
_byPlayback[binding.PlaybackId] = binding;
|
||||
return binding;
|
||||
}
|
||||
}
|
||||
|
||||
public bool PublishFrame(long playbackId, RgbaImage image, string name, int assetId)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (!_byPlayback.ContainsKey(playbackId)) return false;
|
||||
_frames[playbackId] = new MovieSurfaceFrame(image, name, assetId);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public bool Complete(long playbackId)
|
||||
{
|
||||
lock (_lock)
|
||||
return _byPlayback.ContainsKey(playbackId) && _completed.Add(playbackId);
|
||||
}
|
||||
|
||||
public bool IsActive(int surfaceSlot)
|
||||
{
|
||||
lock (_lock)
|
||||
return _bySurface.TryGetValue(surfaceSlot, out var binding)
|
||||
&& !_completed.Contains(binding.PlaybackId);
|
||||
}
|
||||
|
||||
public bool IsBound(int surfaceSlot)
|
||||
{
|
||||
lock (_lock) return _bySurface.ContainsKey(surfaceSlot);
|
||||
}
|
||||
|
||||
public bool HasActivePlayback
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_lock)
|
||||
return _byPlayback.Keys.Any(playbackId => !_completed.Contains(playbackId));
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryResolveSurface(int surfaceSlot, out MovieSurfaceFrame? frame)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_bySurface.TryGetValue(surfaceSlot, out var binding)
|
||||
&& _frames.TryGetValue(binding.PlaybackId, out var found))
|
||||
{
|
||||
frame = found;
|
||||
return true;
|
||||
}
|
||||
frame = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryResolveResource(long resourceId, out MovieSurfaceFrame? frame)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
foreach (var binding in _byPlayback.Values
|
||||
.Where(binding => binding.ResourceId == resourceId)
|
||||
.OrderByDescending(binding => binding.PlaybackId))
|
||||
{
|
||||
if (!_frames.TryGetValue(binding.PlaybackId, out var found)) continue;
|
||||
frame = found;
|
||||
return true;
|
||||
}
|
||||
frame = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Catalog ids are immutable within a mounted resource set. Remembering that an id entered
|
||||
/// the typed movie path prevents a cleanup-frame render snapshot from treating its .AGF-named MPEG
|
||||
/// payload as a still image after the last live surface binding has been detached.</summary>
|
||||
public bool IsKnownMovieResource(long resourceId)
|
||||
{
|
||||
lock (_lock) return _knownMovieResources.Contains(resourceId);
|
||||
}
|
||||
|
||||
public MovieSurfaceRelease ReleaseIfCompleted(int surfaceSlot)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (!_bySurface.TryGetValue(surfaceSlot, out var binding))
|
||||
return new MovieSurfaceRelease(MovieSurfaceReleaseKind.NotBound, default);
|
||||
if (!_completed.Contains(binding.PlaybackId))
|
||||
return new MovieSurfaceRelease(MovieSurfaceReleaseKind.Active, binding);
|
||||
RemoveLocked(binding);
|
||||
return new MovieSurfaceRelease(MovieSurfaceReleaseKind.Released, binding);
|
||||
}
|
||||
}
|
||||
|
||||
public bool Abandon(long playbackId, out MovieSurfaceBinding binding)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (!_byPlayback.TryGetValue(playbackId, out binding)) return false;
|
||||
RemoveLocked(binding);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<MovieSurfaceBinding> ReleaseRange(int firstSlot, int count)
|
||||
{
|
||||
int end = checked(firstSlot + count);
|
||||
lock (_lock)
|
||||
{
|
||||
var released = _bySurface.Values
|
||||
.Where(binding => binding.SurfaceSlot >= firstSlot && binding.SurfaceSlot < end)
|
||||
.OrderBy(binding => binding.SurfaceSlot)
|
||||
.ToArray();
|
||||
foreach (var binding in released) RemoveLocked(binding);
|
||||
return released;
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<MovieSurfaceDiagnostic> Snapshot()
|
||||
{
|
||||
lock (_lock)
|
||||
return _bySurface.Values
|
||||
.OrderBy(binding => binding.SurfaceSlot)
|
||||
.Select(binding => new MovieSurfaceDiagnostic(
|
||||
binding.SurfaceSlot,
|
||||
binding.PlaybackId,
|
||||
binding.ResourceId,
|
||||
_completed.Contains(binding.PlaybackId),
|
||||
_frames.ContainsKey(binding.PlaybackId),
|
||||
_frames.TryGetValue(binding.PlaybackId, out var frame) ? frame.Name : null))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
public IReadOnlyList<long> CompletedPlaybackIds()
|
||||
{
|
||||
lock (_lock) return _completed.OrderBy(id => id).ToArray();
|
||||
}
|
||||
|
||||
private void RemoveLocked(MovieSurfaceBinding binding)
|
||||
{
|
||||
_bySurface.Remove(binding.SurfaceSlot);
|
||||
_byPlayback.Remove(binding.PlaybackId);
|
||||
_frames.Remove(binding.PlaybackId);
|
||||
_completed.Remove(binding.PlaybackId);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user