using System; using System.Collections.Generic; using System.Linq; using System.Threading; using Age.Engine.Hosting; using Age.Engine.Model; using Age.Engine.Sys4; using Age.Engine.Text; public sealed partial class GodotAdvHost : IHost { private readonly Main _main; private readonly ResourceMap _res; private readonly string _rootScene; private readonly MovieSurfaceRegistry _movieSurfaces = new(); private sealed record MovieMaskPlayback( GfxState Gfx, MovieMaskTransitionRequest Request, RgbaImage Captured); private readonly object _movieMaskLock = new(); private readonly Dictionary _movieMasksByPlayback = new(); private readonly Dictionary _movieMaskPlaybackBySurface = new(); private readonly string?[] _sfxNames = new string?[10]; // SC0000 native channel subset private readonly int _screenWidth; private readonly int _screenHeight; private readonly Age.Engine.Hosting.FrameClock _clock; private readonly GodotTimelineLog? _timeline; private readonly PageLocatorState _locator; private int _voiceBgmDuckControl; private (AudioPayload Audio, int PlaybackVariant)? _queuedSkippedVoice; private readonly object _scheduledVoiceLock = new(); private (AudioPayload Audio, int PlaybackVariant, uint DelayMs, uint? StartMs)? _scheduledVoice; public GodotAdvHost(Main main, ResourceMap res, string scene, Age.Engine.Hosting.FrameClock clock, PageLocatorState locator, Sys4LogicalCanvas logicalCanvas, IGlyphMaskRasterizer surfaceTextRasterizer, GodotTimelineLog? timeline = null, bool synchronizeExplicitPresentation = true, int surfaceTextMaskCacheCapacity = 2048) { _main = main; _res = res; _rootScene = scene; _clock = clock; _locator = locator; _timeline = timeline; _screenWidth = logicalCanvas.Width; _screenHeight = logicalCanvas.Height; _slotDims[0] = (_screenWidth, _screenHeight); _synchronizeExplicitPresentation = synchronizeExplicitPresentation; ArgumentNullException.ThrowIfNull(surfaceTextRasterizer); _surfaceTextBackendInfo = (surfaceTextRasterizer as IIdentifiedGlyphMaskRasterizer)?.BackendInfo ?? throw new ArgumentException( "Gameplay glyph rasterizers must identify their policy.", nameof(surfaceTextRasterizer)); _surfaceTextMaskCache = new CachedGlyphMaskRasterizer( surfaceTextRasterizer, capacity: surfaceTextMaskCacheCapacity); _surfaceTextPixelRenderer = new ImmediateSurfaceTextRenderer( _surfaceTextMaskCache, _surfaceTextBackendInfo.Policy); _retainedGlyphLayoutEngine = new RetainedGlyphLayoutEngine(_surfaceTextMaskCache); } public Sys4LogicalCanvas LogicalCanvas => new(_screenWidth, _screenHeight); public void ReportWarning(string message) => System.Console.Error.WriteLine(message); 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, initialPositionMs: 0, startDelayMs: 0, presentationDurationMs: null, movieMask: null, out long? stopTimeMs, out _); return stopTimeMs ?? 0; } public long? PlayMovieToSurfaceAtPosition( long resourceId, int surfaceSlot, long movieFlags, long syncMask, long positionMs) { 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, initialPositionMs: positionMs, startDelayMs: 0, presentationDurationMs: null, movieMask: null, out long? stopTimeMs, out _); return stopTimeMs ?? 0; } public void PlayMovieMaskTransition(GfxState gfx, MovieMaskTransitionRequest request) { gfx.QueueMovieMaskTransition(request); RgbaImage captured = CaptureRetainedRange( gfx, request.SurfaceSlot, request.SourceRangeStart, request.SourceRangeCount); byte initialFill = request.Mode == 1 ? (byte)0 : (byte)255; PublishMaskedCapture( request.SurfaceSlot, captured, CreateFilledMask(request.Width, request.Height, initialFill), request); var asset = _res.ResolveMovie(request.ResourceId); if (asset == null) { Godot.GD.Print($"movie mask unresolved {CurrentScene}:0x{request.ResourceId:x}"); PublishMovieMaskTerminal(new MovieMaskPlayback(gfx, request, captured)); return; } var mask = new MovieMaskPlayback(gfx, request, captured); bool started = StartMovie( asset, request.ResourceId, request.SurfaceSlot, movieFlags: 6, syncMask: 0, modal: false, initialPositionMs: 0, startDelayMs: request.StartDelayMs, presentationDurationMs: request.DurationMs, movieMask: mask, out long? stopTimeMs, out _); gfx.SetMovieStopTime(request.SurfaceSlot, stopTimeMs ?? 0); if (!started) PublishMovieMaskTerminal(mask); } public bool IsMovieSurfaceActive(int surfaceSlot) => _movieSurfaces.IsActive(surfaceSlot); public GodotHostDiagnosticSnapshot CaptureDiagnosticSnapshot() { IReadOnlyList movies = _movieSurfaces.Snapshot(); IReadOnlyList 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) { var asset = _res.ResolveMovie(resourceId); if (asset == null) { Godot.GD.Print($"modal movie unresolved packed:0x{resourceId:x}"); return; } _modalMovieCancelled = false; _modalMovieWaiting = true; bool scriptSuspended = false; try { if (!StartMovie(asset, resourceId, surfaceSlot, movieFlags, 0, modal: true, initialPositionMs: 0, startDelayMs: 0, presentationDurationMs: null, movieMask: null, out _, out long playbackId)) return; _timeline?.State("modal-movie-wait", new() { ["resource"] = resourceId, ["playback"] = playbackId, ["surface"] = surfaceSlot, ["file"] = asset.Name, }); scriptSuspended = SuspendScriptForPresentation(); RequestSynchronizedPresentation(); while (!_stopping && !_modalMovieCancelled) { 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) _movieSurfaces.Complete(playbackId); _timeline?.State("running", new() { ["modal_movie_complete"] = !_modalMovieCancelled, ["modal_movie_cancelled"] = _modalMovieCancelled, }); } finally { ResumeScriptAfterPresentation(scriptSuspended); _modalMovieWaiting = false; _modalMovieCancelled = false; } } private bool StartMovie(AssetEntry asset, long resourceId, int surfaceSlot, long movieFlags, long syncMask, bool modal, long initialPositionMs, long startDelayMs, long? presentationDurationMs, MovieMaskPlayback? movieMask, out long? stopTimeMs, out long playbackId) { stopTimeMs = null; // 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) { AbandonMovieMaskPlayback(prior.PlaybackId); ForgetMovieMaskSurface(prior.SurfaceSlot, prior.PlaybackId); _main.CallDeferred("StopMovie", prior.PlaybackId); } if (movieMask != null) lock (_movieMaskLock) { _movieMasksByPlayback[playbackId] = movieMask; _movieMaskPlaybackBySurface[surfaceSlot] = playbackId; } lock (_imageLock) { if (movieMask == null) _surfaceImages.Remove(surfaceSlot); _surfaceColorKeys.Remove(surfaceSlot); } // Movie surfaces inherit the selected game's primary size until a decoded frame supplies content. _slotDims[surfaceSlot] = movieMask == null ? (_screenWidth, _screenHeight) : (movieMask.Captured.Width, movieMask.Captured.Height); try { var movie = _res.ReadMovie(asset); _timeline?.Event("movie-start", new() { ["resource"] = resourceId, ["playback"] = playbackId, ["surface"] = surfaceSlot, ["file"] = movie.Name, ["flags"] = movieFlags, ["sync_mask"] = syncMask, ["modal"] = modal, ["initial_position_ms"] = Math.Max(0, initialPositionMs), ["start_delay_ms"] = Math.Max(0, startDelayMs), ["presentation_duration_ms"] = presentationDurationMs, ["movie_mask"] = movieMask != null, }); bool started = _main.TryPlayMovie( movie.Bytes, movie.Name, playbackId, resourceId, asset.PackedId, movieFlags, initialPositionMs, startDelayMs, presentationDurationMs, out stopTimeMs); if (!started) { stopTimeMs = 0; NotifyMovieCompleted(playbackId); } return started; } catch (System.Exception e) { AbandonMovieMaskPlayback(playbackId); _movieSurfaces.Abandon(playbackId, out _); _slotDims.Remove(surfaceSlot); stopTimeMs = 0; Godot.GD.Print($"movie read failed {asset.Name}: {e.Message}"); return false; } } public void PublishMovieFrame(long playbackId, string name, int assetId, RgbaImage frame) { MovieMaskPlayback? maskPlayback; lock (_movieMaskLock) _movieMasksByPlayback.TryGetValue(playbackId, out maskPlayback); if (maskPlayback != null) { var request = maskPlayback.Request; byte initialFill = request.Mode == 1 ? (byte)0 : (byte)255; byte[] mask = MovieMaskSurface.ExtractGreen( frame, request.Width, request.Height, initialFill); PublishMaskedCapture(request.SurfaceSlot, maskPlayback.Captured, mask, request); return; } if (_movieSurfaces.PublishFrame(playbackId, frame, name, assetId)) System.Threading.Interlocked.Exchange(ref _presentRequested, 1); } public void NotifyMovieCompleted(long playbackId) { MovieMaskPlayback? maskPlayback = RemoveMovieMaskPlayback(playbackId); if (maskPlayback != null) PublishMovieMaskTerminal(maskPlayback); if (!_movieSurfaces.Complete(playbackId)) return; _timeline?.Event("movie-complete", new() { ["playback"] = playbackId }); _frameSignal.Set(); } private bool HasActiveMoviePresentation() => _movieSurfaces.HasActivePlayback; private RgbaImage CaptureRetainedRange( GfxState gfx, int targetSlot, long firstHandle, int count) { var dimensions = _slotDims.GetValueOrDefault( targetSlot, (W: _screenWidth, H: _screenHeight)); int width = System.Math.Max(0, dimensions.W); int height = System.Math.Max(0, dimensions.H); var captured = new RgbaImage(width, height, new byte[checked(width * height * 4)]); IReadOnlyList visible = gfx.SnapshotVisibleObjects(_clock.NowMs); RetainedSurfaceRasterizer.CompositeRange( captured, visible, firstHandle, System.Math.Max(0, count), item => { var raw = gfx.TryGet(item.Handle); var resolved = raw != null ? ResolveSurfaceTexture(raw.SourceSlot, item.SurfaceResId) : ResolveResIdTexture(item.SurfaceResId); return resolved == null ? null : RgbaSurfaceOps.WithColorKey(resolved.Value.Image, item.ColorKey); }); return captured; } private static byte[] CreateFilledMask(int width, int height, byte fill) { var mask = new byte[checked(System.Math.Max(0, width) * System.Math.Max(0, height))]; if (fill != 0) System.Array.Fill(mask, fill); return mask; } private void PublishMaskedCapture( int surfaceSlot, RgbaImage captured, byte[] mask, MovieMaskTransitionRequest request) { RgbaImage image = MovieMaskSurface.Apply( captured, mask, System.Math.Max(0, request.Width), System.Math.Max(0, request.Height), request.X, request.Y); lock (_imageLock) _surfaceImages[surfaceSlot] = image; _slotDims[surfaceSlot] = (image.Width, image.Height); System.Threading.Interlocked.Exchange(ref _presentRequested, 1); _frameSignal.Set(); } private void PublishMovieMaskTerminal(MovieMaskPlayback playback) { byte terminalFill = playback.Request.Mode == 1 ? (byte)255 : (byte)0; PublishMaskedCapture( playback.Request.SurfaceSlot, playback.Captured, CreateFilledMask(playback.Request.Width, playback.Request.Height, terminalFill), playback.Request); playback.Gfx.CompleteMovieMaskTransition(playback.Request.SurfaceSlot); } private MovieMaskPlayback? RemoveMovieMaskPlayback(long playbackId) { lock (_movieMaskLock) { if (!_movieMasksByPlayback.Remove(playbackId, out var playback)) return null; return playback; } } private void AbandonMovieMaskPlayback(long playbackId) { MovieMaskPlayback? playback = RemoveMovieMaskPlayback(playbackId); if (playback != null) { ForgetMovieMaskSurface(playback.Request.SurfaceSlot, playbackId); playback.Gfx.CompleteMovieMaskTransition(playback.Request.SurfaceSlot); } } private void ForgetMovieMaskSurface(int surfaceSlot, long playbackId) { lock (_movieMaskLock) if (_movieMaskPlaybackBySurface.GetValueOrDefault(surfaceSlot) == playbackId) _movieMaskPlaybackBySurface.Remove(surfaceSlot); } public void PlayBgm(long id) => DispatchBgm(id, 1, false); public void RestartBgm(long id, int startMode) => DispatchBgm(id, startMode, true); private void DispatchBgm(long id, int startMode, bool forceRestart) { var asset = _res.ResolveBgm(id); var audio = asset != null ? LoadAudio(asset) : null; _timeline?.Event("bgm", new() { ["id"] = id, ["file"] = audio?.Name, ["start_mode"] = startMode, ["force_restart"] = forceRestart, }); if (audio == null) return; if (forceRestart) _main.CallDeferred("RestartBgm", audio.Bytes, audio.Name, startMode); else _main.CallDeferred("PlayBgm", audio.Bytes, audio.Name); } public void StopBgm() { _timeline?.Event("bgm-stop", new()); _main.CallDeferred("StopBgm"); } public void PlayVoice(long id) => PlayVoice(id, 0); public void PlayVoice(long id, int playbackVariant) { var asset = _res.ResolveVoice(id); var audio = asset != null ? LoadAudio(asset) : null; _timeline?.Event("voice", new() { ["id"] = id, ["file"] = audio?.Name, ["playback_variant"] = playbackVariant }); if (audio == null) return; bool queuedForSkip; bool firstQueued = false; lock (_messageSkipLock) { queuedForSkip = _messageSkipActive; if (queuedForSkip) { firstQueued = _queuedSkippedVoice == null; _queuedSkippedVoice = (audio, playbackVariant); } } if (queuedForSkip) { if (firstQueued) _main.CallDeferred("StopVoiceForMessageSkip"); return; } DispatchVoice(audio, playbackVariant); } private void DispatchVoice(AudioPayload audio, int playbackVariant) { int generation = _main.QueueVoicePlayback(); bool duckBgm = (System.Threading.Volatile.Read(ref _voiceBgmDuckControl) & 1) == 0; // Godot's stream player has no matching AGE start-mode control. Retain the native // variant through dispatch/timeline so that distinction is not erased at the VM seam. _main.CallDeferred("PlayVoice", audio.Bytes, audio.Name, generation, duckBgm, 50); } public void SetVoiceBgmDuckControl(long flags) { System.Threading.Volatile.Write(ref _voiceBgmDuckControl, unchecked((int)flags)); _timeline?.State("voice-bgm-duck-control", new() { ["flags"] = flags }); } public void ScheduleVoicePlayback(long id, int playbackVariant, long delayMs) { var asset = _res.ResolveVoice(id); var audio = asset != null ? LoadAudio(asset) : null; _timeline?.Event("voice-scheduled", new() { ["id"] = id, ["file"] = audio?.Name, ["playback_variant"] = playbackVariant, ["delay_ms"] = unchecked((uint)delayMs), }); lock (_scheduledVoiceLock) _scheduledVoice = audio == null ? null : (audio, playbackVariant, unchecked((uint)delayMs), null); } public void LoadSoundEffect(long resourceId, int channel) { if ((uint)channel >= (uint)_sfxNames.Length) return; var asset = _res.ResolveSoundEffect(resourceId); var audio = asset != null ? LoadAudio(asset) : null; _sfxNames[channel] = audio?.Name; _timeline?.Event("sfx-load", new() { ["resource"] = resourceId, ["channel"] = channel, ["file"] = audio?.Name }); if (audio != null) _main.CallDeferred("LoadSoundEffect", audio.Bytes, audio.Name, channel); } public void StartSoundEffect(int channel) => StartSoundEffect(channel, 0); public void StartSoundEffect(int channel, int startMode) { if ((uint)channel >= (uint)_sfxNames.Length || _sfxNames[channel] == null) return; _timeline?.Event("sfx-start", new() { ["channel"] = channel, ["start_mode"] = startMode, ["file"] = _sfxNames[channel] }); _main.CallDeferred("StartSoundEffect", channel, startMode); } public void ScheduleSoundEffectStart(int channel, int startMode, long delayMs) { if ((uint)channel >= (uint)_sfxNames.Length || _sfxNames[channel] == null) return; long ms = System.Math.Clamp(delayMs, 0, 60_000); double realSeconds = ms / 1000.0 / System.Math.Max(0.05, _clock.Speed); _timeline?.Event("sfx-start-scheduled", new() { ["channel"] = channel, ["start_mode"] = startMode, ["delay_ms"] = ms, ["file"] = _sfxNames[channel] }); _main.CallDeferred("ScheduleSoundEffectStart", channel, startMode, realSeconds); } public void ReleaseSoundEffect(int channel) { if ((uint)channel >= (uint)_sfxNames.Length) return; _timeline?.Event("sfx-release", new() { ["channel"] = channel, ["file"] = _sfxNames[channel] }); _sfxNames[channel] = null; _main.CallDeferred("ReleaseSoundEffect", channel); } private AudioPayload? LoadAudio(AssetEntry asset) { try { return _res.ReadAudio(asset); } catch (System.Exception e) { Godot.GD.Print($"audio read failed {asset.Name}: {e.Message}"); return null; } } public void FadeBgm(int targetPercent, long durationMs) { long ms = System.Math.Clamp(durationMs, 0, 60_000); double realSeconds = ms / 1000.0 / System.Math.Max(0.05, _clock.Speed); _timeline?.State("bgm-fade", new() { ["target_percent"] = targetPercent, ["duration_ms"] = ms }); _main.CallDeferred("FadeBgm", targetPercent, realSeconds); long deadline = _clock.NowMs + ms; IsSleeping = true; bool scriptSuspended = SuspendScriptForPresentation(); try { // Native parks the interpreter in its audio service while the main render loop continues. // Publish scene changes accumulated before the fade (notably GAMESTART -> SC0000's black // frame), then leave presentation ownership with the compositor for the timed wait. RequestSynchronizedPresentation(); while (_clock.NowMs < deadline && !_stopping) _frameSignal.WaitOne(50); } finally { ResumeScriptAfterPresentation(scriptSuspended); IsSleeping = false; } _timeline?.State("running", new() { ["bgm_fade_complete"] = true }); } public void ApplyAudioVolume(int category, int basisPoints) { _timeline?.State("audio-volume", new() { ["category"] = category, ["basis_points"] = basisPoints, }); _main.CallDeferred("ApplyAudioVolume", category, basisPoints); } public void ApplyAudioRouteEnabled(int category, bool enabled) { _timeline?.State("audio-route", new() { ["category"] = category, ["enabled"] = enabled, }); _main.CallDeferred("ApplyAudioRouteEnabled", category, enabled); } } 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 MovieSurfaces, IReadOnlyList CompletedMoviePlaybackIds);