From 2f7e53ebde80751e7988ae351b1f14a1a7dee337 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sun, 2 Aug 2026 18:17:25 -0400 Subject: [PATCH] Split Main movie playback into partial class --- docs/PROJECT-STRUCTURE.md | 3 +- docs/remake-architecture-and-roadmap.md | 15 +-- godot/Main.Movie.cs | 124 ++++++++++++++++++++++++ godot/Main.cs | 117 ---------------------- 4 files changed, 134 insertions(+), 125 deletions(-) create mode 100644 godot/Main.Movie.cs diff --git a/docs/PROJECT-STRUCTURE.md b/docs/PROJECT-STRUCTURE.md index 52035b3..59a0206 100644 --- a/docs/PROJECT-STRUCTURE.md +++ b/docs/PROJECT-STRUCTURE.md @@ -138,7 +138,8 @@ complete disposable artifact under `build/export/linux-x64/`. `godot/Main.cs` retains the front-end's startup and runtime coordination. Behavior-neutral partial-class companions keep cohesive surfaces independently navigable without changing the Godot node type or invocation paths: `godot/Main.SelfTest.cs` owns the synthetic threaded/headless regression harness, while -`godot/Main.Audio.cs` owns BGM, voice, sound-effect, mixer-routing/persistence, and audio-bus control. +`godot/Main.Audio.cs` owns BGM, voice, sound-effect, mixer-routing/persistence, and audio-bus control, and +`godot/Main.Movie.cs` owns decoder staging, movie frame/audio publication, completion, and teardown. The disposable `build/page-map-.jsonl` files are produced by editor/development Godot runs and map runtime ADV page ordinals to their authoritative script offsets for `tools/locate_page.py`. Packaged exports diff --git a/docs/remake-architecture-and-roadmap.md b/docs/remake-architecture-and-roadmap.md index 31a8d54..70009ab 100644 --- a/docs/remake-architecture-and-roadmap.md +++ b/docs/remake-architecture-and-roadmap.md @@ -559,11 +559,12 @@ do not mix mechanical moves with semantic changes. stage, battle, card, routine, gallery, and general table implementations plus tests into importable modules. - **Progress (2026-08-02):** the first two bounded splits moved `Main`'s synthetic scene builder/threaded + **Progress (2026-08-02):** the first three bounded splits moved `Main`'s synthetic scene builder/threaded self-test harness into `godot/Main.SelfTest.cs`, then its BGM, voice, sound-effect, mixer, and bus-control - surface into `godot/Main.Audio.cs`. The partial class retains the same node type, fields, signatures, and - call sites; runtime validation, including all 590 engine tests and the Godot threaded self-test, remains - green after each move. + surface into `godot/Main.Audio.cs`, then decoder staging, movie frame/audio publication, completion, and + teardown into `godot/Main.Movie.cs`. The partial class retains the same node type, fields, signatures, + execution order, and call sites; runtime validation, including all 590 engine tests and the Godot threaded + self-test, remains green after each move. **Gate:** no externally visible behavior or command changes; generated artifacts are byte-identical where deterministic, and the corresponding engine, Python, Godot, and corpus validations remain green after @@ -935,8 +936,8 @@ layer's rendering diverges from ADV; save layout. ## 8. Immediate next step Continue step 2 of the **codebase consolidation** maintenance slice: behavior-neutral physical splits backed -by the tracked launcher and layered validation driver. With the embedded `Main` self-test and audio surfaces -isolated, move the `Main` movie playback surface next, then one existing domain at a time while preserving -public types, commands, and generated output. +by the tracked launcher and layered validation driver. With the embedded `Main` self-test, audio, and movie +surfaces isolated, move the retained compositor surface next, then one existing domain at a time while +preserving public types, commands, and generated output. Concrete playthrough blockers may still preempt this bounded maintenance work; the consolidation effort does not replace Phase B gameplay validation or the open cross-platform gates. diff --git a/godot/Main.Movie.cs b/godot/Main.Movie.cs new file mode 100644 index 0000000..cc6a72c --- /dev/null +++ b/godot/Main.Movie.cs @@ -0,0 +1,124 @@ +using System.Diagnostics; +using Godot; + +public partial class Main +{ + private readonly System.Collections.Generic.Dictionary _movies = new(); + private readonly System.Collections.Generic.Dictionary _movieAudio = new(); + // 0x236 opens its decoder synchronously on the VM thread so 0x23f can query timing immediately. + // Presentation ownership transfers here; _Process adopts staged decoders before sampling frames. + private readonly System.Collections.Concurrent.ConcurrentDictionary _pendingMovies = new(); + private IMovieDecoderFactory _movieDecoderFactory = new FfmpegMovieDecoderFactory(); + private double _audioOutputLatencySeconds; + private readonly System.Collections.Generic.HashSet _movieFrameSeen = new(); + private readonly System.Collections.Generic.HashSet _movieCompletionNotified = new(); + + public bool TryPlayMovie(byte[] mpegBytes, string assetName, long playbackId, + long resourceId, int assetId, long movieFlags, + long initialPositionMs, long startDelayMs, + long? presentationDurationMs, + out long? stopTimeMs) + { + stopTimeMs = null; + try + { + var payload = new Age.Engine.Sys4.MoviePayload(assetName, mpegBytes); + var runtime = MovieRuntime.Open( + assetName, assetId, resourceId, payload, _movieDecoderFactory, movieFlags, + initialPositionMs, startDelayMs, presentationDurationMs); + stopTimeMs = runtime.Decoder.StopTimeMs; + while (!_pendingMovies.TryAdd(playbackId, runtime)) + if (_pendingMovies.TryRemove(playbackId, out var prior)) prior.Decoder.Dispose(); + return true; + } + catch (System.Exception e) + { + GD.Print($"movie decode failed {assetName}: {e.Message}"); + return false; + } + } + + private void AdoptPendingMovies() + { + foreach (var (playbackId, _) in _pendingMovies) + { + if (!_pendingMovies.TryRemove(playbackId, out var movie)) continue; + if (_movies.Remove(playbackId, out var prior)) prior.Decoder.Dispose(); + _movies[playbackId] = movie; + if (movie.Decoder.AudioInfo != null) + { + if (_movieAudio.Remove(playbackId, out var priorAudio)) priorAudio.Dispose(); + _movieAudio[playbackId] = new MovieAudioOutput( + this, movie.Decoder, MovieAudioRouteFromFlags(movie.MovieFlags), + _audioOutputLatencySeconds); + } + _movieCompletionNotified.Remove(playbackId); + GD.Print($"movie started {movie.Name} playback={playbackId} " + + $"({movie.Decoder.StopTimeMs?.ToString() ?? "unknown"} ms from VFS" + + (movie.InitialPositionMs > 0 ? $", start={movie.InitialPositionMs}ms" : "") + + (movie.Decoder.AudioInfo is { } audio + ? $", audio={audio.SampleRate}Hz stereo route={MovieAudioRouteFromFlags(movie.MovieFlags)}" + : "") + ")"); + } + } + + private void UpdateMovieFrames() + { + if (_host == null) return; + foreach (var (playbackId, movie) in _movies) + { + long elapsedMs = (long)Stopwatch.GetElapsedTime( + movie.StartedAtTimestamp).TotalMilliseconds; + if (elapsedMs < movie.StartDelayMs) continue; + bool frameWasAlreadySeen = _movieFrameSeen.Contains(playbackId); + _movieAudio.TryGetValue(playbackId, out var audio); + if (frameWasAlreadySeen) audio?.Update(); + if (movie.Decoder.TryTakeFrame(out var frame)) + { + _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} " + + $"(source PTS {movie.Decoder.FirstFramePresentationTimeMs?.ToString() ?? "unknown"} ms)"); + // Do not let decode startup consume the opening audio timeline. The first PCM push + // begins only after the first decoded image has reached the retained movie surface. + audio?.Update(); + } + } + bool watchdogExpired = elapsedMs >= movie.WatchdogMs; + 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(playbackId); + } + } + } + + public void StopMovie(long playbackId) + { + if (_movieAudio.Remove(playbackId, out var audio)) audio.Dispose(); + 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} playback={playbackId} at render frame {_timelineFrame}"); + } + _movieFrameSeen.Remove(playbackId); + _movieCompletionNotified.Remove(playbackId); + } + + private static MovieAudioRoute MovieAudioRouteFromFlags(long flags) + { + ulong value = unchecked((ulong)flags); + if ((value & 0x10000) != 0) return MovieAudioRoute.Muted; + if ((value & 0x20000) != 0) return MovieAudioRoute.Music; + if ((value & 0x40000) != 0) return MovieAudioRoute.SoundEffect; + if ((value & 0x80000) != 0) return MovieAudioRoute.Voice; + return MovieAudioRoute.Movie; + } + +} diff --git a/godot/Main.cs b/godot/Main.cs index 0c37154..b79efa8 100644 --- a/godot/Main.cs +++ b/godot/Main.cs @@ -44,15 +44,6 @@ public partial class Main : Godot.Control private DebugSceneLauncher? _debugSceneLauncher; private IReadOnlyList _debugSceneEntries = System.Array.Empty(); private readonly Age.Engine.Hosting.FrameClock _clock = new(); - private readonly System.Collections.Generic.Dictionary _movies = new(); - private readonly System.Collections.Generic.Dictionary _movieAudio = new(); - // 0x236 opens its decoder synchronously on the VM thread so 0x23f can query timing immediately. - // Presentation ownership transfers here; _Process adopts staged decoders before sampling frames. - private readonly System.Collections.Concurrent.ConcurrentDictionary _pendingMovies = new(); - private IMovieDecoderFactory _movieDecoderFactory = new FfmpegMovieDecoderFactory(); - private double _audioOutputLatencySeconds; - private readonly System.Collections.Generic.HashSet _movieFrameSeen = new(); - private readonly System.Collections.Generic.HashSet _movieCompletionNotified = new(); private GodotTraceSink _trace = null!; private PageLocatorState _locator = null!; private bool _locatorHudVisible; @@ -1608,114 +1599,6 @@ public partial class Main : Godot.Control px[i + 3] = 0; } - public bool TryPlayMovie(byte[] mpegBytes, string assetName, long playbackId, - long resourceId, int assetId, long movieFlags, - long initialPositionMs, long startDelayMs, - long? presentationDurationMs, - out long? stopTimeMs) - { - stopTimeMs = null; - try - { - var payload = new Age.Engine.Sys4.MoviePayload(assetName, mpegBytes); - var runtime = MovieRuntime.Open( - assetName, assetId, resourceId, payload, _movieDecoderFactory, movieFlags, - initialPositionMs, startDelayMs, presentationDurationMs); - stopTimeMs = runtime.Decoder.StopTimeMs; - while (!_pendingMovies.TryAdd(playbackId, runtime)) - if (_pendingMovies.TryRemove(playbackId, out var prior)) prior.Decoder.Dispose(); - return true; - } - catch (System.Exception e) - { - GD.Print($"movie decode failed {assetName}: {e.Message}"); - return false; - } - } - - private void AdoptPendingMovies() - { - foreach (var (playbackId, _) in _pendingMovies) - { - if (!_pendingMovies.TryRemove(playbackId, out var movie)) continue; - if (_movies.Remove(playbackId, out var prior)) prior.Decoder.Dispose(); - _movies[playbackId] = movie; - if (movie.Decoder.AudioInfo != null) - { - if (_movieAudio.Remove(playbackId, out var priorAudio)) priorAudio.Dispose(); - _movieAudio[playbackId] = new MovieAudioOutput( - this, movie.Decoder, MovieAudioRouteFromFlags(movie.MovieFlags), - _audioOutputLatencySeconds); - } - _movieCompletionNotified.Remove(playbackId); - GD.Print($"movie started {movie.Name} playback={playbackId} " + - $"({movie.Decoder.StopTimeMs?.ToString() ?? "unknown"} ms from VFS" + - (movie.InitialPositionMs > 0 ? $", start={movie.InitialPositionMs}ms" : "") + - (movie.Decoder.AudioInfo is { } audio - ? $", audio={audio.SampleRate}Hz stereo route={MovieAudioRouteFromFlags(movie.MovieFlags)}" - : "") + ")"); - } - } - - private void UpdateMovieFrames() - { - if (_host == null) return; - foreach (var (playbackId, movie) in _movies) - { - long elapsedMs = (long)Stopwatch.GetElapsedTime( - movie.StartedAtTimestamp).TotalMilliseconds; - if (elapsedMs < movie.StartDelayMs) continue; - bool frameWasAlreadySeen = _movieFrameSeen.Contains(playbackId); - _movieAudio.TryGetValue(playbackId, out var audio); - if (frameWasAlreadySeen) audio?.Update(); - if (movie.Decoder.TryTakeFrame(out var frame)) - { - _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} " + - $"(source PTS {movie.Decoder.FirstFramePresentationTimeMs?.ToString() ?? "unknown"} ms)"); - // Do not let decode startup consume the opening audio timeline. The first PCM push - // begins only after the first decoded image has reached the retained movie surface. - audio?.Update(); - } - } - bool watchdogExpired = elapsedMs >= movie.WatchdogMs; - 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(playbackId); - } - } - } - - public void StopMovie(long playbackId) - { - if (_movieAudio.Remove(playbackId, out var audio)) audio.Dispose(); - 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} playback={playbackId} at render frame {_timelineFrame}"); - } - _movieFrameSeen.Remove(playbackId); - _movieCompletionNotified.Remove(playbackId); - } - - private static MovieAudioRoute MovieAudioRouteFromFlags(long flags) - { - ulong value = unchecked((ulong)flags); - if ((value & 0x10000) != 0) return MovieAudioRoute.Muted; - if ((value & 0x20000) != 0) return MovieAudioRoute.Music; - if ((value & 0x40000) != 0) return MovieAudioRoute.SoundEffect; - if ((value & 0x80000) != 0) return MovieAudioRoute.Voice; - return MovieAudioRoute.Movie; - } - public void PageBreak() { _pageCount++;