Fix oversized GDI glyph buffers

This commit is contained in:
gamer147
2026-08-16 11:24:07 -04:00
parent eef1225b56
commit 7b0987d9a6
4 changed files with 97 additions and 15 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -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 = " 明朝",
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(" 明朝", 24, -12, 700, 'p');
DirectGlyph direct = RasterizeUnicodeDirect(" 明朝", 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

View File

@@ -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);