Split Godot host surface operations

This commit is contained in:
gamer147
2026-08-02 20:46:05 -04:00
parent 064cf4965b
commit b88aa2112f
4 changed files with 385 additions and 365 deletions

View File

@@ -150,7 +150,9 @@ full-width text entry.
history presentation, message-window alpha, and retained wait-indicator configuration/publication. history presentation, message-window alpha, and retained wait-indicator configuration/publication.
`godot/GodotAdvHost.PresentationInput.cs` owns script/presentation synchronization, waits and timing, `godot/GodotAdvHost.PresentationInput.cs` owns script/presentation synchronization, waits and timing,
message-skip/input services, cursor and foreground waits, frame/backbuffer publication, transitions, and message-skip/input services, cursor and foreground waits, frame/backbuffer publication, transitions, and
scene-context lifecycle coordination. scene-context lifecycle coordination. `godot/GodotAdvHost.Surfaces.cs` owns decoded-image caching, mutable
surface pixels/resources/dimensions, fill/copy/resolve operations, render-target publication, and surface/range
teardown.
The disposable `build/page-map-<SCENE>.jsonl` files are produced by editor/development Godot runs and map The disposable `build/page-map-<SCENE>.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 runtime ADV page ordinals to their authoritative script offsets for `tools/locate_page.py`. Packaged exports

View File

@@ -581,6 +581,11 @@ do not mix mechanical moves with semantic changes.
movie, audio, and retained-text state remains directly accessible through the sealed partial class; runtime movie, audio, and retained-text state remains directly accessible through the sealed partial class; runtime
validation remains green. validation remains green.
The third bounded `GodotAdvHost` split moved decoded-image caching, mutable surface pixel/resource/dimension
state, allocation and binding, fill/copy/resolve operations, render-target publication, release/range teardown,
and texture decode caching into `godot/GodotAdvHost.Surfaces.cs`. Movie publication and teardown retain direct
access to the surface store through the sealed partial class; runtime validation remains green.
**Gate:** no externally visible behavior or command changes; generated artifacts are byte-identical where **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 deterministic, and the corresponding engine, Python, Godot, and corpus validations remain green after
each domain move. each domain move.
@@ -952,7 +957,7 @@ layer's rendering diverges from ADV; save layout.
## 8. Immediate next step ## 8. Immediate next step
Continue step 2 of the **codebase consolidation** maintenance slice: behavior-neutral physical splits backed Continue step 2 of the **codebase consolidation** maintenance slice: behavior-neutral physical splits backed
by the tracked launcher and layered validation driver. With the planned `Main` domains and the first by the tracked launcher and layered validation driver. With the planned `Main` domains and the first
two `GodotAdvHost` domains isolated, move host surface storage/mutation next, then continue one existing domain three `GodotAdvHost` domains isolated, move host movie playback/mask ownership next, then continue one existing
at a time while preserving public types, commands, and generated output. 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 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. not replace Phase B gameplay validation or the open cross-platform gates.

View File

@@ -0,0 +1,375 @@
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
{
private readonly object _imageLock = new();
private readonly Dictionary<int, RgbaImage?> _images = new(); // packed catalog id -> decoded pixels
// Mutable AGE surfaces are published by replacing immutable RgbaImage snapshots, so the compositor
// can safely finish reading an old frame while the VM prepares a copied-rectangle update.
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
// slot -> dimensions of the currently allocated surface. Slot 0 begins as the selected game's
// logical canvas, but op 0x1fa releases it like any other slot; subsequent queries return 0x0.
private readonly Dictionary<int, (int W, int H)> _slotDims = new();
public void FillSurfaceRect(SurfaceRectFill fill)
{
RgbaImage? destination = ResolveSurfacePixels(fill.SurfaceSlot);
if (destination == null && _slotDims.TryGetValue(fill.SurfaceSlot, out var dimensions)
&& dimensions.W >= 0 && dimensions.H >= 0)
destination = new RgbaImage(dimensions.W, dimensions.H,
new byte[checked(dimensions.W * dimensions.H * 4)]);
if (destination != null)
{
var updated = new RgbaImage(destination.Width, destination.Height,
(byte[])destination.Pixels.Clone());
if (RgbaSurfaceOps.FillRect(updated, fill.X, fill.Y, fill.Width, fill.Height,
unchecked((byte)fill.Alpha), fill.Rgb))
{
lock (_imageLock) _surfaceImages[fill.SurfaceSlot] = updated;
System.Threading.Interlocked.Exchange(ref _presentRequested, 1);
}
}
_timeline?.Event("surface-fill", new()
{
["surface"] = fill.SurfaceSlot, ["x"] = fill.X, ["y"] = fill.Y,
["w"] = fill.Width, ["h"] = fill.Height, ["alpha"] = fill.Alpha, ["rgb"] = fill.Rgb,
});
}
public bool TraceOps; // --gfx-log: print set-texture/create-texture slot assignments (diagnose slot collisions)
public void CreateTexture(int slot, int width, int height)
{
lock (_textLock) _surfaceResources.Remove(slot);
int safeWidth = System.Math.Max(0, width);
int safeHeight = System.Math.Max(0, height);
lock (_imageLock)
{
_surfaceImages[slot] = new RgbaImage(safeWidth, safeHeight,
new byte[checked(safeWidth * safeHeight * 4)]);
_surfaceColorKeys.Remove(slot);
}
_slotDims[slot] = (safeWidth, safeHeight);
if (TraceOps) Godot.GD.Print($"[op] create-texture slot={slot} {width}x{height}");
}
public void SetTexture(long resourceId, int slot) => SetTexture(resourceId, slot, -1);
public void SetTexture(long resourceId, int slot, long colorKey)
{
lock (_imageLock)
{
_surfaceImages.Remove(slot);
_surfaceColorKeys[slot] = colorKey;
}
lock (_textLock)
{
_surfaceResources[slot] = resourceId;
}
var asset = _res.ResolveTexture(resourceId);
var image = asset != null ? Decode(asset) : null;
_slotDims[slot] = image != null ? (image.Width, image.Height) : (0, 0);
if (TraceOps) Godot.GD.Print($"[op] set-texture slot={slot} resId=0x{resourceId:x} -> {(asset?.Name ?? "<none>")}");
}
// AGF is decoded synchronously on the VM thread so geometry queried immediately afterward sees real dims.
public (int Width, int Height) GetTextureSize(int slot)
=> _slotDims.TryGetValue(slot, out var d) ? (d.W, d.H) : (0, 0);
public RgbaImage? CaptureSurfacePixels(int slot)
{
RgbaImage? image = ResolveSurfacePixels(slot);
return image == null
? null
: new RgbaImage(image.Width, image.Height, (byte[])image.Pixels.Clone());
}
public bool ReplaceSurfacePixels(int slot, RgbaImage image)
{
if (image.Width <= 0 || image.Height <= 0
|| image.Pixels.Length != checked(image.Width * image.Height * 4))
return false;
lock (_imageLock)
{
_surfaceImages[slot] =
new RgbaImage(image.Width, image.Height, (byte[])image.Pixels.Clone());
_surfaceColorKeys.Remove(slot);
}
lock (_textLock)
{
_surfaceResources.Remove(slot);
foreach (int layoutSlot in _retainedTextLayouts
.Where(pair => pair.Value.Binding.SourceSurfaceSlot == slot)
.Select(pair => pair.Key)
.ToArray())
{
_retainedTextLayouts.Remove(layoutSlot);
_retainedHistoryLayouts.Remove(layoutSlot);
}
}
_slotDims[slot] = (image.Width, image.Height);
return true;
}
// Retained render model: draw-texture updates GfxState (object -> surface bind); Main._Process composites
// the visible objects each frame in ascending-handle order. No immediate blit here.
public void DrawTexture(int slot, int srcX, int srcY, int width, int height, int dstX, int dstY) { }
public void CopySurfaceRect(SurfaceRectCopy copy)
{
RgbaImage? source = ResolveSurfacePixels(copy.SourceSurface);
RgbaImage? destination = ResolveSurfacePixels(copy.DestinationSurface);
if (destination == null && _slotDims.TryGetValue(copy.DestinationSurface, out var dimensions)
&& dimensions.W >= 0 && dimensions.H >= 0)
destination = new RgbaImage(dimensions.W, dimensions.H,
new byte[checked(dimensions.W * dimensions.H * 4)]);
if (source == null || destination == null)
{
ReportWarning($"surface copy unresolved source={copy.SourceSurface} destination={copy.DestinationSurface}");
return;
}
var updated = new RgbaImage(destination.Width, destination.Height, (byte[])destination.Pixels.Clone());
if (RgbaSurfaceOps.CopyRect(source, updated, copy.SourceX, copy.SourceY, copy.Width, copy.Height,
copy.DestinationX, copy.DestinationY))
{
lock (_imageLock) _surfaceImages[copy.DestinationSurface] = updated;
System.Threading.Interlocked.Exchange(ref _presentRequested, 1);
}
_timeline?.Event("surface-copy", new()
{
["source"] = copy.SourceSurface, ["source_x"] = copy.SourceX, ["source_y"] = copy.SourceY,
["w"] = copy.Width, ["h"] = copy.Height, ["destination"] = copy.DestinationSurface,
["destination_x"] = copy.DestinationX, ["destination_y"] = copy.DestinationY,
});
}
private RgbaImage? ResolveSurfacePixels(int slot)
{
lock (_imageLock)
if (_surfaceImages.TryGetValue(slot, out var mutable)) return mutable;
long resourceId;
lock (_textLock)
if (!_surfaceResources.TryGetValue(slot, out resourceId)) return null;
var resolved = ResolveResIdTexture(resourceId);
if (resolved == null) return null;
long colorKey;
lock (_imageLock) colorKey = _surfaceColorKeys.GetValueOrDefault(slot, -1);
return RgbaSurfaceOps.WithColorKey(resolved.Value.Image, colorKey);
}
/// <summary>Resolve a gfx surface through universal packed addressing and decode it from the
/// loose-first asset store.</summary>
public (RgbaImage Image, string Name, int AssetId, bool IsDynamic)? ResolveResIdTexture(long resId)
{
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;
}
public (RgbaImage Image, string Name, int AssetId, bool IsDynamic)? ResolveSurfaceTexture(
int surfaceSlot, long fallbackResourceId)
{
lock (_movieMaskLock)
if (_movieMaskPlaybackBySurface.ContainsKey(surfaceSlot))
lock (_imageLock)
if (_surfaceImages.TryGetValue(surfaceSlot, out var masked))
return (masked, $"<movie-mask:{surfaceSlot}>",
int.MinValue + surfaceSlot, true);
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 void ReleaseSurface(int slot)
{
lock (_screenTransitionLock) _renderTargetSnapshots.Remove(slot);
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)
{
_surfaceImages.Remove(slot);
_surfaceColorKeys.Remove(slot);
}
lock (_textLock)
{
_surfaceResources.Remove(slot);
foreach (int layoutSlot in _retainedTextLayouts
.Where(pair => pair.Value.Binding.SourceSurfaceSlot == slot)
.Select(pair => pair.Key)
.ToArray())
{
_retainedTextLayouts.Remove(layoutSlot);
_retainedHistoryLayouts.Remove(layoutSlot);
}
}
_slotDims.Remove(slot);
if (movieRelease.Kind == MovieSurfaceReleaseKind.Released)
{
var binding = movieRelease.Binding;
AbandonMovieMaskPlayback(binding.PlaybackId);
ForgetMovieMaskSurface(binding.SurfaceSlot, binding.PlaybackId);
_timeline?.Event("movie-stop", new()
{
["resource"] = binding.ResourceId,
["playback"] = binding.PlaybackId,
["surface"] = slot,
});
_main.CallDeferred("StopMovie", binding.PlaybackId);
}
}
public void ClearRenderTarget(int surfaceSlot)
{
if (surfaceSlot < 0)
{
lock (_backbufferRangeLock) _backbufferClearPending = true;
}
else
{
lock (_imageLock)
{
if (_surfaceImages.TryGetValue(surfaceSlot, out var image))
System.Array.Clear(image.Pixels);
else if (_slotDims.TryGetValue(surfaceSlot, out var dimensions)
&& dimensions.W > 0 && dimensions.H > 0)
_surfaceImages[surfaceSlot] = new RgbaImage(
dimensions.W, dimensions.H,
new byte[checked(dimensions.W * dimensions.H * 4)]);
}
}
_timeline?.Event("render-target-clear", new() { ["surface"] = surfaceSlot });
}
private void PublishObjectRangeToSurface(
GfxState gfx, long firstHandle, long count,
IReadOnlyList<RenderObject>? sampled = null)
{
int targetSlot = gfx.CurrentRenderTargetSlot;
if (targetSlot < 0 || !_slotDims.TryGetValue(targetSlot, out var dimensions)
|| dimensions.W <= 0 || dimensions.H <= 0)
return;
RgbaImage destination;
lock (_imageLock)
destination = _surfaceImages.TryGetValue(targetSlot, out var current)
? new RgbaImage(current.Width, current.Height, (byte[])current.Pixels.Clone())
: new RgbaImage(dimensions.W, dimensions.H,
new byte[checked(dimensions.W * dimensions.H * 4)]);
IReadOnlyList<RenderObject> visible = sampled ?? gfx.SnapshotVisibleObjects(_clock.NowMs);
int rendered = RetainedSurfaceRasterizer.CompositeRange(
destination, visible, firstHandle, 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);
});
lock (_imageLock) _surfaceImages[targetSlot] = destination;
IReadOnlyList<RenderObject> retained = visible
.Where(item => item.Handle >= firstHandle && item.Handle - firstHandle < count)
.ToArray();
lock (_screenTransitionLock) _renderTargetSnapshots[targetSlot] = retained;
_timeline?.Event("render-target-publish", new()
{
["surface"] = targetSlot,
["first"] = firstHandle,
["count"] = count,
["objects"] = retained.Count,
["rendered"] = rendered,
});
}
public void ReleaseSurfaceRange(int firstSlot, int count)
{
IReadOnlyList<MovieSurfaceBinding> stoppedMovies = _movieSurfaces.ReleaseRange(firstSlot, count);
int end = checked(firstSlot + count);
lock (_screenTransitionLock)
for (int slot = firstSlot; slot < end; slot++) _renderTargetSnapshots.Remove(slot);
lock (_imageLock)
{
for (int slot = firstSlot; slot < end; slot++)
{
_surfaceImages.Remove(slot);
_surfaceColorKeys.Remove(slot);
_slotDims.Remove(slot);
}
}
lock (_textLock)
{
for (int slot = firstSlot; slot < end; slot++)
{
_surfaceResources.Remove(slot);
}
foreach (int layoutSlot in _retainedTextLayouts
.Where(pair =>
pair.Value.Binding.SourceSurfaceSlot >= firstSlot
&& pair.Value.Binding.SourceSurfaceSlot < end)
.Select(pair => pair.Key)
.ToArray())
_retainedTextLayouts.Remove(layoutSlot);
_retainedHistoryLayouts.RemoveWhere(layoutSlot =>
!_retainedTextLayouts.ContainsKey(layoutSlot));
}
foreach (MovieSurfaceBinding binding in stoppedMovies)
{
AbandonMovieMaskPlayback(binding.PlaybackId);
ForgetMovieMaskSurface(binding.SurfaceSlot, binding.PlaybackId);
_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.
private RgbaImage? Decode(AssetEntry asset)
{
lock (_imageLock)
{
if (_images.TryGetValue(asset.PackedId, out var cached)) return cached;
try { return _images[asset.PackedId] = _res.DecodeTexture(asset); }
catch (System.Exception e)
{
Godot.GD.Print($"AGF decode failed {asset.Name}: {e.Message}");
_images[asset.PackedId] = null;
return null;
}
}
}
// ---- audio ops (OGG plays natively in Godot) ----
// BGM is addressed by direct name (BGM{id:D3}.OGG); voice uses the universal packed catalog.
}

View File

@@ -12,13 +12,6 @@ public sealed partial class GodotAdvHost : IHost
private readonly Main _main; private readonly Main _main;
private readonly ResourceMap _res; private readonly ResourceMap _res;
private readonly string _rootScene; private readonly string _rootScene;
private readonly object _imageLock = new();
private readonly Dictionary<int, RgbaImage?> _images = new(); // packed catalog id -> decoded pixels
// Mutable AGE surfaces are published by replacing immutable RgbaImage snapshots, so the compositor
// can safely finish reading an old frame while the VM prepares a copied-rectangle update.
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 MovieSurfaceRegistry _movieSurfaces = new(); private readonly MovieSurfaceRegistry _movieSurfaces = new();
private sealed record MovieMaskPlayback( private sealed record MovieMaskPlayback(
GfxState Gfx, MovieMaskTransitionRequest Request, RgbaImage Captured); GfxState Gfx, MovieMaskTransitionRequest Request, RgbaImage Captured);
@@ -26,9 +19,6 @@ public sealed partial class GodotAdvHost : IHost
private readonly Dictionary<long, MovieMaskPlayback> _movieMasksByPlayback = new(); private readonly Dictionary<long, MovieMaskPlayback> _movieMasksByPlayback = new();
private readonly Dictionary<int, long> _movieMaskPlaybackBySurface = new(); private readonly Dictionary<int, long> _movieMaskPlaybackBySurface = new();
private readonly string?[] _sfxNames = new string?[10]; // SC0000 native channel subset private readonly string?[] _sfxNames = new string?[10]; // SC0000 native channel subset
// slot -> dimensions of the currently allocated surface. Slot 0 begins as the selected game's
// logical canvas, but op 0x1fa releases it like any other slot; subsequent queries return 0x0.
private readonly Dictionary<int, (int W, int H)> _slotDims = new();
private readonly int _screenWidth; private readonly int _screenWidth;
private readonly int _screenHeight; private readonly int _screenHeight;
private readonly Age.Engine.Hosting.FrameClock _clock; private readonly Age.Engine.Hosting.FrameClock _clock;
@@ -69,187 +59,6 @@ public sealed partial class GodotAdvHost : IHost
public Sys4LogicalCanvas LogicalCanvas => new(_screenWidth, _screenHeight); public Sys4LogicalCanvas LogicalCanvas => new(_screenWidth, _screenHeight);
public void ReportWarning(string message) => System.Console.Error.WriteLine(message); public void ReportWarning(string message) => System.Console.Error.WriteLine(message);
public void FillSurfaceRect(SurfaceRectFill fill)
{
RgbaImage? destination = ResolveSurfacePixels(fill.SurfaceSlot);
if (destination == null && _slotDims.TryGetValue(fill.SurfaceSlot, out var dimensions)
&& dimensions.W >= 0 && dimensions.H >= 0)
destination = new RgbaImage(dimensions.W, dimensions.H,
new byte[checked(dimensions.W * dimensions.H * 4)]);
if (destination != null)
{
var updated = new RgbaImage(destination.Width, destination.Height,
(byte[])destination.Pixels.Clone());
if (RgbaSurfaceOps.FillRect(updated, fill.X, fill.Y, fill.Width, fill.Height,
unchecked((byte)fill.Alpha), fill.Rgb))
{
lock (_imageLock) _surfaceImages[fill.SurfaceSlot] = updated;
System.Threading.Interlocked.Exchange(ref _presentRequested, 1);
}
}
_timeline?.Event("surface-fill", new()
{
["surface"] = fill.SurfaceSlot, ["x"] = fill.X, ["y"] = fill.Y,
["w"] = fill.Width, ["h"] = fill.Height, ["alpha"] = fill.Alpha, ["rgb"] = fill.Rgb,
});
}
public bool TraceOps; // --gfx-log: print set-texture/create-texture slot assignments (diagnose slot collisions)
public void CreateTexture(int slot, int width, int height)
{
lock (_textLock) _surfaceResources.Remove(slot);
int safeWidth = System.Math.Max(0, width);
int safeHeight = System.Math.Max(0, height);
lock (_imageLock)
{
_surfaceImages[slot] = new RgbaImage(safeWidth, safeHeight,
new byte[checked(safeWidth * safeHeight * 4)]);
_surfaceColorKeys.Remove(slot);
}
_slotDims[slot] = (safeWidth, safeHeight);
if (TraceOps) Godot.GD.Print($"[op] create-texture slot={slot} {width}x{height}");
}
public void SetTexture(long resourceId, int slot) => SetTexture(resourceId, slot, -1);
public void SetTexture(long resourceId, int slot, long colorKey)
{
lock (_imageLock)
{
_surfaceImages.Remove(slot);
_surfaceColorKeys[slot] = colorKey;
}
lock (_textLock)
{
_surfaceResources[slot] = resourceId;
}
var asset = _res.ResolveTexture(resourceId);
var image = asset != null ? Decode(asset) : null;
_slotDims[slot] = image != null ? (image.Width, image.Height) : (0, 0);
if (TraceOps) Godot.GD.Print($"[op] set-texture slot={slot} resId=0x{resourceId:x} -> {(asset?.Name ?? "<none>")}");
}
// AGF is decoded synchronously on the VM thread so geometry queried immediately afterward sees real dims.
public (int Width, int Height) GetTextureSize(int slot)
=> _slotDims.TryGetValue(slot, out var d) ? (d.W, d.H) : (0, 0);
public RgbaImage? CaptureSurfacePixels(int slot)
{
RgbaImage? image = ResolveSurfacePixels(slot);
return image == null
? null
: new RgbaImage(image.Width, image.Height, (byte[])image.Pixels.Clone());
}
public bool ReplaceSurfacePixels(int slot, RgbaImage image)
{
if (image.Width <= 0 || image.Height <= 0
|| image.Pixels.Length != checked(image.Width * image.Height * 4))
return false;
lock (_imageLock)
{
_surfaceImages[slot] =
new RgbaImage(image.Width, image.Height, (byte[])image.Pixels.Clone());
_surfaceColorKeys.Remove(slot);
}
lock (_textLock)
{
_surfaceResources.Remove(slot);
foreach (int layoutSlot in _retainedTextLayouts
.Where(pair => pair.Value.Binding.SourceSurfaceSlot == slot)
.Select(pair => pair.Key)
.ToArray())
{
_retainedTextLayouts.Remove(layoutSlot);
_retainedHistoryLayouts.Remove(layoutSlot);
}
}
_slotDims[slot] = (image.Width, image.Height);
return true;
}
// Retained render model: draw-texture updates GfxState (object -> surface bind); Main._Process composites
// the visible objects each frame in ascending-handle order. No immediate blit here.
public void DrawTexture(int slot, int srcX, int srcY, int width, int height, int dstX, int dstY) { }
public void CopySurfaceRect(SurfaceRectCopy copy)
{
RgbaImage? source = ResolveSurfacePixels(copy.SourceSurface);
RgbaImage? destination = ResolveSurfacePixels(copy.DestinationSurface);
if (destination == null && _slotDims.TryGetValue(copy.DestinationSurface, out var dimensions)
&& dimensions.W >= 0 && dimensions.H >= 0)
destination = new RgbaImage(dimensions.W, dimensions.H,
new byte[checked(dimensions.W * dimensions.H * 4)]);
if (source == null || destination == null)
{
ReportWarning($"surface copy unresolved source={copy.SourceSurface} destination={copy.DestinationSurface}");
return;
}
var updated = new RgbaImage(destination.Width, destination.Height, (byte[])destination.Pixels.Clone());
if (RgbaSurfaceOps.CopyRect(source, updated, copy.SourceX, copy.SourceY, copy.Width, copy.Height,
copy.DestinationX, copy.DestinationY))
{
lock (_imageLock) _surfaceImages[copy.DestinationSurface] = updated;
System.Threading.Interlocked.Exchange(ref _presentRequested, 1);
}
_timeline?.Event("surface-copy", new()
{
["source"] = copy.SourceSurface, ["source_x"] = copy.SourceX, ["source_y"] = copy.SourceY,
["w"] = copy.Width, ["h"] = copy.Height, ["destination"] = copy.DestinationSurface,
["destination_x"] = copy.DestinationX, ["destination_y"] = copy.DestinationY,
});
}
private RgbaImage? ResolveSurfacePixels(int slot)
{
lock (_imageLock)
if (_surfaceImages.TryGetValue(slot, out var mutable)) return mutable;
long resourceId;
lock (_textLock)
if (!_surfaceResources.TryGetValue(slot, out resourceId)) return null;
var resolved = ResolveResIdTexture(resourceId);
if (resolved == null) return null;
long colorKey;
lock (_imageLock) colorKey = _surfaceColorKeys.GetValueOrDefault(slot, -1);
return RgbaSurfaceOps.WithColorKey(resolved.Value.Image, colorKey);
}
/// <summary>Resolve a gfx surface through universal packed addressing and decode it from the
/// loose-first asset store.</summary>
public (RgbaImage Image, string Name, int AssetId, bool IsDynamic)? ResolveResIdTexture(long resId)
{
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;
}
public (RgbaImage Image, string Name, int AssetId, bool IsDynamic)? ResolveSurfaceTexture(
int surfaceSlot, long fallbackResourceId)
{
lock (_movieMaskLock)
if (_movieMaskPlaybackBySurface.ContainsKey(surfaceSlot))
lock (_imageLock)
if (_surfaceImages.TryGetValue(surfaceSlot, out var masked))
return (masked, $"<movie-mask:{surfaceSlot}>",
int.MinValue + surfaceSlot, true);
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 bool IsMovieSurfaceBound(int surfaceSlot) => _movieSurfaces.IsBound(surfaceSlot);
public long? PlayMovieToSurface(long resourceId, int surfaceSlot, long movieFlags, long syncMask) public long? PlayMovieToSurface(long resourceId, int surfaceSlot, long movieFlags, long syncMask)
@@ -442,160 +251,6 @@ public sealed partial class GodotAdvHost : IHost
} }
} }
public void ReleaseSurface(int slot)
{
lock (_screenTransitionLock) _renderTargetSnapshots.Remove(slot);
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)
{
_surfaceImages.Remove(slot);
_surfaceColorKeys.Remove(slot);
}
lock (_textLock)
{
_surfaceResources.Remove(slot);
foreach (int layoutSlot in _retainedTextLayouts
.Where(pair => pair.Value.Binding.SourceSurfaceSlot == slot)
.Select(pair => pair.Key)
.ToArray())
{
_retainedTextLayouts.Remove(layoutSlot);
_retainedHistoryLayouts.Remove(layoutSlot);
}
}
_slotDims.Remove(slot);
if (movieRelease.Kind == MovieSurfaceReleaseKind.Released)
{
var binding = movieRelease.Binding;
AbandonMovieMaskPlayback(binding.PlaybackId);
ForgetMovieMaskSurface(binding.SurfaceSlot, binding.PlaybackId);
_timeline?.Event("movie-stop", new()
{
["resource"] = binding.ResourceId,
["playback"] = binding.PlaybackId,
["surface"] = slot,
});
_main.CallDeferred("StopMovie", binding.PlaybackId);
}
}
public void ClearRenderTarget(int surfaceSlot)
{
if (surfaceSlot < 0)
{
lock (_backbufferRangeLock) _backbufferClearPending = true;
}
else
{
lock (_imageLock)
{
if (_surfaceImages.TryGetValue(surfaceSlot, out var image))
System.Array.Clear(image.Pixels);
else if (_slotDims.TryGetValue(surfaceSlot, out var dimensions)
&& dimensions.W > 0 && dimensions.H > 0)
_surfaceImages[surfaceSlot] = new RgbaImage(
dimensions.W, dimensions.H,
new byte[checked(dimensions.W * dimensions.H * 4)]);
}
}
_timeline?.Event("render-target-clear", new() { ["surface"] = surfaceSlot });
}
private void PublishObjectRangeToSurface(
GfxState gfx, long firstHandle, long count,
IReadOnlyList<RenderObject>? sampled = null)
{
int targetSlot = gfx.CurrentRenderTargetSlot;
if (targetSlot < 0 || !_slotDims.TryGetValue(targetSlot, out var dimensions)
|| dimensions.W <= 0 || dimensions.H <= 0)
return;
RgbaImage destination;
lock (_imageLock)
destination = _surfaceImages.TryGetValue(targetSlot, out var current)
? new RgbaImage(current.Width, current.Height, (byte[])current.Pixels.Clone())
: new RgbaImage(dimensions.W, dimensions.H,
new byte[checked(dimensions.W * dimensions.H * 4)]);
IReadOnlyList<RenderObject> visible = sampled ?? gfx.SnapshotVisibleObjects(_clock.NowMs);
int rendered = RetainedSurfaceRasterizer.CompositeRange(
destination, visible, firstHandle, 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);
});
lock (_imageLock) _surfaceImages[targetSlot] = destination;
IReadOnlyList<RenderObject> retained = visible
.Where(item => item.Handle >= firstHandle && item.Handle - firstHandle < count)
.ToArray();
lock (_screenTransitionLock) _renderTargetSnapshots[targetSlot] = retained;
_timeline?.Event("render-target-publish", new()
{
["surface"] = targetSlot,
["first"] = firstHandle,
["count"] = count,
["objects"] = retained.Count,
["rendered"] = rendered,
});
}
public void ReleaseSurfaceRange(int firstSlot, int count)
{
IReadOnlyList<MovieSurfaceBinding> stoppedMovies = _movieSurfaces.ReleaseRange(firstSlot, count);
int end = checked(firstSlot + count);
lock (_screenTransitionLock)
for (int slot = firstSlot; slot < end; slot++) _renderTargetSnapshots.Remove(slot);
lock (_imageLock)
{
for (int slot = firstSlot; slot < end; slot++)
{
_surfaceImages.Remove(slot);
_surfaceColorKeys.Remove(slot);
_slotDims.Remove(slot);
}
}
lock (_textLock)
{
for (int slot = firstSlot; slot < end; slot++)
{
_surfaceResources.Remove(slot);
}
foreach (int layoutSlot in _retainedTextLayouts
.Where(pair =>
pair.Value.Binding.SourceSurfaceSlot >= firstSlot
&& pair.Value.Binding.SourceSurfaceSlot < end)
.Select(pair => pair.Key)
.ToArray())
_retainedTextLayouts.Remove(layoutSlot);
_retainedHistoryLayouts.RemoveWhere(layoutSlot =>
!_retainedTextLayouts.ContainsKey(layoutSlot));
}
foreach (MovieSurfaceBinding binding in stoppedMovies)
{
AbandonMovieMaskPlayback(binding.PlaybackId);
ForgetMovieMaskSurface(binding.SurfaceSlot, binding.PlaybackId);
_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 playbackId, string name, int assetId, RgbaImage frame) public void PublishMovieFrame(long playbackId, string name, int assetId, RgbaImage frame)
{ {
MovieMaskPlayback? maskPlayback; MovieMaskPlayback? maskPlayback;
@@ -704,23 +359,6 @@ public sealed partial class GodotAdvHost : IHost
_movieMaskPlaybackBySurface.Remove(surfaceSlot); _movieMaskPlaybackBySurface.Remove(surfaceSlot);
} }
private RgbaImage? Decode(AssetEntry asset)
{
lock (_imageLock)
{
if (_images.TryGetValue(asset.PackedId, out var cached)) return cached;
try { return _images[asset.PackedId] = _res.DecodeTexture(asset); }
catch (System.Exception e)
{
Godot.GD.Print($"AGF decode failed {asset.Name}: {e.Message}");
_images[asset.PackedId] = null;
return null;
}
}
}
// ---- audio ops (OGG plays natively in Godot) ----
// BGM is addressed by direct name (BGM{id:D3}.OGG); voice uses the universal packed catalog.
public void PlayBgm(long id) public void PlayBgm(long id)
=> DispatchBgm(id, 1, false); => DispatchBgm(id, 1, false);