From 7b0987d9a69f61033870855e8e7158d746e37c9e Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sun, 16 Aug 2026 11:24:07 -0400 Subject: [PATCH] Fix oversized GDI glyph buffers --- docs/engine-re.md | 8 +++ docs/phase-b-framework.md | 29 ++++++++-- .../WindowsGdiGlyphMaskRasterizerTests.cs | 56 +++++++++++++++++-- .../WindowsGdiGlyphMaskRasterizer.cs | 19 +++++-- 4 files changed, 97 insertions(+), 15 deletions(-) diff --git a/docs/engine-re.md b/docs/engine-re.md index c432144..7509069 100644 --- a/docs/engine-re.md +++ b/docs/engine-re.md @@ -3062,6 +3062,14 @@ height/width, weight, `DEFAULT_CHARSET`, and raw CP932 face bytes. Each request or two-byte CP932 code for `GetGlyphOutlineA(GGO_GRAY4_BITMAP)` and `GetTextExtentPoint32A`; returned masks flow through the same normalized `GlyphMask` contract without another pixel conversion. +`GetGlyphOutline`'s queried allocation size is not always identical to the rows described by +`GLYPHMETRICS`. On the reference Windows installation, 24-pixel bold MS Mincho `p` reports a 12x15 box with a +12-byte row stride but requests 192 bytes rather than 180; a live SC0000 run likewise produced 672 bytes for a +25x23 box with 28-byte rows. The adapter therefore allocates and reads the complete queried size, as the API +requires, while exposing only `stride * gmBlackBoxY` row bytes to the normalized mask/compositor. Extra storage +is outside the drawable metric box and can contain unspecified data. Zero-byte spacing glyphs retain their +separate transparent-mask handling; nonzero undersized buffers still fail. + This exact ANSI reference requires Windows system ACP 932. `TryGetAvailability` reports the active-platform/ code-page incompatibility and the constructor refuses to masquerade as exact when unavailable. `GlyphRasterizerBackendInfo` identifies the backend as `windows-gdi-gray4`, native-CP932, and pixel-exact. diff --git a/docs/phase-b-framework.md b/docs/phase-b-framework.md index 8d60a7a..708d07a 100644 --- a/docs/phase-b-framework.md +++ b/docs/phase-b-framework.md @@ -87,11 +87,30 @@ style, `GetGlyphOutlineA(GGO_GRAY4_BITMAP)` returns the spacing glyph's advance reports a zero-byte bitmap requirement. The adapter incorrectly required the reported buffer size to equal the four-byte aligned metric box and threw before P009 could be published. It now treats only a zero-byte result as a zero-ink glyph: the GDI placement and advance metrics are retained and the implied mask is filled with -transparent coverage. Nonzero short or oversized payloads remain hard failures. A focused Windows regression -reproduces the original 0-versus-4 result and verifies that the ideographic space advances without drawing ink; -the SYSTEM4/DEBUGMAP boundary regression remains in place. The user then repeated the normal exact-GDI -DEBUGMAP entry and confirmed that the dialogue now continues into the map without the worker failure, accepting -the fix end to end. +transparent coverage. A focused Windows regression reproduces the original 0-versus-4 result and verifies that +the ideographic space advances without drawing ink; nonzero buffers smaller than the metric-defined rows remain +hard failures. The SYSTEM4/DEBUGMAP boundary regression remains in place. The user then repeated the normal +exact-GDI DEBUGMAP entry and confirmed that the dialogue now continues into the map without the worker failure, +accepting the fix end to end. + +### SYSTEM4 P009 oversized GDI buffer corrected (2026-08-16) + +A new-game run with Ctrl held reached `SYSTEM4 P009`, whose text is `SC0000@0xa77` and whose following wait is +`SC0000@0xa7f`, then failed while rasterizing the retained line. GDI reported a 672-byte gray-4 requirement for +a 25x23 metric box with 28-byte rows; the adapter rejected it because the metric-defined coverage is only 644 +bytes. This is not the earlier zero-ink spacing-glyph case. A focused independent ANSI/Unicode probe reproduces +the same oversized-storage contract with 24-pixel bold MS Mincho `p`: `GLYPHMETRICS` reports 12x15 and a +12-byte row stride, while `GetGlyphOutline` requests 192 bytes rather than the 180 bytes described by those +rows. The surplus may contain unspecified data and is not an additional drawable metric row. + +The adapter now follows the two returned contracts independently: it allocates and reads the complete size +requested by GDI, but normalizes only `stride * gmBlackBoxY` bytes into `GlyphMask` for layout/composition. +A nonzero undersized result, a failed read, or changed metrics between the query and read remains an error. The +regression compares the ANSI adapter with an independent Unicode GDI call, proves that the raw buffer is larger +than the normalized mask, and checks the exact metric-defined coverage. A second regression covers every glyph +in the reported SC0000 line with its active 24-pixel bold Mincho style. The real direct-scene harness, exact GDI +backend, and held-Ctrl input then recorded the reported P009 coordinate and continued through P166 without a +worker failure; the probe stopped at the later interactive name-entry boundary. ## Stage B0 — Ground-truth reconnaissance diff --git a/engine/Age.Engine.Tests/WindowsGdiGlyphMaskRasterizerTests.cs b/engine/Age.Engine.Tests/WindowsGdiGlyphMaskRasterizerTests.cs index 6db5223..49fab85 100644 --- a/engine/Age.Engine.Tests/WindowsGdiGlyphMaskRasterizerTests.cs +++ b/engine/Age.Engine.Tests/WindowsGdiGlyphMaskRasterizerTests.cs @@ -1,6 +1,7 @@ using System.ComponentModel; using System.Runtime.InteropServices; using System.Text; +using Age.Engine.Model; using Age.Engine.Text; using Age.Engine.Text.Windows; @@ -73,6 +74,48 @@ public class WindowsGdiGlyphMaskRasterizerTests Assert.All(space.Coverage.ToArray(), value => Assert.Equal(0, value)); } + [Fact] + public void Sc0000OpeningNarrationLineRasterizesEveryGlyph() + { + if (!WindowsGdiGlyphMaskRasterizer.TryGetAvailability(out _)) return; + + const string text = + "だが怨嗟に満ちた言葉は、呪いのように彼らの心に暗い影を落とした。"; + var style = AdvTextStyle.Default with + { + PrimaryFontSize = 24, + Bold = true, + FontFace = "MS 明朝", + RenderMode = 3, + LineSpacing = 8, + }; + + using var rasterizer = new WindowsGdiGlyphMaskRasterizer(); + foreach (GlyphRasterRequest request in + ImmediateSurfaceTextRenderer.CreateRequests(text, style)) + { + Exception? error = Record.Exception(() => rasterizer.Rasterize(request)); + Assert.True( + error == null, + $"U+{request.UnicodeScalar:X4} CP932 0x{request.Cp932Code:X4}: {error}"); + } + } + + [Fact] + public void Gray4StorageBeyondMetricRowsIsTrimmed() + { + if (!WindowsGdiGlyphMaskRasterizer.TryGetAvailability(out _)) return; + + GlyphRasterRequest request = NativeRequest("MS 明朝", 24, -12, 700, 'p'); + DirectGlyph direct = RasterizeUnicodeDirect("MS 明朝", 24, -12, 700, 'p'); + using var rasterizer = new WindowsGdiGlyphMaskRasterizer(); + GlyphMask actual = rasterizer.Rasterize(request); + + Assert.True(direct.RawBufferSize > direct.Coverage.Length); + Assert.Equal(direct.Coverage, actual.Coverage.ToArray()); + Assert.Equal(actual.Stride * actual.Height, actual.Coverage.Length); + } + [Fact] public void FontHandlesUseTheSharedBoundedLruAndDisposeCleanly() { @@ -135,17 +178,19 @@ public class WindowsGdiGlyphMaskRasterizerTests int glyphWidth = checked((int)metrics.BlackBoxX); int glyphHeight = checked((int)metrics.BlackBoxY); int stride = checked((glyphWidth + 3) & ~3); - Assert.Equal(checked(stride * glyphHeight), (int)size); - byte[] coverage = new byte[size]; + int expectedBytes = checked(stride * glyphHeight); + Assert.True(size >= expectedBytes); + byte[] rawCoverage = new byte[size]; if (size > 0) { identity = Mat2.Identity; uint written = Native.GetGlyphOutlineW( dc, (uint)scalar, GgoGray4Bitmap, - out GlyphMetrics second, size, coverage, ref identity); + out GlyphMetrics second, size, rawCoverage, ref identity); Assert.Equal(size, written); Assert.Equal(metrics, second); } + byte[] coverage = rawCoverage.AsSpan(0, expectedBytes).ToArray(); string text = char.ConvertFromUtf32(scalar); if (!Native.GetTextExtentPoint32W(dc, text, text.Length, out NativeSize cell)) @@ -154,7 +199,7 @@ public class WindowsGdiGlyphMaskRasterizerTests glyphWidth, glyphHeight, stride, metrics.GlyphOrigin.X, metrics.GlyphOrigin.Y, metrics.CellIncrementX, metrics.CellIncrementY, - cell.Width, cell.Height, coverage); + cell.Width, cell.Height, coverage, checked((int)size)); } finally { @@ -178,7 +223,8 @@ public class WindowsGdiGlyphMaskRasterizerTests int CellAdvanceY, int CellWidth, int CellHeight, - byte[] Coverage); + byte[] Coverage, + int RawBufferSize); [StructLayout(LayoutKind.Sequential)] private struct NativePoint diff --git a/engine/Age.Engine.Text.Windows/WindowsGdiGlyphMaskRasterizer.cs b/engine/Age.Engine.Text.Windows/WindowsGdiGlyphMaskRasterizer.cs index 6413d08..90fa4a8 100644 --- a/engine/Age.Engine.Text.Windows/WindowsGdiGlyphMaskRasterizer.cs +++ b/engine/Age.Engine.Text.Windows/WindowsGdiGlyphMaskRasterizer.cs @@ -126,22 +126,31 @@ public sealed class WindowsGdiGlyphMaskRasterizer int expectedBytes = checked(stride * height); // CP932 0x8140 (U+3000 IDEOGRAPHIC SPACE) is a zero-ink spacing glyph. GDI reports // its placement as a nominal 1x1 black box but returns a zero-byte required buffer. - // Preserve those metrics and materialize the implied transparent mask; a nonzero - // short/oversized payload still means that the bitmap contract is inconsistent. - if (size != 0 && size != expectedBytes) + // Preserve those metrics and materialize the implied transparent mask. GDI can also + // return storage beyond the metric-defined rows (for example, bold 24px MS Mincho 'p' + // reports 12x15 but requests 192 bytes rather than 180). Treat the queried size as the + // allocation contract and the GLYPHMETRICS box as the raster-consumption contract: read + // exactly the requested buffer, then retain only the rows described by the metrics. Bytes + // outside that box are not drawable coverage and are not required to be initialized to zero. + if (size != 0 && size < expectedBytes) throw new InvalidOperationException( - $"GDI gray-4 buffer size {size} disagrees with {width}x{height}, stride {stride}."); + $"GDI gray-4 buffer size {size} for CP932 0x{code:x4} " + + $"('{request.FontFace}', {request.PixelHeight}px, width {request.RequestedWidth}, " + + $"weight {request.Weight}) is smaller than its {width}x{height} metric box, " + + $"stride {stride} ({expectedBytes} bytes)."); byte[] coverage = new byte[expectedBytes]; if (size > 0) { + byte[] gdiBuffer = new byte[checked((int)size)]; identity = Mat2.Identity; uint written = NativeMethods.GetGlyphOutlineA( _displayIc.DangerousGetHandle(), code, GgoGray4Bitmap, - out GlyphMetrics secondMetrics, size, coverage, ref identity); + out GlyphMetrics secondMetrics, size, gdiBuffer, ref identity); if (written == GdiError) ThrowWin32($"GetGlyphOutlineA read failed for CP932 0x{code:x4}"); if (written != size || !metrics.Equals(secondMetrics)) throw new InvalidOperationException("GDI glyph metrics changed between query and read."); + gdiBuffer.AsSpan(0, expectedBytes).CopyTo(coverage); } byte[] encoded = EncodeCp932Code(code);