diff --git a/docs/engine-re.md b/docs/engine-re.md index 636ce2b..e228f4c 100644 --- a/docs/engine-re.md +++ b/docs/engine-re.md @@ -3876,11 +3876,15 @@ The port now gives op `0xd5` a dedicated non-presenting deadline wait which keep barrier held, while backbuffer `0x222` suspends that barrier and synchronously snapshots the exact callback state before returning. Incremental range publications preserve the preceding backbuffer: the software backend skips its black clear and the GPU backend appends the new draw layers over its retained stage. -An explicit backbuffer `0x20e` disables preservation for the following publication, and ordinary `0x20c` -continues to replace the retained stage as a full reconstruction. This removes the pre-animation -full-popup frame and supplies the preserved pixels needed by the `[0,59999)` close endpoint without -special-casing BUNKI handles. A one-frame black flicker is still visible at the end of close, however, so -some later publication or retained-stage reset remains to be reconciled. +An explicit backbuffer `0x20e` disables preservation for the following publication. Native +`gfx_render_frame@0x4820b0`, used by ordinary `0x20c`, likewise has no implicit clear: it draws all visible +objects over the selected target and leaves clearing to `0x20e`. The software backend can apply that rule +literally to its pixel buffer. Godot GPU nodes require a separate retention policy: partial ranges append +over the retained stage, nonempty full publications replace/reuse the existing Sprite2D pool, and empty +full publications leave the preceding stage untouched. Treating every full publication as an append caused +unbounded draw-node accumulation and rapid slowdown. This split removes the pre-animation full-popup frame +and prevents BUNKI's post-close empty `0x20c`—issued after releasing both the capture and stable menu +objects—from reconstructing an empty selected set over black, without stacking complete scenes. Surface text also remains modeled metadata rather than pixels. Selected-target publication now projects that metadata into the destination surface, and the final Godot labels inherit the retained object's @@ -3892,8 +3896,10 @@ text trails even though preserving the underlying software pixels or GPU draw la Focused timed-callback boundary coverage passes with the complete 532-test engine suite. The Godot build is warning-free and the Himegari-targeted threaded frontend selftest passes. User testing confirmed the -premature opening frame and accumulated text trails are gone. The close-only black frame remains the visual -acceptance gap. +premature opening frame and accumulated text trails are gone. The close-only black frame was traced to the +port's incorrect implicit clear on an empty full `0x20c`. The first preservation correction over-retained +GPU draw nodes; the bounded full/partial/empty publication policy passes the frontend selftest. User +retesting accepted both the restored performance and close behavior on 2026-07-30. ### Formatted integers on text surfaces — opcode `0x205` (2026-07-21) diff --git a/docs/opcode-reference.md b/docs/opcode-reference.md index cf9b875..1bc43d8 100644 --- a/docs/opcode-reference.md +++ b/docs/opcode-reference.md @@ -896,13 +896,13 @@ The worker clips the paired source and destination rectangles against both surfa - **evidence:** Ghidra /v2: op_0x20b_handler@0x422d50 converts x/y/width/height to an exclusive rectangle, clamps alpha to 255, repacks RGB to native ARGB, and calls the surface manager's rectangle-fill path at 0x4790e0. HISTORY.BIN clears each 600x30 name strip on temporary surface 0xc1 before draw-string. ### 0x20c `present-frame` (present-frame, argc 0) -- **summary:** Present the composited frame; label_1235a uses this on the read/message-skip branch to expose the completed foreground endpoint immediately. +- **summary:** Draw every visible retained object over the selected target without implicitly clearing it, then present the composited backbuffer; label_1235a uses this on the read/message-skip branch to expose the completed foreground endpoint immediately. - **grounding:** source=investigation, confidence=high - **depends on:** 0x223, 0x1c7, 0x1cc - **depended on by:** 0x21, 0x22, 0x23, 0x24, 0x25, 0x20d, 0x223 -- **evidence:** Ghidra: dispatch table FUN_00413860 param_1[0x26e9f]=gfx_op_0x20c_present_frame; 0x26e9f-0x26c93=0x20c. 2026-07-08. +- **evidence:** Ghidra /v2: dispatch table FUN_00413860 param_1[0x26e9f]=gfx_op_0x20c_present_frame; 0x26e9f-0x26c93=0x20c. The handler calls gfx_render_frame@0x4820b0, which begins a D3D scene, consumes queued surface commands, draws all visible retained objects, ends the scene, and presents the backbuffer without calling d3d_clear_render_target_black; opcode 0x20e is the separate clear. Refined 2026-07-30. -Native handler gfx_op_0x20c_present_frame -> gfx_render_frame @0x4820b0. This is an explicit retained-state publication boundary, not a continuously visible object-store mutation. The read/message-skip branch resets the animation service then presents; the port publishes and snaps pending 0x223 state here. Normal playback branches to 0x21c, which owns repeated render/wait/resume. Headless hosts remain non-blocking. +Native handler gfx_op_0x20c_present_frame -> gfx_render_frame @0x4820b0. This is an explicit retained-state publication boundary, not a continuously visible object-store mutation. The renderer does not clear its selected target: opcode 0x20e owns explicit black clearing, so full-frame publication preserves preceding target pixels beneath its redraw. The read/message-skip branch resets the animation service then presents; the port publishes and snaps pending 0x223 state here. Normal playback branches to 0x21c, which owns repeated render/wait/resume. Headless hosts remain non-blocking. ### 0x20d `select-render-target` (select-render-target, argc 1) - **summary:** Select an offscreen surface slot as Direct3D render target 0, or restore the device backbuffer when the operand is at least 1000. diff --git a/godot/GodotAdvHost.cs b/godot/GodotAdvHost.cs index 43b3dcb..033e50e 100644 --- a/godot/GodotAdvHost.cs +++ b/godot/GodotAdvHost.cs @@ -17,6 +17,10 @@ public enum HostPresentationReason DiscreteSourceCell = 16, } +public readonly record struct BackbufferPublicationPolicy( + bool PreserveExistingPixels, + bool AppendGpuLayers); + public sealed class GodotAdvHost : IHost { private readonly Main _main; @@ -99,7 +103,7 @@ public sealed class GodotAdvHost : IHost private readonly Dictionary> _renderTargetSnapshots = new(); private readonly object _backbufferRangeLock = new(); private GfxHandleRange _backbufferRange = GfxHandleRange.All; - private bool _backbufferPublicationIsIncremental; + private bool _backbufferPublicationPreservesExistingPixels; private bool _backbufferClearPending; private LegacyScreenTransition? _screenTransition; public volatile bool IsWaiting; @@ -477,7 +481,7 @@ public sealed class GodotAdvHost : IHost lock (_backbufferRangeLock) { _backbufferRange = new GfxHandleRange(firstHandle, count); - _backbufferPublicationIsIncremental = true; + _backbufferPublicationPreservesExistingPixels = true; } _timeline?.Event("present-object-range", new() { ["first"] = firstHandle, ["count"] = count }); // Native backbuffer 0x222 ends its D3D scene and calls Present before returning. Snapshot this @@ -493,7 +497,8 @@ public sealed class GodotAdvHost : IHost } } - public bool SnapshotBackbufferObjects(GfxState gfx, long nowMs, List snapshot) + public BackbufferPublicationPolicy SnapshotBackbufferObjects( + GfxState gfx, long nowMs, List snapshot) { gfx.SnapshotVisibleObjects(nowMs, snapshot); GfxHandleRange range; @@ -501,14 +506,27 @@ public sealed class GodotAdvHost : IHost lock (_backbufferRangeLock) { range = _backbufferRange; - preserveExistingPixels = _backbufferPublicationIsIncremental && !_backbufferClearPending; + preserveExistingPixels = + _backbufferPublicationPreservesExistingPixels && !_backbufferClearPending; _backbufferClearPending = false; } int write = 0; for (int read = 0; read < snapshot.Count; read++) if (range.Contains(snapshot[read].Handle)) snapshot[write++] = snapshot[read]; if (write < snapshot.Count) snapshot.RemoveRange(write, snapshot.Count - write); - return preserveExistingPixels; + return ResolveBackbufferPublicationPolicy( + preserveExistingPixels, range, snapshot.Count); + } + + internal static BackbufferPublicationPolicy ResolveBackbufferPublicationPolicy( + bool preserveExistingPixels, GfxHandleRange range, int visibleObjectCount) + { + // Native preservation is a pixel rule, not permission to retain an unbounded history of + // Godot Sprite2D nodes. Partial ranges must overlay. A nonempty full redraw compacts the GPU + // stage, while an empty full redraw leaves the preceding stage untouched. + bool appendGpuLayers = preserveExistingPixels + && (range != GfxHandleRange.All || visibleObjectCount == 0); + return new BackbufferPublicationPolicy(preserveExistingPixels, appendGpuLayers); } public void ConfigureAdvWaitIndicator(AdvWaitIndicatorConfig config) @@ -872,7 +890,9 @@ public sealed class GodotAdvHost : IHost lock (_backbufferRangeLock) { _backbufferRange = GfxHandleRange.All; - _backbufferPublicationIsIncremental = false; + // Native gfx_render_frame draws every visible retained object over the current target. + // It does not clear first; backbuffer clearing is the separate explicit opcode 0x20e. + _backbufferPublicationPreservesExistingPixels = true; } int started = gfx.StartForegroundTransitions(_clock.NowMs); int completed = gfx.CompleteForegroundTransitions(_clock.NowMs); @@ -1062,7 +1082,7 @@ public sealed class GodotAdvHost : IHost lock (_backbufferRangeLock) { _backbufferRange = GfxHandleRange.All; - _backbufferPublicationIsIncremental = false; + _backbufferPublicationPreservesExistingPixels = false; _backbufferClearPending = false; } lock (_textLock) diff --git a/godot/Main.cs b/godot/Main.cs index 2316b82..275aa69 100644 --- a/godot/Main.cs +++ b/godot/Main.cs @@ -1057,19 +1057,22 @@ public partial class Main : Godot.Control private void Recomposite() { bool gpuSnapshotCaptured = false; - bool preserveExistingPixels = false; + BackbufferPublicationPolicy publicationPolicy = default; if (_useGpuBackend && - TryRecompositeGpu(out gpuSnapshotCaptured, out preserveExistingPixels)) return; + TryRecompositeGpu(out gpuSnapshotCaptured, out publicationPolicy)) return; _gpuRenderer.Visible = false; _screenView.Visible = true; RecompositeSoftware( - gpuSnapshotCaptured ? _visibleSnapshot : null, preserveExistingPixels); + gpuSnapshotCaptured ? _visibleSnapshot : null, + publicationPolicy.PreserveExistingPixels); } - private bool TryRecompositeGpu(out bool snapshotCaptured, out bool preserveExistingPixels) + private bool TryRecompositeGpu( + out bool snapshotCaptured, + out BackbufferPublicationPolicy publicationPolicy) { snapshotCaptured = false; - preserveExistingPixels = 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; @@ -1077,7 +1080,7 @@ public partial class Main : Godot.Control 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 - preserveExistingPixels = + publicationPolicy = _host.SnapshotBackbufferObjects(_vm.Gfx, _clock.NowMs, _visibleSnapshot); snapshotCaptured = true; _perf?.RecordSnapshotAllocation(PerformanceFrameLog.AllocatedBytes() - allocationPhase); @@ -1101,7 +1104,7 @@ public partial class Main : Godot.Control _perf?.RecordClear(PerformanceFrameLog.Timestamp() - phase); int surfaceTextLabelIndex = 0; - _gpuRenderer.BeginFrame(preserveExistingPixels); + _gpuRenderer.BeginFrame(publicationPolicy.AppendGpuLayers); foreach (var v in _visibleSnapshot) { _perf?.RecordObject(v.TimeVarying); @@ -1275,8 +1278,8 @@ public partial class Main : Godot.Control { phase = _perf != null ? PerformanceFrameLog.Timestamp() : 0; allocationPhase = _perf != null ? PerformanceFrameLog.AllocatedBytes() : 0; - preserveExistingPixels = - _host.SnapshotBackbufferObjects(_vm.Gfx, _clock.NowMs, _visibleSnapshot); + preserveExistingPixels = _host.SnapshotBackbufferObjects( + _vm.Gfx, _clock.NowMs, _visibleSnapshot).PreserveExistingPixels; _perf?.RecordSnapshotAllocation( PerformanceFrameLog.AllocatedBytes() - allocationPhase); _perf?.RecordSnapshot(PerformanceFrameLog.Timestamp() - phase); @@ -2433,18 +2436,42 @@ public partial class Main : Godot.Control && _windowOptions == WindowLaunchOptions.Resolve( OS.GetCmdlineUserArgs(), new Sys4LogicalCanvas(_screenWidth, _screenHeight)); + var backbufferSnapshot = new List(); + _host.PresentFrame(_vm.Gfx); + BackbufferPublicationPolicy fullPolicy = + _host.SnapshotBackbufferObjects( + _vm.Gfx, _clock.NowMs, backbufferSnapshot); + bool backbufferPreservationOk = + fullPolicy + == new BackbufferPublicationPolicy( + PreserveExistingPixels: true, + AppendGpuLayers: backbufferSnapshot.Count == 0) + && GodotAdvHost.ResolveBackbufferPublicationPolicy( + true, GfxHandleRange.All, 1) + == new BackbufferPublicationPolicy(true, false) + && GodotAdvHost.ResolveBackbufferPublicationPolicy( + true, new GfxHandleRange(0, 60000), 1) + == new BackbufferPublicationPolicy(true, true); + _host.ClearRenderTarget(-1); + _host.PresentFrame(_vm.Gfx); + BackbufferPublicationPolicy clearedPolicy = + _host.SnapshotBackbufferObjects( + _vm.Gfx, _clock.NowMs, backbufferSnapshot); + backbufferPreservationOk &= + clearedPolicy == new BackbufferPublicationPolicy(false, false); textEffectSmoke.QueueFree(); ok &= launcherOk && sleepMinimumOk && inputTranslationOk && cp932WavMetadataOk && firstRiffBoundaryOk && bgmReplacementCancelsFade && bgmOneShotModeOk && bgmLoopModeOk && bgmStopReleaseOk && textEffectModesOk && fontCalibrationOk - && logicalCanvasOk; + && logicalCanvasOk && backbufferPreservationOk; if (ok) GD.Print($"SELFTEST OK: threaded host matches headless ({actual.Count} lines, full handling); " + $"debug launcher catalog/UI smoke ({debugEntries.Count} packed scripts); " + $"sleep-min=1ms; native-key-translation=ok; cp932-wav-info=ok; " + $"first-riff-boundary=ok; " + $"bgm-fade-replacement=ok; bgm-start-modes-stop=ok; " + $"text-effect-modes=ok; font-calibration=ok; " + + $"backbuffer-preservation=ok; " + $"logical-canvas={_screenWidth}x{_screenHeight}; " + $"window-request={_windowOptions.Width}x{_windowOptions.Height}"); else GD.Print($"SELFTEST FAIL: threaded={actual.Count} vs headless={expected.Count}; " + @@ -2455,6 +2482,7 @@ public partial class Main : Godot.Control $"bgm-one-shot={bgmOneShotModeOk}; bgm-loop={bgmLoopModeOk}; " + $"bgm-stop-release={bgmStopReleaseOk}; " + $"text-effect-modes={textEffectModesOk}; font-calibration={fontCalibrationOk}; " + + $"backbuffer-preservation={backbufferPreservationOk}; " + $"logical-canvas={logicalCanvasOk}({_screenWidth}x{_screenHeight}); " + $"window-request={_windowOptions.Width}x{_windowOptions.Height}"); GetTree().Quit(ok ? 0 : 1); diff --git a/vm-map/opcodes.toml b/vm-map/opcodes.toml index c06f433..4d922a4 100644 --- a/vm-map/opcodes.toml +++ b/vm-map/opcodes.toml @@ -5227,13 +5227,13 @@ abi_source = "kelebek+decode-validated" [opcode.semantics] name = "present-frame" category = "draw" -summary = "Present the composited frame; label_1235a uses this on the read/message-skip branch to expose the completed foreground endpoint immediately." -details = "Native handler gfx_op_0x20c_present_frame -> gfx_render_frame @0x4820b0. This is an explicit retained-state publication boundary, not a continuously visible object-store mutation. The read/message-skip branch resets the animation service then presents; the port publishes and snaps pending 0x223 state here. Normal playback branches to 0x21c, which owns repeated render/wait/resume. Headless hosts remain non-blocking." +summary = "Draw every visible retained object over the selected target without implicitly clearing it, then present the composited backbuffer; label_1235a uses this on the read/message-skip branch to expose the completed foreground endpoint immediately." +details = "Native handler gfx_op_0x20c_present_frame -> gfx_render_frame @0x4820b0. This is an explicit retained-state publication boundary, not a continuously visible object-store mutation. The renderer does not clear its selected target: opcode 0x20e owns explicit black clearing, so full-frame publication preserves preceding target pixels beneath its redraw. The read/message-skip branch resets the animation service then presents; the port publishes and snaps pending 0x223 state here. Normal playback branches to 0x21c, which owns repeated render/wait/resume. Headless hosts remain non-blocking." noop_headless = false source = "investigation" confidence = "high" depends_on = [0x223, 0x1c7, 0x1cc] -evidence = "Ghidra: dispatch table FUN_00413860 param_1[0x26e9f]=gfx_op_0x20c_present_frame; 0x26e9f-0x26c93=0x20c. 2026-07-08." +evidence = "Ghidra /v2: dispatch table FUN_00413860 param_1[0x26e9f]=gfx_op_0x20c_present_frame; 0x26e9f-0x26c93=0x20c. The handler calls gfx_render_frame@0x4820b0, which begins a D3D scene, consumes queued surface commands, draws all visible retained objects, ends the scene, and presents the backbuffer without calling d3d_clear_render_target_black; opcode 0x20e is the separate clear. Refined 2026-07-30." [[opcode]] op = 0x20d