engine: complete portable glyph text backend

This commit is contained in:
gamer147
2026-07-30 19:47:21 -04:00
parent ef19ab79e0
commit d9f24c69ef
12 changed files with 619 additions and 840 deletions

View File

@@ -65,21 +65,16 @@ public sealed class GodotAdvHost : IHost
private readonly System.Threading.AutoResetEvent _frameSignal = new(false);
private volatile bool _stopping;
private readonly object _textLock = new();
private readonly Dictionary<int, List<SurfaceTextDraw>> _surfaceText = new();
private readonly CachedGlyphMaskRasterizer? _surfaceTextMaskCache;
private readonly ImmediateSurfaceTextRenderer? _surfaceTextPixelRenderer;
private readonly RetainedGlyphLayoutEngine? _retainedGlyphLayoutEngine;
private readonly GlyphRasterizerBackendInfo? _surfaceTextBackendInfo;
private string _surfaceTextFallbackReason;
private bool _surfaceTextFallbackWarningReported;
private readonly Dictionary<int, AdvTextHistoryRenderBatch> _historyText = new();
private readonly CachedGlyphMaskRasterizer _surfaceTextMaskCache;
private readonly ImmediateSurfaceTextRenderer _surfaceTextPixelRenderer;
private readonly RetainedGlyphLayoutEngine _retainedGlyphLayoutEngine;
private readonly GlyphRasterizerBackendInfo _surfaceTextBackendInfo;
private readonly HashSet<int> _retainedHistoryLayouts = new();
private sealed class LiveTextState
{
public required AdvLiveTextRun Run;
public required long StartedMs;
public required int GlyphDelayMilliseconds;
public bool RetainedPixels;
public int FirstGlyphIndex;
public int GlyphCount;
}
@@ -137,10 +132,10 @@ public sealed class GodotAdvHost : IHost
public GodotAdvHost(Main main, ResourceMap res, string scene, Age.Engine.Hosting.FrameClock clock,
PageLocatorState locator, Sys4LogicalCanvas logicalCanvas,
IGlyphMaskRasterizer surfaceTextRasterizer,
GodotTimelineLog? timeline = null,
bool synchronizeExplicitPresentation = true,
IGlyphMaskRasterizer? surfaceTextRasterizer = null,
string? surfaceTextFallbackReason = null)
int surfaceTextMaskCacheCapacity = 2048)
{
_main = main; _res = res; _rootScene = scene; _clock = clock;
_locator = locator; _timeline = timeline;
@@ -148,35 +143,30 @@ public sealed class GodotAdvHost : IHost
_screenHeight = logicalCanvas.Height;
_slotDims[0] = (_screenWidth, _screenHeight);
_synchronizeExplicitPresentation = synchronizeExplicitPresentation;
ArgumentNullException.ThrowIfNull(surfaceTextRasterizer);
_surfaceTextBackendInfo =
(surfaceTextRasterizer as IIdentifiedGlyphMaskRasterizer)?.BackendInfo;
if (surfaceTextRasterizer != null)
{
_surfaceTextMaskCache = new CachedGlyphMaskRasterizer(
surfaceTextRasterizer, capacity: 2048);
_surfaceTextPixelRenderer =
new ImmediateSurfaceTextRenderer(_surfaceTextMaskCache);
_retainedGlyphLayoutEngine =
new RetainedGlyphLayoutEngine(_surfaceTextMaskCache);
}
_surfaceTextFallbackReason = surfaceTextFallbackReason
?? (surfaceTextRasterizer == null
? "No glyph-mask rasterizer was selected."
: "");
(surfaceTextRasterizer as IIdentifiedGlyphMaskRasterizer)?.BackendInfo
?? throw new ArgumentException(
"Gameplay glyph rasterizers must identify their policy.",
nameof(surfaceTextRasterizer));
_surfaceTextMaskCache = new CachedGlyphMaskRasterizer(
surfaceTextRasterizer, capacity: surfaceTextMaskCacheCapacity);
_surfaceTextPixelRenderer =
new ImmediateSurfaceTextRenderer(
_surfaceTextMaskCache, _surfaceTextBackendInfo.Policy);
_retainedGlyphLayoutEngine =
new RetainedGlyphLayoutEngine(_surfaceTextMaskCache);
}
public Sys4LogicalCanvas LogicalCanvas => new(_screenWidth, _screenHeight);
public bool UsesSurfaceTextPixels => _surfaceTextPixelRenderer != null;
public GlyphRasterizerBackendInfo? SurfaceTextBackendInfo => _surfaceTextBackendInfo;
public string SurfaceTextFallbackReason => _surfaceTextFallbackReason;
public bool UsesSurfaceTextPixels => true;
public GlyphRasterizerBackendInfo SurfaceTextBackendInfo => _surfaceTextBackendInfo;
public (int Count, int Capacity, long Hits, long Misses) SurfaceTextMaskCacheStats
=> _surfaceTextMaskCache == null
? (0, 0, 0, 0)
: (
_surfaceTextMaskCache.Count,
_surfaceTextMaskCache.Capacity,
_surfaceTextMaskCache.Hits,
_surfaceTextMaskCache.Misses);
=> (
_surfaceTextMaskCache.Count,
_surfaceTextMaskCache.Capacity,
_surfaceTextMaskCache.Hits,
_surfaceTextMaskCache.Misses);
public void ReportWarning(string message) => System.Console.Error.WriteLine(message);
@@ -271,79 +261,12 @@ public sealed class GodotAdvHost : IHost
}
public void ShowText(int offset, string text)
{
AdvTextLayoutSnapshot layout;
int delay;
lock (_textLock)
{
layout = new AdvTextLayoutSnapshot(
_currentAdvLayout, _screenWidth, _screenHeight, 0, 0,
_advTextX, _advTextY, _screenWidth, _screenHeight);
delay = _messageGlyphDelayMilliseconds;
}
ShowText(new AdvLiveTextRun(
offset, layout, AdvTextStyle.Default, text, Array.Empty<string>()), delay);
}
=> throw new NotSupportedException(
"Godot gameplay text requires a retained layout binding.");
public void ShowText(AdvLiveTextRun run, int glyphDelayMilliseconds)
{
Captured.Add((run.SourceOffset, run.Text));
_locator.Text(run.SourceOffset, run.Text);
int delay = System.Math.Max(0, glyphDelayMilliseconds);
var state = new LiveTextState
{
Run = run,
StartedMs = _clock.NowMs,
GlyphDelayMilliseconds = delay,
};
lock (_textLock)
{
_liveText.Add(state);
_activeLiveText = state;
_advText = run.Text;
_advTextX = run.Layout.CursorX;
_advTextY = run.Layout.CursorY;
_currentAdvLayout = run.Layout.Slot;
_advTextStartedMs = state.StartedMs;
_activeGlyphDelayMilliseconds = delay;
_advTextForceComplete = _messageSkipActive;
IsTextRevealing = run.Text.Length > 0 && delay > 0 && !_messageSkipActive;
}
_timeline?.State("text-reveal", new()
{
["offset"] = $"0x{run.SourceOffset:x}",
["layout"] = run.Layout.Slot,
["x"] = run.Layout.OriginX + run.Layout.CursorX,
["y"] = run.Layout.OriginY + run.Layout.CursorY,
["glyphs"] = run.Text.Length, ["delay_ms"] = delay,
});
if (!IsTextRevealing)
{
Interlocked.Exchange(ref _presentRequested, 1);
_timeline?.State("running", new() { ["text_reveal_complete"] = true });
return;
}
bool scriptSuspended = SuspendScriptForPresentation();
try
{
RequestSynchronizedPresentation();
while (IsTextRevealing && !_stopping)
{
lock (_textLock)
{
if (_advTextForceComplete
|| _clock.NowMs - _advTextStartedMs >= run.Text.Length * (long)delay)
IsTextRevealing = false;
}
if (IsTextRevealing) _frameSignal.WaitOne(50);
}
}
finally
{
ResumeScriptAfterPresentation(scriptSuspended);
}
_timeline?.State("running", new() { ["text_reveal_complete"] = true });
}
=> throw new NotSupportedException(
"Godot gameplay text requires a retained layout binding.");
public AdvRetainedTextRunResult? ShowText(
GfxState gfx,
@@ -351,18 +274,15 @@ public sealed class GodotAdvHost : IHost
AdvLiveTextRun run,
int glyphDelayMilliseconds)
{
if (_retainedGlyphLayoutEngine == null
|| binding.LayoutSlot != run.Layout.Slot
if (binding.LayoutSlot != run.Layout.Slot
|| binding.FirstObjectHandle < 0
|| binding.ObjectCapacity <= 0
|| !TryPrepareRetainedTextRun(
gfx, binding, run,
out RetainedAdvTextLayoutPresentation? presentation,
out AdvRetainedTextRunResult result))
{
ShowText(run, glyphDelayMilliseconds);
return null;
}
|| binding.ObjectCapacity <= 0)
throw new InvalidOperationException(
$"ADV layout {run.Layout.Slot} has no retained presentation binding.");
PrepareRetainedTextRun(
gfx, binding, run,
out RetainedAdvTextLayoutPresentation? presentation,
out AdvRetainedTextRunResult result);
Captured.Add((run.SourceOffset, run.Text));
_locator.Text(run.SourceOffset, run.Text);
@@ -376,7 +296,6 @@ public sealed class GodotAdvHost : IHost
Run = run,
StartedMs = _clock.NowMs,
GlyphDelayMilliseconds = delay,
RetainedPixels = true,
FirstGlyphIndex = result.FirstGlyphIndex,
GlyphCount = revealGlyphCount,
};
@@ -451,7 +370,7 @@ public sealed class GodotAdvHost : IHost
return result;
}
private bool TryPrepareRetainedTextRun(
private void PrepareRetainedTextRun(
GfxState gfx,
AdvTextLayoutPresentationBinding binding,
AdvLiveTextRun run,
@@ -461,12 +380,9 @@ public sealed class GodotAdvHost : IHost
presentation = null!;
result = default;
if (run.Layout.Width <= 0 || run.Layout.Height <= 0)
{
ReportSurfaceTextFallback(
throw new InvalidOperationException(
$"ADV layout {run.Layout.Slot} has invalid dimensions " +
$"{run.Layout.Width}x{run.Layout.Height}.");
return false;
}
RgbaImage destination = ResolveSurfacePixels(binding.SourceSurfaceSlot)
?? new RgbaImage(
@@ -475,43 +391,27 @@ public sealed class GodotAdvHost : IHost
new byte[checked(run.Layout.Width * run.Layout.Height * 4)]);
if (destination.Width != run.Layout.Width
|| destination.Height != run.Layout.Height)
{
ReportSurfaceTextFallback(
throw new InvalidOperationException(
$"ADV layout {run.Layout.Slot} surface {binding.SourceSurfaceSlot} is " +
$"{destination.Width}x{destination.Height}; expected " +
$"{run.Layout.Width}x{run.Layout.Height}.");
return false;
}
var updated = new RgbaImage(
destination.Width, destination.Height, (byte[])destination.Pixels.Clone());
GlyphTextLayoutResult rendered;
try
{
IReadOnlyList<GlyphRasterRequest> requests =
ImmediateSurfaceTextRenderer.CreateRequests(run.Text, run.Style);
rendered = _retainedGlyphLayoutEngine!.Render(
updated,
new GlyphTextLayoutOptions(
run.Layout.CursorX,
run.Layout.CursorY,
binding.ResetCursorX,
run.Layout.Right,
run.Layout.Bottom,
WrapHorizontally: true,
run.Style),
requests);
}
catch (Exception error) when (
error is ArgumentException
or InvalidOperationException
or PlatformNotSupportedException
or System.ComponentModel.Win32Exception)
{
ReportSurfaceTextFallback(
$"{error.GetType().Name}: {error.Message}");
return false;
}
IReadOnlyList<GlyphRasterRequest> requests =
ImmediateSurfaceTextRenderer.CreateRequests(
run.Text, run.Style, _surfaceTextBackendInfo.Policy);
GlyphTextLayoutResult rendered = _retainedGlyphLayoutEngine.Render(
updated,
new GlyphTextLayoutOptions(
run.Layout.CursorX,
run.Layout.CursorY,
binding.ResetCursorX,
run.Layout.Right,
run.Layout.Bottom,
WrapHorizontally: true,
run.Style),
requests);
lock (_textLock)
{
@@ -522,11 +422,8 @@ public sealed class GodotAdvHost : IHost
_retainedTextLayouts.Add(binding.LayoutSlot, presentation);
}
else if (presentation.Binding != binding)
{
ReportSurfaceTextFallback(
throw new InvalidOperationException(
$"ADV layout {binding.LayoutSlot} changed its retained binding without reset.");
return false;
}
int first = presentation.Append(
rendered.Records, run.Layout.OriginX, run.Layout.OriginY);
result = new AdvRetainedTextRunResult(
@@ -541,7 +438,6 @@ public sealed class GodotAdvHost : IHost
_surfaceImages[binding.SourceSurfaceSlot] = updated;
_slotDims[binding.SourceSurfaceSlot] = (updated.Width, updated.Height);
gfx.CreateSurface(binding.SourceSurfaceSlot);
return true;
}
public void SetAdvTextCursor(int layoutSlot, int x, int y)
@@ -560,46 +456,19 @@ public sealed class GodotAdvHost : IHost
public void DrawStringToSurface(int surfaceSlot, int x, int y, string text, AdvTextStyle style)
{
if (_surfaceTextPixelRenderer != null
&& TryDrawStringPixels(surfaceSlot, x, y, text, style))
{
lock (_textLock)
if (_surfaceText.TryGetValue(surfaceSlot, out var fallbackDraws))
{
fallbackDraws.RemoveAll(draw => draw.X == x && draw.Y == y);
if (fallbackDraws.Count == 0) _surfaceText.Remove(surfaceSlot);
}
_timeline?.Event("draw-string", new()
{
["surface"] = surfaceSlot,
["x"] = x,
["y"] = y,
["text"] = text,
["presentation"] = "rgba-glyph-mask",
["backend"] = _surfaceTextBackendInfo?.Id ?? "unidentified",
});
return;
}
lock (_textLock)
{
if (!_surfaceText.TryGetValue(surfaceSlot, out var draws))
_surfaceText[surfaceSlot] = draws = new List<SurfaceTextDraw>();
draws.RemoveAll(draw => draw.X == x && draw.Y == y);
draws.Add(new SurfaceTextDraw(x, y, text, style));
}
DrawStringPixels(surfaceSlot, x, y, text, style);
_timeline?.Event("draw-string", new()
{
["surface"] = surfaceSlot,
["x"] = x,
["y"] = y,
["text"] = text,
["presentation"] = "label-fallback",
["reason"] = _surfaceTextFallbackReason,
["presentation"] = "rgba-glyph-mask",
["backend"] = _surfaceTextBackendInfo.Id,
});
}
private bool TryDrawStringPixels(
private void DrawStringPixels(
int surfaceSlot, int x, int y, string text, AdvTextStyle style)
{
RgbaImage? destination = ResolveSurfacePixels(surfaceSlot);
@@ -611,40 +480,15 @@ public sealed class GodotAdvHost : IHost
dimensions.W, dimensions.H,
new byte[checked(dimensions.W * dimensions.H * 4)]);
if (destination == null || destination.Width <= 0 || destination.Height <= 0)
{
ReportSurfaceTextFallback(
throw new InvalidOperationException(
$"Surface {surfaceSlot} has no rasterizable pixel allocation.");
return false;
}
var updated = new RgbaImage(
destination.Width, destination.Height, (byte[])destination.Pixels.Clone());
try
{
_surfaceTextPixelRenderer!.Render(updated, x, y, text, style);
}
catch (Exception error) when (
error is ArgumentException
or InvalidOperationException
or PlatformNotSupportedException
or System.ComponentModel.Win32Exception)
{
ReportSurfaceTextFallback(
$"{error.GetType().Name}: {error.Message}");
return false;
}
_surfaceTextPixelRenderer.Render(updated, x, y, text, style);
lock (_imageLock) _surfaceImages[surfaceSlot] = updated;
System.Threading.Interlocked.Exchange(ref _presentRequested, 1);
return true;
}
private void ReportSurfaceTextFallback(string reason)
{
_surfaceTextFallbackReason = reason;
if (_surfaceTextFallbackWarningReported) return;
_surfaceTextFallbackWarningReported = true;
ReportWarning($"surface text fell back to Label metadata: {reason}");
}
public (string Text, int X, int Y, int VisibleGlyphs, bool Revealing) SnapshotAdvText()
@@ -660,30 +504,10 @@ public sealed class GodotAdvHost : IHost
}
}
public IReadOnlyList<LiveAdvTextSnapshot> SnapshotLiveAdvText()
{
lock (_textLock)
{
var snapshot = new List<LiveAdvTextSnapshot>(_liveText.Count);
foreach (LiveTextState state in _liveText)
{
if (state.RetainedPixels) continue;
bool revealing = ReferenceEquals(state, _activeLiveText) && IsTextRevealing;
int visible = !revealing || _advTextForceComplete || state.GlyphDelayMilliseconds == 0
? state.Run.Text.Length
: (int)System.Math.Clamp(
(_clock.NowMs - state.StartedMs) / state.GlyphDelayMilliseconds + 1,
0, state.Run.Text.Length);
snapshot.Add(new LiveAdvTextSnapshot(state.Run, visible, revealing));
}
return snapshot;
}
}
/// <summary>
/// Layout that owns the ordinary ADV overlay. Nested callback scripts such as HISTORY can select and
/// mutate other layouts while the parent wait remains parked; those transient selections must not move
/// the parent page when its overlay becomes visible again.
/// Layout that owns the ordinary retained ADV page. Nested callback scripts such as HISTORY can select
/// and mutate other layouts while the parent wait remains parked; those transient selections must not
/// move the parent page when its retained range is restored.
/// </summary>
public int AdvPageLayoutSlot
{
@@ -694,28 +518,11 @@ public sealed class GodotAdvHost : IHost
}
}
public IReadOnlyList<SurfaceTextDraw> SnapshotSurfaceText(int surfaceSlot)
{
lock (_textLock)
return _surfaceText.TryGetValue(surfaceSlot, out var draws) ? draws.ToArray() : Array.Empty<SurfaceTextDraw>();
}
public void SnapshotSurfaceText(int surfaceSlot, List<SurfaceTextDraw> snapshot)
{
ArgumentNullException.ThrowIfNull(snapshot);
lock (_textLock)
{
snapshot.Clear();
if (_surfaceText.TryGetValue(surfaceSlot, out var draws)) snapshot.AddRange(draws);
}
}
public void ClearRenderedAdvTextLayout(int layoutSlot)
{
lock (_textLock)
{
int slot = layoutSlot == 0 ? _currentAdvLayout : layoutSlot;
_historyText.Remove(slot);
if (_retainedTextLayouts.ContainsKey(slot)) return;
_liveText.RemoveAll(state => state.Run.Layout.Slot == slot);
if (_activeLiveText?.Run.Layout.Slot == slot)
@@ -733,7 +540,6 @@ public sealed class GodotAdvHost : IHost
{
lock (_textLock)
{
_historyText.Remove(binding.LayoutSlot);
_retainedHistoryLayouts.Remove(binding.LayoutSlot);
_retainedTextLayouts.Remove(binding.LayoutSlot);
_liveText.RemoveAll(
@@ -770,18 +576,6 @@ public sealed class GodotAdvHost : IHost
});
}
public void RenderTextHistory(AdvTextHistoryRenderBatch batch)
{
lock (_textLock) _historyText[batch.LayoutSlot] = batch;
_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,
});
}
public bool RenderTextHistory(
GfxState gfx,
AdvTextLayoutPresentationBinding binding,
@@ -793,25 +587,21 @@ public sealed class GodotAdvHost : IHost
batch.Style,
batch.Text,
Array.Empty<string>());
if (_retainedGlyphLayoutEngine == null
|| binding.LayoutSlot != batch.LayoutSlot
if (binding.LayoutSlot != batch.LayoutSlot
|| binding.FirstObjectHandle < 0
|| binding.ObjectCapacity <= 0
|| !TryPrepareRetainedTextRun(
gfx, binding, run,
out RetainedAdvTextLayoutPresentation? presentation,
out AdvRetainedTextRunResult result))
{
RenderTextHistory(batch);
return false;
}
|| binding.ObjectCapacity <= 0)
throw new InvalidOperationException(
$"History layout {batch.LayoutSlot} has no retained presentation binding.");
PrepareRetainedTextRun(
gfx, binding, run,
out RetainedAdvTextLayoutPresentation? presentation,
out AdvRetainedTextRunResult result);
presentation.PublishThrough(
gfx,
checked(result.FirstGlyphIndex + result.GlyphCount));
lock (_textLock)
{
_historyText.Remove(batch.LayoutSlot);
_retainedHistoryLayouts.Add(batch.LayoutSlot);
}
Interlocked.Exchange(ref _presentRequested, 1);
@@ -828,16 +618,6 @@ public sealed class GodotAdvHost : IHost
return true;
}
public void EndTextHistoryPresentation()
{
lock (_textLock)
{
_historyText.Clear();
_retainedHistoryLayouts.Clear();
}
_timeline?.Event("history-presentation-end");
}
public void EndTextHistoryPresentation(GfxState gfx)
{
RetainedAdvTextLayoutPresentation[] retained;
@@ -856,7 +636,6 @@ public sealed class GodotAdvHost : IHost
foreach (int slot in _retainedHistoryLayouts)
_retainedTextLayouts.Remove(slot);
_retainedHistoryLayouts.Clear();
_historyText.Clear();
}
foreach (RetainedAdvTextLayoutPresentation presentation in retained)
@@ -870,11 +649,6 @@ public sealed class GodotAdvHost : IHost
});
}
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)
@@ -898,17 +672,6 @@ public sealed class GodotAdvHost : IHost
public void FillSurfaceRect(SurfaceRectFill fill)
{
lock (_textLock)
{
if (_surfaceText.TryGetValue(fill.SurfaceSlot, out var draws))
{
long right = (long)fill.X + System.Math.Max(0, fill.Width);
long bottom = (long)fill.Y + System.Math.Max(0, fill.Height);
draws.RemoveAll(draw => draw.X >= fill.X && draw.X < right
&& draw.Y >= fill.Y && draw.Y < bottom);
}
}
RgbaImage? destination = ResolveSurfacePixels(fill.SurfaceSlot);
if (destination == null && _slotDims.TryGetValue(fill.SurfaceSlot, out var dimensions)
&& dimensions.W >= 0 && dimensions.H >= 0)
@@ -1655,9 +1418,7 @@ public sealed class GodotAdvHost : IHost
}
lock (_textLock)
{
_surfaceText.Clear();
_surfaceResources.Clear();
_historyText.Clear();
_retainedHistoryLayouts.Clear();
_liveText.Clear();
_retainedTextLayouts.Clear();
@@ -1792,7 +1553,6 @@ public sealed class GodotAdvHost : IHost
public void CreateTexture(int slot, int width, int height)
{
lock (_textLock) _surfaceText.Remove(slot);
lock (_textLock) _surfaceResources.Remove(slot);
int safeWidth = System.Math.Max(0, width);
int safeHeight = System.Math.Max(0, height);
@@ -1817,7 +1577,6 @@ public sealed class GodotAdvHost : IHost
}
lock (_textLock)
{
_surfaceText.Remove(slot);
_surfaceResources[slot] = resourceId;
}
var asset = _res.ResolveTexture(resourceId);
@@ -1851,7 +1610,6 @@ public sealed class GodotAdvHost : IHost
}
lock (_textLock)
{
_surfaceText.Remove(slot);
_surfaceResources.Remove(slot);
foreach (int layoutSlot in _retainedTextLayouts
.Where(pair => pair.Value.Binding.SourceSurfaceSlot == slot)
@@ -2152,7 +1910,6 @@ public sealed class GodotAdvHost : IHost
}
lock (_textLock)
{
_surfaceText.Remove(slot);
_surfaceResources.Remove(slot);
foreach (int layoutSlot in _retainedTextLayouts
.Where(pair => pair.Value.Binding.SourceSurfaceSlot == slot)
@@ -2187,9 +1944,6 @@ public sealed class GodotAdvHost : IHost
}
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)
{
if (_surfaceImages.TryGetValue(surfaceSlot, out var image))
@@ -2235,8 +1989,6 @@ public sealed class GodotAdvHost : IHost
});
lock (_imageLock) _surfaceImages[targetSlot] = destination;
PublishSurfaceTextRangeToSurface(
gfx, visible, firstHandle, count, targetSlot, dimensions.W, dimensions.H);
IReadOnlyList<RenderObject> retained = visible
.Where(item => item.Handle >= firstHandle && item.Handle - firstHandle < count)
.ToArray();
@@ -2251,52 +2003,6 @@ public sealed class GodotAdvHost : IHost
});
}
private void PublishSurfaceTextRangeToSurface(
GfxState gfx, IReadOnlyList<RenderObject> visible, long firstHandle, long count,
int targetSlot, int targetWidth, int targetHeight)
{
List<SurfaceTextDraw> projected;
lock (_textLock)
projected = _surfaceText.TryGetValue(targetSlot, out var retained)
? new List<SurfaceTextDraw>(retained)
: new List<SurfaceTextDraw>();
foreach (RenderObject item in visible)
{
if (item.Handle < firstHandle || item.Handle - firstHandle >= count) continue;
var raw = gfx.TryGet(item.Handle);
if (raw == null) continue;
List<SurfaceTextDraw>? source;
lock (_textLock)
source = _surfaceText.TryGetValue(raw.SourceSlot, out var draws)
? new List<SurfaceTextDraw>(draws)
: null;
if (source == null) continue;
Affine2D localToTarget =
Transform2DMath.Build(item.Transform, item.Rotation, item.ScaleCycle)
.FromLocalOrigin(item.DstX, item.DstY);
if (item.RangeTransform is { } rangeTransform)
localToTarget = localToTarget.Then(rangeTransform);
foreach (SurfaceTextDraw draw in source)
{
if (draw.X < item.SrcX || draw.X >= item.SrcX + item.W ||
draw.Y < item.SrcY || draw.Y >= item.SrcY + item.H) continue;
var position = localToTarget.Apply(draw.X - item.SrcX, draw.Y - item.SrcY);
int x = (int)System.Math.Round(position.X);
int y = (int)System.Math.Round(position.Y);
if (x < 0 || x >= targetWidth || y < 0 || y >= targetHeight) continue;
projected.Add(new SurfaceTextDraw(x, y, draw.Text, draw.Style));
}
}
lock (_textLock)
{
if (projected.Count == 0) _surfaceText.Remove(targetSlot);
else _surfaceText[targetSlot] = projected;
}
}
public void ReleaseSurfaceRange(int firstSlot, int count)
{
IReadOnlyList<MovieSurfaceBinding> stoppedMovies = _movieSurfaces.ReleaseRange(firstSlot, count);
@@ -2316,7 +2022,6 @@ public sealed class GodotAdvHost : IHost
{
for (int slot = firstSlot; slot < end; slot++)
{
_surfaceText.Remove(slot);
_surfaceResources.Remove(slot);
}
foreach (int layoutSlot in _retainedTextLayouts
@@ -2655,9 +2360,6 @@ public sealed class GodotAdvHost : IHost
}
}
public readonly record struct SurfaceTextDraw(int X, int Y, string Text, AdvTextStyle Style);
public readonly record struct LiveAdvTextSnapshot(
AdvLiveTextRun Run, int VisibleGlyphs, bool Revealing);
public sealed record GodotHostDiagnosticSnapshot(
string CurrentScene, bool IsInputWaiting, bool IsTransitionWaiting, bool IsSleeping,
bool IsTextRevealing, bool IsModalMovieWaiting, bool IsAdvPagePresentationSuspended,

View File

@@ -0,0 +1,180 @@
using System;
using Age.Engine.Text;
using Godot;
/// <summary>
/// Portable, explicitly non-pixel-exact glyph masks obtained from Godot's TextServer atlas.
/// AGE still owns layout, effects, blending, retained ordering, and surface lifetime.
/// </summary>
public sealed class GodotTextServerGlyphMaskRasterizer :
IIdentifiedGlyphMaskRasterizer, IDisposable
{
private readonly record struct FontKey(string RequestedFace, int Weight);
private readonly object _gate = new();
private readonly PortableTextRenderingPolicy _policy;
private readonly TextServer _textServer;
private readonly BoundedLruCache<FontKey, SystemFont> _fonts;
private bool _disposed;
public GodotTextServerGlyphMaskRasterizer(PortableTextRenderingPolicy policy)
{
_policy = policy ?? throw new ArgumentNullException(nameof(policy));
_textServer = TextServerManager.GetPrimaryInterface()
?? throw new InvalidOperationException("Godot has no primary TextServer interface.");
_fonts = new BoundedLruCache<FontKey, SystemFont>(
policy.FontCacheCapacity, font => font.Dispose());
BackendInfo = new GlyphRasterizerBackendInfo(
"portable-godot-textserver",
"Godot TextServer system fonts",
GlyphRasterPolicy.PortableUnicode,
NativePixelExact: false,
$"policy={policy.Id}; raster={policy.Raster.Antialiasing}/" +
$"{policy.Raster.Hinting}/{policy.Raster.SubpixelPositioning}; " +
"Unicode system-font substitution; not GDI pixel-exact");
}
public GlyphRasterizerBackendInfo BackendInfo { get; }
public GlyphMask Rasterize(GlyphRasterRequest request)
{
ArgumentNullException.ThrowIfNull(request);
if (request.Policy != GlyphRasterPolicy.PortableUnicode)
throw new ArgumentException(
"Godot TextServer accepts only portable Unicode glyph requests.",
nameof(request));
lock (_gate)
{
ObjectDisposedException.ThrowIf(_disposed, this);
SystemFont font = ResolveFont(request);
// Asking the high-level Font for the character primes both the selected system face
// and any system fallback RID before we inspect the TextServer cache directly.
_ = font.GetCharSize(request.UnicodeScalar, request.PixelHeight);
Rid rid = FindGlyphRid(font, request);
long glyph = _textServer.FontGetGlyphIndex(
rid, request.PixelHeight, request.UnicodeScalar, 0);
if (glyph == 0 && request.UnicodeScalar != 0)
throw MissingGlyph(request);
var sizeKey = new Vector2I(request.PixelHeight, 0);
_textServer.FontRenderGlyph(rid, sizeKey, glyph);
Vector2 glyphSize = _textServer.FontGetGlyphSize(rid, sizeKey, glyph);
Vector2 glyphOffset = _textServer.FontGetGlyphOffset(rid, sizeKey, glyph);
Vector2 advance = _textServer.FontGetGlyphAdvance(
rid, request.PixelHeight, glyph);
Rect2 uv = _textServer.FontGetGlyphUVRect(rid, sizeKey, glyph);
int width = Math.Max(0, Mathf.RoundToInt(glyphSize.X));
int height = Math.Max(0, Mathf.RoundToInt(glyphSize.Y));
byte[] coverage = width == 0 || height == 0
? []
: ExtractCoverage(rid, sizeKey, glyph, uv, width, height);
int advanceX = Mathf.RoundToInt(advance.X);
return new GlyphMask(
width,
height,
width,
Mathf.RoundToInt(glyphOffset.X),
-Mathf.RoundToInt(glyphOffset.Y),
advanceX,
0,
Math.Max(width, advanceX),
request.PixelHeight,
coverage);
}
}
public void Dispose()
{
lock (_gate)
{
if (_disposed) return;
_disposed = true;
_fonts.Clear();
}
}
private SystemFont ResolveFont(GlyphRasterRequest request)
{
PortableFontRasterPolicy raster = _policy.Raster;
int weight = request.Weight >= raster.BoldThreshold
? raster.BoldWeight
: raster.RegularWeight;
var key = new FontKey(request.FontFace, weight);
if (_fonts.TryGetValue(key, out SystemFont? cached)) return cached;
var font = new SystemFont
{
FontNames = _policy.ResolveFamilies(request.FontFace),
FontWeight = weight,
AllowSystemFallback = raster.AllowSystemFallback,
Antialiasing = raster.Antialiasing switch
{
"none" => TextServer.FontAntialiasing.None,
"lcd" => TextServer.FontAntialiasing.Lcd,
_ => TextServer.FontAntialiasing.Gray,
},
Hinting = raster.Hinting switch
{
"none" => TextServer.Hinting.None,
"light" => TextServer.Hinting.Light,
_ => TextServer.Hinting.Normal,
},
SubpixelPositioning = raster.SubpixelPositioning switch
{
"auto" => TextServer.SubpixelPositioning.Auto,
"one-half" => TextServer.SubpixelPositioning.OneHalf,
"one-quarter" => TextServer.SubpixelPositioning.OneQuarter,
_ => TextServer.SubpixelPositioning.Disabled,
},
MultichannelSignedDistanceField =
raster.MultichannelSignedDistanceField,
};
_fonts.Set(key, font);
return font;
}
private Rid FindGlyphRid(SystemFont font, GlyphRasterRequest request)
{
foreach (Rid rid in font.GetRids())
if (_textServer.FontHasChar(rid, request.UnicodeScalar))
return rid;
throw MissingGlyph(request);
}
private byte[] ExtractCoverage(
Rid rid,
Vector2I sizeKey,
long glyph,
Rect2 uv,
int width,
int height)
{
long textureIndex = _textServer.FontGetGlyphTextureIdx(rid, sizeKey, glyph);
Image atlas = _textServer.FontGetTextureImage(rid, sizeKey, textureIndex)
?? throw new InvalidOperationException("TextServer returned no glyph atlas image.");
int left = Mathf.RoundToInt(uv.Position.X);
int top = Mathf.RoundToInt(uv.Position.Y);
var result = new byte[checked(width * height)];
for (int y = 0; y < height; y++)
for (int x = 0; x < width; x++)
{
Color pixel = atlas.GetPixel(left + x, top + y);
float mask = atlas.GetFormat() switch
{
Image.Format.L8 => pixel.R,
Image.Format.Rgb8 => Math.Max(pixel.R, Math.Max(pixel.G, pixel.B)),
_ => pixel.A,
};
result[y * width + x] =
(byte)Math.Clamp(Mathf.RoundToInt(mask * 16f), 0, 16);
}
return result;
}
private static Exception MissingGlyph(GlyphRasterRequest request)
=> new InvalidOperationException(
$"Portable font policy could not resolve U+{request.UnicodeScalar:X4} " +
$"for authored face '{request.FontFace}'.");
}

View File

@@ -20,12 +20,6 @@ using Script = Age.Engine.Model.Script; // disambiguate from Godot.Script
public partial class Main : Godot.Control
{
// Provisional approximation only: AGE asks GDI to synthesize LOGFONT weight 700, grid-fit a
// GGO_GRAY4 mask, and composites that mask itself. Godot instead applies FreeType embolden plus
// spacing. Do not retune these values from screenshots; replace this approximation from the decoded
// native glyph-mask contract. See docs/engine-re.md.
private const float NativeBoldEmbolden = 0.53f;
private const int NativeBoldGlyphSpacing = 1;
private int _screenWidth = Sys4LogicalCanvas.DefaultWidth;
private int _screenHeight = Sys4LogicalCanvas.DefaultHeight;
private WindowLaunchOptions _windowOptions;
@@ -40,18 +34,6 @@ public partial class Main : Godot.Control
// One managed composition target for the entire frame. Layer helpers mutate it in place; only the
// completed frame crosses the Godot Image boundary, avoiding a full GetData/SetData round-trip per layer.
private byte[] _screenPixels = [];
private Label _text = null!;
private Label _speaker = null!;
private readonly System.Collections.Generic.List<Label> _advTextLabels = new();
private readonly System.Collections.Generic.List<Label> _surfaceTextLabels = new();
private readonly System.Collections.Generic.Dictionary<int, Label> _historyTextLabels = new();
private Font? _presentationFallbackFont;
private readonly Dictionary<string, Font> _presentationFaceFonts =
new(System.StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, FontVariation> _presentationBoldFonts =
new(System.StringComparer.OrdinalIgnoreCase);
private readonly HashSet<string> _unavailablePresentationFaces =
new(System.StringComparer.OrdinalIgnoreCase);
private Label _status = null!;
private Label _locatorHud = null!;
private AudioStreamPlayer _bgm = null!; // looping background music
@@ -69,9 +51,7 @@ public partial class Main : Godot.Control
private Sys4RegIniStore? _sys4RegIniStore;
private VirtualMachine _vm = null!;
private GodotAdvHost _host = null!;
#if AGE_WINDOWS_GDI
private WindowsGdiGlyphMaskRasterizer? _exactGlyphRasterizer;
#endif
private IDisposable? _glyphRasterizerOwner;
private FullwidthTextEditorDialog? _fullwidthTextEditor;
private Sys4ScriptProvider? _scripts;
private DebugSceneLauncher? _debugSceneLauncher;
@@ -191,14 +171,6 @@ public partial class Main : Godot.Control
_screenView.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
_gpuRenderer = new GpuRetainedRenderer(this);
_text = new Label { AutowrapMode = TextServer.AutowrapMode.WordSmart, MouseFilter = MouseFilterEnum.Ignore };
AddChild(_text);
_text.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
_advTextLabels.Add(_text);
_speaker = new Label { MouseFilter = MouseFilterEnum.Ignore, Visible = false };
AddChild(_speaker);
_speaker.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
_surfaceTextLabels.Add(_speaker);
_status = new Label();
AddChild(_status);
_status.SetAnchorsAndOffsetsPreset(LayoutPreset.BottomWide);
@@ -207,7 +179,7 @@ public partial class Main : Godot.Control
_locatorHud.Position = new Vector2(8, 8);
AddChild(_locatorHud);
// Best-effort CJK font so the visual isn't tofu (headless self-test doesn't depend on it).
// Best-effort CJK font for diagnostic/status UI. Gameplay text is always surface pixels.
foreach (var fp in new[] { "C:/Windows/Fonts/msgothic.ttc", "C:/Windows/Fonts/YuGothM.ttc",
"C:/Windows/Fonts/YuGothR.ttc", "C:/Windows/Fonts/meiryo.ttc" })
{
@@ -215,18 +187,8 @@ public partial class Main : Godot.Control
try
{
var ff = new FontFile { Data = System.IO.File.ReadAllBytes(fp) };
_presentationFallbackFont = ff;
_text.AddThemeFontOverride("font", ff);
_speaker.AddThemeFontOverride("font", ff);
_status.AddThemeFontOverride("font", ff);
_locatorHud.AddThemeFontOverride("font", ff);
_text.AddThemeFontSizeOverride("font_size", 25);
_speaker.AddThemeFontSizeOverride("font_size", 25);
_text.AddThemeConstantOverride("outline_size", 1);
_speaker.AddThemeConstantOverride("outline_size", 1);
var outline = new Color(0x60 / 255f, 0x60 / 255f, 0x60 / 255f, 1);
_text.AddThemeColorOverride("font_outline_color", outline);
_speaker.AddThemeColorOverride("font_outline_color", outline);
break;
}
catch { /* fall back to the default font */ }
@@ -258,6 +220,7 @@ public partial class Main : Godot.Control
double speed = 1.0; // --speed <f>: sleeps + retained presentation clocks
long transitionClickMs = -1; // --transition-click-ms <n>: force active transitions after n virtual ms
bool holdMessageSkip = false; // --hold-message-skip: hold native logical action 6 for diagnostics
string textBackend = "auto"; // --text-backend auto|gdi|portable
string? histFile = null; // --trace-histogram <file>: op/call-site execution counts of the REAL run
string? pageMapPath = null; // --page-map <jsonl>: override default build/page-map-SCxxxx.jsonl
for (int i = 0; i < userArgs.Length; i++)
@@ -283,6 +246,8 @@ public partial class Main : Godot.Control
if (userArgs[i] == "--speed" && i + 1 < userArgs.Length) double.TryParse(userArgs[i + 1], out speed);
if (userArgs[i] == "--transition-click-ms" && i + 1 < userArgs.Length) long.TryParse(userArgs[i + 1], out transitionClickMs);
if (userArgs[i] == "--hold-message-skip") holdMessageSkip = true;
if (userArgs[i] == "--text-backend" && i + 1 < userArgs.Length)
textBackend = userArgs[i + 1].ToLowerInvariant();
if (userArgs[i] == "--trace-histogram" && i + 1 < userArgs.Length) histFile = userArgs[i + 1];
if (userArgs[i] == "--page-map" && i + 1 < userArgs.Length) pageMapPath = userArgs[i + 1];
if (userArgs[i] == "--locator-hud") _locatorHudVisible = true;
@@ -299,6 +264,14 @@ public partial class Main : Godot.Control
}
if (!double.IsFinite(speed) || speed <= 0) speed = 1.0;
if (textBackend is not ("auto" or "gdi" or "portable"))
{
GD.PushError(
$"[startup] unknown --text-backend '{textBackend}'; " +
"expected auto, gdi, or portable");
GetTree().Quit(2);
return;
}
_clock.Speed = System.Math.Clamp(speed, 0.05, 8.0);
GD.Print($"[renderer] retained backend={(_useGpuBackend ? "gpu" : "software")}");
@@ -367,43 +340,68 @@ public partial class Main : Godot.Control
? new ResourceMap(scripts.Catalog, trackedAssetStore)
: new ResourceMap(catalog, _assetStore);
IGlyphMaskRasterizer? surfaceTextRasterizer = null;
string surfaceTextFallbackReason;
PortableTextRenderingPolicy? portableTextPolicy = null;
string? exactUnavailable = null;
try
{
#if AGE_WINDOWS_GDI
if (WindowsGdiGlyphMaskRasterizer.TryGetAvailability(out string availability))
{
try
if (textBackend != "portable")
{
_exactGlyphRasterizer = new WindowsGdiGlyphMaskRasterizer();
surfaceTextRasterizer = _exactGlyphRasterizer;
surfaceTextFallbackReason = "";
GD.Print(
$"[text] immediate surface strings use {_exactGlyphRasterizer.BackendInfo.Id}: " +
_exactGlyphRasterizer.BackendInfo.Detail);
if (WindowsGdiGlyphMaskRasterizer.TryGetAvailability(out string availability))
{
try
{
var exact = new WindowsGdiGlyphMaskRasterizer();
surfaceTextRasterizer = exact;
_glyphRasterizerOwner = exact;
}
catch (Exception error) when (textBackend == "auto")
{
exactUnavailable =
$"Exact Windows GDI glyph backend failed to initialize: {error.Message}";
}
}
else exactUnavailable = availability;
}
catch (Exception error)
{
surfaceTextFallbackReason =
$"Exact Windows GDI glyph backend failed to initialize: {error.Message}";
GD.PushWarning($"[text] {surfaceTextFallbackReason}");
}
}
else
{
surfaceTextFallbackReason = availability;
GD.PushWarning(
$"[text] immediate surface strings retain the Label fallback: {availability}");
}
#else
surfaceTextFallbackReason =
"The exact Windows GDI glyph adapter is not part of this platform build.";
GD.PushWarning(
$"[text] immediate surface strings retain the Label fallback: {surfaceTextFallbackReason}");
if (textBackend == "gdi")
exactUnavailable =
"The exact Windows GDI glyph adapter is not part of this platform build.";
#endif
if (surfaceTextRasterizer == null)
{
if (textBackend == "gdi")
throw new PlatformNotSupportedException(
exactUnavailable ?? "Exact Windows GDI text is unavailable.");
portableTextPolicy = PortableTextRenderingPolicy.Load();
var portable =
new GodotTextServerGlyphMaskRasterizer(portableTextPolicy);
surfaceTextRasterizer = portable;
_glyphRasterizerOwner = portable;
}
}
catch (Exception error)
{
GD.PushError($"[startup] text backend selection failed: {error.Message}");
GetTree().Quit(2);
return;
}
var selectedTextBackend =
(surfaceTextRasterizer as IIdentifiedGlyphMaskRasterizer)?.BackendInfo
?? throw new InvalidOperationException(
"Selected text backend does not report its policy.");
GD.Print(
$"[text] gameplay strings use {selectedTextBackend.Id}: " +
selectedTextBackend.Detail);
if (textBackend == "auto" && exactUnavailable != null)
GD.Print($"[text] exact backend unavailable; selected portable: {exactUnavailable}");
_host = new GodotAdvHost(
this, resources, scene, _clock, _locator, logicalCanvas, _timeline,
this, resources, scene, _clock, _locator, logicalCanvas,
surfaceTextRasterizer,
timeline: _timeline,
synchronizeExplicitPresentation: !_selftest,
surfaceTextRasterizer: surfaceTextRasterizer,
surfaceTextFallbackReason: surfaceTextFallbackReason)
surfaceTextMaskCacheCapacity:
portableTextPolicy?.GlyphMaskCacheCapacity ?? 2048)
{
SleepScale = sleepScale,
TraceOps = _gfxLogPath != null,
@@ -567,13 +565,6 @@ public partial class Main : Godot.Control
}
perf?.RecordRecomposeAllocation(PerformanceFrameLog.AllocatedBytes() - allocationPhase);
phase = perf != null ? PerformanceFrameLog.Timestamp() : 0;
allocationPhase = perf != null ? PerformanceFrameLog.AllocatedBytes() : 0;
if (!_selftest && _host != null) UpdateAdvTextPresentation();
if (!_selftest && _host != null) UpdateHistoryTextPresentation();
perf?.RecordUiAllocation(PerformanceFrameLog.AllocatedBytes() - allocationPhase);
perf?.RecordUi(PerformanceFrameLog.Timestamp() - phase);
// --shot-sequence: dump one PNG per frame across the opening so a time-based (paced) effect can be
// verified as distinct frames, not just the final state. Captures after Recomposite; quits when full.
if (_seqDir != null && _seqIdx < _seqFrames && !_done)
@@ -1044,10 +1035,8 @@ public partial class Main : Godot.Control
DumpHistogram(); _timeline?.Dispose(); _locator?.Dispose();
_gpuRenderer?.Dispose();
#if AGE_WINDOWS_GDI
_exactGlyphRasterizer?.Dispose();
_exactGlyphRasterizer = null;
#endif
_glyphRasterizerOwner?.Dispose();
_glyphRasterizerOwner = null;
if (_perf != null)
{
_perf.Dispose();
@@ -1087,7 +1076,6 @@ public partial class Main : Godot.Control
private sealed record CachedPixels(int Width, int Height, byte[] Rgba);
private readonly System.Collections.Generic.Dictionary<(int AssetId, long Key), CachedPixels> _pixelCache = new();
private readonly System.Collections.Generic.List<RenderObject> _visibleSnapshot = new(1024);
private readonly System.Collections.Generic.List<SurfaceTextDraw> _surfaceTextSnapshot = new();
private void Recomposite()
{
@@ -1134,11 +1122,6 @@ public partial class Main : Godot.Control
presentStep?.Offset ?? -1, presentStep?.Opcode ?? -1);
}
_perf?.BeginRecomposite(screenTransition: false);
phase = _perf != null ? PerformanceFrameLog.Timestamp() : 0;
foreach (var label in _surfaceTextLabels) label.Visible = false;
_perf?.RecordClear(PerformanceFrameLog.Timestamp() - phase);
int surfaceTextLabelIndex = 0;
_gpuRenderer.BeginFrame(publicationPolicy.AppendGpuLayers);
foreach (var v in _visibleSnapshot)
{
@@ -1197,24 +1180,6 @@ public partial class Main : Godot.Control
resolved.IsDynamic, v.Blend);
}
}
if (rawObject != null)
{
_host.SnapshotSurfaceText(rawObject.SourceSlot, _surfaceTextSnapshot);
foreach (var surfaceText in _surfaceTextSnapshot)
{
if (surfaceText.X < v.SrcX || surfaceText.X >= v.SrcX + v.W ||
surfaceText.Y < v.SrcY || surfaceText.Y >= v.SrcY + v.H) continue;
var label = GetSurfaceTextLabel(surfaceTextLabelIndex++);
ApplySurfaceTextTransform(
label, affine, surfaceText.X - v.SrcX, surfaceText.Y - v.SrcY);
label.Size = new Vector2(System.Math.Max(1, v.W - (surfaceText.X - v.SrcX)),
System.Math.Max(1, v.H - (surfaceText.Y - v.SrcY)));
label.Text = surfaceText.Text;
ApplyAdvTextStyle(label, surfaceText.Style);
label.Visible = true;
}
}
}
var stats = _gpuRenderer.EndFrame();
@@ -1327,25 +1292,22 @@ public partial class Main : Godot.Control
{
System.Array.Clear(_screenPixels);
}
foreach (var label in _surfaceTextLabels) label.Visible = false;
_perf?.RecordClear(PerformanceFrameLog.Timestamp() - phase);
int surfaceTextLabelIndex = 0;
System.Collections.Generic.Dictionary<long, string>? decisions = _gfxLogPath != null || _timeline != null ? new() : null;
if (hasScreenTransition)
{
allocationPhase = _perf != null ? PerformanceFrameLog.AllocatedBytes() : 0;
// Native mode 4 keeps the captured source opaque and alpha-composites the complete target
// surface over it. Each offscreen target has an opaque-black clear beneath its objects.
CompositeVisibleObjects(transition.Source, 1f, ref surfaceTextLabelIndex, decisions, false);
CompositeVisibleObjects(transition.Source, 1f, decisions);
FillQuad(0, 0, _screenWidth, _screenHeight, 0, (float)transition.Progress);
CompositeVisibleObjects(transition.Target, (float)transition.Progress,
ref surfaceTextLabelIndex, decisions, false);
CompositeVisibleObjects(transition.Target, (float)transition.Progress, decisions);
_perf?.RecordCompositeAllocation(PerformanceFrameLog.AllocatedBytes() - allocationPhase);
}
else
{
allocationPhase = _perf != null ? PerformanceFrameLog.AllocatedBytes() : 0;
CompositeVisibleObjects(sampledVisible ?? _visibleSnapshot, 1f, ref surfaceTextLabelIndex, decisions, true);
CompositeVisibleObjects(sampledVisible ?? _visibleSnapshot, 1f, decisions);
_perf?.RecordCompositeAllocation(PerformanceFrameLog.AllocatedBytes() - allocationPhase);
}
phase = _perf != null ? PerformanceFrameLog.Timestamp() : 0;
@@ -1360,10 +1322,10 @@ public partial class Main : Godot.Control
_perf?.EndRecomposite();
}
private void CompositeVisibleObjects(IReadOnlyList<RenderObject> visible, float globalOpacity,
ref int surfaceTextLabelIndex,
System.Collections.Generic.Dictionary<long, string>? decisions,
bool includeSurfaceText)
private void CompositeVisibleObjects(
IReadOnlyList<RenderObject> visible,
float globalOpacity,
System.Collections.Generic.Dictionary<long, string>? decisions)
{
int z = 0;
foreach (var v in visible) // interpolate at the retained-presentation clock
@@ -1454,247 +1416,10 @@ public partial class Main : Godot.Control
}
}
if (decisions != null) decisions[v.Handle] = $"z{z} {outcome}";
if (includeSurfaceText && rawObject != null)
{
_host.SnapshotSurfaceText(rawObject.SourceSlot, _surfaceTextSnapshot);
foreach (var surfaceText in _surfaceTextSnapshot)
{
if (surfaceText.X < v.SrcX || surfaceText.X >= v.SrcX + v.W ||
surfaceText.Y < v.SrcY || surfaceText.Y >= v.SrcY + v.H) continue;
var label = GetSurfaceTextLabel(surfaceTextLabelIndex++);
ApplySurfaceTextTransform(
label, localToDest, surfaceText.X - v.SrcX, surfaceText.Y - v.SrcY);
label.Size = new Vector2(System.Math.Max(1, v.W - (surfaceText.X - v.SrcX)),
System.Math.Max(1, v.H - (surfaceText.Y - v.SrcY)));
label.Text = surfaceText.Text;
ApplyAdvTextStyle(label, surfaceText.Style);
label.Visible = true;
}
}
z++;
}
}
private void UpdateAdvTextPresentation()
{
// Modal callback scripts composite their own full-screen UI while the enclosing ADV wait remains
// parked. Live layout text is a Godot overlay rather than part of the retained surface. During a
// raw-input callback, keep runs owned by that callback's script stack (STUDY -> MAMES) while hiding
// enclosing ADV runs that would otherwise leak above a nested screen such as HISTORY. Native
// retained-glyph erases remove ordinary live runs through the host before this presentation pass.
foreach (var label in _advTextLabels) label.Visible = false;
if (_host.IsAdvPagePresentationSuspended) return;
int labelIndex = 0;
string? rawInputOwner = _vm.RawInputCallbackScriptName;
var snapshots = _host.SnapshotLiveAdvText();
for (int i = 0; i < snapshots.Count; i++)
{
var snapshot = snapshots[i];
var run = snapshot.Run;
if (run.Text.Length == 0 || !ShouldShow(run)) continue;
var visibleText = new System.Text.StringBuilder();
AppendVisible(snapshot);
while (i + 1 < snapshots.Count
&& snapshots[i + 1].Run.Layout == run.Layout
&& snapshots[i + 1].Run.Style == run.Style
&& ShouldShow(snapshots[i + 1].Run))
{
i++;
AppendVisible(snapshots[i]);
}
var label = GetAdvTextLabel(labelIndex++);
var layout = run.Layout;
label.Position = new Vector2(layout.OriginX + layout.CursorX, layout.OriginY + layout.CursorY);
label.Size = new Vector2(System.Math.Max(1, layout.Right - layout.CursorX),
System.Math.Max(1, layout.Bottom - layout.CursorY));
label.Text = visibleText.ToString();
ApplyAdvTextStyle(label, run.Style);
label.Visible = true;
void AppendVisible(LiveAdvTextSnapshot item)
{
int count = System.Math.Clamp(item.VisibleGlyphs, 0, item.Run.Text.Length);
if (count != 0) visibleText.Append(item.Run.Text, 0, count);
}
bool ShouldShow(AdvLiveTextRun item)
=> rawInputOwner == null || item.BelongsToScript(rawInputOwner);
}
}
private void UpdateHistoryTextPresentation()
{
foreach (var label in _historyTextLabels.Values) label.Visible = false;
foreach (var batch in _host.SnapshotRenderedTextHistory())
{
if (batch.Text.Length == 0) continue;
if (!_historyTextLabels.TryGetValue(batch.LayoutSlot, out var label))
{
label = CreateAdvPresentationLabel();
_historyTextLabels.Add(batch.LayoutSlot, label);
}
var layout = batch.Layout;
label.Position = new Vector2(layout.OriginX + layout.CursorX, layout.OriginY + layout.CursorY);
label.Size = new Vector2(System.Math.Max(1, layout.Right - layout.CursorX),
System.Math.Max(1, layout.Bottom - layout.CursorY));
label.Text = batch.Text;
ApplyAdvTextStyle(label, batch.Style);
label.Visible = true;
}
}
private Label GetSurfaceTextLabel(int index)
{
while (_surfaceTextLabels.Count <= index) _surfaceTextLabels.Add(CreateAdvPresentationLabel());
return _surfaceTextLabels[index];
}
private Label GetAdvTextLabel(int index)
{
while (_advTextLabels.Count <= index) _advTextLabels.Add(CreateAdvPresentationLabel());
return _advTextLabels[index];
}
private static void ApplySurfaceTextTransform(Label label, Affine2D localToDestination,
int localX, int localY)
{
var position = localToDestination.Apply(localX, localY);
var (rotation, scaleX, scaleY) = localToDestination.DecomposeCanvasAxes();
label.Position = new Vector2((float)position.X, (float)position.Y);
label.PivotOffset = Vector2.Zero;
label.Rotation = (float)rotation;
label.Scale = new Vector2((float)scaleX, (float)scaleY);
}
private Label CreateAdvPresentationLabel()
{
var label = new Label
{
MouseFilter = MouseFilterEnum.Ignore,
Visible = false,
AutowrapMode = TextServer.AutowrapMode.WordSmart,
ClipText = true,
};
label.AddThemeFontOverride("font", _text.GetThemeFont("font"));
AddChild(label);
return label;
}
private void ApplyAdvTextStyle(Label label, AdvTextStyle style)
{
int fontSize = style.PrimaryFontSize > 0 ? style.PrimaryFontSize : 24;
Font regularFont = ResolvePresentationFont(style.FontFace, out string fontKey);
Font presentationFont = regularFont;
if (style.Bold)
{
if (!_presentationBoldFonts.TryGetValue(fontKey, out FontVariation? boldFont))
{
boldFont = new FontVariation
{
BaseFont = regularFont,
VariationEmbolden = NativeBoldEmbolden,
SpacingGlyph = NativeBoldGlyphSpacing,
};
_presentationBoldFonts.Add(fontKey, boldFont);
}
presentationFont = boldFont;
}
label.AddThemeFontOverride("font", presentationFont);
label.AddThemeFontSizeOverride("font_size", fontSize);
label.AddThemeColorOverride("font_color", RgbColor(style.TextColor, Colors.White));
Color effectColor = RgbColor(style.EffectColor, new Color(0.38f, 0.38f, 0.38f));
AdvTextEffectTheme effect = ResolveAdvTextEffectTheme(style);
label.AddThemeColorOverride("font_outline_color", effectColor);
label.AddThemeColorOverride("font_shadow_color", effect.ShadowEnabled ? effectColor : Colors.Transparent);
label.AddThemeConstantOverride("line_spacing",
CalibratedLineSpacing(style.LineSpacing, fontSize, presentationFont.GetHeight(fontSize)));
label.AddThemeConstantOverride("outline_size", effect.OutlineSize);
label.AddThemeConstantOverride("shadow_offset_x", effect.ShadowOffsetX);
label.AddThemeConstantOverride("shadow_offset_y", effect.ShadowOffsetY);
label.AddThemeConstantOverride("shadow_outline_size", 0);
}
private static int CalibratedLineSpacing(int nativeSpacing, int nativeFontHeight, float backendFontHeight)
=> nativeSpacing + nativeFontHeight - System.Math.Max(1, (int)System.MathF.Round(backendFontHeight));
private readonly record struct AdvTextEffectTheme(
int OutlineSize,
bool ShadowEnabled,
int ShadowOffsetX,
int ShadowOffsetY);
private static AdvTextEffectTheme ResolveAdvTextEffectTheme(AdvTextStyle style)
=> style.RenderMode switch
{
// AGE mode 1 draws one effect-color glyph at (+x,+y), then the primary glyph.
1 => new(0, true, style.EffectOffsetX, style.EffectOffsetY),
// AGE mode 3 samples an ellipse. Himegari always uses equal (1,1) radii, for which
// Godot's symmetric outline is the closest backend-native presentation.
3 => new(System.Math.Max(System.Math.Abs(style.EffectOffsetX),
System.Math.Abs(style.EffectOffsetY)),
false, 0, 0),
_ => new(0, false, 0, 0),
};
private Font ResolvePresentationFont(string requestedFace, out string fontKey)
{
_presentationFallbackFont ??= _text.GetThemeFont("font");
string face = requestedFace?.Trim() ?? "";
if (face.Length == 0)
{
fontKey = "";
return _presentationFallbackFont;
}
if (_presentationFaceFonts.TryGetValue(face, out Font? cached))
{
fontKey = face;
return cached;
}
if (!_unavailablePresentationFaces.Contains(face))
{
foreach (string path in PresentationFontPaths(face))
{
if (!System.IO.File.Exists(path)) continue;
try
{
var loaded = new FontFile { Data = System.IO.File.ReadAllBytes(path) };
_presentationFaceFonts.Add(face, loaded);
fontKey = face;
return loaded;
}
catch
{
// Try the next known file before falling back to the presentation default.
}
}
_unavailablePresentationFaces.Add(face);
}
fontKey = "";
return _presentationFallbackFont;
}
private static IEnumerable<string> PresentationFontPaths(string face)
{
if (face.Equals(" 明朝", System.StringComparison.OrdinalIgnoreCase)
|| face.Equals("MS Mincho", System.StringComparison.OrdinalIgnoreCase))
{
yield return "C:/Windows/Fonts/msmincho.ttc";
}
else if (face.Equals(" ゴシック", System.StringComparison.OrdinalIgnoreCase)
|| face.Equals("MS Gothic", System.StringComparison.OrdinalIgnoreCase))
{
yield return "C:/Windows/Fonts/msgothic.ttc";
}
}
private static Color RgbColor(long rgb, Color fallback)
{
if ((rgb & 0x00ff_ffff) == 0) return fallback;
return new Color(((rgb >> 16) & 0xff) / 255f, ((rgb >> 8) & 0xff) / 255f, (rgb & 0xff) / 255f, 1);
}
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}"
@@ -2289,7 +2014,6 @@ public partial class Main : Godot.Control
}
}
public void AppendLine(string text) => _text.Text += text + "\n";
public void PageBreak()
{
_pageCount++;
@@ -2298,7 +2022,6 @@ public partial class Main : Godot.Control
}
public void ClearPage()
{
foreach (var label in _advTextLabels) label.Text = "";
_status.Text = "";
}
public void ShowEnd() => _status.Text = "— end —";
@@ -2372,71 +2095,29 @@ public partial class Main : Godot.Control
&& !_bgm.Playing
&& _bgmFadeTween == null
&& System.Math.Abs(_bgm.VolumeDb) < 0.001f;
AdvTextEffectTheme mode1 = ResolveAdvTextEffectTheme(AdvTextStyle.Default with
{
RenderMode = 1,
EffectOffsetX = 0,
EffectOffsetY = 0,
});
AdvTextEffectTheme mode3 = ResolveAdvTextEffectTheme(AdvTextStyle.Default with
{
RenderMode = 3,
EffectOffsetX = 1,
EffectOffsetY = 1,
});
AdvTextEffectTheme mode0 = ResolveAdvTextEffectTheme(AdvTextStyle.Default);
bool textEffectModesOk = mode1 == new AdvTextEffectTheme(0, true, 0, 0)
&& mode3 == new AdvTextEffectTheme(1, false, 0, 0)
&& mode0 == new AdvTextEffectTheme(0, false, 0, 0);
var textEffectSmoke = new Label();
AddChild(textEffectSmoke);
ApplyAdvTextStyle(textEffectSmoke, AdvTextStyle.Default with
{
RenderMode = 1,
EffectColor = 0x123456,
EffectOffsetX = 2,
EffectOffsetY = -1,
});
textEffectModesOk &= textEffectSmoke.GetThemeConstant("outline_size") == 0
&& textEffectSmoke.GetThemeConstant("shadow_offset_x") == 2
&& textEffectSmoke.GetThemeConstant("shadow_offset_y") == -1
&& textEffectSmoke.GetThemeColor("font_shadow_color").A > 0.99f;
ApplyAdvTextStyle(textEffectSmoke, AdvTextStyle.Default with
{
RenderMode = 3,
EffectColor = 0x123456,
EffectOffsetX = 1,
EffectOffsetY = 1,
});
textEffectModesOk &= textEffectSmoke.GetThemeConstant("outline_size") == 1
&& textEffectSmoke.GetThemeColor("font_shadow_color").A < 0.01f;
ApplyAdvTextStyle(textEffectSmoke, AdvTextStyle.Default with
{
PrimaryFontSize = 24,
Bold = true,
FontFace = " 明朝",
LineSpacing = 8,
});
var calibratedBold = textEffectSmoke.GetThemeFont("font") as FontVariation;
bool fontCalibrationOk = calibratedBold != null
&& System.Math.Abs(calibratedBold.VariationEmbolden
- NativeBoldEmbolden) < 0.001f
&& calibratedBold.SpacingGlyph == NativeBoldGlyphSpacing
&& CalibratedLineSpacing(8, 24, 25) == 7
&& CalibratedLineSpacing(9, 16, 17) == 8
&& textEffectSmoke.GetThemeConstant("line_spacing")
== CalibratedLineSpacing(
8, 24, calibratedBold.GetHeight(24));
IReadOnlyList<(int X, int Y)> outline =
AgeGlyphMaskCompositor.GetMode3OutlineOffsets(1, 1);
bool textEffectModesOk =
outline.Count == 12
&& outline.Contains((1, 0))
&& outline.Contains((-1, 0))
&& outline.Contains((0, 1))
&& outline.Contains((0, -1));
bool fontCalibrationOk =
ImmediateSurfaceTextRenderer.NativePixelHeight(24) == 24
&& ImmediateSurfaceTextRenderer.NativePixelHeight(25) == 24
&& ImmediateSurfaceTextRenderer.NativePixelHeight(32) == 31
&& ImmediateSurfaceTextRenderer.NativePixelHeight(33) == 31;
bool immediateSurfaceTextOk;
string immediateSurfaceTextMode;
_host.CreateTexture(997, 32, 24);
_host.CreateTexture(997, 64, 24);
var immediateStyle = AdvTextStyle.Default with
{
PrimaryFontSize = 16,
TextColor = 0xffffff,
FontFace = ImmediateSurfaceTextRenderer.DefaultFontFace,
};
_host.DrawStringToSurface(997, 1, 1, "A", immediateStyle);
_host.DrawStringToSurface(997, 1, 1, "A", immediateStyle);
if (_host.UsesSurfaceTextPixels)
{
RgbaImage? pixels = _host.CaptureSurfacePixels(997);
@@ -2459,40 +2140,44 @@ public partial class Main : Godot.Control
gpuAccepted &= stats.DrawItems == 1 && stats.TextureUploads == 1;
}
_host.CreateTexture(996, 32, 24);
_host.CreateTexture(996, 64, 24);
_host.CopySurfaceRect(new SurfaceRectCopy(
997, 996, 0, 0, 32, 24, 0, 0));
997, 996, 0, 0, 64, 24, 0, 0));
RgbaImage? copied = _host.CaptureSurfacePixels(996);
bool copiedPixels = pixels != null
&& copied != null
&& pixels.Pixels.SequenceEqual(copied.Pixels);
_host.FillSurfaceRect(new SurfaceRectFill(
996, 0, 0, 32, 24, 0, 0));
996, 0, 0, 64, 24, 0, 0));
RgbaImage? cleared = _host.CaptureSurfacePixels(996);
bool clearedPixels = cleared != null && cleared.Pixels.All(value => value == 0);
immediateSurfaceTextOk =
pixelsPresent
&& _host.SnapshotSurfaceText(997).Count == 0
&& backend is
{
Id: "windows-gdi-gray4",
Policy: GlyphRasterPolicy.NativeCp932Gray4,
NativePixelExact: true,
}
&& backend is not null
&& (backend.Policy == GlyphRasterPolicy.NativeCp932Gray4
? backend is
{
Id: "windows-gdi-gray4",
NativePixelExact: true,
}
: backend is
{
Id: "portable-godot-textserver",
Policy: GlyphRasterPolicy.PortableUnicode,
NativePixelExact: false,
})
&& cache.Count is > 0 and <= 2048
&& cache.Capacity == 2048
&& gpuAccepted
&& copiedPixels
&& clearedPixels;
immediateSurfaceTextMode = "exact-rgba";
immediateSurfaceTextMode =
$"rgba:{backend?.Id}";
}
else
{
immediateSurfaceTextOk =
_host.CaptureSurfacePixels(997)?.Pixels.All(value => value == 0) == true
&& _host.SnapshotSurfaceText(997).Count == 1
&& !string.IsNullOrWhiteSpace(_host.SurfaceTextFallbackReason);
immediateSurfaceTextMode = "label-fallback";
immediateSurfaceTextOk = false;
immediateSurfaceTextMode = "unavailable";
}
_host.ReleaseSurface(996);
_host.ReleaseSurface(997);
@@ -2519,7 +2204,6 @@ public partial class Main : Godot.Control
&& liveSurface != null
&& liveSurface.Pixels.Where((_, index) => index % 4 == 3)
.Any(alpha => alpha != 0)
&& _host.SnapshotLiveAdvText().Count == 0
&& _vm.TextHistory.GetLayoutSnapshot(1).CursorX
> liveBinding.ResetCursorX;
@@ -2556,14 +2240,15 @@ public partial class Main : Godot.Control
&& suspended
&& restored
&& reset;
liveRetainedTextMode = "retained-glyphs";
liveRetainedTextMode =
$"retained-glyphs;built={builtCompleteLine};objects={liveObjects.Count};" +
$"republished={republishedPartialErase};suspended={suspended};" +
$"restored={restored};reset={reset}";
}
else
{
liveRetainedTextOk =
_host.SnapshotLiveAdvText().Count == 3
&& !string.IsNullOrWhiteSpace(_host.SurfaceTextFallbackReason);
liveRetainedTextMode = "label-fallback";
liveRetainedTextOk = false;
liveRetainedTextMode = "unavailable";
}
_vm.TextHistory.DefineLayout(2, 256, 64, 10, 100);
_vm.TextHistory.SetResetCursor(2, 1, 1);
@@ -2603,8 +2288,7 @@ public partial class Main : Godot.Control
== historyBatch.Text.Length
&& historySurface != null
&& historySurface.Pixels.Where((_, index) => index % 4 == 3)
.Any(alpha => alpha != 0)
&& _host.SnapshotRenderedTextHistory().Count == 0;
.Any(alpha => alpha != 0);
_vm.Gfx.EraseRange(
historyBinding.FirstObjectHandle,
historyBinding.ObjectCapacity);
@@ -2618,15 +2302,14 @@ public partial class Main : Godot.Control
< historyBinding.ObjectCapacity)
&& _host.CaptureSurfacePixels(historyBinding.SourceSurfaceSlot)
?.Pixels.All(value => value == 0) == true;
historyRetainedTextMode = "retained-glyphs";
historyRetainedTextMode =
$"retained-glyphs;used={historyUsedRetained}";
}
else
{
historyRetainedTextOk =
!historyUsedRetained
&& _host.SnapshotRenderedTextHistory().Count == 1;
historyRetainedTextOk = false;
_host.EndTextHistoryPresentation(_vm.Gfx);
historyRetainedTextMode = "label-fallback";
historyRetainedTextMode = "unavailable";
}
Window rootWindow = GetTree().Root;
bool logicalCanvasOk = _host.LogicalCanvas == new Sys4LogicalCanvas(_screenWidth, _screenHeight)
@@ -2661,7 +2344,6 @@ public partial class Main : Godot.Control
_vm.Gfx, _clock.NowMs, backbufferSnapshot);
backbufferPreservationOk &=
clearedPolicy == new BackbufferPublicationPolicy(false, false);
textEffectSmoke.QueueFree();
ok &= launcherOk && sleepMinimumOk && inputTranslationOk && cp932WavMetadataOk
&& firstRiffBoundaryOk
&& bgmReplacementCancelsFade && bgmOneShotModeOk && bgmLoopModeOk

View File

@@ -0,0 +1,104 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
public sealed class PortableTextRenderingPolicy
{
public const string ResourcePath = "res://config/himegari-text-rendering.json";
public string Id { get; set; } = "";
public int FontCacheCapacity { get; set; } = 8;
public int GlyphMaskCacheCapacity { get; set; } = 2048;
public PortableFontRasterPolicy Raster { get; set; } = new();
public string[] DefaultSubstitutes { get; set; } = [];
public PortableFontFamilyPolicy[] Families { get; set; } = [];
public static PortableTextRenderingPolicy Load()
{
string json = Godot.FileAccess.GetFileAsString(ResourcePath);
if (string.IsNullOrWhiteSpace(json))
throw new InvalidOperationException(
$"Portable text policy '{ResourcePath}' is missing or empty.");
var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
PortableTextRenderingPolicy policy =
JsonSerializer.Deserialize<PortableTextRenderingPolicy>(json, options)
?? throw new InvalidOperationException(
$"Portable text policy '{ResourcePath}' decoded to null.");
policy.Validate();
return policy;
}
public string[] ResolveFamilies(string requestedFace)
{
string requested = requestedFace?.Trim() ?? "";
PortableFontFamilyPolicy? mapped = Families.FirstOrDefault(
family => family.Requested.Any(
alias => alias.Equals(requested, StringComparison.OrdinalIgnoreCase)));
IEnumerable<string> candidates = mapped?.Substitutes ?? DefaultSubstitutes;
// The authored face remains the last candidate. This preserves a useful match on systems
// that happen to provide it without making that Windows font a portable dependency.
return candidates
.Append(requested)
.Where(name => !string.IsNullOrWhiteSpace(name))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
}
private void Validate()
{
if (string.IsNullOrWhiteSpace(Id))
throw new InvalidOperationException("Portable text policy requires a non-empty id.");
if (FontCacheCapacity <= 0)
throw new InvalidOperationException("Portable text fontCacheCapacity must be positive.");
if (GlyphMaskCacheCapacity <= 0)
throw new InvalidOperationException(
"Portable text glyphMaskCacheCapacity must be positive.");
if (DefaultSubstitutes.Length == 0)
throw new InvalidOperationException(
"Portable text policy requires at least one default substitute.");
Raster.Validate();
foreach (PortableFontFamilyPolicy family in Families)
{
if (family.Requested.Length == 0 || family.Substitutes.Length == 0)
throw new InvalidOperationException(
"Every portable font family mapping requires requested aliases and substitutes.");
}
}
}
public sealed class PortableFontFamilyPolicy
{
public string[] Requested { get; set; } = [];
public string[] Substitutes { get; set; } = [];
}
public sealed class PortableFontRasterPolicy
{
public string Antialiasing { get; set; } = "gray";
public string Hinting { get; set; } = "normal";
public string SubpixelPositioning { get; set; } = "disabled";
public bool AllowSystemFallback { get; set; } = true;
public bool MultichannelSignedDistanceField { get; set; }
public int RegularWeight { get; set; } = 400;
public int BoldWeight { get; set; } = 700;
public int BoldThreshold { get; set; } = 700;
public void Validate()
{
if (Antialiasing is not ("none" or "gray" or "lcd"))
throw new InvalidOperationException(
"Portable raster antialiasing must be none, gray, or lcd.");
if (Hinting is not ("none" or "light" or "normal"))
throw new InvalidOperationException(
"Portable raster hinting must be none, light, or normal.");
if (SubpixelPositioning is not ("disabled" or "auto" or "one-half" or "one-quarter"))
throw new InvalidOperationException(
"Portable raster subpixelPositioning has an unsupported value.");
if (RegularWeight is < 100 or > 999
|| BoldWeight is < 100 or > 999
|| BoldThreshold is < 0 or > 1000)
throw new InvalidOperationException(
"Portable raster weights are outside their valid ranges.");
}
}

View File

@@ -0,0 +1,47 @@
{
"id": "himegari-godot-system-font-v1",
"fontCacheCapacity": 8,
"glyphMaskCacheCapacity": 2048,
"raster": {
"antialiasing": "gray",
"hinting": "normal",
"subpixelPositioning": "disabled",
"allowSystemFallback": true,
"multichannelSignedDistanceField": false,
"regularWeight": 400,
"boldWeight": 700,
"boldThreshold": 700
},
"defaultSubstitutes": [
"Noto Sans CJK JP",
"Noto Sans JP",
"Yu Gothic",
"Hiragino Kaku Gothic ProN",
"IPAGothic",
"sans-serif"
],
"families": [
{
"requested": [ " 明朝", "MS Mincho" ],
"substitutes": [
"Noto Serif CJK JP",
"Noto Serif JP",
"Yu Mincho",
"Hiragino Mincho ProN",
"IPAMincho",
"serif"
]
},
{
"requested": [ " ゴシック", "MS Gothic" ],
"substitutes": [
"Noto Sans CJK JP",
"Noto Sans JP",
"Yu Gothic",
"Hiragino Kaku Gothic ProN",
"IPAGothic",
"sans-serif"
]
}
]
}