Split Main compositor into partial class

This commit is contained in:
gamer147
2026-08-02 18:38:12 -04:00
parent 2f7e53ebde
commit f5bc0adc8f
4 changed files with 586 additions and 572 deletions

View File

@@ -140,6 +140,8 @@ companions keep cohesive surfaces independently navigable without changing the G
paths: `godot/Main.SelfTest.cs` owns the synthetic threaded/headless regression harness, while 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, and `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. `godot/Main.Movie.cs` owns decoder staging, movie frame/audio publication, completion, and teardown.
`godot/Main.Compositor.cs` owns retained/GPU/software composition state, texture resolution and caching,
surface-transition drawing, raster helpers, and compositor decision logging.
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

@@ -559,12 +559,13 @@ do not mix mechanical moves with semantic changes.
stage, battle, card, routine, gallery, and general table implementations plus tests into importable stage, battle, card, routine, gallery, and general table implementations plus tests into importable
modules. modules.
**Progress (2026-08-02):** the first three bounded splits moved `Main`'s synthetic scene builder/threaded **Progress (2026-08-02):** the first four 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 self-test harness into `godot/Main.SelfTest.cs`, then its BGM, voice, sound-effect, mixer, and bus-control
surface into `godot/Main.Audio.cs`, then decoder staging, movie frame/audio publication, completion, and 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, teardown into `godot/Main.Movie.cs`, then retained GPU/software composition, texture caching, transitions,
execution order, and call sites; runtime validation, including all 590 engine tests and the Godot threaded raster helpers, and decision logging into `godot/Main.Compositor.cs`. The partial class retains the same
self-test, remains green after each move. 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 **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
@@ -936,8 +937,8 @@ 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 embedded `Main` self-test, audio, and movie by the tracked launcher and layered validation driver. With the embedded `Main` self-test, audio, movie, and
surfaces isolated, move the retained compositor surface next, then one existing domain at a time while retained compositor surfaces isolated, move input/cursor/debug-launcher handling next, then continue one
preserving public types, commands, and generated output. 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 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.

576
godot/Main.Compositor.cs Normal file
View File

@@ -0,0 +1,576 @@
using System.Collections.Generic;
using System.Linq;
using Godot;
using Age.Engine.Model;
using Age.Engine.Sys4;
public partial class Main
{
private TextureRect _screenView = null!; // shows the composited screen backbuffer
private Image _screen = null!; // SYS4INI-sized immediate-mode canvas
private ImageTexture _screenTex = null!;
private GpuRetainedRenderer _gpuRenderer = null!;
private bool _useGpuBackend = true;
// One managed composition target for the entire frame. Layer helpers mutate it in place; only the
// completed frame crosses the Godot Image boundary, avoiding a full GetData/SetData round-trip per layer.
private byte[] _screenPixels = [];
private string? _gfxLogPath; // --gfx-log <file>: log per-object compositor draw/skip CHANGES
private System.IO.StreamWriter? _gfxLog;
private readonly System.Collections.Generic.Dictionary<long, string> _lastGfxDecision = new();
private int _gfxLogFrame;
// ---- retained per-frame compositor (main thread, from _Process) ----
// Clear the screen and composite the VM's current VISIBLE gfx objects in ascending-handle order (= the
// engine's z-order), each blitting its live surface's rect at its position. Decoded AGF pixels are cached
// by catalog identity (this runs every frame). Native scale/translation matrix channels are sampled independently by
// GfxState and applied here; object opacity comes only from the actual blend/color path.
private sealed record CachedPixels(int Width, int Height, byte[] Rgba);
private readonly System.Collections.Generic.Dictionary<(int AssetId, long Key), CachedPixels> _pixelCache = new();
private readonly System.Collections.Generic.List<RenderObject> _visibleSnapshot = new(1024);
private void Recomposite()
{
bool gpuSnapshotCaptured = false;
BackbufferPublicationPolicy publicationPolicy = default;
if (_useGpuBackend &&
TryRecompositeGpu(out gpuSnapshotCaptured, out publicationPolicy)) return;
_gpuRenderer.Visible = false;
_screenView.Visible = true;
RecompositeSoftware(
gpuSnapshotCaptured ? _visibleSnapshot : null,
publicationPolicy.PreserveExistingPixels);
}
private bool TryRecompositeGpu(
out bool snapshotCaptured,
out BackbufferPublicationPolicy publicationPolicy)
{
snapshotCaptured = false;
publicationPolicy = default;
// Preserve the existing high-volume object/timeline diagnostics exactly. They are debugging tools,
// not performance workloads, and their software decision strings remain the canonical evidence.
if (_gfxLogPath != null || _timeline != null) return false;
long phase = _perf != null ? PerformanceFrameLog.Timestamp() : 0;
long allocationPhase = _perf != null ? PerformanceFrameLog.AllocatedBytes() : 0;
if (_host.TrySnapshotScreenTransition(out _)) return false; // P4: whole-screen offscreen targets
publicationPolicy =
_host.SnapshotBackbufferObjects(_vm.Gfx, _clock.NowMs, _visibleSnapshot);
snapshotCaptured = true;
_perf?.RecordSnapshotAllocation(PerformanceFrameLog.AllocatedBytes() - allocationPhase);
_perf?.RecordSnapshot(PerformanceFrameLog.Timestamp() - phase);
// Additive LERP-tint has not appeared in the target workloads and needs a dedicated additive shader
// variant before leaving the software oracle.
if (_visibleSnapshot.Any(v =>
v.Blend == BlendKind.Additive && !v.MultiplyTint && v.TintStrength > 0))
return false;
if (_perf != null)
{
var presentStep = _trace.LatestStep;
_perf.RecordPresentationCoordinate(presentStep?.Script ?? "<startup>",
presentStep?.Offset ?? -1, presentStep?.Opcode ?? -1);
}
_perf?.BeginRecomposite(screenTransition: false);
_gpuRenderer.BeginFrame(publicationPolicy.AppendGpuLayers);
foreach (var v in _visibleSnapshot)
{
_perf?.RecordObject(v.TimeVarying);
var affine = Transform2DMath.Build(v.Transform, v.Rotation, v.ScaleCycle)
.FromLocalOrigin(v.DstX, v.DstY);
if (v.RangeTransform is { } rangeTransform) affine = affine.Then(rangeTransform);
float opacity = v.Alpha / 255f;
var rawObject = _vm.Gfx.TryGet(v.Handle);
long resolveStarted = _perf != null ? PerformanceFrameLog.Timestamp() : 0;
var texture = rawObject != null
? _host.ResolveSurfaceTexture(rawObject.SourceSlot, v.SurfaceResId)
: null;
_perf?.RecordResolve(PerformanceFrameLog.Timestamp() - resolveStarted);
bool movieSurfaceBound = rawObject != null && _host.IsMovieSurfaceBound(rawObject.SourceSlot);
if (v.SurfaceTransition is { } transition)
{
_perf?.RecordTransitionLayer();
DrawTransitionRangeGpu(_visibleSnapshot, transition);
}
else if (v.SurfaceResId == 0 && texture == null)
{
if (v.Blend != BlendKind.Opaque)
{
int width = v.W > 0 ? v.W : _screenWidth;
int height = v.H > 0 ? v.H : _screenHeight;
float fillOpacity = v.MultiplyTint
? opacity
: opacity * v.TintStrength / 255f;
_perf?.RecordFillLayer();
if (_gpuRenderer.DrawFill(width, height, affine, v.Tint, fillOpacity))
_perf?.RecordGpuLayer(width, height, affine, _screenWidth, _screenHeight,
dynamic: false, BlendKind.Alpha);
}
else _perf?.RecordSkippedLayer();
}
else
{
if (texture == null && !movieSurfaceBound)
{
resolveStarted = _perf != null ? PerformanceFrameLog.Timestamp() : 0;
texture = _host.ResolveResIdTexture(v.SurfaceResId);
_perf?.RecordResolve(PerformanceFrameLog.Timestamp() - resolveStarted);
}
if (texture == null) _perf?.RecordSkippedLayer();
else
{
var resolved = texture.Value;
bool drawn = _gpuRenderer.DrawTexture(resolved.Image, resolved.AssetId, v.ColorKey,
v.SrcX, v.SrcY, v.W, v.H, affine, v.Tint, v.TintStrength,
opacity, v.MultiplyTint, resolved.IsDynamic,
rawObject?.SourceSlot ?? v.Handle, v.Blend);
if (drawn)
_perf?.RecordGpuLayer(v.W, v.H, affine, _screenWidth, _screenHeight,
resolved.IsDynamic, v.Blend);
}
}
}
var stats = _gpuRenderer.EndFrame();
_perf?.RecordGpu(stats.DrawItems, stats.TextureUploads, stats.TextureUploadTicks);
_screenView.Visible = false;
_gpuRenderer.Visible = true;
_perf?.EndRecomposite();
return true;
}
// Native type-0 retained range transition: range A has already passed through ordinary z-order;
// republish range B at the transition placeholder with progress-scaled source opacity. This mirrors
// DrawTransitionRange's software-oracle order without allocating an offscreen CPU surface.
private int DrawTransitionRangeGpu(IReadOnlyList<RenderObject> visible, SurfaceTransitionState transition)
{
int drawn = 0;
long end = transition.RangeBStart + transition.RangeBCount;
foreach (var source in visible)
{
if (source.Handle < transition.RangeBStart || source.Handle >= end || source.SurfaceTransition != null)
continue;
_perf?.RecordObject(source.TimeVarying);
var affine = Transform2DMath.Build(source.Transform, source.Rotation, source.ScaleCycle)
.FromLocalOrigin(source.DstX, source.DstY);
if (source.RangeTransform is { } rangeTransform) affine = affine.Then(rangeTransform);
float opacity = source.Alpha / 255f * (float)transition.Progress;
var rawObject = _vm.Gfx.TryGet(source.Handle);
long resolveStarted = _perf != null ? PerformanceFrameLog.Timestamp() : 0;
var texture = rawObject != null
? _host.ResolveSurfaceTexture(rawObject.SourceSlot, source.SurfaceResId)
: null;
_perf?.RecordResolve(PerformanceFrameLog.Timestamp() - resolveStarted);
bool movieSurfaceBound = rawObject != null && _host.IsMovieSurfaceBound(rawObject.SourceSlot);
if (source.SurfaceResId == 0 && texture == null)
{
if (source.Blend == BlendKind.Opaque)
{
_perf?.RecordSkippedLayer();
continue;
}
int width = source.W > 0 ? source.W : _screenWidth;
int height = source.H > 0 ? source.H : _screenHeight;
_perf?.RecordFillLayer();
if (_gpuRenderer.DrawFill(width, height, affine, source.Tint,
opacity * source.TintStrength / 255f))
{
_perf?.RecordGpuLayer(width, height, affine, _screenWidth, _screenHeight,
dynamic: false, BlendKind.Alpha);
drawn++;
}
continue;
}
if (!movieSurfaceBound && texture == null)
{
resolveStarted = _perf != null ? PerformanceFrameLog.Timestamp() : 0;
texture = _host.ResolveResIdTexture(source.SurfaceResId);
_perf?.RecordResolve(PerformanceFrameLog.Timestamp() - resolveStarted);
}
if (texture == null)
{
_perf?.RecordSkippedLayer();
continue;
}
var resolved = texture.Value;
if (_gpuRenderer.DrawTexture(resolved.Image, resolved.AssetId, source.ColorKey,
source.SrcX, source.SrcY, source.W, source.H, affine, source.Tint, source.TintStrength,
opacity, source.MultiplyTint, resolved.IsDynamic,
rawObject?.SourceSlot ?? source.Handle, source.Blend))
{
_perf?.RecordGpuLayer(source.W, source.H, affine, _screenWidth, _screenHeight,
resolved.IsDynamic, source.Blend);
drawn++;
}
}
return drawn;
}
private void RecompositeSoftware(
IReadOnlyList<RenderObject>? sampledVisible = null,
bool preserveExistingPixels = false)
{
if (_perf != null)
{
var presentStep = _trace.LatestStep;
_perf.RecordPresentationCoordinate(presentStep?.Script ?? "<startup>",
presentStep?.Offset ?? -1, presentStep?.Opcode ?? -1);
}
long phase = _perf != null ? PerformanceFrameLog.Timestamp() : 0;
long allocationPhase = _perf != null ? PerformanceFrameLog.AllocatedBytes() : 0;
bool hasScreenTransition = _host.TrySnapshotScreenTransition(out var transition);
_perf?.RecordSnapshotAllocation(PerformanceFrameLog.AllocatedBytes() - allocationPhase);
_perf?.RecordSnapshot(PerformanceFrameLog.Timestamp() - phase);
if (!hasScreenTransition && sampledVisible == null)
{
phase = _perf != null ? PerformanceFrameLog.Timestamp() : 0;
allocationPhase = _perf != null ? PerformanceFrameLog.AllocatedBytes() : 0;
preserveExistingPixels = _host.SnapshotBackbufferObjects(
_vm.Gfx, _clock.NowMs, _visibleSnapshot).PreserveExistingPixels;
_perf?.RecordSnapshotAllocation(
PerformanceFrameLog.AllocatedBytes() - allocationPhase);
_perf?.RecordSnapshot(PerformanceFrameLog.Timestamp() - phase);
}
if (hasScreenTransition) preserveExistingPixels = false;
_perf?.BeginRecomposite(hasScreenTransition);
phase = _perf != null ? PerformanceFrameLog.Timestamp() : 0;
if (!preserveExistingPixels)
{
System.Array.Clear(_screenPixels);
}
_perf?.RecordClear(PerformanceFrameLog.Timestamp() - phase);
System.Collections.Generic.Dictionary<long, string>? decisions = _gfxLogPath != null || _timeline != null ? new() : null;
if (hasScreenTransition)
{
allocationPhase = _perf != null ? PerformanceFrameLog.AllocatedBytes() : 0;
// Native mode 4 keeps the captured source opaque and alpha-composites the complete target
// surface over it. Each offscreen target has an opaque-black clear beneath its objects.
CompositeVisibleObjects(transition.Source, 1f, decisions);
FillQuad(0, 0, _screenWidth, _screenHeight, 0, (float)transition.Progress);
CompositeVisibleObjects(transition.Target, (float)transition.Progress, decisions);
_perf?.RecordCompositeAllocation(PerformanceFrameLog.AllocatedBytes() - allocationPhase);
}
else
{
allocationPhase = _perf != null ? PerformanceFrameLog.AllocatedBytes() : 0;
CompositeVisibleObjects(sampledVisible ?? _visibleSnapshot, 1f, decisions);
_perf?.RecordCompositeAllocation(PerformanceFrameLog.AllocatedBytes() - allocationPhase);
}
phase = _perf != null ? PerformanceFrameLog.Timestamp() : 0;
allocationPhase = _perf != null ? PerformanceFrameLog.AllocatedBytes() : 0;
_screen.SetData(_screenWidth, _screenHeight, false, Image.Format.Rgba8, _screenPixels);
_perf?.RecordSetDataAllocation(PerformanceFrameLog.AllocatedBytes() - allocationPhase);
_perf?.RecordSetData(PerformanceFrameLog.Timestamp() - phase);
phase = _perf != null ? PerformanceFrameLog.Timestamp() : 0;
_screenTex.Update(_screen);
_perf?.RecordTextureUpdate(PerformanceFrameLog.Timestamp() - phase);
if (decisions != null) LogGfxDecisionChanges(decisions);
_perf?.EndRecomposite();
}
private void CompositeVisibleObjects(
IReadOnlyList<RenderObject> visible,
float globalOpacity,
System.Collections.Generic.Dictionary<long, string>? decisions)
{
int z = 0;
foreach (var v in visible) // interpolate at the retained-presentation clock
{
_perf?.RecordObject(v.TimeVarying);
var t = v.Transform;
var affine = Age.Engine.Model.Transform2DMath.Build(t, v.Rotation, v.ScaleCycle);
var localToDest = affine.FromLocalOrigin(v.DstX, v.DstY);
if (v.RangeTransform is { } rangeTransform)
localToDest = localToDest.Then(rangeTransform);
var projected = localToDest.Apply(0, 0);
int dstX = (int)System.Math.Round(projected.X);
int dstY = (int)System.Math.Round(projected.Y);
float opacity = v.Alpha / 255f * globalOpacity; // transform Z is never opacity
float strength = v.TintStrength / 255f; // tint-blend / fill strength
var rawObject = _vm.Gfx.TryGet(v.Handle);
long resolveStarted = _perf != null ? PerformanceFrameLog.Timestamp() : 0;
var surfaceTexture = rawObject != null
? _host.ResolveSurfaceTexture(rawObject.SourceSlot, v.SurfaceResId)
: null;
_perf?.RecordResolve(PerformanceFrameLog.Timestamp() - resolveStarted);
bool movieSurfaceBound = rawObject != null && _host.IsMovieSurfaceBound(rawObject.SourceSlot);
// These strings exist only for --gfx-log/timeline diagnostics. DEBUGMAP visits roughly one
// thousand retained objects per composition, so formatting them unconditionally creates
// several megabytes of short-lived garbage even in an ordinary run.
string? outcome = null;
if (v.SurfaceTransition is { } transition)
{
_perf?.RecordTransitionLayer();
int layers = DrawTransitionRange(visible, transition);
if (decisions != null)
outcome = $"TRANSITION slot={transition.TargetSlot} key=0x{transition.CommandKey:x} " +
$"progress={transition.Progress:0.000} forced={transition.Forced} layers={layers}";
}
else if (v.SurfaceResId == 0 && surfaceTexture == null)
{
// A colored object with no bound surface = a fade/flash fill (e.g. fade-to-black). Its presence
// is the tint STRENGTH (0=absent, 255=solid), scaled by any object opacity. Uncolored surfaceless
// objects are render targets — still skipped (slice C).
if (v.Blend != Age.Engine.Model.BlendKind.Opaque)
{
int baseW = v.W > 0 ? v.W : _screenWidth;
int baseH = v.H > 0 ? v.H : _screenHeight;
// One-shot/mode-1 packed color supplies opacity directly. Static mode-0 fills retain
// the tint-strength convention used by the existing effect objects.
float fillA = v.MultiplyTint ? opacity : opacity * strength;
_perf?.RecordFillLayer();
FillAffineQuad(baseW, baseH, localToDest, v.Tint, fillA);
if (decisions != null)
outcome = $"FILL tint=0x{v.Tint:x6} a={fillA:0.00} {baseW}x{baseH}@({dstX},{dstY}) " +
$"base=({v.DstX},{v.DstY}) anchor=({t.AnchorX:0.0},{t.AnchorY:0.0}) " +
$"scale=({t.ScaleX:0.00},{t.ScaleY:0.00}) " +
$"trans=({t.TranslateX:0.0},{t.TranslateY:0.0}) rot={v.Rotation.AngleDegrees:0.0}" +
ColorTimeline(v.ColorTransition);
}
else
{
_perf?.RecordSkippedLayer();
if (decisions != null) outcome = "SKIP(no-resId, opaque render-target)";
}
}
else
{
var texture = surfaceTexture;
if (texture == null && !movieSurfaceBound)
{
resolveStarted = _perf != null ? PerformanceFrameLog.Timestamp() : 0;
texture = _host.ResolveResIdTexture(v.SurfaceResId);
_perf?.RecordResolve(PerformanceFrameLog.Timestamp() - resolveStarted);
}
if (texture == null)
{
_perf?.RecordSkippedLayer();
if (decisions != null) outcome = $"SKIP(resId=0x{v.SurfaceResId:x} UNRESOLVED)";
}
else
{
BlitLayer(texture.Value.Image, texture.Value.AssetId, v.ColorKey, v.Tint, strength, v.SrcX, v.SrcY, v.W, v.H,
localToDest, opacity, v.MultiplyTint, texture.Value.IsDynamic, v.Blend);
if (decisions != null)
outcome = $"slot={rawObject?.SourceSlot} DRAWN resId=0x{v.SurfaceResId:x} {texture.Value.Name} " +
$"src=({v.SrcX},{v.SrcY} {v.W}x{v.H}) base=({v.DstX},{v.DstY}) " +
$"anchor=({t.AnchorX:0.0},{t.AnchorY:0.0}) dst=({dstX},{dstY}) " +
$"scale=({t.ScaleX:0.00},{t.ScaleY:0.00}) trans=({t.TranslateX:0.0},{t.TranslateY:0.0}) " +
$"rot=({t.RotationAngleDegrees:0.0}+{v.Rotation.AngleDegrees:0.0}) " +
$"mode={rawObject?.StaticColorMode} op={opacity:0.00} tintStr={strength:0.00}" +
ColorTimeline(v.ColorTransition);
}
}
if (decisions != null) decisions[v.Handle] = $"z{z} {outcome}";
z++;
}
}
private static string ColorTimeline(Age.Engine.Model.ColorTransitionState? state)
=> state is { } c
? $" color=0x{c.Current:x8}->0x{c.Target:x8} colorProgress={c.Progress:0.000}"
: "";
// Native type-0 surface commands first leave range A in normal z-order, then alpha-composite range B
// into the target surface. SC0000 binds that target to handle+2, above both source handles, so drawing
// range B here with progress produces old*(1-progress)+new*progress without disturbing ambient channels.
private int DrawTransitionRange(IReadOnlyList<RenderObject> visible, SurfaceTransitionState transition)
{
int drawn = 0;
long end = transition.RangeBStart + transition.RangeBCount;
foreach (var source in visible)
{
if (source.Handle < transition.RangeBStart || source.Handle >= end || source.SurfaceTransition != null)
continue;
_perf?.RecordObject(source.TimeVarying);
var affine = Transform2DMath.Build(source.Transform, source.Rotation, source.ScaleCycle)
.FromLocalOrigin(source.DstX, source.DstY);
if (source.RangeTransform is { } rangeTransform)
affine = affine.Then(rangeTransform);
float opacity = source.Alpha / 255f * (float)transition.Progress;
var rawObject = _vm.Gfx.TryGet(source.Handle);
long resolveStarted = _perf != null ? PerformanceFrameLog.Timestamp() : 0;
var texture = rawObject != null
? _host.ResolveSurfaceTexture(rawObject.SourceSlot, source.SurfaceResId)
: null;
_perf?.RecordResolve(PerformanceFrameLog.Timestamp() - resolveStarted);
bool movieSurfaceBound = rawObject != null && _host.IsMovieSurfaceBound(rawObject.SourceSlot);
if (source.SurfaceResId == 0 && texture == null)
{
if (source.Blend == BlendKind.Opaque)
{
_perf?.RecordSkippedLayer();
continue;
}
int w = source.W > 0 ? source.W : _screenWidth;
int h = source.H > 0 ? source.H : _screenHeight;
_perf?.RecordFillLayer();
FillAffineQuad(w, h, affine, source.Tint, opacity * source.TintStrength / 255f);
}
else
{
if (!movieSurfaceBound && texture == null)
{
resolveStarted = _perf != null ? PerformanceFrameLog.Timestamp() : 0;
texture = _host.ResolveResIdTexture(source.SurfaceResId);
_perf?.RecordResolve(PerformanceFrameLog.Timestamp() - resolveStarted);
}
if (texture == null)
{
_perf?.RecordSkippedLayer();
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,
texture.Value.IsDynamic, source.Blend);
}
drawn++;
}
return drawn;
}
// Diagnostic (--gfx-log): print, per rendered frame, only the objects whose compositor outcome CHANGED
// since last frame (added / gone / drawn↔skip / resId change). Quiet until something actually changes, so
// the frame where the background drops out — and WHY — stands out. See systematic-debugging of the grey-BG.
private void LogGfxDecisionChanges(System.Collections.Generic.Dictionary<long, string> curr)
{
if (_gfxLogPath != null && _gfxLog == null)
{
var dir = System.IO.Path.GetDirectoryName(_gfxLogPath);
if (!string.IsNullOrEmpty(dir)) System.IO.Directory.CreateDirectory(dir);
_gfxLog = new System.IO.StreamWriter(_gfxLogPath!) { AutoFlush = true };
}
_gfxLogFrame++;
var lines = new System.Collections.Generic.List<string>();
foreach (var kv in curr)
if (!_lastGfxDecision.TryGetValue(kv.Key, out var prev) || prev != kv.Value)
lines.Add($" 0x{kv.Key:x}: {kv.Value}" + (_lastGfxDecision.ContainsKey(kv.Key) ? "" : " [NEW]"));
foreach (var kv in _lastGfxDecision)
if (!curr.ContainsKey(kv.Key))
lines.Add($" 0x{kv.Key:x}: GONE (was {kv.Value})");
if (lines.Count > 0 && _gfxLog != null)
{
_gfxLog.WriteLine($"[frame {_gfxLogFrame} nowMs={_clock.NowMs} page={_pageCount}] {curr.Count} visible, {lines.Count} changes:");
foreach (var l in lines) _gfxLog.WriteLine(l);
}
if (lines.Count > 0)
_timeline?.Event("objects", new() { ["visible_count"] = curr.Count, ["changes"] = lines.ToArray() });
_lastGfxDecision.Clear();
foreach (var kv in curr) _lastGfxDecision[kv.Key] = kv.Value;
}
// Blit one object's surface rect. Static source pixels are cached per (assetId, colorKey): on first use, texels
// matching the surface colorkey are made transparent (native bakes the key at load — engine-re.md §Blend).
// Mode 0 uses tintStrength to LERP texel RGB toward tint. Mode 1 uses packed RGB modulation and
// SRCALPHA/ONE additive composition; black source pixels therefore contribute nothing.
private void BlitLayer(RgbaImage decoded, int assetId, long colorKey, long tint, float tintStrength, int srcX, int srcY, int w, int h,
Age.Engine.Model.Affine2D localToDest, float alpha = 1f, bool multiplyTint = false,
bool dynamic = false, BlendKind blend = BlendKind.Alpha)
{
// Native gfx_object_blit_d3d9 clips the explicit source rectangle and returns without drawing when
// right<=left or bottom<=top. FIELD deliberately creates zero-area prototype objects from SO005;
// expanding those dimensions to the full texture leaks the entire spritesheet onto the map.
if (w <= 0 || h <= 0) return;
long sourcePrepStarted = _perf != null ? PerformanceFrameLog.Timestamp() : 0;
long sourcePrepAllocated = _perf != null ? PerformanceFrameLog.AllocatedBytes() : 0;
var cacheKey = (assetId, colorKey);
int sourceWidth, sourceHeight;
byte[] sourcePixels;
if (dynamic)
{
// Decoder samples replace the pixels of one retained surface. Never enter them in the static cache.
// Clone only when applying a key so the decoder-owned newest-frame buffer remains untouched.
sourceWidth = decoded.Width;
sourceHeight = decoded.Height;
sourcePixels = decoded.Pixels;
if (Age.Engine.Model.BlendMath.HasColorKey(colorKey))
{
sourcePixels = (byte[])sourcePixels.Clone();
BakeColorKey(sourcePixels, colorKey);
}
}
else
{
if (!_pixelCache.TryGetValue(cacheKey, out var cached))
{
byte[] pixels = decoded.Pixels;
if (Age.Engine.Model.BlendMath.HasColorKey(colorKey))
{
pixels = (byte[])pixels.Clone();
BakeColorKey(pixels, colorKey);
}
cached = new CachedPixels(decoded.Width, decoded.Height, pixels);
_pixelCache[cacheKey] = cached;
}
sourceWidth = cached.Width;
sourceHeight = cached.Height;
sourcePixels = cached.Rgba;
}
int sw = w;
int sh = h;
sw = System.Math.Min(sw, sourceWidth - srcX);
sh = System.Math.Min(sh, sourceHeight - srcY);
_perf?.RecordSourcePrep(PerformanceFrameLog.Timestamp() - sourcePrepStarted);
_perf?.RecordSourcePrepAllocation(PerformanceFrameLog.AllocatedBytes() - sourcePrepAllocated);
if (sw <= 0 || sh <= 0) return;
long rasterStarted = _perf != null ? PerformanceFrameLog.Timestamp() : 0;
Age.Engine.Model.SoftwareAffineRasterizer.BlitRgba(
_screenPixels, _screenWidth, _screenHeight, sourcePixels, sourceWidth, sourceHeight,
srcX, srcY, sw, sh, localToDest, tint, tintStrength, alpha, multiplyTint, blend);
_perf?.RecordRaster(sw, sh, localToDest, _screenWidth, _screenHeight, dynamic, blend,
PerformanceFrameLog.Timestamp() - rasterStarted);
}
private void FillAffineQuad(int w, int h, Age.Engine.Model.Affine2D localToDest, long tint, float alpha)
{
long rasterStarted = _perf != null ? PerformanceFrameLog.Timestamp() : 0;
Age.Engine.Model.SoftwareAffineRasterizer.FillRgba(
_screenPixels, _screenWidth, _screenHeight, w, h, localToDest, tint, alpha);
_perf?.RecordRaster(w, h, localToDest, _screenWidth, _screenHeight, false, BlendKind.Alpha,
PerformanceFrameLog.Timestamp() - rasterStarted);
}
// Alpha-blend a solid tint (0xRRGGBB) rectangle over the screen — the surfaceless fade/flash fill.
private void FillQuad(int dstX, int dstY, int w, int h, long tint, float alpha)
{
int ia = (int)(System.Math.Clamp(alpha, 0f, 1f) * 255);
if (ia == 0) return;
long rasterStarted = _perf != null ? PerformanceFrameLog.Timestamp() : 0;
int tr = (int)((tint >> 16) & 0xff), tg = (int)((tint >> 8) & 0xff), tb = (int)(tint & 0xff);
byte[] dst = _screenPixels;
int dw = _screenWidth, dh = _screenHeight;
int x0 = System.Math.Max(0, -dstX), x1 = System.Math.Min(w, dw - dstX);
int y0 = System.Math.Max(0, -dstY), y1 = System.Math.Min(h, dh - dstY);
if (x1 <= x0 || y1 <= y0) return;
for (int y = y0; y < y1; y++)
for (int x = x0; x < x1; x++)
{
int dxp = dstX + x, dyp = dstY + y;
int di = (dyp * dw + dxp) * 4;
dst[di] = (byte)((tr * ia + dst[di] * (255 - ia)) / 255);
dst[di + 1] = (byte)((tg * ia + dst[di + 1] * (255 - ia)) / 255);
dst[di + 2] = (byte)((tb * ia + dst[di + 2] * (255 - ia)) / 255);
dst[di + 3] = (byte)System.Math.Min(255, dst[di + 3] + ia);
}
_perf?.RecordFillLayer();
_perf?.RecordRaster(w, h, new Affine2D(1, 0, 0, 1, dstX, dstY),
_screenWidth, _screenHeight, false, BlendKind.Alpha,
PerformanceFrameLog.Timestamp() - rasterStarted);
}
// Make colorkey-matching texels transparent (native colorkey is baked at surface load).
private static void BakeColorKey(byte[] px, long colorKey)
{
for (int i = 0; i < px.Length; i += 4)
if (Age.Engine.Model.BlendMath.ColorKeyMatches(px[i], px[i + 1], px[i + 2], colorKey))
px[i + 3] = 0;
}
}

View File

@@ -25,15 +25,7 @@ public partial class Main : Godot.Control
private WindowLaunchOptions _windowOptions; private WindowLaunchOptions _windowOptions;
private Sys4AssetCatalog _catalog = null!; private Sys4AssetCatalog _catalog = null!;
private IAssetStore _assetStore = null!; private IAssetStore _assetStore = null!;
private TextureRect _screenView = null!; // shows the composited screen backbuffer
private Image _screen = null!; // SYS4INI-sized immediate-mode canvas
private ImageTexture _screenTex = null!;
private GpuRetainedRenderer _gpuRenderer = null!;
private bool _useGpuBackend = true;
private ImageTexture? _ageCursorTexture; private ImageTexture? _ageCursorTexture;
// One managed composition target for the entire frame. Layer helpers mutate it in place; only the
// completed frame crosses the Godot Image boundary, avoiding a full GetData/SetData round-trip per layer.
private byte[] _screenPixels = [];
private Label _status = null!; private Label _status = null!;
private Label _locatorHud = null!; private Label _locatorHud = null!;
private VirtualMachine _vm = null!; private VirtualMachine _vm = null!;
@@ -65,10 +57,6 @@ public partial class Main : Godot.Control
private string? _seqDir; // --shot-sequence <dir>: dump one PNG per frame (verify paced anim) private string? _seqDir; // --shot-sequence <dir>: dump one PNG per frame (verify paced anim)
private int _seqFrames = 180; // --frames <n>: how many frames to dump (default ~3s @60fps) private int _seqFrames = 180; // --frames <n>: how many frames to dump (default ~3s @60fps)
private int _seqIdx; private int _seqIdx;
private string? _gfxLogPath; // --gfx-log <file>: log per-object compositor draw/skip CHANGES
private System.IO.StreamWriter? _gfxLog;
private readonly System.Collections.Generic.Dictionary<long, string> _lastGfxDecision = new();
private int _gfxLogFrame;
private string? _timelineLogPath; // --timeline-log <jsonl>: synchronized VM/host/compositor evidence private string? _timelineLogPath; // --timeline-log <jsonl>: synchronized VM/host/compositor evidence
private GodotTimelineLog? _timeline; private GodotTimelineLog? _timeline;
private int _timelineFrame; private int _timelineFrame;
@@ -1046,559 +1034,6 @@ public partial class Main : Godot.Control
catch (System.Exception e) { GD.Print($"[trace-histogram] write failed: {e.Message}"); } catch (System.Exception e) { GD.Print($"[trace-histogram] write failed: {e.Message}"); }
} }
// ---- retained per-frame compositor (main thread, from _Process) ----
// Clear the screen and composite the VM's current VISIBLE gfx objects in ascending-handle order (= the
// engine's z-order), each blitting its live surface's rect at its position. Decoded AGF pixels are cached
// by catalog identity (this runs every frame). Native scale/translation matrix channels are sampled independently by
// GfxState and applied here; object opacity comes only from the actual blend/color path.
private sealed record CachedPixels(int Width, int Height, byte[] Rgba);
private readonly System.Collections.Generic.Dictionary<(int AssetId, long Key), CachedPixels> _pixelCache = new();
private readonly System.Collections.Generic.List<RenderObject> _visibleSnapshot = new(1024);
private void Recomposite()
{
bool gpuSnapshotCaptured = false;
BackbufferPublicationPolicy publicationPolicy = default;
if (_useGpuBackend &&
TryRecompositeGpu(out gpuSnapshotCaptured, out publicationPolicy)) return;
_gpuRenderer.Visible = false;
_screenView.Visible = true;
RecompositeSoftware(
gpuSnapshotCaptured ? _visibleSnapshot : null,
publicationPolicy.PreserveExistingPixels);
}
private bool TryRecompositeGpu(
out bool snapshotCaptured,
out BackbufferPublicationPolicy publicationPolicy)
{
snapshotCaptured = false;
publicationPolicy = default;
// Preserve the existing high-volume object/timeline diagnostics exactly. They are debugging tools,
// not performance workloads, and their software decision strings remain the canonical evidence.
if (_gfxLogPath != null || _timeline != null) return false;
long phase = _perf != null ? PerformanceFrameLog.Timestamp() : 0;
long allocationPhase = _perf != null ? PerformanceFrameLog.AllocatedBytes() : 0;
if (_host.TrySnapshotScreenTransition(out _)) return false; // P4: whole-screen offscreen targets
publicationPolicy =
_host.SnapshotBackbufferObjects(_vm.Gfx, _clock.NowMs, _visibleSnapshot);
snapshotCaptured = true;
_perf?.RecordSnapshotAllocation(PerformanceFrameLog.AllocatedBytes() - allocationPhase);
_perf?.RecordSnapshot(PerformanceFrameLog.Timestamp() - phase);
// Additive LERP-tint has not appeared in the target workloads and needs a dedicated additive shader
// variant before leaving the software oracle.
if (_visibleSnapshot.Any(v =>
v.Blend == BlendKind.Additive && !v.MultiplyTint && v.TintStrength > 0))
return false;
if (_perf != null)
{
var presentStep = _trace.LatestStep;
_perf.RecordPresentationCoordinate(presentStep?.Script ?? "<startup>",
presentStep?.Offset ?? -1, presentStep?.Opcode ?? -1);
}
_perf?.BeginRecomposite(screenTransition: false);
_gpuRenderer.BeginFrame(publicationPolicy.AppendGpuLayers);
foreach (var v in _visibleSnapshot)
{
_perf?.RecordObject(v.TimeVarying);
var affine = Transform2DMath.Build(v.Transform, v.Rotation, v.ScaleCycle)
.FromLocalOrigin(v.DstX, v.DstY);
if (v.RangeTransform is { } rangeTransform) affine = affine.Then(rangeTransform);
float opacity = v.Alpha / 255f;
var rawObject = _vm.Gfx.TryGet(v.Handle);
long resolveStarted = _perf != null ? PerformanceFrameLog.Timestamp() : 0;
var texture = rawObject != null
? _host.ResolveSurfaceTexture(rawObject.SourceSlot, v.SurfaceResId)
: null;
_perf?.RecordResolve(PerformanceFrameLog.Timestamp() - resolveStarted);
bool movieSurfaceBound = rawObject != null && _host.IsMovieSurfaceBound(rawObject.SourceSlot);
if (v.SurfaceTransition is { } transition)
{
_perf?.RecordTransitionLayer();
DrawTransitionRangeGpu(_visibleSnapshot, transition);
}
else if (v.SurfaceResId == 0 && texture == null)
{
if (v.Blend != BlendKind.Opaque)
{
int width = v.W > 0 ? v.W : _screenWidth;
int height = v.H > 0 ? v.H : _screenHeight;
float fillOpacity = v.MultiplyTint
? opacity
: opacity * v.TintStrength / 255f;
_perf?.RecordFillLayer();
if (_gpuRenderer.DrawFill(width, height, affine, v.Tint, fillOpacity))
_perf?.RecordGpuLayer(width, height, affine, _screenWidth, _screenHeight,
dynamic: false, BlendKind.Alpha);
}
else _perf?.RecordSkippedLayer();
}
else
{
if (texture == null && !movieSurfaceBound)
{
resolveStarted = _perf != null ? PerformanceFrameLog.Timestamp() : 0;
texture = _host.ResolveResIdTexture(v.SurfaceResId);
_perf?.RecordResolve(PerformanceFrameLog.Timestamp() - resolveStarted);
}
if (texture == null) _perf?.RecordSkippedLayer();
else
{
var resolved = texture.Value;
bool drawn = _gpuRenderer.DrawTexture(resolved.Image, resolved.AssetId, v.ColorKey,
v.SrcX, v.SrcY, v.W, v.H, affine, v.Tint, v.TintStrength,
opacity, v.MultiplyTint, resolved.IsDynamic,
rawObject?.SourceSlot ?? v.Handle, v.Blend);
if (drawn)
_perf?.RecordGpuLayer(v.W, v.H, affine, _screenWidth, _screenHeight,
resolved.IsDynamic, v.Blend);
}
}
}
var stats = _gpuRenderer.EndFrame();
_perf?.RecordGpu(stats.DrawItems, stats.TextureUploads, stats.TextureUploadTicks);
_screenView.Visible = false;
_gpuRenderer.Visible = true;
_perf?.EndRecomposite();
return true;
}
// Native type-0 retained range transition: range A has already passed through ordinary z-order;
// republish range B at the transition placeholder with progress-scaled source opacity. This mirrors
// DrawTransitionRange's software-oracle order without allocating an offscreen CPU surface.
private int DrawTransitionRangeGpu(IReadOnlyList<RenderObject> visible, SurfaceTransitionState transition)
{
int drawn = 0;
long end = transition.RangeBStart + transition.RangeBCount;
foreach (var source in visible)
{
if (source.Handle < transition.RangeBStart || source.Handle >= end || source.SurfaceTransition != null)
continue;
_perf?.RecordObject(source.TimeVarying);
var affine = Transform2DMath.Build(source.Transform, source.Rotation, source.ScaleCycle)
.FromLocalOrigin(source.DstX, source.DstY);
if (source.RangeTransform is { } rangeTransform) affine = affine.Then(rangeTransform);
float opacity = source.Alpha / 255f * (float)transition.Progress;
var rawObject = _vm.Gfx.TryGet(source.Handle);
long resolveStarted = _perf != null ? PerformanceFrameLog.Timestamp() : 0;
var texture = rawObject != null
? _host.ResolveSurfaceTexture(rawObject.SourceSlot, source.SurfaceResId)
: null;
_perf?.RecordResolve(PerformanceFrameLog.Timestamp() - resolveStarted);
bool movieSurfaceBound = rawObject != null && _host.IsMovieSurfaceBound(rawObject.SourceSlot);
if (source.SurfaceResId == 0 && texture == null)
{
if (source.Blend == BlendKind.Opaque)
{
_perf?.RecordSkippedLayer();
continue;
}
int width = source.W > 0 ? source.W : _screenWidth;
int height = source.H > 0 ? source.H : _screenHeight;
_perf?.RecordFillLayer();
if (_gpuRenderer.DrawFill(width, height, affine, source.Tint,
opacity * source.TintStrength / 255f))
{
_perf?.RecordGpuLayer(width, height, affine, _screenWidth, _screenHeight,
dynamic: false, BlendKind.Alpha);
drawn++;
}
continue;
}
if (!movieSurfaceBound && texture == null)
{
resolveStarted = _perf != null ? PerformanceFrameLog.Timestamp() : 0;
texture = _host.ResolveResIdTexture(source.SurfaceResId);
_perf?.RecordResolve(PerformanceFrameLog.Timestamp() - resolveStarted);
}
if (texture == null)
{
_perf?.RecordSkippedLayer();
continue;
}
var resolved = texture.Value;
if (_gpuRenderer.DrawTexture(resolved.Image, resolved.AssetId, source.ColorKey,
source.SrcX, source.SrcY, source.W, source.H, affine, source.Tint, source.TintStrength,
opacity, source.MultiplyTint, resolved.IsDynamic,
rawObject?.SourceSlot ?? source.Handle, source.Blend))
{
_perf?.RecordGpuLayer(source.W, source.H, affine, _screenWidth, _screenHeight,
resolved.IsDynamic, source.Blend);
drawn++;
}
}
return drawn;
}
private void RecompositeSoftware(
IReadOnlyList<RenderObject>? sampledVisible = null,
bool preserveExistingPixels = false)
{
if (_perf != null)
{
var presentStep = _trace.LatestStep;
_perf.RecordPresentationCoordinate(presentStep?.Script ?? "<startup>",
presentStep?.Offset ?? -1, presentStep?.Opcode ?? -1);
}
long phase = _perf != null ? PerformanceFrameLog.Timestamp() : 0;
long allocationPhase = _perf != null ? PerformanceFrameLog.AllocatedBytes() : 0;
bool hasScreenTransition = _host.TrySnapshotScreenTransition(out var transition);
_perf?.RecordSnapshotAllocation(PerformanceFrameLog.AllocatedBytes() - allocationPhase);
_perf?.RecordSnapshot(PerformanceFrameLog.Timestamp() - phase);
if (!hasScreenTransition && sampledVisible == null)
{
phase = _perf != null ? PerformanceFrameLog.Timestamp() : 0;
allocationPhase = _perf != null ? PerformanceFrameLog.AllocatedBytes() : 0;
preserveExistingPixels = _host.SnapshotBackbufferObjects(
_vm.Gfx, _clock.NowMs, _visibleSnapshot).PreserveExistingPixels;
_perf?.RecordSnapshotAllocation(
PerformanceFrameLog.AllocatedBytes() - allocationPhase);
_perf?.RecordSnapshot(PerformanceFrameLog.Timestamp() - phase);
}
if (hasScreenTransition) preserveExistingPixels = false;
_perf?.BeginRecomposite(hasScreenTransition);
phase = _perf != null ? PerformanceFrameLog.Timestamp() : 0;
if (!preserveExistingPixels)
{
System.Array.Clear(_screenPixels);
}
_perf?.RecordClear(PerformanceFrameLog.Timestamp() - phase);
System.Collections.Generic.Dictionary<long, string>? decisions = _gfxLogPath != null || _timeline != null ? new() : null;
if (hasScreenTransition)
{
allocationPhase = _perf != null ? PerformanceFrameLog.AllocatedBytes() : 0;
// Native mode 4 keeps the captured source opaque and alpha-composites the complete target
// surface over it. Each offscreen target has an opaque-black clear beneath its objects.
CompositeVisibleObjects(transition.Source, 1f, decisions);
FillQuad(0, 0, _screenWidth, _screenHeight, 0, (float)transition.Progress);
CompositeVisibleObjects(transition.Target, (float)transition.Progress, decisions);
_perf?.RecordCompositeAllocation(PerformanceFrameLog.AllocatedBytes() - allocationPhase);
}
else
{
allocationPhase = _perf != null ? PerformanceFrameLog.AllocatedBytes() : 0;
CompositeVisibleObjects(sampledVisible ?? _visibleSnapshot, 1f, decisions);
_perf?.RecordCompositeAllocation(PerformanceFrameLog.AllocatedBytes() - allocationPhase);
}
phase = _perf != null ? PerformanceFrameLog.Timestamp() : 0;
allocationPhase = _perf != null ? PerformanceFrameLog.AllocatedBytes() : 0;
_screen.SetData(_screenWidth, _screenHeight, false, Image.Format.Rgba8, _screenPixels);
_perf?.RecordSetDataAllocation(PerformanceFrameLog.AllocatedBytes() - allocationPhase);
_perf?.RecordSetData(PerformanceFrameLog.Timestamp() - phase);
phase = _perf != null ? PerformanceFrameLog.Timestamp() : 0;
_screenTex.Update(_screen);
_perf?.RecordTextureUpdate(PerformanceFrameLog.Timestamp() - phase);
if (decisions != null) LogGfxDecisionChanges(decisions);
_perf?.EndRecomposite();
}
private void CompositeVisibleObjects(
IReadOnlyList<RenderObject> visible,
float globalOpacity,
System.Collections.Generic.Dictionary<long, string>? decisions)
{
int z = 0;
foreach (var v in visible) // interpolate at the retained-presentation clock
{
_perf?.RecordObject(v.TimeVarying);
var t = v.Transform;
var affine = Age.Engine.Model.Transform2DMath.Build(t, v.Rotation, v.ScaleCycle);
var localToDest = affine.FromLocalOrigin(v.DstX, v.DstY);
if (v.RangeTransform is { } rangeTransform)
localToDest = localToDest.Then(rangeTransform);
var projected = localToDest.Apply(0, 0);
int dstX = (int)System.Math.Round(projected.X);
int dstY = (int)System.Math.Round(projected.Y);
float opacity = v.Alpha / 255f * globalOpacity; // transform Z is never opacity
float strength = v.TintStrength / 255f; // tint-blend / fill strength
var rawObject = _vm.Gfx.TryGet(v.Handle);
long resolveStarted = _perf != null ? PerformanceFrameLog.Timestamp() : 0;
var surfaceTexture = rawObject != null
? _host.ResolveSurfaceTexture(rawObject.SourceSlot, v.SurfaceResId)
: null;
_perf?.RecordResolve(PerformanceFrameLog.Timestamp() - resolveStarted);
bool movieSurfaceBound = rawObject != null && _host.IsMovieSurfaceBound(rawObject.SourceSlot);
// These strings exist only for --gfx-log/timeline diagnostics. DEBUGMAP visits roughly one
// thousand retained objects per composition, so formatting them unconditionally creates
// several megabytes of short-lived garbage even in an ordinary run.
string? outcome = null;
if (v.SurfaceTransition is { } transition)
{
_perf?.RecordTransitionLayer();
int layers = DrawTransitionRange(visible, transition);
if (decisions != null)
outcome = $"TRANSITION slot={transition.TargetSlot} key=0x{transition.CommandKey:x} " +
$"progress={transition.Progress:0.000} forced={transition.Forced} layers={layers}";
}
else if (v.SurfaceResId == 0 && surfaceTexture == null)
{
// A colored object with no bound surface = a fade/flash fill (e.g. fade-to-black). Its presence
// is the tint STRENGTH (0=absent, 255=solid), scaled by any object opacity. Uncolored surfaceless
// objects are render targets — still skipped (slice C).
if (v.Blend != Age.Engine.Model.BlendKind.Opaque)
{
int baseW = v.W > 0 ? v.W : _screenWidth;
int baseH = v.H > 0 ? v.H : _screenHeight;
// One-shot/mode-1 packed color supplies opacity directly. Static mode-0 fills retain
// the tint-strength convention used by the existing effect objects.
float fillA = v.MultiplyTint ? opacity : opacity * strength;
_perf?.RecordFillLayer();
FillAffineQuad(baseW, baseH, localToDest, v.Tint, fillA);
if (decisions != null)
outcome = $"FILL tint=0x{v.Tint:x6} a={fillA:0.00} {baseW}x{baseH}@({dstX},{dstY}) " +
$"base=({v.DstX},{v.DstY}) anchor=({t.AnchorX:0.0},{t.AnchorY:0.0}) " +
$"scale=({t.ScaleX:0.00},{t.ScaleY:0.00}) " +
$"trans=({t.TranslateX:0.0},{t.TranslateY:0.0}) rot={v.Rotation.AngleDegrees:0.0}" +
ColorTimeline(v.ColorTransition);
}
else
{
_perf?.RecordSkippedLayer();
if (decisions != null) outcome = "SKIP(no-resId, opaque render-target)";
}
}
else
{
var texture = surfaceTexture;
if (texture == null && !movieSurfaceBound)
{
resolveStarted = _perf != null ? PerformanceFrameLog.Timestamp() : 0;
texture = _host.ResolveResIdTexture(v.SurfaceResId);
_perf?.RecordResolve(PerformanceFrameLog.Timestamp() - resolveStarted);
}
if (texture == null)
{
_perf?.RecordSkippedLayer();
if (decisions != null) outcome = $"SKIP(resId=0x{v.SurfaceResId:x} UNRESOLVED)";
}
else
{
BlitLayer(texture.Value.Image, texture.Value.AssetId, v.ColorKey, v.Tint, strength, v.SrcX, v.SrcY, v.W, v.H,
localToDest, opacity, v.MultiplyTint, texture.Value.IsDynamic, v.Blend);
if (decisions != null)
outcome = $"slot={rawObject?.SourceSlot} DRAWN resId=0x{v.SurfaceResId:x} {texture.Value.Name} " +
$"src=({v.SrcX},{v.SrcY} {v.W}x{v.H}) base=({v.DstX},{v.DstY}) " +
$"anchor=({t.AnchorX:0.0},{t.AnchorY:0.0}) dst=({dstX},{dstY}) " +
$"scale=({t.ScaleX:0.00},{t.ScaleY:0.00}) trans=({t.TranslateX:0.0},{t.TranslateY:0.0}) " +
$"rot=({t.RotationAngleDegrees:0.0}+{v.Rotation.AngleDegrees:0.0}) " +
$"mode={rawObject?.StaticColorMode} op={opacity:0.00} tintStr={strength:0.00}" +
ColorTimeline(v.ColorTransition);
}
}
if (decisions != null) decisions[v.Handle] = $"z{z} {outcome}";
z++;
}
}
private static string ColorTimeline(Age.Engine.Model.ColorTransitionState? state)
=> state is { } c
? $" color=0x{c.Current:x8}->0x{c.Target:x8} colorProgress={c.Progress:0.000}"
: "";
// Native type-0 surface commands first leave range A in normal z-order, then alpha-composite range B
// into the target surface. SC0000 binds that target to handle+2, above both source handles, so drawing
// range B here with progress produces old*(1-progress)+new*progress without disturbing ambient channels.
private int DrawTransitionRange(IReadOnlyList<RenderObject> visible, SurfaceTransitionState transition)
{
int drawn = 0;
long end = transition.RangeBStart + transition.RangeBCount;
foreach (var source in visible)
{
if (source.Handle < transition.RangeBStart || source.Handle >= end || source.SurfaceTransition != null)
continue;
_perf?.RecordObject(source.TimeVarying);
var affine = Transform2DMath.Build(source.Transform, source.Rotation, source.ScaleCycle)
.FromLocalOrigin(source.DstX, source.DstY);
if (source.RangeTransform is { } rangeTransform)
affine = affine.Then(rangeTransform);
float opacity = source.Alpha / 255f * (float)transition.Progress;
var rawObject = _vm.Gfx.TryGet(source.Handle);
long resolveStarted = _perf != null ? PerformanceFrameLog.Timestamp() : 0;
var texture = rawObject != null
? _host.ResolveSurfaceTexture(rawObject.SourceSlot, source.SurfaceResId)
: null;
_perf?.RecordResolve(PerformanceFrameLog.Timestamp() - resolveStarted);
bool movieSurfaceBound = rawObject != null && _host.IsMovieSurfaceBound(rawObject.SourceSlot);
if (source.SurfaceResId == 0 && texture == null)
{
if (source.Blend == BlendKind.Opaque)
{
_perf?.RecordSkippedLayer();
continue;
}
int w = source.W > 0 ? source.W : _screenWidth;
int h = source.H > 0 ? source.H : _screenHeight;
_perf?.RecordFillLayer();
FillAffineQuad(w, h, affine, source.Tint, opacity * source.TintStrength / 255f);
}
else
{
if (!movieSurfaceBound && texture == null)
{
resolveStarted = _perf != null ? PerformanceFrameLog.Timestamp() : 0;
texture = _host.ResolveResIdTexture(source.SurfaceResId);
_perf?.RecordResolve(PerformanceFrameLog.Timestamp() - resolveStarted);
}
if (texture == null)
{
_perf?.RecordSkippedLayer();
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,
texture.Value.IsDynamic, source.Blend);
}
drawn++;
}
return drawn;
}
// Diagnostic (--gfx-log): print, per rendered frame, only the objects whose compositor outcome CHANGED
// since last frame (added / gone / drawn↔skip / resId change). Quiet until something actually changes, so
// the frame where the background drops out — and WHY — stands out. See systematic-debugging of the grey-BG.
private void LogGfxDecisionChanges(System.Collections.Generic.Dictionary<long, string> curr)
{
if (_gfxLogPath != null && _gfxLog == null)
{
var dir = System.IO.Path.GetDirectoryName(_gfxLogPath);
if (!string.IsNullOrEmpty(dir)) System.IO.Directory.CreateDirectory(dir);
_gfxLog = new System.IO.StreamWriter(_gfxLogPath!) { AutoFlush = true };
}
_gfxLogFrame++;
var lines = new System.Collections.Generic.List<string>();
foreach (var kv in curr)
if (!_lastGfxDecision.TryGetValue(kv.Key, out var prev) || prev != kv.Value)
lines.Add($" 0x{kv.Key:x}: {kv.Value}" + (_lastGfxDecision.ContainsKey(kv.Key) ? "" : " [NEW]"));
foreach (var kv in _lastGfxDecision)
if (!curr.ContainsKey(kv.Key))
lines.Add($" 0x{kv.Key:x}: GONE (was {kv.Value})");
if (lines.Count > 0 && _gfxLog != null)
{
_gfxLog.WriteLine($"[frame {_gfxLogFrame} nowMs={_clock.NowMs} page={_pageCount}] {curr.Count} visible, {lines.Count} changes:");
foreach (var l in lines) _gfxLog.WriteLine(l);
}
if (lines.Count > 0)
_timeline?.Event("objects", new() { ["visible_count"] = curr.Count, ["changes"] = lines.ToArray() });
_lastGfxDecision.Clear();
foreach (var kv in curr) _lastGfxDecision[kv.Key] = kv.Value;
}
// Blit one object's surface rect. Static source pixels are cached per (assetId, colorKey): on first use, texels
// matching the surface colorkey are made transparent (native bakes the key at load — engine-re.md §Blend).
// Mode 0 uses tintStrength to LERP texel RGB toward tint. Mode 1 uses packed RGB modulation and
// SRCALPHA/ONE additive composition; black source pixels therefore contribute nothing.
private void BlitLayer(RgbaImage decoded, int assetId, long colorKey, long tint, float tintStrength, int srcX, int srcY, int w, int h,
Age.Engine.Model.Affine2D localToDest, float alpha = 1f, bool multiplyTint = false,
bool dynamic = false, BlendKind blend = BlendKind.Alpha)
{
// Native gfx_object_blit_d3d9 clips the explicit source rectangle and returns without drawing when
// right<=left or bottom<=top. FIELD deliberately creates zero-area prototype objects from SO005;
// expanding those dimensions to the full texture leaks the entire spritesheet onto the map.
if (w <= 0 || h <= 0) return;
long sourcePrepStarted = _perf != null ? PerformanceFrameLog.Timestamp() : 0;
long sourcePrepAllocated = _perf != null ? PerformanceFrameLog.AllocatedBytes() : 0;
var cacheKey = (assetId, colorKey);
int sourceWidth, sourceHeight;
byte[] sourcePixels;
if (dynamic)
{
// Decoder samples replace the pixels of one retained surface. Never enter them in the static cache.
// Clone only when applying a key so the decoder-owned newest-frame buffer remains untouched.
sourceWidth = decoded.Width;
sourceHeight = decoded.Height;
sourcePixels = decoded.Pixels;
if (Age.Engine.Model.BlendMath.HasColorKey(colorKey))
{
sourcePixels = (byte[])sourcePixels.Clone();
BakeColorKey(sourcePixels, colorKey);
}
}
else
{
if (!_pixelCache.TryGetValue(cacheKey, out var cached))
{
byte[] pixels = decoded.Pixels;
if (Age.Engine.Model.BlendMath.HasColorKey(colorKey))
{
pixels = (byte[])pixels.Clone();
BakeColorKey(pixels, colorKey);
}
cached = new CachedPixels(decoded.Width, decoded.Height, pixels);
_pixelCache[cacheKey] = cached;
}
sourceWidth = cached.Width;
sourceHeight = cached.Height;
sourcePixels = cached.Rgba;
}
int sw = w;
int sh = h;
sw = System.Math.Min(sw, sourceWidth - srcX);
sh = System.Math.Min(sh, sourceHeight - srcY);
_perf?.RecordSourcePrep(PerformanceFrameLog.Timestamp() - sourcePrepStarted);
_perf?.RecordSourcePrepAllocation(PerformanceFrameLog.AllocatedBytes() - sourcePrepAllocated);
if (sw <= 0 || sh <= 0) return;
long rasterStarted = _perf != null ? PerformanceFrameLog.Timestamp() : 0;
Age.Engine.Model.SoftwareAffineRasterizer.BlitRgba(
_screenPixels, _screenWidth, _screenHeight, sourcePixels, sourceWidth, sourceHeight,
srcX, srcY, sw, sh, localToDest, tint, tintStrength, alpha, multiplyTint, blend);
_perf?.RecordRaster(sw, sh, localToDest, _screenWidth, _screenHeight, dynamic, blend,
PerformanceFrameLog.Timestamp() - rasterStarted);
}
private void FillAffineQuad(int w, int h, Age.Engine.Model.Affine2D localToDest, long tint, float alpha)
{
long rasterStarted = _perf != null ? PerformanceFrameLog.Timestamp() : 0;
Age.Engine.Model.SoftwareAffineRasterizer.FillRgba(
_screenPixels, _screenWidth, _screenHeight, w, h, localToDest, tint, alpha);
_perf?.RecordRaster(w, h, localToDest, _screenWidth, _screenHeight, false, BlendKind.Alpha,
PerformanceFrameLog.Timestamp() - rasterStarted);
}
// Alpha-blend a solid tint (0xRRGGBB) rectangle over the screen — the surfaceless fade/flash fill.
private void FillQuad(int dstX, int dstY, int w, int h, long tint, float alpha)
{
int ia = (int)(System.Math.Clamp(alpha, 0f, 1f) * 255);
if (ia == 0) return;
long rasterStarted = _perf != null ? PerformanceFrameLog.Timestamp() : 0;
int tr = (int)((tint >> 16) & 0xff), tg = (int)((tint >> 8) & 0xff), tb = (int)(tint & 0xff);
byte[] dst = _screenPixels;
int dw = _screenWidth, dh = _screenHeight;
int x0 = System.Math.Max(0, -dstX), x1 = System.Math.Min(w, dw - dstX);
int y0 = System.Math.Max(0, -dstY), y1 = System.Math.Min(h, dh - dstY);
if (x1 <= x0 || y1 <= y0) return;
for (int y = y0; y < y1; y++)
for (int x = x0; x < x1; x++)
{
int dxp = dstX + x, dyp = dstY + y;
int di = (dyp * dw + dxp) * 4;
dst[di] = (byte)((tr * ia + dst[di] * (255 - ia)) / 255);
dst[di + 1] = (byte)((tg * ia + dst[di + 1] * (255 - ia)) / 255);
dst[di + 2] = (byte)((tb * ia + dst[di + 2] * (255 - ia)) / 255);
dst[di + 3] = (byte)System.Math.Min(255, dst[di + 3] + ia);
}
_perf?.RecordFillLayer();
_perf?.RecordRaster(w, h, new Affine2D(1, 0, 0, 1, dstX, dstY),
_screenWidth, _screenHeight, false, BlendKind.Alpha,
PerformanceFrameLog.Timestamp() - rasterStarted);
}
// Make colorkey-matching texels transparent (native colorkey is baked at surface load).
private static void BakeColorKey(byte[] px, long colorKey)
{
for (int i = 0; i < px.Length; i += 4)
if (Age.Engine.Model.BlendMath.ColorKeyMatches(px[i], px[i + 1], px[i + 2], colorKey))
px[i + 3] = 0;
}
public void PageBreak() public void PageBreak()
{ {
_pageCount++; _pageCount++;