engine: retain History and wait presentation

This commit is contained in:
gamer147
2026-07-30 19:21:38 -04:00
parent 1367563485
commit ef19ab79e0
14 changed files with 585 additions and 95 deletions

View File

@@ -73,6 +73,7 @@ public sealed class GodotAdvHost : IHost
private string _surfaceTextFallbackReason;
private bool _surfaceTextFallbackWarningReported;
private readonly Dictionary<int, AdvTextHistoryRenderBatch> _historyText = new();
private readonly HashSet<int> _retainedHistoryLayouts = new();
private sealed class LiveTextState
{
public required AdvLiveTextRun Run;
@@ -96,6 +97,11 @@ public sealed class GodotAdvHost : IHost
private volatile int _messageWindowAlphaSetting;
private bool _advTextForceComplete;
private readonly Dictionary<int, AdvWaitIndicatorConfig> _waitIndicators = new();
private readonly Dictionary<int, (
AdvTextLayoutPresentationBinding Binding,
AdvTextLayoutSnapshot Layout)> _waitIndicatorBindings = new();
private readonly Dictionary<int, RetainedAdvWaitIndicatorPresentation>
_retainedWaitIndicators = new();
private readonly object _messageSkipLock = new();
private bool _scriptMessageSkipActive;
private bool _physicalMessageSkipActive;
@@ -728,6 +734,7 @@ public sealed class GodotAdvHost : IHost
lock (_textLock)
{
_historyText.Remove(binding.LayoutSlot);
_retainedHistoryLayouts.Remove(binding.LayoutSlot);
_retainedTextLayouts.Remove(binding.LayoutSlot);
_liveText.RemoveAll(
state => state.Run.Layout.Slot == binding.LayoutSlot);
@@ -775,17 +782,112 @@ public sealed class GodotAdvHost : IHost
});
}
public bool RenderTextHistory(
GfxState gfx,
AdvTextLayoutPresentationBinding binding,
AdvTextHistoryRenderBatch batch)
{
var run = new AdvLiveTextRun(
batch.FirstRecordIndex,
batch.Layout,
batch.Style,
batch.Text,
Array.Empty<string>());
if (_retainedGlyphLayoutEngine == null
|| binding.LayoutSlot != batch.LayoutSlot
|| binding.FirstObjectHandle < 0
|| binding.ObjectCapacity <= 0
|| !TryPrepareRetainedTextRun(
gfx, binding, run,
out RetainedAdvTextLayoutPresentation? presentation,
out AdvRetainedTextRunResult result))
{
RenderTextHistory(batch);
return false;
}
presentation.PublishThrough(
gfx,
checked(result.FirstGlyphIndex + result.GlyphCount));
lock (_textLock)
{
_historyText.Remove(batch.LayoutSlot);
_retainedHistoryLayouts.Add(batch.LayoutSlot);
}
Interlocked.Exchange(ref _presentRequested, 1);
_timeline?.Event("history-render", new()
{
["layout"] = batch.LayoutSlot,
["record"] = batch.FirstRecordIndex,
["x"] = batch.Layout.OriginX + batch.Layout.CursorX,
["y"] = batch.Layout.OriginY + batch.Layout.CursorY,
["text"] = batch.Text,
["glyphs"] = result.GlyphCount,
["presentation"] = "retained-glyphs",
});
return true;
}
public void EndTextHistoryPresentation()
{
lock (_textLock) _historyText.Clear();
lock (_textLock)
{
_historyText.Clear();
_retainedHistoryLayouts.Clear();
}
_timeline?.Event("history-presentation-end");
}
public void EndTextHistoryPresentation(GfxState gfx)
{
RetainedAdvTextLayoutPresentation[] retained;
int[] surfaceSlots;
lock (_textLock)
{
retained = _retainedHistoryLayouts
.Select(slot => _retainedTextLayouts.GetValueOrDefault(slot))
.Where(presentation => presentation != null)
.Cast<RetainedAdvTextLayoutPresentation>()
.ToArray();
surfaceSlots = retained
.Select(presentation => presentation.Binding.SourceSurfaceSlot)
.Distinct()
.ToArray();
foreach (int slot in _retainedHistoryLayouts)
_retainedTextLayouts.Remove(slot);
_retainedHistoryLayouts.Clear();
_historyText.Clear();
}
foreach (RetainedAdvTextLayoutPresentation presentation in retained)
presentation.ErasePublished(gfx);
foreach (int surfaceSlot in surfaceSlots)
ClearAllocatedSurfacePixels(surfaceSlot);
Interlocked.Exchange(ref _presentRequested, 1);
_timeline?.Event("history-presentation-end", new()
{
["retained_layouts"] = retained.Length,
});
}
public IReadOnlyList<AdvTextHistoryRenderBatch> SnapshotRenderedTextHistory()
{
lock (_textLock) return _historyText.Values.OrderBy(batch => batch.LayoutSlot).ToArray();
}
private void ClearAllocatedSurfacePixels(int surfaceSlot)
{
if (!_slotDims.TryGetValue(surfaceSlot, out var dimensions)
|| dimensions.W <= 0
|| dimensions.H <= 0)
return;
lock (_imageLock)
_surfaceImages[surfaceSlot] = new RgbaImage(
dimensions.W,
dimensions.H,
new byte[checked(dimensions.W * dimensions.H * 4)]);
}
public int MessageWindowAlphaSetting => _messageWindowAlphaSetting;
public void SetMessageWindowAlphaSetting(int value)
@@ -890,7 +992,11 @@ public sealed class GodotAdvHost : IHost
public void ConfigureAdvWaitIndicator(AdvWaitIndicatorConfig config)
{
lock (_textLock) _waitIndicators[config.LayoutSlot] = config;
lock (_textLock)
{
_waitIndicators[config.LayoutSlot] = config;
RebuildRetainedWaitIndicator(config.LayoutSlot);
}
_timeline?.Event("wait-indicator-config", new()
{
["layout"] = config.LayoutSlot, ["x"] = config.X, ["y"] = config.Y,
@@ -900,6 +1006,44 @@ public sealed class GodotAdvHost : IHost
});
}
public void BindAdvWaitIndicator(
AdvTextLayoutPresentationBinding binding,
AdvTextLayoutSnapshot layout)
{
lock (_textLock)
{
_waitIndicatorBindings[binding.LayoutSlot] = (binding, layout);
RebuildRetainedWaitIndicator(binding.LayoutSlot);
}
_timeline?.Event("wait-indicator-bind", new()
{
["layout"] = binding.LayoutSlot,
["handle"] = binding.WaitIndicatorObjectHandle,
["origin_x"] = layout.OriginX,
["origin_y"] = layout.OriginY,
});
}
private void RebuildRetainedWaitIndicator(int layoutSlot)
{
if (!_waitIndicators.TryGetValue(layoutSlot, out AdvWaitIndicatorConfig config)
|| !_waitIndicatorBindings.TryGetValue(
layoutSlot,
out (AdvTextLayoutPresentationBinding Binding, AdvTextLayoutSnapshot Layout) retained)
|| retained.Binding.WaitIndicatorObjectHandle < 0)
{
_retainedWaitIndicators.Remove(layoutSlot);
return;
}
_retainedWaitIndicators[layoutSlot] =
new RetainedAdvWaitIndicatorPresentation(
config,
retained.Binding,
retained.Layout.OriginX,
retained.Layout.OriginY);
}
public void SetAdvWaitIndicatorEnabled(bool enabled)
{
lock (_textLock)
@@ -907,6 +1051,7 @@ public sealed class GodotAdvHost : IHost
if (enabled && !_waitIndicatorEnabled) _waitIndicatorStartedMs = _clock.NowMs;
_waitIndicatorEnabled = enabled;
}
Interlocked.Exchange(ref _presentRequested, 1);
_timeline?.Event("wait-indicator-enabled", new() { ["enabled"] = enabled });
}
@@ -920,29 +1065,39 @@ public sealed class GodotAdvHost : IHost
GfxState gfx, AdvTextLayoutPresentationBinding binding)
{
lock (_textLock)
{
if (_retainedTextLayouts.TryGetValue(
binding.LayoutSlot, out RetainedAdvTextLayoutPresentation? presentation)
&& presentation.Binding == binding)
presentation.Republish(gfx);
if (_retainedWaitIndicators.TryGetValue(
binding.LayoutSlot,
out RetainedAdvWaitIndicatorPresentation? indicator))
indicator.Republish(
gfx,
_clock.NowMs - _waitIndicatorStartedMs,
WaitIndicatorShouldBeVisible(binding.LayoutSlot));
}
PublishAdvTextLayout(binding.LayoutSlot);
}
public AdvWaitIndicatorSnapshot? SnapshotAdvWaitIndicator()
private bool WaitIndicatorShouldBeVisible(int layoutSlot)
=> IsWaiting
&& !_advPagePresentationSuspended
&& _waitIndicatorEnabled
&& _activeWaitLayout == layoutSlot;
private void UpdateRetainedWaitIndicator(GfxState gfx)
{
if (!IsWaiting || _advPagePresentationSuspended) return null;
AdvWaitIndicatorConfig config;
long resourceId;
lock (_textLock)
{
if (!_waitIndicatorEnabled) return null;
if (!_waitIndicators.TryGetValue(_activeWaitLayout, out config)) return null;
if (!_surfaceResources.TryGetValue(config.SurfaceSlot, out resourceId)) return null;
foreach ((int layoutSlot, RetainedAdvWaitIndicatorPresentation presentation)
in _retainedWaitIndicators)
presentation.Update(
gfx,
_clock.NowMs - _waitIndicatorStartedMs,
WaitIndicatorShouldBeVisible(layoutSlot));
}
var asset = _res.ResolveTexture(resourceId);
var image = asset != null ? Decode(asset) : null;
if (asset == null || image == null || config.CellWidth <= 0 || config.CellHeight <= 0) return null;
int frame = config.FrameAt(_clock.NowMs - _waitIndicatorStartedMs);
return new AdvWaitIndicatorSnapshot(image, asset.Name, asset.PackedId, config, frame);
}
public volatile int Pages; // VM-thread page counter (incremented before IsWaiting so shot-gating can't race)
@@ -1029,6 +1184,7 @@ public sealed class GodotAdvHost : IHost
}
IsWaiting = false;
lock (_textLock) _waitIndicatorEnabled = false;
Interlocked.Exchange(ref _presentRequested, 1);
_timeline?.State("running", new()
{
["input"] = messageSkipped ? "message-skip" : autoAdvanced ? "auto" : "user",
@@ -1166,10 +1322,13 @@ public sealed class GodotAdvHost : IHost
: _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)
{
@@ -1177,6 +1336,15 @@ public sealed class GodotAdvHost : IHost
_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;
}
}
@@ -1436,10 +1604,11 @@ public sealed class GodotAdvHost : IHost
}
// 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. Text reveal is a separate
// Godot Label; waiting/sleeping alone do not alter background pixels.
// 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;
@@ -1489,6 +1658,7 @@ public sealed class GodotAdvHost : IHost
_surfaceText.Clear();
_surfaceResources.Clear();
_historyText.Clear();
_retainedHistoryLayouts.Clear();
_liveText.Clear();
_retainedTextLayouts.Clear();
_activeLiveText = null;
@@ -1498,6 +1668,8 @@ public sealed class GodotAdvHost : IHost
_advTextY = 47;
_advTextForceComplete = false;
_waitIndicators.Clear();
_waitIndicatorBindings.Clear();
_retainedWaitIndicators.Clear();
_activeWaitLayout = 0;
_waitIndicatorEnabled = false;
}
@@ -1681,6 +1853,14 @@ public sealed class GodotAdvHost : IHost
{
_surfaceText.Remove(slot);
_surfaceResources.Remove(slot);
foreach (int layoutSlot in _retainedTextLayouts
.Where(pair => pair.Value.Binding.SourceSurfaceSlot == slot)
.Select(pair => pair.Key)
.ToArray())
{
_retainedTextLayouts.Remove(layoutSlot);
_retainedHistoryLayouts.Remove(layoutSlot);
}
}
_slotDims[slot] = (image.Width, image.Height);
return true;
@@ -1974,6 +2154,14 @@ public sealed class GodotAdvHost : IHost
{
_surfaceText.Remove(slot);
_surfaceResources.Remove(slot);
foreach (int layoutSlot in _retainedTextLayouts
.Where(pair => pair.Value.Binding.SourceSurfaceSlot == slot)
.Select(pair => pair.Key)
.ToArray())
{
_retainedTextLayouts.Remove(layoutSlot);
_retainedHistoryLayouts.Remove(layoutSlot);
}
}
_slotDims.Remove(slot);
if (movieRelease.Kind == MovieSurfaceReleaseKind.Released)
@@ -2138,6 +2326,8 @@ public sealed class GodotAdvHost : IHost
.Select(pair => pair.Key)
.ToArray())
_retainedTextLayouts.Remove(layoutSlot);
_retainedHistoryLayouts.RemoveWhere(layoutSlot =>
!_retainedTextLayouts.ContainsKey(layoutSlot));
}
foreach (MovieSurfaceBinding binding in stoppedMovies)
{
@@ -2493,6 +2683,3 @@ internal sealed class LegacyScreenTransition
DurationMs = durationMs;
}
}
public readonly record struct AdvWaitIndicatorSnapshot(
RgbaImage Image, string Name, int AssetId, AdvWaitIndicatorConfig Config, int Frame);

View File

@@ -37,10 +37,6 @@ public partial class Main : Godot.Control
private GpuRetainedRenderer _gpuRenderer = null!;
private bool _useGpuBackend = true;
private ImageTexture? _ageCursorTexture;
private TextureRect _waitIndicator = null!;
private ImageTexture? _waitIndicatorSheet;
private AtlasTexture? _waitIndicatorAtlas;
private int _waitIndicatorAssetId = -1;
// One managed composition target for the entire frame. Layer helpers mutate it in place; only the
// completed frame crosses the Godot Image boundary, avoiding a full GetData/SetData round-trip per layer.
private byte[] _screenPixels = [];
@@ -195,17 +191,6 @@ public partial class Main : Godot.Control
_screenView.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
_gpuRenderer = new GpuRetainedRenderer(this);
// Native ADV wait marker: a tiny independently animated atlas region. Keeping it separate from the
// logical software backbuffer avoids recompositing the entire retained scene throughout static waits.
_waitIndicator = new TextureRect
{
Visible = false,
ExpandMode = TextureRect.ExpandModeEnum.IgnoreSize,
StretchMode = TextureRect.StretchModeEnum.Keep,
MouseFilter = MouseFilterEnum.Ignore,
};
AddChild(_waitIndicator);
_text = new Label { AutowrapMode = TextServer.AutowrapMode.WordSmart, MouseFilter = MouseFilterEnum.Ignore };
AddChild(_text);
_text.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
@@ -471,6 +456,11 @@ public partial class Main : Godot.Control
_vm.Gfx.SetSurface(0x0c, waitIndicator.PackedId, 0xff00);
_host.ConfigureAdvWaitIndicator(new AdvWaitIndicatorConfig(
1, 385, 140, 0x0c, 0, 0, 30, 27, 12, 48));
AdvTextLayoutSnapshot waitLayout =
_vm.TextHistory.GetLayoutSnapshot(1);
_host.BindAdvWaitIndicator(
_vm.TextHistory.GetPresentationBinding(1),
waitLayout);
}
// --boot: run SYSTEM4's state prefix (INITCONFIG/INIT2/INIT) so the scene sees boot state — chiefly
// INIT2's gfx handle array 0x62455.. (skips the UI scripts LOGO/OP/TITLE). State carries via globals.
@@ -580,7 +570,6 @@ public partial class Main : Godot.Control
phase = perf != null ? PerformanceFrameLog.Timestamp() : 0;
allocationPhase = perf != null ? PerformanceFrameLog.AllocatedBytes() : 0;
if (!_selftest && _host != null) UpdateAdvTextPresentation();
if (!_selftest && _host != null) UpdateAdvWaitIndicatorPresentation();
if (!_selftest && _host != null) UpdateHistoryTextPresentation();
perf?.RecordUiAllocation(PerformanceFrameLog.AllocatedBytes() - allocationPhase);
perf?.RecordUi(PerformanceFrameLog.Timestamp() - phase);
@@ -1706,40 +1695,6 @@ public partial class Main : Godot.Control
return new Color(((rgb >> 16) & 0xff) / 255f, ((rgb >> 8) & 0xff) / 255f, (rgb & 0xff) / 255f, 1);
}
private void UpdateAdvWaitIndicatorPresentation()
{
// As with the ordinary dialogue Label, the enclosing page's separately animated marker must sit
// out while HISTORY/HIDEWIN owns raw input; native retained composition naturally covers it.
if (_vm.IsRawInputCallbackActive)
{
_waitIndicator.Visible = false;
return;
}
var snapshot = _host.SnapshotAdvWaitIndicator();
if (snapshot == null)
{
_waitIndicator.Visible = false;
return;
}
var s = snapshot.Value;
if (_waitIndicatorAssetId != s.AssetId)
{
var image = Image.CreateFromData(s.Image.Width, s.Image.Height, false, Image.Format.Rgba8, s.Image.Pixels);
_waitIndicatorSheet = ImageTexture.CreateFromImage(image);
_waitIndicatorAtlas = new AtlasTexture { Atlas = _waitIndicatorSheet };
_waitIndicator.Texture = _waitIndicatorAtlas;
_waitIndicatorAssetId = s.AssetId;
}
var c = s.Config;
_waitIndicatorAtlas!.Region = new Rect2(
c.SourceX + s.Frame * c.CellWidth, c.SourceY, c.CellWidth, c.CellHeight);
_waitIndicator.Position = new Vector2(c.X, 430 + c.Y);
_waitIndicator.Size = new Vector2(c.CellWidth, c.CellHeight);
_waitIndicator.Visible = true;
}
private static string ColorTimeline(Age.Engine.Model.ColorTransitionState? state)
=> state is { } c
? $" color=0x{c.Current:x8}->0x{c.Target:x8} colorProgress={c.Progress:0.000}"
@@ -2610,6 +2565,69 @@ public partial class Main : Godot.Control
&& !string.IsNullOrWhiteSpace(_host.SurfaceTextFallbackReason);
liveRetainedTextMode = "label-fallback";
}
_vm.TextHistory.DefineLayout(2, 256, 64, 10, 100);
_vm.TextHistory.SetResetCursor(2, 1, 1);
_vm.TextHistory.SetBounds(2, 255, 63);
_vm.TextHistory.SetTextObjectRange(2, 710000, 64);
_vm.TextHistory.ResetLayout(2);
AdvTextLayoutSnapshot historyLayout =
_vm.TextHistory.GetLayoutSnapshot(2);
AdvTextLayoutPresentationBinding historyBinding =
_vm.TextHistory.GetPresentationBinding(2);
_host.ResetRenderedAdvTextLayout(_vm.Gfx, historyBinding);
var historyBatch = new AdvTextHistoryRenderBatch(
2,
0,
0,
historyLayout,
"History",
immediateStyle);
bool historyUsedRetained =
_host.RenderTextHistory(
_vm.Gfx,
historyBinding,
historyBatch);
bool historyRetainedTextOk;
string historyRetainedTextMode;
if (_host.UsesSurfaceTextPixels)
{
RgbaImage? historySurface =
_host.CaptureSurfacePixels(historyBinding.SourceSurfaceSlot);
historyRetainedTextOk =
historyUsedRetained
&& _vm.Gfx.SnapshotVisibleObjects()
.Count(item =>
item.Handle >= historyBinding.FirstObjectHandle
&& item.Handle - historyBinding.FirstObjectHandle
< historyBinding.ObjectCapacity)
== historyBatch.Text.Length
&& historySurface != null
&& historySurface.Pixels.Where((_, index) => index % 4 == 3)
.Any(alpha => alpha != 0)
&& _host.SnapshotRenderedTextHistory().Count == 0;
_vm.Gfx.EraseRange(
historyBinding.FirstObjectHandle,
historyBinding.ObjectCapacity);
_host.ClearRenderedAdvTextLayout(historyBinding.LayoutSlot);
_host.EndTextHistoryPresentation(_vm.Gfx);
historyRetainedTextOk &=
!_vm.Gfx.SnapshotVisibleObjects()
.Any(item =>
item.Handle >= historyBinding.FirstObjectHandle
&& item.Handle - historyBinding.FirstObjectHandle
< historyBinding.ObjectCapacity)
&& _host.CaptureSurfacePixels(historyBinding.SourceSurfaceSlot)
?.Pixels.All(value => value == 0) == true;
historyRetainedTextMode = "retained-glyphs";
}
else
{
historyRetainedTextOk =
!historyUsedRetained
&& _host.SnapshotRenderedTextHistory().Count == 1;
_host.EndTextHistoryPresentation(_vm.Gfx);
historyRetainedTextMode = "label-fallback";
}
Window rootWindow = GetTree().Root;
bool logicalCanvasOk = _host.LogicalCanvas == new Sys4LogicalCanvas(_screenWidth, _screenHeight)
&& _screen.GetWidth() == _screenWidth
@@ -2650,6 +2668,7 @@ public partial class Main : Godot.Control
&& bgmStopReleaseOk && textEffectModesOk && fontCalibrationOk
&& immediateSurfaceTextOk
&& liveRetainedTextOk
&& historyRetainedTextOk
&& 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); " +
@@ -2659,6 +2678,7 @@ public partial class Main : Godot.Control
$"text-effect-modes=ok; font-calibration=ok; " +
$"immediate-surface-text={immediateSurfaceTextMode}; " +
$"live-adv-text={liveRetainedTextMode}; " +
$"history-text={historyRetainedTextMode}; " +
$"backbuffer-preservation=ok; " +
$"logical-canvas={_screenWidth}x{_screenHeight}; " +
$"window-request={_windowOptions.Width}x{_windowOptions.Height}");
@@ -2672,6 +2692,7 @@ public partial class Main : Godot.Control
$"text-effect-modes={textEffectModesOk}; font-calibration={fontCalibrationOk}; " +
$"immediate-surface-text={immediateSurfaceTextOk}({immediateSurfaceTextMode}); " +
$"live-adv-text={liveRetainedTextOk}({liveRetainedTextMode}); " +
$"history-text={historyRetainedTextOk}({historyRetainedTextMode}); " +
$"backbuffer-preservation={backbufferPreservationOk}; " +
$"logical-canvas={logicalCanvasOk}({_screenWidth}x{_screenHeight}); " +
$"window-request={_windowOptions.Width}x{_windowOptions.Height}");