fix retained glyph bottom clipping

This commit is contained in:
gamer147
2026-07-30 20:29:39 -04:00
parent d9f24c69ef
commit 49722c55fe
9 changed files with 185 additions and 13 deletions

View File

@@ -2894,6 +2894,16 @@ The shipped effect profiles use only modes 1 and 3:
`360 / (sqrt(effect_x²+effect_y²)*8)`, blits the effect-colored glyph at every rounded offset, then
draws the primary glyph. All 164 paired sites use `(1,1)`, producing the normal one-pixel outline.
**Measured cell versus raster ink (2026-07-30).** `adv_text_build_glyph_records@0x4576c0` stores
the `GetTextExtentPoint32A` measured cell edges in each retained record, and
`adv_text_publish_next_glyph@0x451220` uses those edges when it binds the native object.
`text_raster_string_cached@0x45b600`, however, composites the complete `GetGlyphOutlineA` black-box
bitmap and effect copies into the shared layout surface. Direct 24-pixel bold Mincho probes show several
ordinary Japanese glyphs reaching below the 24-pixel measured cell even before the mode-3 `(1,1)` outline.
Thus the record is a layout/publication cell, not a guaranteed ink bounding box. The port preserves that
native record and derives a separate transient vertical publication crop from mask origin/height and effect
offsets; its horizontal crop remains the measured cell to avoid exposing the next glyph during timed reveal.
The ordinary ADV preset is therefore 24-pixel ` 明朝`, weight 700, white fill, mode-3 `(1,1)` outline
in `0x606060`, with 8 pixels of line leading. The small menu-description preset is 16-pixel
` ゴシック`, weight 0, white fill, mode 1 with zero displacement, and 9 pixels of leading.

View File

@@ -3674,6 +3674,15 @@ surface's z-order, alpha, tint, fade, transform, clipping, capture, and lifetime
Validation: engine **580/580**, zero-warning Godot build, and threaded `SELFTEST OK` in both forced
`portable-godot-textserver` and forced `windows-gdi-gray4` modes.
**Bottom-edge clipping follow-up (2026-07-30):** retained publication originally reused the native
measured-cell rectangle as its exact source crop. Direct GDI probing confirmed that the glyph black-box
bitmap, plus the mode-3 outline, can extend below that cell even though the pixels are present in the
layout surface. The native `AdvRetainedGlyphRecord` remains unchanged for layout and reconstruction; each
transient placement now carries a separate vertically ink-safe publication rectangle. Horizontal edges
remain cell-bounded so timed reveal cannot expose a neighboring glyph. Synthetic compositor/publication
regressions pass, the Godot build is clean, and forced exact and portable self-tests both report
`SELFTEST OK`.
## Persistence native-format reconnaissance complete (2026-07-24)
The deferred save/profile ownership question now has a compatibility-mode answer. The remaining native

View File

@@ -627,7 +627,10 @@ primitive, which cannot be made equivalent by choosing another embolden constant
per-run origins, partial erase/republication, suspension, measured cursor handoff, reset, and
reconstruction. The synthesized Godot VM self-test exercises timed exact reveal and reports
`live-adv-text=retained-glyphs`; real GPU captures of SC0000 pages 1 and 7 confirm ordinary and voiced
dialogue placement/wrapping alongside the immediate speaker-name surface.
dialogue placement/wrapping alongside the immediate speaker-name surface. A 2026-07-30 clipping
follow-up keeps those native measured-cell records intact but gives transient placements a separate
vertically ink-safe source crop, because backend glyph black boxes and mode-3 outlines can extend below
the measured cell; horizontal crops remain cell-bounded to preserve reveal isolation.
6. **Migrate History and the ADV wait indicator through the same retained path. — Completed
2026-07-30.** Op `0x1d1` now rasterizes each HISTORY row into its target layout surface and publishes its
native glyph interval through ordinary `GfxState` bindings. Row reset, wheel redraw, the broad native exit
@@ -645,7 +648,8 @@ primitive, which cannot be made equivalent by choosing another embolden constant
sizes. `auto` selects exact GDI when its ACP-932 gate passes and otherwise selects the explicitly
non-pixel-exact Unicode backend; development runs can force either with `--text-backend`. Both modes pass
the same immediate/live/History retained-glyph self-test. Gameplay `Label` pools and surface-text overlay
projection are removed, so text always participates in the ordinary surface compositor.
projection are removed, so text always participates in the ordinary surface compositor. Publication uses
the transient ink-safe crop established in step 5 in both backends.
**Acceptance gates:**

View File

@@ -193,6 +193,40 @@ public class GlyphMaskCompositorTests
Assert.Equal(255, Pixel(destination, 2, 7).A);
}
[Fact]
public void RetainedPresentationCropIncludesInkBelowTheNativeCell()
{
var mask = new GlyphMask(
width: 1, height: 4, stride: 1,
originX: 0, originY: 3,
cellAdvanceX: 4, cellAdvanceY: 0,
cellWidth: 4, cellHeight: 5,
coverage: [16, 16, 16, 16]);
var destination = Image(16, 16);
var engine = new RetainedGlyphLayoutEngine(
new CountingRasterizer(_ => mask));
GlyphTextLayoutResult result = engine.Render(
destination,
new GlyphTextLayoutOptions(
CursorX: 2, CursorY: 1, LineOriginX: 2,
RightBound: 16, BottomBound: 16, WrapHorizontally: true,
Style(renderMode: 3) with
{
EffectOffsetX = 1,
EffectOffsetY = 1,
}),
[NativeRequest(0x82a0, 0x3042)]);
Assert.Equal(
new AdvRetainedGlyphRecord(0, 2, 1, 6, 6),
Assert.Single(result.Records));
Assert.Equal(
new AdvRetainedGlyphPresentationRect(2, 1, 6, 8),
Assert.Single(result.PresentationRects));
Assert.NotEqual(0, Pixel(destination, 2, 7).A);
}
[Fact]
public void ClosingPunctuationStaysOnOverflowingPrecedingLine()
{

View File

@@ -79,6 +79,29 @@ public class RetainedAdvTextLayoutPresentationTests
gfx.SnapshotVisibleObjects().Select(item => (item.DstX, item.DstY)));
}
[Fact]
public void PublicationUsesInkSafeCropWithoutChangingNativeRecord()
{
var presentation = new RetainedAdvTextLayoutPresentation(
new AdvTextLayoutPresentationBinding(
1, 21, 100, 4, -1, 10, 20));
var record = new AdvRetainedGlyphRecord(0, 3, 4, 7, 10);
presentation.Append(
[record],
[new AdvRetainedGlyphPresentationRect(3, 3, 7, 12)],
layoutOriginX: 30,
layoutOriginY: 40);
var gfx = new GfxState();
gfx.CreateSurface(21);
presentation.PublishThrough(gfx, 1);
RenderObject item = Assert.Single(gfx.SnapshotVisibleObjects());
Assert.Equal((3, 3, 4, 9, 33, 43),
(item.SrcX, item.SrcY, item.W, item.H, item.DstX, item.DstY));
Assert.Equal(record, Assert.Single(presentation.Glyphs).Record);
}
[Fact]
public void SavedFrameStyleReconstructionRebuildsTheSameBindings()
{

View File

@@ -34,8 +34,23 @@ public readonly record struct AdvTextLayoutPresentationBinding(
int ResetCursorX,
int ResetCursorY);
/// <summary>
/// Transient publication crop. Native records retain measured cell edges, but a backend mask/effect can
/// carry ink outside that cell. Keeping the expanded crop separate preserves the native layout ABI.
/// </summary>
public readonly record struct AdvRetainedGlyphPresentationRect(
int Left,
int Top,
int Right,
int Bottom)
{
public int Width => Right - Left;
public int Height => Bottom - Top;
}
public readonly record struct AdvRetainedGlyphPlacement(
AdvRetainedGlyphRecord Record,
AdvRetainedGlyphPresentationRect PresentationRect,
int LayoutOriginX,
int LayoutOriginY);

View File

@@ -38,7 +38,30 @@ public sealed class RetainedAdvTextLayoutPresentation
int first = _glyphs.Count;
foreach (AdvRetainedGlyphRecord record in records)
_glyphs.Add(new AdvRetainedGlyphPlacement(
record, layoutOriginX, layoutOriginY));
record,
new AdvRetainedGlyphPresentationRect(
record.Left, record.Top, record.Right, record.Bottom),
layoutOriginX,
layoutOriginY));
return first;
}
public int Append(
IReadOnlyList<AdvRetainedGlyphRecord> records,
IReadOnlyList<AdvRetainedGlyphPresentationRect> presentationRects,
int layoutOriginX,
int layoutOriginY)
{
ArgumentNullException.ThrowIfNull(records);
ArgumentNullException.ThrowIfNull(presentationRects);
if (presentationRects.Count != records.Count)
throw new ArgumentException(
"Every retained glyph record requires one presentation rectangle.",
nameof(presentationRects));
int first = _glyphs.Count;
for (int index = 0; index < records.Count; index++)
_glyphs.Add(new AdvRetainedGlyphPlacement(
records[index], presentationRects[index], layoutOriginX, layoutOriginY));
return first;
}
@@ -71,16 +94,16 @@ public sealed class RetainedAdvTextLayoutPresentation
private void Bind(GfxState gfx, int index)
{
AdvRetainedGlyphPlacement placement = _glyphs[index];
AdvRetainedGlyphRecord record = placement.Record;
if (record.Width <= 0 || record.Height <= 0) return;
AdvRetainedGlyphPresentationRect rect = placement.PresentationRect;
if (rect.Width <= 0 || rect.Height <= 0) return;
gfx.BindDraw(
checked(Binding.FirstObjectHandle + index),
Binding.SourceSurfaceSlot,
record.Left,
record.Top,
record.Width,
record.Height,
checked(placement.LayoutOriginX + record.Left),
checked(placement.LayoutOriginY + record.Top));
rect.Left,
rect.Top,
rect.Width,
rect.Height,
checked(placement.LayoutOriginX + rect.Left),
checked(placement.LayoutOriginY + rect.Top));
}
}

View File

@@ -14,6 +14,7 @@ public readonly record struct GlyphTextLayoutOptions(
public sealed record GlyphTextLayoutResult(
IReadOnlyList<AdvRetainedGlyphRecord> Records,
IReadOnlyList<AdvRetainedGlyphPresentationRect> PresentationRects,
int CursorX,
int CursorY,
int ConsumedGlyphs,
@@ -40,6 +41,8 @@ public sealed class RetainedGlyphLayoutEngine
ArgumentNullException.ThrowIfNull(destination);
ArgumentNullException.ThrowIfNull(glyphs);
var records = new List<AdvRetainedGlyphRecord>(glyphs.Count);
var presentationRects =
new List<AdvRetainedGlyphPresentationRect>(glyphs.Count);
int cursorX = options.CursorX;
int cursorY = options.CursorY;
int wrappedLines = 0;
@@ -81,11 +84,59 @@ public sealed class RetainedGlyphLayoutEngine
AgeGlyphMaskCompositor.DrawGlyph(
destination, mask, cursorX, cursorY, request.PixelHeight, options.Style);
records.Add(new AdvRetainedGlyphRecord(0, cursorX, cursorY, right, bottom));
presentationRects.Add(PresentationRect(
destination.Height,
mask,
cursorX,
cursorY,
right,
bottom,
request.PixelHeight,
options.Style));
cursorX = checked(cursorX + mask.CellAdvanceX);
cursorY = checked(cursorY + mask.CellAdvanceY);
}
return new GlyphTextLayoutResult(
records, cursorX, cursorY, records.Count, wrappedLines, observed, stopped);
records, presentationRects, cursorX, cursorY, records.Count,
wrappedLines, observed, stopped);
}
private static AdvRetainedGlyphPresentationRect PresentationRect(
int surfaceHeight,
GlyphMask mask,
int cellLeft,
int cellTop,
int cellRight,
int cellBottom,
int topToBaseline,
AdvTextStyle style)
{
int minimumEffectY = 0;
int maximumEffectY = 0;
if (style.RenderMode == 1)
{
minimumEffectY = Math.Min(0, style.EffectOffsetY);
maximumEffectY = Math.Max(0, style.EffectOffsetY);
}
else if (style.RenderMode == 3)
{
foreach ((int _, int y) in AgeGlyphMaskCompositor.GetMode3OutlineOffsets(
style.EffectOffsetX, style.EffectOffsetY))
{
minimumEffectY = Math.Min(minimumEffectY, y);
maximumEffectY = Math.Max(maximumEffectY, y);
}
}
int inkTop = checked(
cellTop + topToBaseline - mask.OriginY + minimumEffectY);
int inkBottom = checked(
cellTop + topToBaseline - mask.OriginY + mask.Height + maximumEffectY);
return new AdvRetainedGlyphPresentationRect(
cellLeft,
Math.Clamp(Math.Min(cellTop, inkTop), 0, surfaceHeight),
cellRight,
Math.Clamp(Math.Max(cellBottom, inkBottom), 0, surfaceHeight));
}
}

View File

@@ -425,7 +425,10 @@ public sealed class GodotAdvHost : IHost
throw new InvalidOperationException(
$"ADV layout {binding.LayoutSlot} changed its retained binding without reset.");
int first = presentation.Append(
rendered.Records, run.Layout.OriginX, run.Layout.OriginY);
rendered.Records,
rendered.PresentationRects,
run.Layout.OriginX,
run.Layout.OriginY);
result = new AdvRetainedTextRunResult(
binding.LayoutSlot,
first,