Implement SC0000 movie playback lifecycle

This commit is contained in:
gamer147
2026-07-11 12:27:51 -04:00
parent 233f791c10
commit 024accd8d1
15 changed files with 711 additions and 31 deletions

View File

@@ -0,0 +1,278 @@
using System;
using System.IO;
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
using System.Threading;
using Age.Engine.Sys4;
[ComVisible(true), Guid("0579154A-2B53-4994-B0D0-E773148EFF85"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
internal interface ISampleGrabberCB
{
[PreserveSig] int SampleCB(double time, IntPtr sample);
[PreserveSig] int BufferCB(double time, IntPtr buffer, int length);
}
/// <summary>
/// Windows DirectShow bridge for AGE's MPEG program-stream movie payloads. The payload is supplied by
/// IAssetStore; a private temporary file only adapts those owned bytes to DirectShow's stock MPEG source.
/// Decoded RGB32 samples are copied into process memory and consumed by Godot's retained compositor.
/// </summary>
[SupportedOSPlatform("windows")]
internal sealed class DirectShowMovieDecoder : IDisposable, ISampleGrabberCB
{
private readonly byte[] _payload;
private readonly Thread _thread;
private readonly ManualResetEventSlim _ready = new(false);
private readonly object _frameLock = new();
private byte[]? _latestRgba;
private int _width, _height;
private bool _bottomUp;
private volatile bool _stopping;
private volatile bool _completed;
private string? _error;
private string? _tempPath;
private object? _graphObject;
private IMediaControl? _control;
public bool IsCompleted => _completed;
public DirectShowMovieDecoder(MoviePayload movie)
{
_payload = movie.Bytes;
_thread = new Thread(DecodeThread) { IsBackground = true, Name = $"AGE movie {movie.Name}" };
_thread.SetApartmentState(ApartmentState.MTA);
_thread.Start();
// Native 0x236 builds the graph synchronously. Preserve that initialization boundary while keeping
// actual playback asynchronous; timeout becomes a deterministic load error rather than a VM hang.
if (!_ready.Wait(TimeSpan.FromSeconds(10))) throw new InvalidOperationException("movie graph initialization timed out");
if (_error != null) throw new InvalidOperationException(_error);
}
public bool TryTakeFrame(out RgbaImage frame)
{
lock (_frameLock)
{
if (_latestRgba == null) { frame = default!; return false; }
frame = new RgbaImage(_width, _height, _latestRgba);
_latestRgba = null;
return true;
}
}
private void DecodeThread()
{
int co = CoInitializeEx(IntPtr.Zero, 0); // COINIT_MULTITHREADED; DirectShow graph and callbacks share this apartment.
try
{
_tempPath = Path.Combine(Path.GetTempPath(), $"age-movie-{Guid.NewGuid():N}.mpg");
File.WriteAllBytes(_tempPath, _payload);
_graphObject = Activator.CreateInstance(Type.GetTypeFromCLSID(CLSID_FilterGraph, throwOnError: true)!)!;
var graph = (IGraphBuilder)_graphObject;
var grabberObject = Activator.CreateInstance(Type.GetTypeFromCLSID(CLSID_SampleGrabber, true)!)!;
var nullObject = Activator.CreateInstance(Type.GetTypeFromCLSID(CLSID_NullRenderer, true)!)!;
var grabberFilter = (IBaseFilter)grabberObject;
var nullFilter = (IBaseFilter)nullObject;
var grabber = (ISampleGrabber)grabberObject;
Check(graph.AddFilter(grabberFilter, "AGE Sample Grabber"), "add sample grabber");
Check(graph.AddFilter(nullFilter, "AGE Null Renderer"), "add null renderer");
var requested = new AMMediaType { MajorType = MEDIATYPE_Video, SubType = MEDIASUBTYPE_RGB32,
FormatType = FORMAT_VideoInfo };
Check(grabber.SetMediaType(ref requested), "request RGB32 movie output");
Check(graph.AddSourceFilter(_tempPath, "AGE VFS MPEG Source", out var source), "open MPEG source");
var sourceOut = FirstPin(source, PinDirection.Output);
var grabberIn = FirstPin(grabberFilter, PinDirection.Input);
var grabberOut = FirstPin(grabberFilter, PinDirection.Output);
var nullIn = FirstPin(nullFilter, PinDirection.Input);
// Intelligent connection inserts only the MPEG splitter/video decoder needed to reach RGB32.
// The splitter's audio pin remains unrendered, keeping movie audio outside this slice.
Check(graph.Connect(sourceOut, grabberIn), "connect MPEG video decoder");
Check(graph.ConnectDirect(grabberOut, nullIn, IntPtr.Zero), "connect null renderer");
var connected = new AMMediaType();
Check(grabber.GetConnectedMediaType(ref connected), "query movie format");
try
{
if (connected.FormatPtr == IntPtr.Zero) throw new InvalidDataException("movie decoder returned no VIDEOINFOHEADER");
var vi = Marshal.PtrToStructure<VideoInfoHeader>(connected.FormatPtr);
_width = vi.BitmapInfo.Width;
_bottomUp = vi.BitmapInfo.Height > 0;
_height = Math.Abs(vi.BitmapInfo.Height);
if (_width <= 0 || _height <= 0) throw new InvalidDataException($"invalid movie dimensions {_width}x{_height}");
}
finally { FreeMediaType(ref connected); }
Check(grabber.SetOneShot(false), "configure continuous samples");
Check(grabber.SetBufferSamples(false), "disable redundant frame buffering");
Check(grabber.SetCallback(this, 1), "install decoded frame callback");
_control = (IMediaControl)_graphObject;
Check(_control.Run(), "start movie graph");
int stateHr = _control.GetState(5000, out int graphState);
if (stateHr < 0) Check(stateHr, "wait for running movie graph");
if (graphState != 2) throw new InvalidOperationException($"movie graph entered unexpected state {graphState}");
_ready.Set();
var mediaEvent = (IMediaEvent)_graphObject;
while (!_stopping)
{
int eventHr = mediaEvent.WaitForCompletion(0, out _);
if (eventHr >= 0) { _completed = true; break; }
if (eventHr != E_ABORT) Check(eventHr, "poll movie completion");
Thread.Sleep(10);
}
}
catch (Exception e)
{
_error = $"DirectShow MPEG decode failed: {e}";
_ready.Set();
}
finally
{
try { _control?.Stop(); } catch { }
ReleaseCom(_control); _control = null;
ReleaseCom(_graphObject); _graphObject = null;
if (_tempPath != null) try { File.Delete(_tempPath); } catch { }
if (co >= 0) CoUninitialize();
}
}
public int SampleCB(double sampleTime, IntPtr sample) => 0;
public int BufferCB(double sampleTime, IntPtr buffer, int length)
{ AcceptBgra(buffer, length); return 0; }
private void AcceptBgra(IntPtr buffer, int length)
{
int rowBytes = checked(_width * 4);
if (buffer == IntPtr.Zero || length < rowBytes * _height) return;
byte[] bgra = new byte[rowBytes * _height];
Marshal.Copy(buffer, bgra, 0, bgra.Length);
byte[] rgba = new byte[bgra.Length];
for (int y = 0; y < _height; y++)
{
int src = (_bottomUp ? _height - 1 - y : y) * rowBytes;
int dst = y * rowBytes;
for (int x = 0; x < _width; x++, src += 4, dst += 4)
{
rgba[dst] = bgra[src + 2]; rgba[dst + 1] = bgra[src + 1];
rgba[dst + 2] = bgra[src]; rgba[dst + 3] = 255;
}
}
lock (_frameLock) _latestRgba = rgba; // newest decoded frame wins if Godot renders more slowly
}
public void Dispose()
{
_stopping = true;
if (_thread.IsAlive) _thread.Join(TimeSpan.FromSeconds(3));
_ready.Dispose();
}
private static IPin FirstPin(IBaseFilter filter, PinDirection direction)
{
Check(filter.EnumPins(out var pins), "enumerate source pins");
var one = new IPin[1];
while (pins.Next(1, one, IntPtr.Zero) == 0)
{
Check(one[0].QueryDirection(out var found), "query source pin direction");
if (found == direction) return one[0];
}
throw new InvalidOperationException($"DirectShow source has no {direction} pin");
}
private static void Check(int hr, string operation)
{ if (hr < 0) throw new COMException($"{operation} failed (HRESULT 0x{hr:x8})", hr); }
private static void ReleaseCom(object? value)
{ if (value != null && Marshal.IsComObject(value)) try { Marshal.ReleaseComObject(value); } catch { } }
private static void FreeMediaType(ref AMMediaType mt)
{
if (mt.FormatPtr != IntPtr.Zero) { Marshal.FreeCoTaskMem(mt.FormatPtr); mt.FormatPtr = IntPtr.Zero; }
if (mt.UnknownPtr != IntPtr.Zero) { Marshal.Release(mt.UnknownPtr); mt.UnknownPtr = IntPtr.Zero; }
}
private static readonly Guid CLSID_FilterGraph = new("E436EBB3-524F-11CE-9F53-0020AF0BA770");
private static readonly Guid CLSID_SampleGrabber = new("C1F400A0-3F08-11D3-9F0B-006008039E37");
private static readonly Guid CLSID_NullRenderer = new("C1F400A4-3F08-11D3-9F0B-006008039E37");
private static readonly Guid MEDIATYPE_Video = new("73646976-0000-0010-8000-00AA00389B71");
private static readonly Guid MEDIASUBTYPE_RGB32 = new("E436EB7E-524F-11CE-9F53-0020AF0BA770");
private static readonly Guid FORMAT_VideoInfo = new("05589F80-C356-11CE-BF01-00AA0055595A");
private const int E_ABORT = unchecked((int)0x80004004);
[DllImport("ole32.dll")] private static extern int CoInitializeEx(IntPtr reserved, uint coInit);
[DllImport("ole32.dll")] private static extern void CoUninitialize();
private enum PinDirection { Input, Output }
[StructLayout(LayoutKind.Sequential)] private struct AMMediaType
{
public Guid MajorType, SubType; [MarshalAs(UnmanagedType.Bool)] public bool FixedSizeSamples;
[MarshalAs(UnmanagedType.Bool)] public bool TemporalCompression; public int SampleSize;
public Guid FormatType; public IntPtr UnknownPtr; public int FormatSize; public IntPtr FormatPtr;
}
[StructLayout(LayoutKind.Sequential)] private struct DsRect { public int Left, Top, Right, Bottom; }
[StructLayout(LayoutKind.Sequential)] private struct BitmapInfoHeader
{ public int Size, Width, Height; public short Planes, BitCount; public int Compression, ImageSize, XPelsPerMeter, YPelsPerMeter, ColorsUsed, ColorsImportant; }
[StructLayout(LayoutKind.Sequential)] private struct VideoInfoHeader
{ public DsRect Source, Target; public int BitRate, BitErrorRate; public long AvgTimePerFrame; public BitmapInfoHeader BitmapInfo; }
[ComImport, Guid("6B652FFF-11FE-4FCE-92AD-0266B5D7C78F"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
private interface ISampleGrabber
{
[PreserveSig] int SetOneShot([MarshalAs(UnmanagedType.Bool)] bool value);
[PreserveSig] int SetMediaType(ref AMMediaType type);
[PreserveSig] int GetConnectedMediaType(ref AMMediaType type);
[PreserveSig] int SetBufferSamples([MarshalAs(UnmanagedType.Bool)] bool value);
[PreserveSig] int GetCurrentBuffer(ref int size, IntPtr buffer);
[PreserveSig] int GetCurrentSample(out IntPtr sample);
[PreserveSig] int SetCallback(ISampleGrabberCB callback, int method);
}
[ComImport, Guid("56A868A9-0AD4-11CE-B03A-0020AF0BA770"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
private interface IGraphBuilder
{
[PreserveSig] int AddFilter(IBaseFilter filter, [MarshalAs(UnmanagedType.LPWStr)] string name);
[PreserveSig] int RemoveFilter(IBaseFilter filter); [PreserveSig] int EnumFilters(out IntPtr filters);
[PreserveSig] int FindFilterByName([MarshalAs(UnmanagedType.LPWStr)] string name, out IBaseFilter filter);
[PreserveSig] int ConnectDirect(IPin output, IPin input, IntPtr mediaType);
[PreserveSig] int Reconnect(IPin pin); [PreserveSig] int Disconnect(IPin pin); [PreserveSig] int SetDefaultSyncSource();
[PreserveSig] int Connect(IPin output, IPin input); [PreserveSig] int Render(IPin output);
[PreserveSig] int RenderFile([MarshalAs(UnmanagedType.LPWStr)] string file, [MarshalAs(UnmanagedType.LPWStr)] string? playList);
[PreserveSig] int AddSourceFilter([MarshalAs(UnmanagedType.LPWStr)] string file, [MarshalAs(UnmanagedType.LPWStr)] string name, out IBaseFilter filter);
[PreserveSig] int SetLogFile(IntPtr file); [PreserveSig] int Abort(); [PreserveSig] int ShouldOperationContinue();
}
[ComImport, Guid("56A86895-0AD4-11CE-B03A-0020AF0BA770"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
private interface IBaseFilter
{
[PreserveSig] int GetClassID(out Guid clsid); [PreserveSig] int Stop(); [PreserveSig] int Pause();
[PreserveSig] int Run(long start); [PreserveSig] int GetState(int timeout, out int state);
[PreserveSig] int SetSyncSource(IntPtr clock); [PreserveSig] int GetSyncSource(out IntPtr clock);
[PreserveSig] int EnumPins(out IEnumPins pins); [PreserveSig] int FindPin([MarshalAs(UnmanagedType.LPWStr)] string id, out IPin pin);
[PreserveSig] int QueryFilterInfo(IntPtr info); [PreserveSig] int JoinFilterGraph(IntPtr graph, [MarshalAs(UnmanagedType.LPWStr)] string name);
[PreserveSig] int QueryVendorInfo([MarshalAs(UnmanagedType.LPWStr)] out string vendor);
}
[ComImport, Guid("56A86892-0AD4-11CE-B03A-0020AF0BA770"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
private interface IEnumPins
{ [PreserveSig] int Next(int count, [Out, MarshalAs(UnmanagedType.LPArray, SizeParamIndex = 0)] IPin[] pins, IntPtr fetched); [PreserveSig] int Skip(int count); [PreserveSig] int Reset(); [PreserveSig] int Clone(out IEnumPins clone); }
[ComImport, Guid("56A86891-0AD4-11CE-B03A-0020AF0BA770"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
private interface IPin
{
[PreserveSig] int Connect(IPin receive, IntPtr mediaType); [PreserveSig] int ReceiveConnection(IPin connector, IntPtr mediaType);
[PreserveSig] int Disconnect(); [PreserveSig] int ConnectedTo(out IPin pin); [PreserveSig] int ConnectionMediaType(IntPtr mediaType);
[PreserveSig] int QueryPinInfo(IntPtr info); [PreserveSig] int QueryDirection(out PinDirection direction);
[PreserveSig] int QueryId([MarshalAs(UnmanagedType.LPWStr)] out string id); [PreserveSig] int QueryAccept(IntPtr mediaType);
[PreserveSig] int EnumMediaTypes(out IntPtr types); [PreserveSig] int QueryInternalConnections(IntPtr pins, ref int count);
[PreserveSig] int EndOfStream(); [PreserveSig] int BeginFlush(); [PreserveSig] int EndFlush(); [PreserveSig] int NewSegment(long start, long stop, double rate);
}
[ComImport, Guid("56A868B1-0AD4-11CE-B03A-0020AF0BA770"), InterfaceType(ComInterfaceType.InterfaceIsDual)]
private interface IMediaControl
{ [PreserveSig] int Run(); [PreserveSig] int Pause(); [PreserveSig] int Stop(); [PreserveSig] int GetState(int timeout, out int state); }
[ComImport, Guid("56A868B6-0AD4-11CE-B03A-0020AF0BA770"), InterfaceType(ComInterfaceType.InterfaceIsDual)]
private interface IMediaEvent
{
[PreserveSig] int GetEventHandle(out IntPtr eventHandle);
[PreserveSig] int GetEvent(out int eventCode, out IntPtr param1, out IntPtr param2, int timeoutMs);
[PreserveSig] int WaitForCompletion(int timeoutMs, out int eventCode);
[PreserveSig] int CancelDefaultHandling(int eventCode);
[PreserveSig] int RestoreDefaultHandling(int eventCode);
[PreserveSig] int FreeEventParams(int eventCode, IntPtr param1, IntPtr param2);
}
}

View File

@@ -11,6 +11,9 @@ public sealed class GodotAdvHost : IHost
private readonly string _scene; // e.g. "SC0000" — for section_base
private readonly object _imageLock = new();
private readonly Dictionary<int, RgbaImage?> _images = new(); // raw catalog id -> decoded pixels
private readonly Dictionary<long, (RgbaImage Image, string Name, int RawIndex)> _movieFrames = 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
// slot -> dims. Slot 0 is the primary/screen surface (800x600), normally created at engine boot which
// the single-scene harness skips; seed it so the first CG's anchor math stays correct (not 0x0).
@@ -143,13 +146,13 @@ public sealed class GodotAdvHost : IHost
public void WaitForForegroundTransition(GfxState gfx)
{
int started = gfx.StartForegroundTransitions(_clock.NowMs);
if (started == 0 && !gfx.HasActiveTimedPresentation(_clock.NowMs)) return;
if (started == 0 && !gfx.HasActiveTimedPresentation(_clock.NowMs) && !HasActiveMoviePresentation()) return;
_foregroundGfx = gfx;
System.Threading.Interlocked.Exchange(ref _transitionStartedAtMs, _clock.NowMs);
IsTransitionWaiting = true;
_timeline?.State("transition-start", new() { ["count"] = started });
int lastBucket = -1;
while (gfx.HasActiveTimedPresentation(_clock.NowMs) && !_stopping)
while ((gfx.HasActiveTimedPresentation(_clock.NowMs) || HasActiveMoviePresentation()) && !_stopping)
{
var active = gfx.SnapshotForegroundTransitions(_clock.NowMs);
int bucket = active.Count == 0 ? 100 : (int)System.Math.Floor(active[0].Progress * 10);
@@ -256,11 +259,93 @@ public sealed class GodotAdvHost : IHost
/// from the loose-first asset store.</summary>
public (RgbaImage Image, string Name, int AssetId)? ResolveResIdTexture(long resId)
{
lock (_imageLock)
if (_movieFrames.TryGetValue(resId, out var movie))
return (movie.Image, movie.Name, movie.RawIndex);
var asset = _res.ResolveTexture(_scene, resId);
var image = asset != null ? Decode(asset) : null;
return asset != null && image != null ? (image, asset.Name, asset.RawIndex) : null;
}
public void PlayMovieToSurface(long resourceId, int surfaceSlot, long movieFlags, long syncMask)
{
var asset = _res.Resolve(_scene, resourceId);
if (asset == null) { Godot.GD.Print($"movie unresolved {_scene}:0x{resourceId:x}"); return; }
try
{
var movie = _res.ReadMovie(asset);
ReleaseSurface(surfaceSlot);
lock (_imageLock)
{
_movieBySurface[surfaceSlot] = resourceId;
_completedMovies.Remove(resourceId);
}
_slotDims[surfaceSlot] = (800, 600); // SC0000 creates this native-sized surface immediately beforehand.
_timeline?.Event("movie-start", new()
{
["resource"] = resourceId, ["surface"] = surfaceSlot, ["file"] = movie.Name,
["flags"] = movieFlags, ["sync_mask"] = syncMask,
});
_main.CallDeferred("PlayMovie", movie.Bytes, movie.Name, resourceId, asset.RawIndex);
}
catch (System.Exception e) { Godot.GD.Print($"movie read failed {asset.Name}: {e.Message}"); }
}
public void ReleaseSurface(int slot)
{
long resourceId;
lock (_imageLock)
{
if (!_movieBySurface.Remove(slot, out resourceId)) 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);
}
_timeline?.Event("movie-stop", new() { ["resource"] = resourceId, ["surface"] = slot });
_main.CallDeferred("StopMovie", resourceId);
}
// 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 rawIndex, RgbaImage frame)
{
lock (_imageLock) _movieFrames[resourceId] = (frame, name, rawIndex);
System.Threading.Interlocked.Exchange(ref _presentRequested, 1);
}
public void NotifyMovieCompleted(long resourceId)
{
lock (_imageLock)
if (!_completedMovies.Add(resourceId)) return;
_timeline?.Event("movie-complete", new() { ["resource"] = resourceId });
_frameSignal.Set();
}
private bool HasActiveMoviePresentation()
{
lock (_imageLock)
foreach (long resourceId in _movieBySurface.Values)
if (!_completedMovies.Contains(resourceId)) return true;
return false;
}
public bool TryGetActiveMovieFrame(out RgbaImage frame)
{
lock (_imageLock)
foreach (long resourceId in _movieBySurface.Values)
if (_movieFrames.TryGetValue(resourceId, out var movie))
{
frame = movie.Image;
return true;
}
frame = default!;
return false;
}
private RgbaImage? Decode(AssetEntry asset)
{
lock (_imageLock)

View File

@@ -1,4 +1,5 @@
using System.Collections.Generic;
using System.Runtime.Versioning;
using System.Text.Json;
using System.Threading.Tasks;
using Godot;
@@ -8,6 +9,7 @@ using Age.Engine.Sys4;
using Age.Engine.Vm;
using Script = Age.Engine.Model.Script; // disambiguate from Godot.Script
[SupportedOSPlatform("windows")]
public partial class Main : Godot.Control
{
private TextureRect _screenView = null!; // shows the composited screen backbuffer
@@ -22,6 +24,8 @@ public partial class Main : Godot.Control
private VirtualMachine _vm = null!;
private GodotAdvHost _host = null!;
private readonly Age.Engine.Hosting.FrameClock _clock = new();
private readonly System.Collections.Generic.Dictionary<long, MovieRuntime> _movies = new();
private readonly System.Collections.Generic.HashSet<long> _movieFrameSeen = new();
private GodotTraceSink _trace = null!;
private Age.Engine.Diagnostics.HistogramTraceSink? _hist; // --trace-histogram: profile the real run
private string? _histFile;
@@ -214,8 +218,10 @@ public partial class Main : Godot.Control
public override void _Process(double delta)
{
_clock.Advance(delta);
_timeline?.SetFrame(++_timelineFrame, _clock.NowMs);
_timelineFrame++;
_timeline?.SetFrame(_timelineFrame, _clock.NowMs);
_host?.PulseFrame();
UpdateMovieFrames();
if (!_selftest && _vm != null && _host != null && _host.ShouldRecomposite())
Recomposite(); // native publishes retained mutations only at present/service boundaries
if (!_selftest && _host != null) UpdateAdvTextPresentation();
@@ -266,7 +272,12 @@ public partial class Main : Godot.Control
_host.SignalInput();
}
public override void _ExitTree() { DumpHistogram(); _host?.Stop(); _timeline?.Dispose(); }
public override void _ExitTree()
{
DumpHistogram(); _host?.Stop(); _timeline?.Dispose();
foreach (var movie in _movies.Values) movie.Decoder.Dispose();
_movies.Clear();
}
// Write the real-run op/call-site histogram to --trace-histogram <file>. Idempotent; called when the
// scene ends or the window closes (the opening parks at wait-for-input, so closing is the usual trigger).
@@ -295,6 +306,11 @@ public partial class Main : Godot.Control
private void Recomposite()
{
_screen.Fill(new Color(0, 0, 0, 0));
// SC0000's movie is an independently updating retained background. The script prepares later
// static surfaces before its 0x21c yield; those layers composite above the current movie sample.
if (_host.TryGetActiveMovieFrame(out var movieFrame) &&
movieFrame.Width == _screen.GetWidth() && movieFrame.Height == _screen.GetHeight())
_screen.SetData(movieFrame.Width, movieFrame.Height, false, Image.Format.Rgba8, movieFrame.Pixels);
_speaker.Visible = false;
System.Collections.Generic.Dictionary<long, string>? decisions = _gfxLogPath != null || _timeline != null ? new() : null;
int z = 0;
@@ -566,6 +582,49 @@ public partial class Main : Godot.Control
CreateTween().TweenProperty(_bgm, "volume_db", targetDb, realDurationSeconds);
}
public void PlayMovie(byte[] mpegBytes, string assetName, long resourceId, int rawIndex)
{
if (_movies.Remove(resourceId, out var prior)) prior.Decoder.Dispose();
try
{
var payload = new Age.Engine.Sys4.MoviePayload(assetName, mpegBytes);
_movies[resourceId] = new MovieRuntime(assetName, rawIndex, new DirectShowMovieDecoder(payload));
GD.Print($"movie started {assetName} ({mpegBytes.Length} bytes from VFS)");
}
catch (System.Exception e)
{
GD.Print($"movie decode failed {assetName}: {e.Message}");
_host.NotifyMovieCompleted(resourceId); // release a pending 0x21c boundary on deterministic load failure
}
}
private void UpdateMovieFrames()
{
if (_host == null) return;
foreach (var (resourceId, movie) in _movies)
{
if (movie.Decoder.TryTakeFrame(out var frame))
{
_host.PublishMovieFrame(resourceId, movie.Name, movie.RawIndex, frame);
if (_movieFrameSeen.Add(resourceId))
GD.Print($"movie first frame {movie.Name}: {frame.Width}x{frame.Height} RGBA8 at render frame {_timelineFrame}");
}
if (movie.Decoder.IsCompleted) _host.NotifyMovieCompleted(resourceId);
}
}
public void StopMovie(long resourceId)
{
if (_movies.Remove(resourceId, out var movie))
{
movie.Decoder.Dispose();
GD.Print($"movie stopped {movie.Name} at render frame {_timelineFrame}");
}
_movieFrameSeen.Remove(resourceId);
}
private sealed record MovieRuntime(string Name, int RawIndex, DirectShowMovieDecoder Decoder);
public void AppendLine(string text) => _text.Text += text + "\n";
public void PageBreak() { _pageCount++; _status.Text = ""; }
public void ClearPage() { _text.Text = ""; _status.Text = ""; }