From f5bc0adc8fa80de846ee5e6de02f959cd12c7ef1 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sun, 2 Aug 2026 18:38:12 -0400 Subject: [PATCH] Split Main compositor into partial class --- docs/PROJECT-STRUCTURE.md | 2 + docs/remake-architecture-and-roadmap.md | 15 +- godot/Main.Compositor.cs | 576 ++++++++++++++++++++++++ godot/Main.cs | 565 ----------------------- 4 files changed, 586 insertions(+), 572 deletions(-) create mode 100644 godot/Main.Compositor.cs diff --git a/docs/PROJECT-STRUCTURE.md b/docs/PROJECT-STRUCTURE.md index 59a0206..9eaa7f4 100644 --- a/docs/PROJECT-STRUCTURE.md +++ b/docs/PROJECT-STRUCTURE.md @@ -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 `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.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-.jsonl` files are produced by editor/development Godot runs and map runtime ADV page ordinals to their authoritative script offsets for `tools/locate_page.py`. Packaged exports diff --git a/docs/remake-architecture-and-roadmap.md b/docs/remake-architecture-and-roadmap.md index 70009ab..183c7d1 100644 --- a/docs/remake-architecture-and-roadmap.md +++ b/docs/remake-architecture-and-roadmap.md @@ -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 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 surface into `godot/Main.Audio.cs`, then decoder staging, movie frame/audio publication, completion, and - teardown into `godot/Main.Movie.cs`. The partial class retains the same node type, fields, signatures, - execution order, and call sites; runtime validation, including all 590 engine tests and the Godot threaded - self-test, remains green after each move. + teardown into `godot/Main.Movie.cs`, then retained GPU/software composition, texture caching, transitions, + raster helpers, and decision logging into `godot/Main.Compositor.cs`. The partial class retains the same + node type, fields, signatures, execution order, and call sites; runtime validation, including all 590 + engine tests and the Godot threaded self-test, remains green after each move. **Gate:** no externally visible behavior or command changes; generated artifacts are byte-identical where deterministic, and the corresponding engine, Python, Godot, and corpus validations remain green after @@ -936,8 +937,8 @@ layer's rendering diverges from ADV; save layout. ## 8. Immediate next step Continue step 2 of the **codebase consolidation** maintenance slice: behavior-neutral physical splits backed -by the tracked launcher and layered validation driver. With the embedded `Main` self-test, audio, and movie -surfaces isolated, move the retained compositor surface next, then one existing domain at a time while -preserving public types, commands, and generated output. +by the tracked launcher and layered validation driver. With the embedded `Main` self-test, audio, movie, and +retained compositor surfaces isolated, move input/cursor/debug-launcher handling next, then continue one +existing domain at a time while preserving public types, commands, and generated output. Concrete playthrough blockers may still preempt this bounded maintenance work; the consolidation effort does not replace Phase B gameplay validation or the open cross-platform gates. diff --git a/godot/Main.Compositor.cs b/godot/Main.Compositor.cs new file mode 100644 index 0000000..7697a85 --- /dev/null +++ b/godot/Main.Compositor.cs @@ -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 : log per-object compositor draw/skip CHANGES + private System.IO.StreamWriter? _gfxLog; + private readonly System.Collections.Generic.Dictionary _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 _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 ?? "", + 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 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? sampledVisible = null, + bool preserveExistingPixels = false) + { + if (_perf != null) + { + var presentStep = _trace.LatestStep; + _perf.RecordPresentationCoordinate(presentStep?.Script ?? "", + 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? 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 visible, + float globalOpacity, + System.Collections.Generic.Dictionary? 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 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 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(); + 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; + } + +} diff --git a/godot/Main.cs b/godot/Main.cs index b79efa8..e852c24 100644 --- a/godot/Main.cs +++ b/godot/Main.cs @@ -25,15 +25,7 @@ public partial class Main : Godot.Control private WindowLaunchOptions _windowOptions; private Sys4AssetCatalog _catalog = 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; - // 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 _locatorHud = null!; private VirtualMachine _vm = null!; @@ -65,10 +57,6 @@ public partial class Main : Godot.Control private string? _seqDir; // --shot-sequence : dump one PNG per frame (verify paced anim) private int _seqFrames = 180; // --frames : how many frames to dump (default ~3s @60fps) private int _seqIdx; - private string? _gfxLogPath; // --gfx-log : log per-object compositor draw/skip CHANGES - private System.IO.StreamWriter? _gfxLog; - private readonly System.Collections.Generic.Dictionary _lastGfxDecision = new(); - private int _gfxLogFrame; private string? _timelineLogPath; // --timeline-log : synchronized VM/host/compositor evidence private GodotTimelineLog? _timeline; 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}"); } } - // ---- 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 _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 ?? "", - 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 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? sampledVisible = null, - bool preserveExistingPixels = false) - { - if (_perf != null) - { - var presentStep = _trace.LatestStep; - _perf.RecordPresentationCoordinate(presentStep?.Script ?? "", - 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? 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 visible, - float globalOpacity, - System.Collections.Generic.Dictionary? 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 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 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(); - 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() { _pageCount++;