diff --git a/docs/engine-re.md b/docs/engine-re.md index 0954c9a..636ce2b 100644 --- a/docs/engine-re.md +++ b/docs/engine-re.md @@ -3849,17 +3849,51 @@ timed callbacks. Each transition frame publishes `[0,60000)`, deliberately inclu while excluding BUNKI's stable objects. On open, BUNKI publishes the stable menu only after BUNKIMOVE returns; on close, it removes the stable range after the reverse animation. -The port previously treated a backbuffer `0x222` as a generic repaint request and reconstructed every +The port originally treated a backbuffer `0x222` as a generic repaint request and reconstructed every visible retained object. It therefore drew handles `60000+` at full size above handle `59999`, making the real popup appear immediately while its opening animation ran behind it; closing had the symmetric error. -Godot now retains zero-based backbuffer publication ranges and filters reconstruction to the active range. -Nonzero ranges remain incremental overlays over a full reconstruction because the port does not preserve -native D3D backbuffer pixels between publications. +The first correction retained zero-based publication ranges and filtered reconstruction to the active +range, which removed the stable-popup overlay but still rebuilt each selected range from black rather than +preserving native D3D backbuffer pixels. + +The native lifecycle was refined on 2026-07-30 while diagnosing two remaining endpoint flashes. +`gfx_present_object_range@0x482230` never clears the selected target: it begins a D3D scene through +`d3d_begin_scene_nested@0x4709d0`, draws the selected retained objects, ends the scene through +`d3d_end_scene_nested@0x470a10`, and, for target `-1`, immediately reaches +`d3d_present_backbuffer@0x4713b0` through its default wrapper. `IDirect3DDevice9::Present` is therefore +part of each backbuffer `0x222`, while black clearing remains the separate explicit opcode `0x20e`. +Backbuffer ranges are incremental draws over preserved native pixels rather than complete scenes rebuilt +from black. + +Timed-callback pacing is not an implicit publication boundary in native AGE. Op `0xd5` enables run-state +bit `0x40`; that bit bypasses the outer loop's automatic `gfx_render_frame` path, and +`timed_callback_sequence_tick@0x408170` only sleeps and redirects the script PC. BUNKIMOVE's first visible +frame is consequently the first callback's scale mutation followed by explicit `0x222`. At the other +endpoint, close sets global `0x2` to one and BUNKIMOVE explicitly publishes `[0,59999)`, excluding capture +handle `59999`, before cleanup and BUNKI's final `0x20c`. + +The port now gives op `0xd5` a dedicated non-presenting deadline wait which keeps the script presentation +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. 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 projected scale and rotation. Consequently BUNKIMOVE's captured labels follow the same transform as its -panel instead of escaping as full-size Control overlays. +panel instead of escaping as full-size Control overlays. Because those labels are live metadata overlays +rather than pixels in the preserved backbuffer, each incremental publication replaces the preceding label +projection from index zero. Retaining old labels would accumulate every intermediate transform as visible +text trails even though preserving the underlying software pixels or GPU draw layers is correct. + +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. ### Formatted integers on text surfaces — opcode `0x205` (2026-07-21) diff --git a/docs/opcode-reference.md b/docs/opcode-reference.md index 81bf5aa..cf9b875 100644 --- a/docs/opcode-reference.md +++ b/docs/opcode-reference.md @@ -574,7 +574,9 @@ Native handler sleep_op_0xc8 @0x420ec0 is NON-BLOCKING: it arms a timer (sleep_t ### 0xd5 `run-timed-callback-sequence` (u004262C0, argc 1) - **summary:** Start or service the frame-local timed sequence, dispatching scheduled local callbacks through the penultimate entry and selecting the catch-up target when the following deadline is already late. - **grounding:** source=investigation, confidence=high -- **evidence:** Ghidra /v2: op_0xd5_handler@0x421090 retains the current script resource, starts the ctx+0x5f3ac timer, sorts the schedule, and keeps run-state bit 0x40 active only while cursor < last_index, making the final entry a non-dispatched look-ahead sentinel. timed_callback_sequence_tick@0x408170 waits to each deadline, compares the following entry deadline with elapsed time to select entry+8 versus entry+0xc, redirects the frame PC, and advances ctx+0x5f6a8. Operand 1 is an optional abort/fallback PC; HISTORY passes 0xffffffff. +- **evidence:** Ghidra /v2: op_0xd5_handler@0x421090 retains the current script resource, starts the ctx+0x5f3ac timer, sorts the schedule, and keeps run-state bit 0x40 active only while cursor < last_index, making the final entry a non-dispatched look-ahead sentinel. timed_callback_sequence_tick@0x408170 waits to each deadline, compares the following entry deadline with elapsed time to select entry+8 versus entry+0xc, redirects the frame PC, and advances ctx+0x5f6a8. engine_main_tick_with_exception_policy@0x411840 bypasses its ordinary gfx_render_frame path while only run-state bit 0x40 owns the wait. Operand 1 is an optional abort/fallback PC; HISTORY and BUNKIMOVE pass 0xffffffff. + +Run-state bit 0x40 suppresses the outer loop's ordinary retained-frame renderer while the timed service sleeps. A scheduled callback becomes visible only through its own explicit presentation opcode; the pacing wait is not itself a retained-state publication boundary. ### 0xd9 `clear-run-state-0x1000` (u00415880, argc 0) - **summary:** Clear native run/service bit 0x1000; if the secondary context is active, clear the same bit there. SC0000 executes it once after the initial SFX-channel reset, with no VM-visible result. diff --git a/engine/Age.Engine.Tests/TestSupport.cs b/engine/Age.Engine.Tests/TestSupport.cs index 65501e9..4f7062f 100644 --- a/engine/Age.Engine.Tests/TestSupport.cs +++ b/engine/Age.Engine.Tests/TestSupport.cs @@ -33,6 +33,7 @@ internal class RecordingHost : IHost public readonly List WaitIndicatorEnabledChanges = new(); public readonly List PublishedAdvTextLayouts = new(); public readonly List SleptDurations = new(); + public readonly List TimedCallbackWaitDurations = new(); public readonly List<(long Resource, int Channel)> SfxLoads = new(); public readonly List Voices = new(); public readonly List<(long Id, int PlaybackVariant)> VoiceRequests = new(); @@ -148,6 +149,11 @@ internal class RecordingHost : IHost public void SetCursorResource(long resourceId) => CursorResources.Add(resourceId); public void ClearCursorResource() => CursorClearCount++; public virtual void Sleep(long duration) => SleptDurations.Add(duration); + public virtual void WaitForTimedCallbackDeadline(long duration) + { + TimedCallbackWaitDurations.Add(duration); + Sleep(duration); + } public virtual void FrameYield() { } public void ResetSceneContext() => SceneContextResets++; public bool IsMessageSkipActive => MessageSkip; diff --git a/engine/Age.Engine.Tests/TimedCallbackOpsTests.cs b/engine/Age.Engine.Tests/TimedCallbackOpsTests.cs index 93eb5ee..5723240 100644 --- a/engine/Age.Engine.Tests/TimedCallbackOpsTests.cs +++ b/engine/Age.Engine.Tests/TimedCallbackOpsTests.cs @@ -22,6 +22,21 @@ public class TimedCallbackOpsTests } } + private sealed class NonPresentingClockHost : RecordingHost + { + private long _now; + public override long InputClockMilliseconds => _now; + + public override void Sleep(long duration) + => throw new InvalidOperationException("Timed callback pacing used presentation-capable sleep."); + + public override void WaitForTimedCallbackDeadline(long duration) + { + TimedCallbackWaitDurations.Add(duration); + _now += duration; + } + } + private static Script BuildTwoEventSequence() => ScriptAssembler.Assemble(Table, "TIMED_CALLBACKS", new List<(int, Operand[])> @@ -48,6 +63,7 @@ public class TimedCallbackOpsTests Assert.Equal(2, vm.Globals[0x100]); Assert.Equal(0, vm.Globals.GetValueOrDefault(0x101)); Assert.Equal(new long[] { 10, 10 }, host.SleptDurations); + Assert.Equal(new long[] { 10, 10 }, host.TimedCallbackWaitDurations); } [Fact] @@ -62,5 +78,19 @@ public class TimedCallbackOpsTests Assert.Equal(1, vm.Globals[0x100]); Assert.Equal(1, vm.Globals[0x101]); Assert.Equal(new long[] { 10 }, host.SleptDurations); + Assert.Equal(new long[] { 10 }, host.TimedCallbackWaitDurations); + } + + [Fact] + public void SequencePacingDoesNotUsePresentationCapableScriptSleep() + { + var host = new NonPresentingClockHost(); + var vm = new VirtualMachine(BuildTwoEventSequence(), Table, host); + + vm.Run(); + + Assert.Equal("exit", vm.HaltReason); + Assert.Equal(new long[] { 10, 10 }, host.TimedCallbackWaitDurations); + Assert.Empty(host.SleptDurations); } } diff --git a/engine/Age.Engine/Hosting/IHost.cs b/engine/Age.Engine/Hosting/IHost.cs index f4d69ea..fea8794 100644 --- a/engine/Age.Engine/Hosting/IHost.cs +++ b/engine/Age.Engine/Hosting/IHost.cs @@ -111,6 +111,9 @@ public interface IHost void SetCursorResource(long resourceId) { } void ClearCursorResource() { } void Sleep(long duration); + // Native op-0xd5 pacing sleeps inside run-state 0x40 without publishing retained gfx state. + // Interactive hosts must keep this distinct from presentation-capable script op-0xc8 sleep. + void WaitForTimedCallbackDeadline(long duration) => Sleep(duration); void FrameYield(); // Native op 0x9 resets scene-owned host services before reloading root script resource 0. // Global banks, engine configuration, decoded-asset caches, and persistent profile state survive. diff --git a/engine/Age.Engine/Vm/VirtualMachine.cs b/engine/Age.Engine/Vm/VirtualMachine.cs index 622f3db..fd65a38 100644 --- a/engine/Age.Engine/Vm/VirtualMachine.cs +++ b/engine/Age.Engine/Vm/VirtualMachine.cs @@ -2260,7 +2260,7 @@ public sealed class VirtualMachine long elapsedMs = _host.InputClockMilliseconds - _cur.TimedCallbackStartedAtMs.Value; if (elapsedMs < callback.DeadlineMs) { - _host.Sleep(callback.DeadlineMs - elapsedMs); + _host.WaitForTimedCallbackDeadline(callback.DeadlineMs - elapsedMs); elapsedMs = _host.InputClockMilliseconds - _cur.TimedCallbackStartedAtMs.Value; } diff --git a/godot/GodotAdvHost.cs b/godot/GodotAdvHost.cs index f853829..43b3dcb 100644 --- a/godot/GodotAdvHost.cs +++ b/godot/GodotAdvHost.cs @@ -99,6 +99,8 @@ 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 _backbufferClearPending; private LegacyScreenTransition? _screenTransition; public volatile bool IsWaiting; public volatile bool IsTransitionWaiting; @@ -472,26 +474,41 @@ public sealed class GodotAdvHost : IHost PublishObjectRangeToSurface(gfx, firstHandle, count); return; } - // The software/GPU port reconstructs the backbuffer instead of preserving native D3D pixels. - // Zero-based ranges therefore define the complete published scene. A nonzero range is an - // incremental overlay in native code, so retain the full reconstruction for those call sites. lock (_backbufferRangeLock) - _backbufferRange = firstHandle == 0 - ? new GfxHandleRange(firstHandle, count) - : GfxHandleRange.All; - System.Threading.Interlocked.Exchange(ref _presentRequested, 1); + { + _backbufferRange = new GfxHandleRange(firstHandle, count); + _backbufferPublicationIsIncremental = 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 + // exact retained state before its callback can mutate or release the capture surface. + bool scriptSuspended = SuspendScriptForPresentation(); + try + { + RequestSynchronizedPresentation(); + } + finally + { + ResumeScriptAfterPresentation(scriptSuspended); + } } - public void SnapshotBackbufferObjects(GfxState gfx, long nowMs, List snapshot) + public bool SnapshotBackbufferObjects(GfxState gfx, long nowMs, List snapshot) { gfx.SnapshotVisibleObjects(nowMs, snapshot); GfxHandleRange range; - lock (_backbufferRangeLock) range = _backbufferRange; + bool preserveExistingPixels; + lock (_backbufferRangeLock) + { + range = _backbufferRange; + preserveExistingPixels = _backbufferPublicationIsIncremental && !_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; } public void ConfigureAdvWaitIndicator(AdvWaitIndicatorConfig config) @@ -852,7 +869,11 @@ public sealed class GodotAdvHost : IHost }); return; } - lock (_backbufferRangeLock) _backbufferRange = GfxHandleRange.All; + lock (_backbufferRangeLock) + { + _backbufferRange = GfxHandleRange.All; + _backbufferPublicationIsIncremental = false; + } int started = gfx.StartForegroundTransitions(_clock.NowMs); int completed = gfx.CompleteForegroundTransitions(_clock.NowMs); if (started > 0 || completed > 0) @@ -1038,7 +1059,12 @@ public sealed class GodotAdvHost : IHost // scene_context_init_reset releases ordinary surface/movie bindings but keeps decoded asset // caches and process-owned audio/configuration available to the reloaded SYSTEM4 root. ReleaseSurfaceRange(0, 1000); - lock (_backbufferRangeLock) _backbufferRange = GfxHandleRange.All; + lock (_backbufferRangeLock) + { + _backbufferRange = GfxHandleRange.All; + _backbufferPublicationIsIncremental = false; + _backbufferClearPending = false; + } lock (_textLock) { _surfaceText.Clear(); @@ -1155,6 +1181,19 @@ public sealed class GodotAdvHost : IHost _timeline?.State("running", new() { ["sleep_complete"] = true }); } + public void WaitForTimedCallbackDeadline(long duration) + { + long ms = NormalizeSleepMilliseconds(duration, 1.0); + long deadline = _clock.NowMs + ms; + _timeline?.State("timed-callback-wait", + new() { ["duration_ms"] = ms, ["deadline_ms"] = deadline }); + // Keep the script-side presentation barrier held. Native run-state 0x40 advances the timer + // and message pump but suppresses ordinary retained rendering until the callback's explicit 0x222. + while (_clock.NowMs < deadline && !_stopping) + _frameSignal.WaitOne(50); + _timeline?.State("running", new() { ["timed_callback_due"] = true }); + } + // ---- texture ops (run on the VM thread; marshal Godot node work to the main thread) ---- public bool TraceOps; // --gfx-log: print set-texture/create-texture slot assignments (diagnose slot collisions) @@ -1533,11 +1572,14 @@ public sealed class GodotAdvHost : IHost public void ClearRenderTarget(int surfaceSlot) { - // The retained compositor rebuilds the backbuffer from black at the next publication boundary. - // For an offscreen target, discard separately retained text draws so its modeled pixel contents - // observe the native D3D clear as well. - if (surfaceSlot >= 0) + if (surfaceSlot < 0) { + lock (_backbufferRangeLock) _backbufferClearPending = true; + } + else + { + // For an offscreen target, discard separately retained text draws so its modeled pixel + // contents observe the native D3D clear as well. lock (_textLock) _surfaceText.Remove(surfaceSlot); lock (_imageLock) { diff --git a/godot/GpuRetainedRenderer.cs b/godot/GpuRetainedRenderer.cs index fd84fdc..4f78e81 100644 --- a/godot/GpuRetainedRenderer.cs +++ b/godot/GpuRetainedRenderer.cs @@ -64,9 +64,9 @@ internal sealed class GpuRetainedRenderer : IDisposable }; } - public void BeginFrame() + public void BeginFrame(bool preserveExistingLayers) { - _used = 0; + if (!preserveExistingLayers) _used = 0; _textureUploads = 0; _textureUploadTicks = 0; } diff --git a/godot/Main.cs b/godot/Main.cs index fa78970..2316b82 100644 --- a/godot/Main.cs +++ b/godot/Main.cs @@ -1057,15 +1057,19 @@ public partial class Main : Godot.Control private void Recomposite() { bool gpuSnapshotCaptured = false; - if (_useGpuBackend && TryRecompositeGpu(out gpuSnapshotCaptured)) return; + bool preserveExistingPixels = false; + if (_useGpuBackend && + TryRecompositeGpu(out gpuSnapshotCaptured, out preserveExistingPixels)) return; _gpuRenderer.Visible = false; _screenView.Visible = true; - RecompositeSoftware(gpuSnapshotCaptured ? _visibleSnapshot : null); + RecompositeSoftware( + gpuSnapshotCaptured ? _visibleSnapshot : null, preserveExistingPixels); } - private bool TryRecompositeGpu(out bool snapshotCaptured) + private bool TryRecompositeGpu(out bool snapshotCaptured, out bool preserveExistingPixels) { snapshotCaptured = false; + preserveExistingPixels = false; // 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; @@ -1073,7 +1077,8 @@ 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 - _host.SnapshotBackbufferObjects(_vm.Gfx, _clock.NowMs, _visibleSnapshot); + preserveExistingPixels = + _host.SnapshotBackbufferObjects(_vm.Gfx, _clock.NowMs, _visibleSnapshot); snapshotCaptured = true; _perf?.RecordSnapshotAllocation(PerformanceFrameLog.AllocatedBytes() - allocationPhase); _perf?.RecordSnapshot(PerformanceFrameLog.Timestamp() - phase); @@ -1096,7 +1101,7 @@ public partial class Main : Godot.Control _perf?.RecordClear(PerformanceFrameLog.Timestamp() - phase); int surfaceTextLabelIndex = 0; - _gpuRenderer.BeginFrame(); + _gpuRenderer.BeginFrame(preserveExistingPixels); foreach (var v in _visibleSnapshot) { _perf?.RecordObject(v.TimeVarying); @@ -1251,7 +1256,9 @@ public partial class Main : Godot.Control return drawn; } - private void RecompositeSoftware(IReadOnlyList? sampledVisible = null) + private void RecompositeSoftware( + IReadOnlyList? sampledVisible = null, + bool preserveExistingPixels = false) { if (_perf != null) { @@ -1264,10 +1271,24 @@ public partial class Main : Godot.Control 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); + _perf?.RecordSnapshotAllocation( + PerformanceFrameLog.AllocatedBytes() - allocationPhase); + _perf?.RecordSnapshot(PerformanceFrameLog.Timestamp() - phase); + } + if (hasScreenTransition) preserveExistingPixels = false; _perf?.BeginRecomposite(hasScreenTransition); phase = _perf != null ? PerformanceFrameLog.Timestamp() : 0; - System.Array.Clear(_screenPixels); + if (!preserveExistingPixels) + { + System.Array.Clear(_screenPixels); + } foreach (var label in _surfaceTextLabels) label.Visible = false; _perf?.RecordClear(PerformanceFrameLog.Timestamp() - phase); int surfaceTextLabelIndex = 0; @@ -1285,13 +1306,6 @@ public partial class Main : Godot.Control } else { - phase = _perf != null ? PerformanceFrameLog.Timestamp() : 0; - allocationPhase = _perf != null ? PerformanceFrameLog.AllocatedBytes() : 0; - if (sampledVisible == null) - _host.SnapshotBackbufferObjects( - _vm.Gfx, _clock.NowMs, _visibleSnapshot); // synchronized objects + publication range - _perf?.RecordSnapshotAllocation(PerformanceFrameLog.AllocatedBytes() - allocationPhase); - _perf?.RecordSnapshot(PerformanceFrameLog.Timestamp() - phase); allocationPhase = _perf != null ? PerformanceFrameLog.AllocatedBytes() : 0; CompositeVisibleObjects(sampledVisible ?? _visibleSnapshot, 1f, ref surfaceTextLabelIndex, decisions, true); _perf?.RecordCompositeAllocation(PerformanceFrameLog.AllocatedBytes() - allocationPhase); diff --git a/vm-map/opcodes.toml b/vm-map/opcodes.toml index 642b233..c06f433 100644 --- a/vm-map/opcodes.toml +++ b/vm-map/opcodes.toml @@ -2202,11 +2202,12 @@ abi_source = "kelebek+decode-validated" name = "run-timed-callback-sequence" category = "control" summary = "Start or service the frame-local timed sequence, dispatching scheduled local callbacks through the penultimate entry and selecting the catch-up target when the following deadline is already late." +details = "Run-state bit 0x40 suppresses the outer loop's ordinary retained-frame renderer while the timed service sleeps. A scheduled callback becomes visible only through its own explicit presentation opcode; the pacing wait is not itself a retained-state publication boundary." noop_headless = false source = "investigation" confidence = "high" depends_on = [] -evidence = "Ghidra /v2: op_0xd5_handler@0x421090 retains the current script resource, starts the ctx+0x5f3ac timer, sorts the schedule, and keeps run-state bit 0x40 active only while cursor < last_index, making the final entry a non-dispatched look-ahead sentinel. timed_callback_sequence_tick@0x408170 waits to each deadline, compares the following entry deadline with elapsed time to select entry+8 versus entry+0xc, redirects the frame PC, and advances ctx+0x5f6a8. Operand 1 is an optional abort/fallback PC; HISTORY passes 0xffffffff." +evidence = "Ghidra /v2: op_0xd5_handler@0x421090 retains the current script resource, starts the ctx+0x5f3ac timer, sorts the schedule, and keeps run-state bit 0x40 active only while cursor < last_index, making the final entry a non-dispatched look-ahead sentinel. timed_callback_sequence_tick@0x408170 waits to each deadline, compares the following entry deadline with elapsed time to select entry+8 versus entry+0xc, redirects the frame PC, and advances ctx+0x5f6a8. engine_main_tick_with_exception_policy@0x411840 bypasses its ordinary gfx_render_frame path while only run-state bit 0x40 owns the wait. Operand 1 is an optional abort/fallback PC; HISTORY and BUNKIMOVE pass 0xffffffff." [[opcode.semantics.args]] i = 1