Split Godot host presentation and input

This commit is contained in:
gamer147
2026-08-02 19:45:02 -04:00
parent b8eeb18b0c
commit c819e7b40b
4 changed files with 917 additions and 894 deletions

View File

@@ -148,6 +148,9 @@ full-width text entry.
`godot/GodotAdvHost.cs` retains cross-domain host coordination. Its partial-class companion `godot/GodotAdvHost.cs` retains cross-domain host coordination. Its partial-class companion
`godot/GodotAdvHost.AdvText.cs` owns live/retained ADV text, surface glyph rasterization and caching, `godot/GodotAdvHost.AdvText.cs` owns live/retained ADV text, surface glyph rasterization and caching,
history presentation, message-window alpha, and retained wait-indicator configuration/publication. history presentation, message-window alpha, and retained wait-indicator configuration/publication.
`godot/GodotAdvHost.PresentationInput.cs` owns script/presentation synchronization, waits and timing,
message-skip/input services, cursor and foreground waits, frame/backbuffer publication, transitions, and
scene-context lifecycle coordination.
The disposable `build/page-map-<SCENE>.jsonl` files are produced by editor/development Godot runs and map The disposable `build/page-map-<SCENE>.jsonl` files are produced by editor/development Godot runs and map
runtime ADV page ordinals to their authoritative script offsets for `tools/locate_page.py`. Packaged exports runtime ADV page ordinals to their authoritative script offsets for `tools/locate_page.py`. Packaged exports

View File

@@ -575,6 +575,12 @@ do not mix mechanical moves with semantic changes.
reset, and surface-lifecycle consumers retain direct partial-class access to the moved state. Runtime reset, and surface-lifecycle consumers retain direct partial-class access to the moved state. Runtime
validation remains green. validation remains green.
The second bounded `GodotAdvHost` split moved script/presentation barriers, diagnostic and text-entry waits,
input/message-skip services, cursor/foreground waits, frame and backbuffer publication, legacy transitions,
scene reset/stop, frame pulse, and timed waits into `godot/GodotAdvHost.PresentationInput.cs`. Shared surface,
movie, audio, and retained-text state remains directly accessible through the sealed partial class; runtime
validation remains green.
**Gate:** no externally visible behavior or command changes; generated artifacts are byte-identical where **Gate:** no externally visible behavior or command changes; generated artifacts are byte-identical where
deterministic, and the corresponding engine, Python, Godot, and corpus validations remain green after deterministic, and the corresponding engine, Python, Godot, and corpus validations remain green after
each domain move. each domain move.
@@ -946,7 +952,7 @@ layer's rendering diverges from ADV; save layout.
## 8. Immediate next step ## 8. Immediate next step
Continue step 2 of the **codebase consolidation** maintenance slice: behavior-neutral physical splits backed Continue step 2 of the **codebase consolidation** maintenance slice: behavior-neutral physical splits backed
by the tracked launcher and layered validation driver. With the planned `Main` domains and the first by the tracked launcher and layered validation driver. With the planned `Main` domains and the first
`GodotAdvHost` ADV-text surface isolated, move host presentation/input next, then continue one existing domain two `GodotAdvHost` domains isolated, move host surface storage/mutation next, then continue one existing domain
at a time while preserving public types, commands, and generated output. at a time while preserving public types, commands, and generated output.
Concrete playthrough blockers may still preempt this bounded maintenance work; the consolidation effort does Concrete playthrough blockers may still preempt this bounded maintenance work; the consolidation effort does
not replace Phase B gameplay validation or the open cross-platform gates. not replace Phase B gameplay validation or the open cross-platform gates.

View File

@@ -0,0 +1,907 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using Age.Engine.Hosting;
using Age.Engine.Model;
using Age.Engine.Sys4;
using Age.Engine.Text;
[Flags]
public enum HostPresentationReason
{
None = 0,
HostRequest = 1,
ScreenTransition = 2,
RetainedMutation = 4,
ContinuousChannel = 8,
DiscreteSourceCell = 16,
}
public readonly record struct BackbufferPublicationPolicy(
bool PreserveExistingPixels,
bool AppendGpuLayers);
public sealed partial class GodotAdvHost
{
private readonly object _scriptContextLock = new();
private readonly Stack<string> _scriptContexts = new();
private readonly ScriptPresentationBarrier _presentationBarrier = new();
private readonly AutoResetEvent _presentationRequestConsumed = new(false);
private readonly AutoResetEvent _diagnosticMessageCompleted = new(false);
private readonly AutoResetEvent _fullwidthTextEditCompleted = new(false);
private readonly object _fullwidthTextEditLock = new();
private FullwidthTextEditResult _fullwidthTextEditResult;
private readonly bool _synchronizeExplicitPresentation;
private long _explicitPresentationRequestGeneration;
private long _consumedPresentationRequestGeneration;
private readonly SemaphoreSlim _gate = new(0, 1);
private readonly AutoResetEvent _inputCallbackSignal = new(false);
private readonly System.Threading.AutoResetEvent _frameSignal = new(false);
private volatile bool _stopping;
private readonly object _messageSkipLock = new();
private bool _scriptMessageSkipActive;
private bool _physicalMessageSkipActive;
private volatile bool _messageSkipActive;
private volatile bool _advPagePresentationSuspended;
private volatile bool _modalMovieWaiting;
private volatile bool _modalMovieCancelled;
private GfxState? _foregroundGfx;
private volatile bool _foregroundTransitionWaitBypassed;
private readonly object _screenTransitionLock = new();
private readonly Dictionary<int, IReadOnlyList<RenderObject>> _renderTargetSnapshots = new();
private readonly object _backbufferRangeLock = new();
private GfxHandleRange _backbufferRange = GfxHandleRange.All;
private bool _backbufferPublicationPreservesExistingPixels;
private bool _backbufferClearPending;
private LegacyScreenTransition? _screenTransition;
public volatile bool IsWaiting;
public volatile bool IsTransitionWaiting;
public volatile bool IsSleeping;
public bool IsModalMovieWaiting => _modalMovieWaiting;
private int _presentRequested = 1;
private long _transitionStartedAtMs = -1;
public long TransitionStartedAtMs => System.Threading.Interlocked.Read(ref _transitionStartedAtMs);
public void ShowDiagnosticMessage(DiagnosticMessage message)
{
if (_stopping) return;
_timeline?.Event("diagnostic-message", new()
{
["caption"] = message.Caption,
["text"] = message.Text,
});
_main.CallDeferred("ShowAgeDiagnostic", message.Text, message.Caption);
while (!_stopping && !_diagnosticMessageCompleted.WaitOne(50)) { }
}
public void CompleteDiagnosticMessage() => _diagnosticMessageCompleted.Set();
public FullwidthTextEditResult EditFullwidthString(FullwidthTextEditRequest request)
{
if (_stopping) return new(false, request.CurrentText);
lock (_fullwidthTextEditLock)
_fullwidthTextEditResult = new(false, request.CurrentText);
_timeline?.Event("fullwidth-text-edit", new()
{
["current"] = request.CurrentText,
["initial"] = request.InitialText,
});
_main.CallDeferred("ShowAgeFullwidthTextEditor", request.InitialText);
while (!_stopping && !_fullwidthTextEditCompleted.WaitOne(50)) { }
lock (_fullwidthTextEditLock) return _fullwidthTextEditResult;
}
public void CompleteFullwidthTextEdit(bool accepted, string text)
{
lock (_fullwidthTextEditLock)
_fullwidthTextEditResult = new(accepted, text);
_fullwidthTextEditCompleted.Set();
}
private string CurrentScene
{
get { lock (_scriptContextLock) return _scriptContexts.TryPeek(out var scene) ? scene : _rootScene; }
}
public void EnterScriptContext(string scriptName)
{
_presentationBarrier.EnterScript();
string scene = System.IO.Path.GetFileNameWithoutExtension(scriptName).ToUpperInvariant();
lock (_scriptContextLock) _scriptContexts.Push(scene);
_timeline?.Event("script-context-enter", new() { ["scene"] = scene });
}
public void ExitScriptContext()
{
string? scene = null;
lock (_scriptContextLock)
if (_scriptContexts.TryPop(out var popped)) scene = popped;
if (scene != null) _timeline?.Event("script-context-exit", new() { ["scene"] = scene });
_presentationBarrier.ExitScript();
}
public bool TryEnterPresentation() => _presentationBarrier.TryEnterPresentation();
public void ExitPresentation() => _presentationBarrier.ExitPresentation();
private bool SuspendScriptForPresentation() => _presentationBarrier.SuspendScript();
private void ResumeScriptAfterPresentation(bool suspended)
=> _presentationBarrier.ResumeScript(suspended);
private void RequestSynchronizedPresentation()
{
long requested = Interlocked.Increment(ref _explicitPresentationRequestGeneration);
Interlocked.Exchange(ref _presentRequested, 1);
if (!_synchronizeExplicitPresentation) return;
while (Interlocked.Read(ref _consumedPresentationRequestGeneration) < requested && !_stopping)
_presentationRequestConsumed.WaitOne(50);
}
public void PresentObjectRange(GfxState gfx, long firstHandle, long count)
{
if (gfx.CurrentRenderTargetSlot >= 0)
{
PublishObjectRangeToSurface(gfx, firstHandle, count);
return;
}
lock (_backbufferRangeLock)
{
_backbufferRange = new GfxHandleRange(firstHandle, count);
_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
// exact retained state before its callback can mutate or release the capture surface.
bool scriptSuspended = SuspendScriptForPresentation();
try
{
RequestSynchronizedPresentation();
}
finally
{
ResumeScriptAfterPresentation(scriptSuspended);
}
}
public BackbufferPublicationPolicy SnapshotBackbufferObjects(
GfxState gfx, long nowMs, List<RenderObject> snapshot)
{
gfx.SnapshotVisibleObjects(nowMs, snapshot);
GfxHandleRange range;
bool preserveExistingPixels;
lock (_backbufferRangeLock)
{
range = _backbufferRange;
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 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 volatile int Pages; // VM-thread page counter (incremented before IsWaiting so shot-gating can't race)
public void WaitForInput() => WaitForInput(0);
public void WaitForInput(int layoutSlot)
=> WaitForInput(layoutSlot, static () => false, static () => default);
public void WaitForInput(int layoutSlot, Func<bool> serviceInputCallback)
=> WaitForInput(layoutSlot, serviceInputCallback, static () => default);
public void WaitForInput(int layoutSlot, Func<bool> serviceInputCallback,
Func<AdvAutoWaitState> autoWaitState)
{
Pages++;
_locator.Wait(Pages);
_main.CallDeferred("PageBreak");
lock (_textLock)
{
_activeWaitLayout = layoutSlot == 0 ? _currentAdvLayout : layoutSlot;
_waitIndicatorStartedMs = _clock.NowMs;
_waitIndicatorEnabled = true;
}
IsWaiting = true;
_timeline?.State("input-wait", new() { ["page"] = Pages });
var autoTimer = new AdvAutoAdvanceTimer();
bool autoAdvanced = false;
bool messageSkipped = false;
bool scriptSuspended = SuspendScriptForPresentation();
try
{
// Publish the completed pre-wait burst once. Callback scripts temporarily reacquire the
// write side below, so each hover/click update is likewise exposed only after it returns.
RequestSynchronizedPresentation();
while (!_stopping)
{
while (true)
{
bool markerWasEnabled;
lock (_textLock) markerWasEnabled = _waitIndicatorEnabled;
if (scriptSuspended) ResumeScriptAfterPresentation(true);
bool keepServicing;
try
{
keepServicing = serviceInputCallback();
}
finally
{
if (scriptSuspended && !SuspendScriptForPresentation())
throw new InvalidOperationException("Input callback did not retain the script burst.");
}
// The native callback returns through the shared ADV redraw/wait path, whose op 0x72
// re-arms a marker stopped by nested HISTORY. Our blocking host keeps the parent wait
// parked, so restore that parent-owned state at the equivalent callback boundary.
lock (_textLock)
{
if (markerWasEnabled && !_waitIndicatorEnabled)
{
_waitIndicatorEnabled = true;
_waitIndicatorStartedMs = _clock.NowMs;
}
}
if (!keepServicing) break;
}
if (_messageSkipActive)
{
messageSkipped = true;
break;
}
if (autoTimer.Poll(autoWaitState(), _main.IsVoicePlaybackActive, _clock.NowMs))
{
autoAdvanced = true;
break;
}
if (_gate.Wait(0)) break;
WaitHandle.WaitAny(new[] { _gate.AvailableWaitHandle, _inputCallbackSignal, _frameSignal });
if (_gate.Wait(0)) break;
}
}
finally
{
ResumeScriptAfterPresentation(scriptSuspended);
}
IsWaiting = false;
lock (_textLock) _waitIndicatorEnabled = false;
Interlocked.Exchange(ref _presentRequested, 1);
_timeline?.State("running", new()
{
["input"] = messageSkipped ? "message-skip" : autoAdvanced ? "auto" : "user",
});
lock (_textLock)
{
_advText = "";
_advTextX = 100;
_advTextY = 47;
}
_main.CallDeferred("ClearPage");
}
// Called from the main thread (click) or an auto-clicker. A transition click is consumed by
// the foreground lifecycle; it never pre-arms or advances the following stable input wait.
public void SignalInput()
{
if (_modalMovieWaiting)
{
_modalMovieCancelled = true;
_timeline?.State("modal-movie-cancel", new());
_frameSignal.Set();
return;
}
if (IsTextRevealing)
{
lock (_textLock) _advTextForceComplete = true;
_timeline?.State("text-reveal-forced-complete", new());
_frameSignal.Set();
return;
}
if (TryForceActiveTransition("advance")) return;
if (IsWaiting && _gate.CurrentCount == 0) _gate.Release();
}
private bool TryForceActiveTransition(string source)
{
if (IsTransitionWaiting && _foregroundGfx != null)
{
bool accepted = _foregroundGfx.TryCompleteClickSkippableTimedPresentation(
_clock.NowMs, out int completed);
if (accepted)
{
_foregroundTransitionWaitBypassed = true;
_timeline?.State("transition-skip-accepted", new()
{
["endpoints_completed"] = completed,
["source"] = source,
["wait_bypassed"] = true,
});
_frameSignal.Set();
return true;
}
}
if (IsTransitionWaiting)
{
lock (_screenTransitionLock)
{
if (_screenTransition != null)
{
_screenTransition.Forced = true;
_timeline?.State("screen-transition-forced-complete", new()
{
["source"] = source,
});
_frameSignal.Set();
return true;
}
}
}
return false;
}
public bool IsMessageSkipActive => _messageSkipActive;
public void SetMessageSkipActive(bool active)
=> SetMessageSkipChannel(active, physical: false);
public void SetPhysicalMessageSkipActive(bool active)
=> SetMessageSkipChannel(active, physical: true);
private void SetMessageSkipChannel(bool active, bool physical)
{
bool effective;
bool changed;
(AudioPayload Audio, int PlaybackVariant)? queued = null;
lock (_messageSkipLock)
{
if (physical) _physicalMessageSkipActive = active;
else _scriptMessageSkipActive = active;
effective = _scriptMessageSkipActive || _physicalMessageSkipActive;
changed = effective != _messageSkipActive;
_messageSkipActive = effective;
if (changed && !effective)
{
queued = _queuedSkippedVoice;
_queuedSkippedVoice = null;
}
}
if (!changed) return;
_timeline?.State("message-skip", new()
{
["enabled"] = effective,
["source"] = physical ? "logical-action-6" : "script",
});
if (effective)
{
lock (_textLock) _advTextForceComplete = true;
TryForceActiveTransition("message-skip");
_frameSignal.Set();
_inputCallbackSignal.Set();
return;
}
if (queued != null) DispatchVoice(queued.Value.Audio, queued.Value.PlaybackVariant);
}
public void WakeInputCallbackService() => _inputCallbackSignal.Set();
public bool IsAdvPagePresentationSuspended => _advPagePresentationSuspended;
public void SetAdvPagePresentationSuspended(bool suspended)
{
_advPagePresentationSuspended = suspended;
_timeline?.State(suspended ? "adv-page-suspended" : "adv-page-restored", new());
}
public void SetAdvPagePresentationSuspended(GfxState gfx, bool suspended)
{
lock (_textLock)
{
if (suspended)
{
int slot = IsWaiting && _activeWaitLayout != 0
? _activeWaitLayout
: _currentAdvLayout;
if (_retainedTextLayouts.TryGetValue(
slot, out RetainedAdvTextLayoutPresentation? presentation))
presentation.ErasePublished(gfx);
if (_retainedWaitIndicators.TryGetValue(
slot, out RetainedAdvWaitIndicatorPresentation? indicator))
indicator.Erase(gfx);
if (_retainedTextLayouts.ContainsKey(slot)
|| _retainedWaitIndicators.ContainsKey(slot))
_suspendedRetainedTextLayoutSlot = slot;
}
else if (_suspendedRetainedTextLayoutSlot != 0)
{
if (_retainedTextLayouts.TryGetValue(
_suspendedRetainedTextLayoutSlot,
out RetainedAdvTextLayoutPresentation? presentation))
presentation.Republish(gfx);
if (_retainedWaitIndicators.TryGetValue(
_suspendedRetainedTextLayoutSlot,
out RetainedAdvWaitIndicatorPresentation? indicator))
indicator.Republish(
gfx,
_clock.NowMs - _waitIndicatorStartedMs,
IsWaiting
&& _waitIndicatorEnabled
&& _activeWaitLayout == _suspendedRetainedTextLayoutSlot);
_suspendedRetainedTextLayoutSlot = 0;
}
}
Interlocked.Exchange(ref _presentRequested, 1);
SetAdvPagePresentationSuspended(suspended);
}
public void InputCallbackCompleted(GfxState gfx)
{
// Callback completion itself is not native graphics dirtiness. Any retained writes made by the
// callback are published through GfxState's mutation generation; host-owned surface writes set
// _presentRequested at their actual mutation sites. FIELD services a 50 ms hover callback even
// while the pointer is idle, so an unconditional request here recreates its sleep-poll overdraw.
}
public long InputClockMilliseconds => _clock.NowMs;
public void SetCursorResource(long resourceId)
{
var asset = _res.ResolveCursor(resourceId);
if (asset == null) return;
try
{
var cursor = _res.DecodeCursor(asset);
_main.CallDeferred("SetAgeCursor", cursor.Image.Pixels, cursor.Image.Width, cursor.Image.Height,
cursor.HotspotX, cursor.HotspotY);
_timeline?.Event("cursor-set", new() { ["resource"] = resourceId, ["asset"] = asset.Name });
}
catch (Exception ex)
{
System.Console.Error.WriteLine($"cursor decode {asset.Name}: {ex.Message}");
}
}
public void ClearCursorResource()
{
_main.CallDeferred("ClearAgeCursor");
_timeline?.Event("cursor-clear", new());
}
public void WaitForForegroundTransition(GfxState gfx)
{
_foregroundTransitionWaitBypassed = false;
int started = gfx.StartForegroundTransitions(_clock.NowMs);
bool hasActivePresentation =
gfx.HasActiveTimedPresentation(_clock.NowMs) || HasActiveMoviePresentation();
if (hasActivePresentation)
{
_foregroundGfx = gfx;
System.Threading.Interlocked.Exchange(ref _transitionStartedAtMs, _clock.NowMs);
IsTransitionWaiting = true;
_timeline?.State("transition-start", new() { ["count"] = started });
// Skip may already have been active before this service boundary. Native ADV script usually
// selects 0x243+0x20c in that case, but applying it here also covers transition helpers that
// enter run-state 0x400 without repeating the script-side query.
if (_messageSkipActive) TryForceActiveTransition("message-skip");
}
bool scriptSuspended = SuspendScriptForPresentation();
try
{
RequestSynchronizedPresentation();
if (!hasActivePresentation) return;
int lastBucket = -1;
while (!_foregroundTransitionWaitBypassed
&& (gfx.HasActiveTimedPresentation(_clock.NowMs) || HasActiveMoviePresentation())
&& !_stopping)
{
var active = gfx.SnapshotForegroundTransitions(_clock.NowMs);
int bucket = active.Count == 0 ? 100 : (int)System.Math.Floor(active[0].Progress * 10);
if (bucket != lastBucket)
{
lastBucket = bucket;
_timeline?.State("transition-progress", new()
{
["progress"] = active.Count == 0 ? 1.0 : active[0].Progress,
["forced"] = active.Count != 0 && active[0].Forced,
});
}
_frameSignal.WaitOne(50);
}
// Publish the natural terminal sample or the forced service-resume sample once before the
// following script burst mutates/releases its retained inputs.
RequestSynchronizedPresentation();
}
finally
{
ResumeScriptAfterPresentation(scriptSuspended);
}
IsTransitionWaiting = false;
_foregroundTransitionWaitBypassed = false;
System.Threading.Interlocked.Exchange(ref _transitionStartedAtMs, -1);
_foregroundGfx = null;
_timeline?.State("running", new() { ["transition_complete"] = true });
}
public void PresentFrame(GfxState gfx)
{
if (gfx.CurrentRenderTargetSlot >= 0)
{
int slot = gfx.CurrentRenderTargetSlot;
var snapshot = gfx.SnapshotVisibleObjects(_clock.NowMs);
PublishObjectRangeToSurface(gfx, 0, long.MaxValue, snapshot);
lock (_screenTransitionLock) _renderTargetSnapshots[slot] = snapshot;
_timeline?.Event("render-target-snapshot", new()
{
["surface"] = slot, ["objects"] = snapshot.Count,
});
return;
}
lock (_backbufferRangeLock)
{
_backbufferRange = GfxHandleRange.All;
// 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);
if (started > 0 || completed > 0)
_timeline?.State("transition-skip-complete", new()
{
["started"] = started, ["completed"] = completed,
});
bool scriptSuspended = SuspendScriptForPresentation();
try
{
RequestSynchronizedPresentation();
}
finally
{
ResumeScriptAfterPresentation(scriptSuspended);
}
}
public void FadeSurfaceWithBlack(
GfxState gfx, int surface, long intervalArgument, SurfaceBlackFadeDirection direction,
bool forceEndpoint = false)
{
long start = _clock.NowMs;
IReadOnlyList<RenderObject> captured;
lock (_screenTransitionLock)
captured = _renderTargetSnapshots.TryGetValue(surface, out var snapshot)
? snapshot : System.Array.Empty<RenderObject>();
IReadOnlyList<RenderObject> black = System.Array.Empty<RenderObject>();
IReadOnlyList<RenderObject> source =
direction == SurfaceBlackFadeDirection.FromBlack ? black : captured;
IReadOnlyList<RenderObject> target =
direction == SurfaceBlackFadeDirection.FromBlack ? captured : black;
RunLegacyScreenTransition(source, target, start, intervalArgument, forceEndpoint, new()
{
["kind"] = "surface-black-fade",
["surface"] = surface,
["direction"] = direction == SurfaceBlackFadeDirection.FromBlack
? "from-black" : "to-black",
});
}
public void CrossfadeSurfaces(
GfxState gfx, int sourceSurface, int targetSurface, long intervalArgument,
bool forceEndpoint = false)
{
IReadOnlyList<RenderObject> source;
IReadOnlyList<RenderObject> target;
long start = _clock.NowMs;
lock (_screenTransitionLock)
{
source = _renderTargetSnapshots.TryGetValue(sourceSurface, out var capturedSource)
? capturedSource : System.Array.Empty<RenderObject>();
target = _renderTargetSnapshots.TryGetValue(targetSurface, out var capturedTarget)
? capturedTarget : gfx.SnapshotVisibleObjects(start);
}
RunLegacyScreenTransition(source, target, start, intervalArgument, forceEndpoint, new()
{
["kind"] = "surface-crossfade",
["source"] = sourceSurface,
["target"] = targetSurface,
});
}
// The native 0x21/0x22/0x25 family advances an 8-bit alpha accumulator. Values <=64 use
// the operand as the timer interval and step alpha by sixteen; larger values divide the
// interval by sixteen and step alpha by one. This reproduces its blocking wall-clock duration.
private void RunLegacyScreenTransition(
IReadOnlyList<RenderObject> source, IReadOnlyList<RenderObject> target,
long start, long intervalArgument, bool forceEndpoint,
Dictionary<string, object?> timelineDetail)
{
long duration = LegacyScreenTransitionTiming.DurationMilliseconds(intervalArgument);
lock (_screenTransitionLock)
_screenTransition = new LegacyScreenTransition(source, target, start, duration)
{
Forced = forceEndpoint,
};
_foregroundGfx = null;
System.Threading.Interlocked.Exchange(ref _transitionStartedAtMs, start);
System.Threading.Interlocked.Exchange(ref _presentRequested, 1);
IsTransitionWaiting = true;
timelineDetail["interval_argument"] = intervalArgument;
timelineDetail["duration_ms"] = duration;
timelineDetail["source_objects"] = source.Count;
timelineDetail["target_objects"] = target.Count;
timelineDetail["forced"] = forceEndpoint;
_timeline?.State("screen-transition-start", timelineDetail);
bool scriptSuspended = SuspendScriptForPresentation();
try
{
RequestSynchronizedPresentation();
while (!_stopping)
{
bool complete;
lock (_screenTransitionLock)
complete = _screenTransition == null || _screenTransition.Forced
|| _clock.NowMs - start >= duration;
if (complete) break;
_frameSignal.WaitOne(50);
}
// Publish the exact target endpoint before the following surface releases/root reload.
lock (_screenTransitionLock)
if (_screenTransition != null) _screenTransition.Forced = true;
RequestSynchronizedPresentation();
// Do not let the VM release both source surfaces (or immediately reload SYSTEM4) until the
// main thread has actually published the terminal target frame.
while (!_stopping)
{
lock (_screenTransitionLock)
if (_screenTransition == null) break;
_frameSignal.WaitOne(50);
}
}
finally
{
ResumeScriptAfterPresentation(scriptSuspended);
}
IsTransitionWaiting = false;
System.Threading.Interlocked.Exchange(ref _transitionStartedAtMs, -1);
_timeline?.State("running", new() { ["screen_transition_complete"] = true });
}
public bool TrySnapshotScreenTransition(out LegacyScreenTransitionSnapshot snapshot)
{
lock (_screenTransitionLock)
{
if (_screenTransition == null)
{
snapshot = default;
return false;
}
double progress = _screenTransition.Forced ? 1.0
: System.Math.Clamp((_clock.NowMs - _screenTransition.StartMs)
/ (double)_screenTransition.DurationMs, 0.0, 1.0);
snapshot = new LegacyScreenTransitionSnapshot(
_screenTransition.Source, _screenTransition.Target, progress);
if (_screenTransition.Forced) _screenTransition = null;
return true;
}
}
// Native retained-object writes are not front-buffer writes. Publish explicit/service-boundary dirtiness
// once, then continue only while the sampled retained scene can actually change. The wait atlas advances
// its ordinary retained source cell here so it contributes only when the frame actually changes.
public HostPresentationReason ConsumePresentationReasons(GfxState gfx)
{
UpdateRetainedWaitIndicator(gfx);
bool screenTransitionActive;
lock (_screenTransitionLock) screenTransitionActive = _screenTransition != null;
var reasons = HostPresentationReason.None;
if (System.Threading.Interlocked.Exchange(ref _presentRequested, 0) != 0)
{
reasons |= HostPresentationReason.HostRequest;
long requested = Interlocked.Read(ref _explicitPresentationRequestGeneration);
Interlocked.Exchange(ref _consumedPresentationRequestGeneration, requested);
_presentationRequestConsumed.Set();
}
if (screenTransitionActive) reasons |= HostPresentationReason.ScreenTransition;
GfxPresentationReason gfxReasons = gfx.ConsumePresentationReasons(_clock.NowMs);
if ((gfxReasons & GfxPresentationReason.RetainedMutation) != 0)
reasons |= HostPresentationReason.RetainedMutation;
if ((gfxReasons & GfxPresentationReason.ContinuousChannel) != 0)
reasons |= HostPresentationReason.ContinuousChannel;
if ((gfxReasons & GfxPresentationReason.DiscreteSourceCell) != 0)
reasons |= HostPresentationReason.DiscreteSourceCell;
return reasons;
}
public void Stop()
{
_stopping = true;
_main.CallDeferred("ClearAgeCursor");
lock (_textLock) _advTextForceComplete = true;
if (_gate.CurrentCount == 0) _gate.Release();
_inputCallbackSignal.Set();
_frameSignal.Set();
_diagnosticMessageCompleted.Set();
_fullwidthTextEditCompleted.Set();
}
public void ResetSceneContext()
{
// 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;
_backbufferPublicationPreservesExistingPixels = false;
_backbufferClearPending = false;
}
lock (_textLock)
{
_surfaceResources.Clear();
_retainedHistoryLayouts.Clear();
_liveText.Clear();
_retainedTextLayouts.Clear();
_activeLiveText = null;
_suspendedRetainedTextLayoutSlot = 0;
_advText = "";
_advTextX = 100;
_advTextY = 47;
_advTextForceComplete = false;
_waitIndicators.Clear();
_waitIndicatorBindings.Clear();
_retainedWaitIndicators.Clear();
_activeWaitLayout = 0;
_waitIndicatorEnabled = false;
}
while (_gate.Wait(0)) { }
_inputCallbackSignal.WaitOne(0);
lock (_messageSkipLock)
{
_scriptMessageSkipActive = false;
_physicalMessageSkipActive = false;
_messageSkipActive = false;
_queuedSkippedVoice = null;
}
lock (_scheduledVoiceLock) _scheduledVoice = null;
System.Threading.Volatile.Write(ref _voiceBgmDuckControl, 0);
_advPagePresentationSuspended = false;
_modalMovieCancelled = false;
_modalMovieWaiting = false;
_foregroundGfx = null;
lock (_screenTransitionLock)
{
_renderTargetSnapshots.Clear();
_screenTransition = null;
}
IsWaiting = false;
IsTransitionWaiting = false;
IsSleeping = false;
IsTextRevealing = false;
System.Threading.Interlocked.Exchange(ref _transitionStartedAtMs, -1);
System.Threading.Interlocked.Exchange(ref _presentRequested, 1);
_main.CallDeferred("CancelScheduledSoundEffectStarts");
_main.CallDeferred("ClearPage");
_timeline?.State("scene-context-reset", new());
}
// Main thread, once per rendered frame: releases a VM thread parked in Sleep or a presentation/input wait.
public void PulseFrame()
{
(AudioPayload Audio, int PlaybackVariant)? due = null;
lock (_scheduledVoiceLock)
{
if (_scheduledVoice is { } pending)
{
uint now = unchecked((uint)_clock.NowMs);
if (!pending.StartMs.HasValue)
_scheduledVoice = pending with { StartMs = now };
else if (unchecked(now - pending.StartMs.Value) >= pending.DelayMs)
{
due = (pending.Audio, pending.PlaybackVariant);
_scheduledVoice = null;
}
}
}
if (due.HasValue)
{
_timeline?.Event("voice-scheduled-start", new()
{
["file"] = due.Value.Audio.Name, ["playback_variant"] = due.Value.PlaybackVariant,
});
DispatchVoice(due.Value.Audio, due.Value.PlaybackVariant);
}
_frameSignal.Set();
}
// Native dispatch remains burst-fast between explicit presentation/sleep/input services even when
// message Skip removes those services. Per-op frame pacing turns ordinary skipped setup/cleanup bursts
// into multi-second invisible stalls.
public void FrameYield() { }
// op 0xc8: block the VM background thread while the main-thread compositor keeps presenting retained state.
// Time-based sibling of WaitForInput's suspend. The native op arms a non-blocking main-loop-polled timer;
// blocking this throwaway task thread is behaviorally equivalent given our threading model. Operand is
// MILLISECONDS (docs/engine-re.md sleep section + opcodes.toml 0xc8). Headless CLI hosts no-op it (parity).
public double SleepScale = 1.0; // --sleep-scale <f>: debug multiplier for explicit op-0xc8 holds only
// Wait on the unified FrameClock timebase (not Thread.Sleep) so the Speed multiplier scales
// sleeps together with retained presentation clocks. Main._Process advances the clock + pulses each frame.
internal static long NormalizeSleepMilliseconds(long duration, double scale)
=> (long)System.Math.Clamp(duration * scale, 1, 60_000);
public void Sleep(long duration)
{
// Native sleep_timer_arm clamps the duration to at least 1 ms. In menu poll loops, sleep(0)
// therefore yields to the next engine tick instead of becoming a free-running no-op.
long ms = NormalizeSleepMilliseconds(duration, SleepScale);
long deadline = _clock.NowMs + ms;
_timeline?.State("sleep", new() { ["duration_ms"] = ms, ["deadline_ms"] = deadline });
IsSleeping = true;
bool scriptSuspended = SuspendScriptForPresentation();
try
{
RequestSynchronizedPresentation();
while (_clock.NowMs < deadline)
{
if (_stopping) break;
_frameSignal.WaitOne(50);
}
}
finally
{
ResumeScriptAfterPresentation(scriptSuspended);
}
IsSleeping = false;
_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 readonly record struct LegacyScreenTransitionSnapshot(
IReadOnlyList<RenderObject> Source, IReadOnlyList<RenderObject> Target, double Progress);
internal sealed class LegacyScreenTransition
{
public IReadOnlyList<RenderObject> Source { get; }
public IReadOnlyList<RenderObject> Target { get; }
public long StartMs { get; }
public long DurationMs { get; }
public bool Forced { get; set; }
public LegacyScreenTransition(IReadOnlyList<RenderObject> source, IReadOnlyList<RenderObject> target,
long startMs, long durationMs)
{
Source = source;
Target = target;
StartMs = startMs;
DurationMs = durationMs;
}
}

View File

@@ -7,37 +7,11 @@ using Age.Engine.Model;
using Age.Engine.Sys4; using Age.Engine.Sys4;
using Age.Engine.Text; using Age.Engine.Text;
[Flags]
public enum HostPresentationReason
{
None = 0,
HostRequest = 1,
ScreenTransition = 2,
RetainedMutation = 4,
ContinuousChannel = 8,
DiscreteSourceCell = 16,
}
public readonly record struct BackbufferPublicationPolicy(
bool PreserveExistingPixels,
bool AppendGpuLayers);
public sealed partial class GodotAdvHost : IHost public sealed partial class GodotAdvHost : IHost
{ {
private readonly Main _main; private readonly Main _main;
private readonly ResourceMap _res; private readonly ResourceMap _res;
private readonly string _rootScene; private readonly string _rootScene;
private readonly object _scriptContextLock = new();
private readonly Stack<string> _scriptContexts = new();
private readonly ScriptPresentationBarrier _presentationBarrier = new();
private readonly AutoResetEvent _presentationRequestConsumed = new(false);
private readonly AutoResetEvent _diagnosticMessageCompleted = new(false);
private readonly AutoResetEvent _fullwidthTextEditCompleted = new(false);
private readonly object _fullwidthTextEditLock = new();
private FullwidthTextEditResult _fullwidthTextEditResult;
private readonly bool _synchronizeExplicitPresentation;
private long _explicitPresentationRequestGeneration;
private long _consumedPresentationRequestGeneration;
private readonly object _imageLock = new(); private readonly object _imageLock = new();
private readonly Dictionary<int, RgbaImage?> _images = new(); // packed catalog id -> decoded pixels private readonly Dictionary<int, RgbaImage?> _images = new(); // packed catalog id -> decoded pixels
// Mutable AGE surfaces are published by replacing immutable RgbaImage snapshots, so the compositor // Mutable AGE surfaces are published by replacing immutable RgbaImage snapshots, so the compositor
@@ -57,40 +31,13 @@ public sealed partial class GodotAdvHost : IHost
private readonly Dictionary<int, (int W, int H)> _slotDims = new(); private readonly Dictionary<int, (int W, int H)> _slotDims = new();
private readonly int _screenWidth; private readonly int _screenWidth;
private readonly int _screenHeight; private readonly int _screenHeight;
private readonly SemaphoreSlim _gate = new(0, 1);
private readonly AutoResetEvent _inputCallbackSignal = new(false);
private readonly Age.Engine.Hosting.FrameClock _clock; private readonly Age.Engine.Hosting.FrameClock _clock;
private readonly GodotTimelineLog? _timeline; private readonly GodotTimelineLog? _timeline;
private readonly PageLocatorState _locator; private readonly PageLocatorState _locator;
private readonly System.Threading.AutoResetEvent _frameSignal = new(false);
private volatile bool _stopping;
private readonly object _messageSkipLock = new();
private bool _scriptMessageSkipActive;
private bool _physicalMessageSkipActive;
private volatile bool _messageSkipActive;
private int _voiceBgmDuckControl; private int _voiceBgmDuckControl;
private (AudioPayload Audio, int PlaybackVariant)? _queuedSkippedVoice; private (AudioPayload Audio, int PlaybackVariant)? _queuedSkippedVoice;
private readonly object _scheduledVoiceLock = new(); private readonly object _scheduledVoiceLock = new();
private (AudioPayload Audio, int PlaybackVariant, uint DelayMs, uint? StartMs)? _scheduledVoice; private (AudioPayload Audio, int PlaybackVariant, uint DelayMs, uint? StartMs)? _scheduledVoice;
private volatile bool _advPagePresentationSuspended;
private volatile bool _modalMovieWaiting;
private volatile bool _modalMovieCancelled;
private GfxState? _foregroundGfx;
private volatile bool _foregroundTransitionWaitBypassed;
private readonly object _screenTransitionLock = new();
private readonly Dictionary<int, IReadOnlyList<RenderObject>> _renderTargetSnapshots = new();
private readonly object _backbufferRangeLock = new();
private GfxHandleRange _backbufferRange = GfxHandleRange.All;
private bool _backbufferPublicationPreservesExistingPixels;
private bool _backbufferClearPending;
private LegacyScreenTransition? _screenTransition;
public volatile bool IsWaiting;
public volatile bool IsTransitionWaiting;
public volatile bool IsSleeping;
public bool IsModalMovieWaiting => _modalMovieWaiting;
private int _presentRequested = 1;
private long _transitionStartedAtMs = -1;
public long TransitionStartedAtMs => System.Threading.Interlocked.Read(ref _transitionStartedAtMs);
public GodotAdvHost(Main main, ResourceMap res, string scene, Age.Engine.Hosting.FrameClock clock, public GodotAdvHost(Main main, ResourceMap res, string scene, Age.Engine.Hosting.FrameClock clock,
PageLocatorState locator, Sys4LogicalCanvas logicalCanvas, PageLocatorState locator, Sys4LogicalCanvas logicalCanvas,
IGlyphMaskRasterizer surfaceTextRasterizer, IGlyphMaskRasterizer surfaceTextRasterizer,
@@ -122,82 +69,6 @@ public sealed partial class GodotAdvHost : IHost
public Sys4LogicalCanvas LogicalCanvas => new(_screenWidth, _screenHeight); public Sys4LogicalCanvas LogicalCanvas => new(_screenWidth, _screenHeight);
public void ReportWarning(string message) => System.Console.Error.WriteLine(message); public void ReportWarning(string message) => System.Console.Error.WriteLine(message);
public void ShowDiagnosticMessage(DiagnosticMessage message)
{
if (_stopping) return;
_timeline?.Event("diagnostic-message", new()
{
["caption"] = message.Caption,
["text"] = message.Text,
});
_main.CallDeferred("ShowAgeDiagnostic", message.Text, message.Caption);
while (!_stopping && !_diagnosticMessageCompleted.WaitOne(50)) { }
}
public void CompleteDiagnosticMessage() => _diagnosticMessageCompleted.Set();
public FullwidthTextEditResult EditFullwidthString(FullwidthTextEditRequest request)
{
if (_stopping) return new(false, request.CurrentText);
lock (_fullwidthTextEditLock)
_fullwidthTextEditResult = new(false, request.CurrentText);
_timeline?.Event("fullwidth-text-edit", new()
{
["current"] = request.CurrentText,
["initial"] = request.InitialText,
});
_main.CallDeferred("ShowAgeFullwidthTextEditor", request.InitialText);
while (!_stopping && !_fullwidthTextEditCompleted.WaitOne(50)) { }
lock (_fullwidthTextEditLock) return _fullwidthTextEditResult;
}
public void CompleteFullwidthTextEdit(bool accepted, string text)
{
lock (_fullwidthTextEditLock)
_fullwidthTextEditResult = new(accepted, text);
_fullwidthTextEditCompleted.Set();
}
private string CurrentScene
{
get { lock (_scriptContextLock) return _scriptContexts.TryPeek(out var scene) ? scene : _rootScene; }
}
public void EnterScriptContext(string scriptName)
{
_presentationBarrier.EnterScript();
string scene = System.IO.Path.GetFileNameWithoutExtension(scriptName).ToUpperInvariant();
lock (_scriptContextLock) _scriptContexts.Push(scene);
_timeline?.Event("script-context-enter", new() { ["scene"] = scene });
}
public void ExitScriptContext()
{
string? scene = null;
lock (_scriptContextLock)
if (_scriptContexts.TryPop(out var popped)) scene = popped;
if (scene != null) _timeline?.Event("script-context-exit", new() { ["scene"] = scene });
_presentationBarrier.ExitScript();
}
public bool TryEnterPresentation() => _presentationBarrier.TryEnterPresentation();
public void ExitPresentation() => _presentationBarrier.ExitPresentation();
private bool SuspendScriptForPresentation() => _presentationBarrier.SuspendScript();
private void ResumeScriptAfterPresentation(bool suspended)
=> _presentationBarrier.ResumeScript(suspended);
private void RequestSynchronizedPresentation()
{
long requested = Interlocked.Increment(ref _explicitPresentationRequestGeneration);
Interlocked.Exchange(ref _presentRequested, 1);
if (!_synchronizeExplicitPresentation) return;
while (Interlocked.Read(ref _consumedPresentationRequestGeneration) < requested && !_stopping)
_presentationRequestConsumed.WaitOne(50);
}
public void FillSurfaceRect(SurfaceRectFill fill) public void FillSurfaceRect(SurfaceRectFill fill)
{ {
RgbaImage? destination = ResolveSurfacePixels(fill.SurfaceSlot); RgbaImage? destination = ResolveSurfacePixels(fill.SurfaceSlot);
@@ -223,750 +94,6 @@ public sealed partial class GodotAdvHost : IHost
}); });
} }
public void PresentObjectRange(GfxState gfx, long firstHandle, long count)
{
if (gfx.CurrentRenderTargetSlot >= 0)
{
PublishObjectRangeToSurface(gfx, firstHandle, count);
return;
}
lock (_backbufferRangeLock)
{
_backbufferRange = new GfxHandleRange(firstHandle, count);
_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
// exact retained state before its callback can mutate or release the capture surface.
bool scriptSuspended = SuspendScriptForPresentation();
try
{
RequestSynchronizedPresentation();
}
finally
{
ResumeScriptAfterPresentation(scriptSuspended);
}
}
public BackbufferPublicationPolicy SnapshotBackbufferObjects(
GfxState gfx, long nowMs, List<RenderObject> snapshot)
{
gfx.SnapshotVisibleObjects(nowMs, snapshot);
GfxHandleRange range;
bool preserveExistingPixels;
lock (_backbufferRangeLock)
{
range = _backbufferRange;
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 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 volatile int Pages; // VM-thread page counter (incremented before IsWaiting so shot-gating can't race)
public void WaitForInput() => WaitForInput(0);
public void WaitForInput(int layoutSlot)
=> WaitForInput(layoutSlot, static () => false, static () => default);
public void WaitForInput(int layoutSlot, Func<bool> serviceInputCallback)
=> WaitForInput(layoutSlot, serviceInputCallback, static () => default);
public void WaitForInput(int layoutSlot, Func<bool> serviceInputCallback,
Func<AdvAutoWaitState> autoWaitState)
{
Pages++;
_locator.Wait(Pages);
_main.CallDeferred("PageBreak");
lock (_textLock)
{
_activeWaitLayout = layoutSlot == 0 ? _currentAdvLayout : layoutSlot;
_waitIndicatorStartedMs = _clock.NowMs;
_waitIndicatorEnabled = true;
}
IsWaiting = true;
_timeline?.State("input-wait", new() { ["page"] = Pages });
var autoTimer = new AdvAutoAdvanceTimer();
bool autoAdvanced = false;
bool messageSkipped = false;
bool scriptSuspended = SuspendScriptForPresentation();
try
{
// Publish the completed pre-wait burst once. Callback scripts temporarily reacquire the
// write side below, so each hover/click update is likewise exposed only after it returns.
RequestSynchronizedPresentation();
while (!_stopping)
{
while (true)
{
bool markerWasEnabled;
lock (_textLock) markerWasEnabled = _waitIndicatorEnabled;
if (scriptSuspended) ResumeScriptAfterPresentation(true);
bool keepServicing;
try
{
keepServicing = serviceInputCallback();
}
finally
{
if (scriptSuspended && !SuspendScriptForPresentation())
throw new InvalidOperationException("Input callback did not retain the script burst.");
}
// The native callback returns through the shared ADV redraw/wait path, whose op 0x72
// re-arms a marker stopped by nested HISTORY. Our blocking host keeps the parent wait
// parked, so restore that parent-owned state at the equivalent callback boundary.
lock (_textLock)
{
if (markerWasEnabled && !_waitIndicatorEnabled)
{
_waitIndicatorEnabled = true;
_waitIndicatorStartedMs = _clock.NowMs;
}
}
if (!keepServicing) break;
}
if (_messageSkipActive)
{
messageSkipped = true;
break;
}
if (autoTimer.Poll(autoWaitState(), _main.IsVoicePlaybackActive, _clock.NowMs))
{
autoAdvanced = true;
break;
}
if (_gate.Wait(0)) break;
WaitHandle.WaitAny(new[] { _gate.AvailableWaitHandle, _inputCallbackSignal, _frameSignal });
if (_gate.Wait(0)) break;
}
}
finally
{
ResumeScriptAfterPresentation(scriptSuspended);
}
IsWaiting = false;
lock (_textLock) _waitIndicatorEnabled = false;
Interlocked.Exchange(ref _presentRequested, 1);
_timeline?.State("running", new()
{
["input"] = messageSkipped ? "message-skip" : autoAdvanced ? "auto" : "user",
});
lock (_textLock)
{
_advText = "";
_advTextX = 100;
_advTextY = 47;
}
_main.CallDeferred("ClearPage");
}
// Called from the main thread (click) or an auto-clicker. A transition click is consumed by
// the foreground lifecycle; it never pre-arms or advances the following stable input wait.
public void SignalInput()
{
if (_modalMovieWaiting)
{
_modalMovieCancelled = true;
_timeline?.State("modal-movie-cancel", new());
_frameSignal.Set();
return;
}
if (IsTextRevealing)
{
lock (_textLock) _advTextForceComplete = true;
_timeline?.State("text-reveal-forced-complete", new());
_frameSignal.Set();
return;
}
if (TryForceActiveTransition("advance")) return;
if (IsWaiting && _gate.CurrentCount == 0) _gate.Release();
}
private bool TryForceActiveTransition(string source)
{
if (IsTransitionWaiting && _foregroundGfx != null)
{
bool accepted = _foregroundGfx.TryCompleteClickSkippableTimedPresentation(
_clock.NowMs, out int completed);
if (accepted)
{
_foregroundTransitionWaitBypassed = true;
_timeline?.State("transition-skip-accepted", new()
{
["endpoints_completed"] = completed,
["source"] = source,
["wait_bypassed"] = true,
});
_frameSignal.Set();
return true;
}
}
if (IsTransitionWaiting)
{
lock (_screenTransitionLock)
{
if (_screenTransition != null)
{
_screenTransition.Forced = true;
_timeline?.State("screen-transition-forced-complete", new()
{
["source"] = source,
});
_frameSignal.Set();
return true;
}
}
}
return false;
}
public bool IsMessageSkipActive => _messageSkipActive;
public void SetMessageSkipActive(bool active)
=> SetMessageSkipChannel(active, physical: false);
public void SetPhysicalMessageSkipActive(bool active)
=> SetMessageSkipChannel(active, physical: true);
private void SetMessageSkipChannel(bool active, bool physical)
{
bool effective;
bool changed;
(AudioPayload Audio, int PlaybackVariant)? queued = null;
lock (_messageSkipLock)
{
if (physical) _physicalMessageSkipActive = active;
else _scriptMessageSkipActive = active;
effective = _scriptMessageSkipActive || _physicalMessageSkipActive;
changed = effective != _messageSkipActive;
_messageSkipActive = effective;
if (changed && !effective)
{
queued = _queuedSkippedVoice;
_queuedSkippedVoice = null;
}
}
if (!changed) return;
_timeline?.State("message-skip", new()
{
["enabled"] = effective,
["source"] = physical ? "logical-action-6" : "script",
});
if (effective)
{
lock (_textLock) _advTextForceComplete = true;
TryForceActiveTransition("message-skip");
_frameSignal.Set();
_inputCallbackSignal.Set();
return;
}
if (queued != null) DispatchVoice(queued.Value.Audio, queued.Value.PlaybackVariant);
}
public void WakeInputCallbackService() => _inputCallbackSignal.Set();
public bool IsAdvPagePresentationSuspended => _advPagePresentationSuspended;
public void SetAdvPagePresentationSuspended(bool suspended)
{
_advPagePresentationSuspended = suspended;
_timeline?.State(suspended ? "adv-page-suspended" : "adv-page-restored", new());
}
public void SetAdvPagePresentationSuspended(GfxState gfx, bool suspended)
{
lock (_textLock)
{
if (suspended)
{
int slot = IsWaiting && _activeWaitLayout != 0
? _activeWaitLayout
: _currentAdvLayout;
if (_retainedTextLayouts.TryGetValue(
slot, out RetainedAdvTextLayoutPresentation? presentation))
presentation.ErasePublished(gfx);
if (_retainedWaitIndicators.TryGetValue(
slot, out RetainedAdvWaitIndicatorPresentation? indicator))
indicator.Erase(gfx);
if (_retainedTextLayouts.ContainsKey(slot)
|| _retainedWaitIndicators.ContainsKey(slot))
_suspendedRetainedTextLayoutSlot = slot;
}
else if (_suspendedRetainedTextLayoutSlot != 0)
{
if (_retainedTextLayouts.TryGetValue(
_suspendedRetainedTextLayoutSlot,
out RetainedAdvTextLayoutPresentation? presentation))
presentation.Republish(gfx);
if (_retainedWaitIndicators.TryGetValue(
_suspendedRetainedTextLayoutSlot,
out RetainedAdvWaitIndicatorPresentation? indicator))
indicator.Republish(
gfx,
_clock.NowMs - _waitIndicatorStartedMs,
IsWaiting
&& _waitIndicatorEnabled
&& _activeWaitLayout == _suspendedRetainedTextLayoutSlot);
_suspendedRetainedTextLayoutSlot = 0;
}
}
Interlocked.Exchange(ref _presentRequested, 1);
SetAdvPagePresentationSuspended(suspended);
}
public void InputCallbackCompleted(GfxState gfx)
{
// Callback completion itself is not native graphics dirtiness. Any retained writes made by the
// callback are published through GfxState's mutation generation; host-owned surface writes set
// _presentRequested at their actual mutation sites. FIELD services a 50 ms hover callback even
// while the pointer is idle, so an unconditional request here recreates its sleep-poll overdraw.
}
public long InputClockMilliseconds => _clock.NowMs;
public void SetCursorResource(long resourceId)
{
var asset = _res.ResolveCursor(resourceId);
if (asset == null) return;
try
{
var cursor = _res.DecodeCursor(asset);
_main.CallDeferred("SetAgeCursor", cursor.Image.Pixels, cursor.Image.Width, cursor.Image.Height,
cursor.HotspotX, cursor.HotspotY);
_timeline?.Event("cursor-set", new() { ["resource"] = resourceId, ["asset"] = asset.Name });
}
catch (Exception ex)
{
System.Console.Error.WriteLine($"cursor decode {asset.Name}: {ex.Message}");
}
}
public void ClearCursorResource()
{
_main.CallDeferred("ClearAgeCursor");
_timeline?.Event("cursor-clear", new());
}
public void WaitForForegroundTransition(GfxState gfx)
{
_foregroundTransitionWaitBypassed = false;
int started = gfx.StartForegroundTransitions(_clock.NowMs);
bool hasActivePresentation =
gfx.HasActiveTimedPresentation(_clock.NowMs) || HasActiveMoviePresentation();
if (hasActivePresentation)
{
_foregroundGfx = gfx;
System.Threading.Interlocked.Exchange(ref _transitionStartedAtMs, _clock.NowMs);
IsTransitionWaiting = true;
_timeline?.State("transition-start", new() { ["count"] = started });
// Skip may already have been active before this service boundary. Native ADV script usually
// selects 0x243+0x20c in that case, but applying it here also covers transition helpers that
// enter run-state 0x400 without repeating the script-side query.
if (_messageSkipActive) TryForceActiveTransition("message-skip");
}
bool scriptSuspended = SuspendScriptForPresentation();
try
{
RequestSynchronizedPresentation();
if (!hasActivePresentation) return;
int lastBucket = -1;
while (!_foregroundTransitionWaitBypassed
&& (gfx.HasActiveTimedPresentation(_clock.NowMs) || HasActiveMoviePresentation())
&& !_stopping)
{
var active = gfx.SnapshotForegroundTransitions(_clock.NowMs);
int bucket = active.Count == 0 ? 100 : (int)System.Math.Floor(active[0].Progress * 10);
if (bucket != lastBucket)
{
lastBucket = bucket;
_timeline?.State("transition-progress", new()
{
["progress"] = active.Count == 0 ? 1.0 : active[0].Progress,
["forced"] = active.Count != 0 && active[0].Forced,
});
}
_frameSignal.WaitOne(50);
}
// Publish the natural terminal sample or the forced service-resume sample once before the
// following script burst mutates/releases its retained inputs.
RequestSynchronizedPresentation();
}
finally
{
ResumeScriptAfterPresentation(scriptSuspended);
}
IsTransitionWaiting = false;
_foregroundTransitionWaitBypassed = false;
System.Threading.Interlocked.Exchange(ref _transitionStartedAtMs, -1);
_foregroundGfx = null;
_timeline?.State("running", new() { ["transition_complete"] = true });
}
public void PresentFrame(GfxState gfx)
{
if (gfx.CurrentRenderTargetSlot >= 0)
{
int slot = gfx.CurrentRenderTargetSlot;
var snapshot = gfx.SnapshotVisibleObjects(_clock.NowMs);
PublishObjectRangeToSurface(gfx, 0, long.MaxValue, snapshot);
lock (_screenTransitionLock) _renderTargetSnapshots[slot] = snapshot;
_timeline?.Event("render-target-snapshot", new()
{
["surface"] = slot, ["objects"] = snapshot.Count,
});
return;
}
lock (_backbufferRangeLock)
{
_backbufferRange = GfxHandleRange.All;
// 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);
if (started > 0 || completed > 0)
_timeline?.State("transition-skip-complete", new()
{
["started"] = started, ["completed"] = completed,
});
bool scriptSuspended = SuspendScriptForPresentation();
try
{
RequestSynchronizedPresentation();
}
finally
{
ResumeScriptAfterPresentation(scriptSuspended);
}
}
public void FadeSurfaceWithBlack(
GfxState gfx, int surface, long intervalArgument, SurfaceBlackFadeDirection direction,
bool forceEndpoint = false)
{
long start = _clock.NowMs;
IReadOnlyList<RenderObject> captured;
lock (_screenTransitionLock)
captured = _renderTargetSnapshots.TryGetValue(surface, out var snapshot)
? snapshot : System.Array.Empty<RenderObject>();
IReadOnlyList<RenderObject> black = System.Array.Empty<RenderObject>();
IReadOnlyList<RenderObject> source =
direction == SurfaceBlackFadeDirection.FromBlack ? black : captured;
IReadOnlyList<RenderObject> target =
direction == SurfaceBlackFadeDirection.FromBlack ? captured : black;
RunLegacyScreenTransition(source, target, start, intervalArgument, forceEndpoint, new()
{
["kind"] = "surface-black-fade",
["surface"] = surface,
["direction"] = direction == SurfaceBlackFadeDirection.FromBlack
? "from-black" : "to-black",
});
}
public void CrossfadeSurfaces(
GfxState gfx, int sourceSurface, int targetSurface, long intervalArgument,
bool forceEndpoint = false)
{
IReadOnlyList<RenderObject> source;
IReadOnlyList<RenderObject> target;
long start = _clock.NowMs;
lock (_screenTransitionLock)
{
source = _renderTargetSnapshots.TryGetValue(sourceSurface, out var capturedSource)
? capturedSource : System.Array.Empty<RenderObject>();
target = _renderTargetSnapshots.TryGetValue(targetSurface, out var capturedTarget)
? capturedTarget : gfx.SnapshotVisibleObjects(start);
}
RunLegacyScreenTransition(source, target, start, intervalArgument, forceEndpoint, new()
{
["kind"] = "surface-crossfade",
["source"] = sourceSurface,
["target"] = targetSurface,
});
}
// The native 0x21/0x22/0x25 family advances an 8-bit alpha accumulator. Values <=64 use
// the operand as the timer interval and step alpha by sixteen; larger values divide the
// interval by sixteen and step alpha by one. This reproduces its blocking wall-clock duration.
private void RunLegacyScreenTransition(
IReadOnlyList<RenderObject> source, IReadOnlyList<RenderObject> target,
long start, long intervalArgument, bool forceEndpoint,
Dictionary<string, object?> timelineDetail)
{
long duration = LegacyScreenTransitionTiming.DurationMilliseconds(intervalArgument);
lock (_screenTransitionLock)
_screenTransition = new LegacyScreenTransition(source, target, start, duration)
{
Forced = forceEndpoint,
};
_foregroundGfx = null;
System.Threading.Interlocked.Exchange(ref _transitionStartedAtMs, start);
System.Threading.Interlocked.Exchange(ref _presentRequested, 1);
IsTransitionWaiting = true;
timelineDetail["interval_argument"] = intervalArgument;
timelineDetail["duration_ms"] = duration;
timelineDetail["source_objects"] = source.Count;
timelineDetail["target_objects"] = target.Count;
timelineDetail["forced"] = forceEndpoint;
_timeline?.State("screen-transition-start", timelineDetail);
bool scriptSuspended = SuspendScriptForPresentation();
try
{
RequestSynchronizedPresentation();
while (!_stopping)
{
bool complete;
lock (_screenTransitionLock)
complete = _screenTransition == null || _screenTransition.Forced
|| _clock.NowMs - start >= duration;
if (complete) break;
_frameSignal.WaitOne(50);
}
// Publish the exact target endpoint before the following surface releases/root reload.
lock (_screenTransitionLock)
if (_screenTransition != null) _screenTransition.Forced = true;
RequestSynchronizedPresentation();
// Do not let the VM release both source surfaces (or immediately reload SYSTEM4) until the
// main thread has actually published the terminal target frame.
while (!_stopping)
{
lock (_screenTransitionLock)
if (_screenTransition == null) break;
_frameSignal.WaitOne(50);
}
}
finally
{
ResumeScriptAfterPresentation(scriptSuspended);
}
IsTransitionWaiting = false;
System.Threading.Interlocked.Exchange(ref _transitionStartedAtMs, -1);
_timeline?.State("running", new() { ["screen_transition_complete"] = true });
}
public bool TrySnapshotScreenTransition(out LegacyScreenTransitionSnapshot snapshot)
{
lock (_screenTransitionLock)
{
if (_screenTransition == null)
{
snapshot = default;
return false;
}
double progress = _screenTransition.Forced ? 1.0
: System.Math.Clamp((_clock.NowMs - _screenTransition.StartMs)
/ (double)_screenTransition.DurationMs, 0.0, 1.0);
snapshot = new LegacyScreenTransitionSnapshot(
_screenTransition.Source, _screenTransition.Target, progress);
if (_screenTransition.Forced) _screenTransition = null;
return true;
}
}
// Native retained-object writes are not front-buffer writes. Publish explicit/service-boundary dirtiness
// once, then continue only while the sampled retained scene can actually change. The wait atlas advances
// its ordinary retained source cell here so it contributes only when the frame actually changes.
public HostPresentationReason ConsumePresentationReasons(GfxState gfx)
{
UpdateRetainedWaitIndicator(gfx);
bool screenTransitionActive;
lock (_screenTransitionLock) screenTransitionActive = _screenTransition != null;
var reasons = HostPresentationReason.None;
if (System.Threading.Interlocked.Exchange(ref _presentRequested, 0) != 0)
{
reasons |= HostPresentationReason.HostRequest;
long requested = Interlocked.Read(ref _explicitPresentationRequestGeneration);
Interlocked.Exchange(ref _consumedPresentationRequestGeneration, requested);
_presentationRequestConsumed.Set();
}
if (screenTransitionActive) reasons |= HostPresentationReason.ScreenTransition;
GfxPresentationReason gfxReasons = gfx.ConsumePresentationReasons(_clock.NowMs);
if ((gfxReasons & GfxPresentationReason.RetainedMutation) != 0)
reasons |= HostPresentationReason.RetainedMutation;
if ((gfxReasons & GfxPresentationReason.ContinuousChannel) != 0)
reasons |= HostPresentationReason.ContinuousChannel;
if ((gfxReasons & GfxPresentationReason.DiscreteSourceCell) != 0)
reasons |= HostPresentationReason.DiscreteSourceCell;
return reasons;
}
public void Stop()
{
_stopping = true;
_main.CallDeferred("ClearAgeCursor");
lock (_textLock) _advTextForceComplete = true;
if (_gate.CurrentCount == 0) _gate.Release();
_inputCallbackSignal.Set();
_frameSignal.Set();
_diagnosticMessageCompleted.Set();
_fullwidthTextEditCompleted.Set();
}
public void ResetSceneContext()
{
// 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;
_backbufferPublicationPreservesExistingPixels = false;
_backbufferClearPending = false;
}
lock (_textLock)
{
_surfaceResources.Clear();
_retainedHistoryLayouts.Clear();
_liveText.Clear();
_retainedTextLayouts.Clear();
_activeLiveText = null;
_suspendedRetainedTextLayoutSlot = 0;
_advText = "";
_advTextX = 100;
_advTextY = 47;
_advTextForceComplete = false;
_waitIndicators.Clear();
_waitIndicatorBindings.Clear();
_retainedWaitIndicators.Clear();
_activeWaitLayout = 0;
_waitIndicatorEnabled = false;
}
while (_gate.Wait(0)) { }
_inputCallbackSignal.WaitOne(0);
lock (_messageSkipLock)
{
_scriptMessageSkipActive = false;
_physicalMessageSkipActive = false;
_messageSkipActive = false;
_queuedSkippedVoice = null;
}
lock (_scheduledVoiceLock) _scheduledVoice = null;
System.Threading.Volatile.Write(ref _voiceBgmDuckControl, 0);
_advPagePresentationSuspended = false;
_modalMovieCancelled = false;
_modalMovieWaiting = false;
_foregroundGfx = null;
lock (_screenTransitionLock)
{
_renderTargetSnapshots.Clear();
_screenTransition = null;
}
IsWaiting = false;
IsTransitionWaiting = false;
IsSleeping = false;
IsTextRevealing = false;
System.Threading.Interlocked.Exchange(ref _transitionStartedAtMs, -1);
System.Threading.Interlocked.Exchange(ref _presentRequested, 1);
_main.CallDeferred("CancelScheduledSoundEffectStarts");
_main.CallDeferred("ClearPage");
_timeline?.State("scene-context-reset", new());
}
// Main thread, once per rendered frame: releases a VM thread parked in Sleep or a presentation/input wait.
public void PulseFrame()
{
(AudioPayload Audio, int PlaybackVariant)? due = null;
lock (_scheduledVoiceLock)
{
if (_scheduledVoice is { } pending)
{
uint now = unchecked((uint)_clock.NowMs);
if (!pending.StartMs.HasValue)
_scheduledVoice = pending with { StartMs = now };
else if (unchecked(now - pending.StartMs.Value) >= pending.DelayMs)
{
due = (pending.Audio, pending.PlaybackVariant);
_scheduledVoice = null;
}
}
}
if (due.HasValue)
{
_timeline?.Event("voice-scheduled-start", new()
{
["file"] = due.Value.Audio.Name, ["playback_variant"] = due.Value.PlaybackVariant,
});
DispatchVoice(due.Value.Audio, due.Value.PlaybackVariant);
}
_frameSignal.Set();
}
// Native dispatch remains burst-fast between explicit presentation/sleep/input services even when
// message Skip removes those services. Per-op frame pacing turns ordinary skipped setup/cleanup bursts
// into multi-second invisible stalls.
public void FrameYield() { }
// op 0xc8: block the VM background thread while the main-thread compositor keeps presenting retained state.
// Time-based sibling of WaitForInput's suspend. The native op arms a non-blocking main-loop-polled timer;
// blocking this throwaway task thread is behaviorally equivalent given our threading model. Operand is
// MILLISECONDS (docs/engine-re.md sleep section + opcodes.toml 0xc8). Headless CLI hosts no-op it (parity).
public double SleepScale = 1.0; // --sleep-scale <f>: debug multiplier for explicit op-0xc8 holds only
// Wait on the unified FrameClock timebase (not Thread.Sleep) so the Speed multiplier scales
// sleeps together with retained presentation clocks. Main._Process advances the clock + pulses each frame.
internal static long NormalizeSleepMilliseconds(long duration, double scale)
=> (long)System.Math.Clamp(duration * scale, 1, 60_000);
public void Sleep(long duration)
{
// Native sleep_timer_arm clamps the duration to at least 1 ms. In menu poll loops, sleep(0)
// therefore yields to the next engine tick instead of becoming a free-running no-op.
long ms = NormalizeSleepMilliseconds(duration, SleepScale);
long deadline = _clock.NowMs + ms;
_timeline?.State("sleep", new() { ["duration_ms"] = ms, ["deadline_ms"] = deadline });
IsSleeping = true;
bool scriptSuspended = SuspendScriptForPresentation();
try
{
RequestSynchronizedPresentation();
while (_clock.NowMs < deadline)
{
if (_stopping) break;
_frameSignal.WaitOne(50);
}
}
finally
{
ResumeScriptAfterPresentation(scriptSuspended);
}
IsSleeping = false;
_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) public bool TraceOps; // --gfx-log: print set-texture/create-texture slot assignments (diagnose slot collisions)
public void CreateTexture(int slot, int width, int height) public void CreateTexture(int slot, int width, int height)
@@ -1783,23 +910,3 @@ public sealed record GodotHostDiagnosticSnapshot(
bool IsTextRevealing, bool IsModalMovieWaiting, bool IsAdvPagePresentationSuspended, bool IsTextRevealing, bool IsModalMovieWaiting, bool IsAdvPagePresentationSuspended,
bool IsMessageSkipActive, bool IsScreenTransitionActive, long TransitionStartedAtMs, bool IsMessageSkipActive, bool IsScreenTransitionActive, long TransitionStartedAtMs,
IReadOnlyList<MovieSurfaceDiagnostic> MovieSurfaces, IReadOnlyList<long> CompletedMoviePlaybackIds); IReadOnlyList<MovieSurfaceDiagnostic> MovieSurfaces, IReadOnlyList<long> CompletedMoviePlaybackIds);
public readonly record struct LegacyScreenTransitionSnapshot(
IReadOnlyList<RenderObject> Source, IReadOnlyList<RenderObject> Target, double Progress);
internal sealed class LegacyScreenTransition
{
public IReadOnlyList<RenderObject> Source { get; }
public IReadOnlyList<RenderObject> Target { get; }
public long StartMs { get; }
public long DurationMs { get; }
public bool Forced { get; set; }
public LegacyScreenTransition(IReadOnlyList<RenderObject> source, IReadOnlyList<RenderObject> target,
long startMs, long durationMs)
{
Source = source;
Target = target;
StartMs = startMs;
DurationMs = durationMs;
}
}