engine: add exact Windows GDI glyph backend

This commit is contained in:
gamer147
2026-07-30 18:35:46 -04:00
parent fe06618439
commit a58108522e
10 changed files with 679 additions and 7 deletions

View File

@@ -106,9 +106,11 @@ S:\Game Hacking\Eushully\Himegari\ ← workspace root (three siblings)
│ and resource facade
│ ├── Age.Engine/Text/ backend-neutral glyph-mask requests/results, deterministic AGE
│ text compositor/layout, and bounded font/glyph cache primitives
── Age.Engine/Persistence/ native S3SD/S4SD + S3RT codecs, shared payload/ReadTextDB,
── Age.Engine/Persistence/ native S3SD/S4SD + S3RT codecs, shared payload/ReadTextDB,
│ layout-3 numbered state/history/gfx, DAT/STH pair + BMP codec,
│ and profile-owned state
│ └── Age.Engine.Text.Windows/ optional exact Windows GDI gray-4 reference backend; kept
│ outside the platform-neutral VM/runtime core
├── native/ authored native runtime boundaries
│ └── age_movie_ffmpeg/ project-owned FFmpeg C ABI, immutable Windows dependency manifest,
│ and bootstrap/build scripts (outputs stay under disposable build/)

View File

@@ -2985,6 +2985,21 @@ callbacks for future native font handles; `CachedGlyphMaskRasterizer` applies th
core is not yet connected to ops `0x204`/`0x205`, live ADV, History, or Godot, so the Label backend and
visible behavior remain unchanged until the later integration steps.
The independent Windows reference backend landed alongside, but outside, the neutral engine as
`Age.Engine.Text.Windows.WindowsGdiGlyphMaskRasterizer`. It holds one `CreateICA("DISPLAY")` information
context and selects bounded, safely disposed `CreateFontIndirectA` objects carrying the requested negative
height/width, weight, `DEFAULT_CHARSET`, and raw CP932 face bytes. Each request preserves the original one-
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.
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.
On the reference installation, three test requests—24px regular Mincho ``, 24px weight-700 Mincho ``,
and 16px weight-700 Gothic ``—match an independently created Unicode GDI font/DC call byte-for-byte in
coverage and exactly in black box, aligned stride, glyph origin, cell extent, and advance. The backend is
not wired into Godot yet, so this gate validates mask provenance without changing visible rendering.
#### ADV wait indicator -- ops `0x73` / `0x72` (2026-07-11)
The small bat marker is a configured ADV-layout sprite, not a glyph or part of SO001. `SYSTEM4.BIN`

View File

@@ -33,7 +33,7 @@ or replaced before claiming portable exports.
| AGE movie decode (`0x236` scene movies; `0x20f` modal LOGO/OP/ED; `0x24d` movie masks) | `FfmpegMovieDecoder` is the sole factory over the project-owned `native/age_movie_ffmpeg` ABI | Windows-x64 passes the complete 213-payload installed video/audio corpus gate plus audible LOGO/OP/CHAPTER playback and real TEST.AGF green-mask decode | Add target-specific native builds and export packaging |
| Movie integration | Each surface owns a unique playback-instance id; `MovieRuntime` owns `IMovieDecoder` from an injected factory; video-only streams use monotonic pacing while audio-bearing streams use the Godot output clock. Opcode `0x24d` redirects decoded green bytes through the platform-neutral retained rasterizer and exact managed packed-alpha mask helper | Concurrent/restarted uses of one asset have independent frame/audio/completion/teardown state. Ordinary and mask movies share VFS/FFmpeg ownership; the mask result is a backend-neutral dynamic RGBA surface consumed by either Godot renderer. Managed code is no longer Windows-annotated, while only the win-x64 native bundle exists today | Add Linux/macOS native builds and smoke gates |
| Movie audio | ABI v2 returns timestamped stereo float PCM; bounded managed buffering feeds a per-playback Godot `AudioStreamGenerator` and routes native movie flags to engine buses | All 29 installed audio-bearing streams decode with signal; synchronized LOGO/OP/CHAPTER playback is audibly accepted | Treat absent, distorted, or unsynchronized audio from an audio-bearing movie as a runtime bug |
| ADV font discovery/raster fidelity | Live presentation still uses `godot/Main.cs` to load Windows ` 明朝`/` ゴシック` when available and otherwise choose the existing Japanese-font/default fallback; its `Label` path substitutes FreeType embolden and a Godot outline. The new `Age.Engine.Text` core is OS-neutral: requests select native-CP932 or portable-Unicode policy, mask results normalize to 0..16 coverage/GDI-style metrics, and managed code owns deterministic compositing/layout plus bounded caches | Visible behavior is unchanged. On the reference Windows install, regular advances and SC0000 placement agree, but provisional bold remains visibly lighter/softer. Synthetic masks now test the common compositor independently of any OS font; no authored runtime code calls GDI | Next add the independent Windows GDI adapter outside `Age.Engine`, then integrate immediate surface pixels. Keep explicit portable face substitution/raster policy and do not claim cross-backend pixel equivalence |
| ADV font discovery/raster fidelity | Live presentation still uses `godot/Main.cs` to load Windows ` 明朝`/` ゴシック` when available and otherwise choose the existing Japanese-font/default fallback; its `Label` path substitutes FreeType embolden and a Godot outline. `Age.Engine.Text` remains OS-neutral. The separate optional `Age.Engine.Text.Windows` reference library now owns the authored GDI calls: `CreateICA`, bounded `CreateFontIndirectA` handles, `GetTextExtentPoint32A`, and `GetGlyphOutlineA(GGO_GRAY4_BITMAP)` | Visible behavior is unchanged because the reference backend is not wired into Godot. It explicitly requires Windows ACP 932 and reports unavailable elsewhere. Three Mincho/Gothic regular/bold samples byte-match an independent Unicode GDI oracle; synthetic masks continue to test the common compositor on every platform | Integrate immediate surface pixels against this exact Windows reference, then add an explicitly non-identical portable backend/face-substitution policy. Keep the GDI project optional and out of non-Windows deliverables rather than hiding failed exact selection |
| Filesystem semantics | Several filename and containment comparisons use `OrdinalIgnoreCase`; installed assets are conventionally uppercase | Needs validation on case-sensitive filesystems; may hide casing or containment mistakes | Add Linux/macOS tests with mixed-case synthetic roots and use filesystem-appropriate containment rules |
| Save/profile/settings storage | `Sys4PersistencePaths` models AGE's independent `SAVEPATH` and `REGFILEPATH` resolutions. Godot replaces Himegari's related profile directory with `user://`, yielding `user://SAVE` for native S3SD/S4SD/S3RT files and thumbnails plus `user://SYS4REG.INI` for the BOM-less CP932 options file; the preserving writer changes only its nine `[sound]` keys | Save payloads and engine options are isolated together without changing either native format. Native/drop-in resolution remains available through `USEAPPDATAFOLDER` plus both SYS4INI paths. A single-root override is rejected when `SAVEPATH` is not beneath `REGFILEPATH`, preventing cross-profile guesses | Expose explicit profile/native selection through the future launcher and allow independent overrides for profiles whose two native paths are unrelated. Validate CP932 availability, replace/flush, case, permissions, and interrupted-write behavior on each export target |
| Game-install and repository discovery | `GameRootSelection` accepts `--game-root`, then probes the executable directory and current working directory for `SYS4INI.BIN`; on Unix the frontend prefers inherited shell `PWD` because Godot may change the process directory during project startup. Godot injects the selected root into its catalog and loose-first ALF store. `Paths.cs` remains the development/test locator for generated opcode data, diagnostics, and CLI conveniences | Installed game data no longer depends on the workspace sibling layout, enabling an executable beside `AGE.EXE`, a terminal launch from the install, or a launcher-supplied absolute profile root. A packaged export still needs its generated runtime metadata bundled independently of repository discovery | Add export packaging for the opcode/profile artifacts, let the future profile launcher pass `--game-root`, and run executable-directory/CWD plus case/permission smoke gates on Linux and macOS |

View File

@@ -591,11 +591,16 @@ primitive, which cannot be made equivalent by choosing another embolden constant
synthetic-mask tests, independent of OS fonts, pin coverage rounding, bearings/clipping, mode-1 displacement,
mode-2 quarter coverage, mode-3 sampling/overlap, wrapping, punctuation, vertical overflow, cursor results,
and cache eviction.
3. **Add the independent Windows GDI reference backend.** Keep the P/Invoke adapter outside the
platform-neutral `Age.Engine` project so the VM continues to call no OS APIs. Reproduce the decoded display
IC, LOGFONT, measurement, and `GGO_GRAY4_BITMAP` request, and compare masks/`GLYPHMETRICS`/advances directly
on Windows. Backend selection must be explicit and diagnostic; an unavailable exact backend falls back
rather than silently claiming parity.
3. **Add the independent Windows GDI reference backend.** Completed 2026-07-30 as the separate
`Age.Engine.Text.Windows` library; the VM and platform-neutral `Age.Engine` still call no OS APIs.
`WindowsGdiGlyphMaskRasterizer` owns a `CreateICA("DISPLAY")` information context, bounded/safely disposed
`CreateFontIndirectA` handles, CP932 `GetTextExtentPoint32A`, identity `MAT2`, and
`GetGlyphOutlineA(GGO_GRAY4_BITMAP)`. It accepts only explicit native-CP932 requests and reports backend id,
policy, exactness, and call-chain detail through `GlyphRasterizerBackendInfo`. Availability requires Windows
system ACP 932; another OS/ACP reports a diagnostic reason instead of silently claiming parity. Three
representative regular/bold Mincho and bold Gothic requests byte-match an independent Unicode GDI oracle
for coverage, stride, `GLYPHMETRICS`, extent, and advance. This reference backend remains opt-in and is not
yet connected to live presentation; the existing Label path therefore remains the current fallback.
4. **Move immediate surface strings first.** Make ops `0x204`/`0x205` rasterize directly into the numbered
RGBA surface instead of appending `SurfaceTextDraw` metadata. Remove the separate surface-label projection
only after GPU and software paths prove that ordinary handle order, object alpha/tint, affine transforms,

View File

@@ -22,6 +22,7 @@
<ItemGroup>
<ProjectReference Include="..\Age.Engine\Age.Engine.csproj" />
<ProjectReference Include="..\Age.Engine.Text.Windows\Age.Engine.Text.Windows.csproj" />
<Compile Include="..\..\godot\IMovieDecoder.cs" Link="IMovieDecoder.cs" />
<Compile Include="..\..\godot\MovieRuntime.cs" Link="MovieRuntime.cs" />
<Compile Include="..\..\godot\MovieAudioTimeline.cs" Link="MovieAudioTimeline.cs" />

View File

@@ -0,0 +1,272 @@
using System.ComponentModel;
using System.Runtime.InteropServices;
using System.Text;
using Age.Engine.Text;
using Age.Engine.Text.Windows;
public class WindowsGdiGlyphMaskRasterizerTests
{
private const uint GgoGray4Bitmap = 5;
private const uint GdiError = 0xffffffff;
[Fact]
public void BackendSelectionIsExplicitAndDiagnostic()
{
if (!WindowsGdiGlyphMaskRasterizer.TryGetAvailability(out string availability))
{
Assert.NotEmpty(availability);
return;
}
using var rasterizer = new WindowsGdiGlyphMaskRasterizer(fontCacheCapacity: 2);
Assert.Equal(
new GlyphRasterizerBackendInfo(
"windows-gdi-gray4",
"Windows GDI Gray-4 (AGE reference)",
GlyphRasterPolicy.NativeCp932Gray4,
NativePixelExact: true,
"CreateICA(\"DISPLAY\") + CreateFontIndirectA + GetGlyphOutlineA(GGO_GRAY4_BITMAP), ACP 932"),
rasterizer.BackendInfo);
var portable = new GlyphRasterRequest(
" 明朝", 24, -12, 700, 0x3042, null, GlyphRasterPolicy.PortableUnicode);
Assert.Throws<ArgumentException>(() => rasterizer.Rasterize(portable));
}
[Theory]
[InlineData(" 明朝", 24, -12, 0, 0x3042)] // あ
[InlineData(" 明朝", 24, -12, 700, 0x59eb)] // 姫
[InlineData(" ゴシック", 16, -8, 700, 0x30a2)] // ア
public void AnsiCp932BackendMatchesIndependentUnicodeGdiOracle(
string face, int height, int width, int weight, int scalar)
{
if (!WindowsGdiGlyphMaskRasterizer.TryGetAvailability(out _)) return;
GlyphRasterRequest request = NativeRequest(face, height, width, weight, scalar);
using var rasterizer = new WindowsGdiGlyphMaskRasterizer();
GlyphMask actual = rasterizer.Rasterize(request);
DirectGlyph expected = RasterizeUnicodeDirect(face, height, width, weight, scalar);
Assert.Equal(
(expected.Width, expected.Height, expected.Stride,
expected.OriginX, expected.OriginY,
expected.CellAdvanceX, expected.CellAdvanceY,
expected.CellWidth, expected.CellHeight),
(actual.Width, actual.Height, actual.Stride,
actual.OriginX, actual.OriginY,
actual.CellAdvanceX, actual.CellAdvanceY,
actual.CellWidth, actual.CellHeight));
Assert.Equal(expected.Coverage, actual.Coverage.ToArray());
Assert.All(actual.Coverage.ToArray(), value => Assert.InRange(value, (byte)0, (byte)16));
}
[Fact]
public void FontHandlesUseTheSharedBoundedLruAndDisposeCleanly()
{
if (!WindowsGdiGlyphMaskRasterizer.TryGetAvailability(out _)) return;
var rasterizer = new WindowsGdiGlyphMaskRasterizer(fontCacheCapacity: 1);
rasterizer.Rasterize(NativeRequest(" 明朝", 24, -12, 0, 0x3042));
rasterizer.Rasterize(NativeRequest(" 明朝", 24, -12, 700, 0x3042));
Assert.Equal(1, rasterizer.FontCacheCount);
Assert.Equal(1, rasterizer.FontCacheCapacity);
rasterizer.Dispose();
Assert.Throws<ObjectDisposedException>(
() => rasterizer.Rasterize(NativeRequest(" 明朝", 24, -12, 700, 0x3042)));
}
private static GlyphRasterRequest NativeRequest(
string face, int height, int width, int weight, int scalar)
{
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
byte[] encoded = Encoding.GetEncoding(
932, EncoderFallback.ExceptionFallback, DecoderFallback.ExceptionFallback)
.GetBytes(char.ConvertFromUtf32(scalar));
Assert.InRange(encoded.Length, 1, 2);
ushort cp932 = encoded.Length == 1
? encoded[0]
: (ushort)((encoded[0] << 8) | encoded[1]);
return new GlyphRasterRequest(
face, height, width, weight, scalar, cp932, GlyphRasterPolicy.NativeCp932Gray4);
}
private static DirectGlyph RasterizeUnicodeDirect(
string face, int height, int width, int weight, int scalar)
{
IntPtr dc = Native.CreateICW("DISPLAY", null, null, IntPtr.Zero);
if (dc == IntPtr.Zero) ThrowWin32("CreateICW failed");
IntPtr font = IntPtr.Zero;
IntPtr previous = IntPtr.Zero;
try
{
var logFont = new LogFontW
{
Height = -height,
Width = width,
Weight = weight,
CharSet = 1,
FaceName = face,
};
font = Native.CreateFontIndirectW(ref logFont);
if (font == IntPtr.Zero) ThrowWin32("CreateFontIndirectW failed");
previous = Native.SelectObject(dc, font);
if (previous == IntPtr.Zero || previous == new IntPtr(-1))
ThrowWin32("SelectObject failed");
Mat2 identity = Mat2.Identity;
uint size = Native.GetGlyphOutlineW(
dc, (uint)scalar, GgoGray4Bitmap,
out GlyphMetrics metrics, 0, null, ref identity);
if (size == GdiError) ThrowWin32("GetGlyphOutlineW query failed");
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];
if (size > 0)
{
identity = Mat2.Identity;
uint written = Native.GetGlyphOutlineW(
dc, (uint)scalar, GgoGray4Bitmap,
out GlyphMetrics second, size, coverage, ref identity);
Assert.Equal(size, written);
Assert.Equal(metrics, second);
}
string text = char.ConvertFromUtf32(scalar);
if (!Native.GetTextExtentPoint32W(dc, text, text.Length, out NativeSize cell))
ThrowWin32("GetTextExtentPoint32W failed");
return new DirectGlyph(
glyphWidth, glyphHeight, stride,
metrics.GlyphOrigin.X, metrics.GlyphOrigin.Y,
metrics.CellIncrementX, metrics.CellIncrementY,
cell.Width, cell.Height, coverage);
}
finally
{
if (previous != IntPtr.Zero && previous != new IntPtr(-1))
Native.SelectObject(dc, previous);
if (font != IntPtr.Zero) Native.DeleteObject(font);
Native.DeleteDC(dc);
}
}
private static void ThrowWin32(string message)
=> throw new Win32Exception(Marshal.GetLastPInvokeError(), message);
private sealed record DirectGlyph(
int Width,
int Height,
int Stride,
int OriginX,
int OriginY,
int CellAdvanceX,
int CellAdvanceY,
int CellWidth,
int CellHeight,
byte[] Coverage);
[StructLayout(LayoutKind.Sequential)]
private struct NativePoint
{
public int X;
public int Y;
}
[StructLayout(LayoutKind.Sequential)]
private struct NativeSize
{
public int Width;
public int Height;
}
[StructLayout(LayoutKind.Sequential)]
private struct GlyphMetrics
{
public uint BlackBoxX;
public uint BlackBoxY;
public NativePoint GlyphOrigin;
public short CellIncrementX;
public short CellIncrementY;
}
[StructLayout(LayoutKind.Sequential)]
private struct Fixed
{
public ushort Fraction;
public short Value;
public static Fixed One => new() { Value = 1 };
}
[StructLayout(LayoutKind.Sequential)]
private struct Mat2
{
public Fixed M11;
public Fixed M12;
public Fixed M21;
public Fixed M22;
public static Mat2 Identity => new() { M11 = Fixed.One, M22 = Fixed.One };
}
[StructLayout(LayoutKind.Sequential, CharSet = System.Runtime.InteropServices.CharSet.Unicode)]
private struct LogFontW
{
public int Height;
public int Width;
public int Escapement;
public int Orientation;
public int Weight;
public byte Italic;
public byte Underline;
public byte StrikeOut;
public byte CharSet;
public byte OutPrecision;
public byte ClipPrecision;
public byte Quality;
public byte PitchAndFamily;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)]
public string FaceName;
}
private static class Native
{
[DllImport("gdi32.dll", EntryPoint = "CreateICW", CharSet = CharSet.Unicode, SetLastError = true)]
public static extern IntPtr CreateICW(
string driver, string? device, string? output, IntPtr initializationData);
[DllImport("gdi32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool DeleteDC(IntPtr deviceContext);
[DllImport("gdi32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool DeleteObject(IntPtr gdiObject);
[DllImport("gdi32.dll", EntryPoint = "CreateFontIndirectW", SetLastError = true)]
public static extern IntPtr CreateFontIndirectW(ref LogFontW logFont);
[DllImport("gdi32.dll", SetLastError = true)]
public static extern IntPtr SelectObject(IntPtr deviceContext, IntPtr gdiObject);
[DllImport("gdi32.dll", EntryPoint = "GetGlyphOutlineW", SetLastError = true)]
public static extern uint GetGlyphOutlineW(
IntPtr deviceContext,
uint character,
uint format,
out GlyphMetrics metrics,
uint bufferSize,
[Out] byte[]? buffer,
ref Mat2 transform);
[DllImport("gdi32.dll", EntryPoint = "GetTextExtentPoint32W", CharSet = CharSet.Unicode,
SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool GetTextExtentPoint32W(
IntPtr deviceContext,
string text,
int length,
out NativeSize size);
}
}

View File

@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<NuGetAudit>false</NuGetAudit>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Age.Engine\Age.Engine.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,345 @@
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using System.Runtime.InteropServices;
using System.Text;
using Age.Engine.Text;
using Microsoft.Win32.SafeHandles;
namespace Age.Engine.Text.Windows;
/// <summary>
/// Exact Windows reference backend for AGE's CreateICA/CreateFontIndirectA/GetGlyphOutlineA
/// GGO_GRAY4_BITMAP path. It is opt-in and requires the Japanese ANSI system code page.
/// </summary>
public sealed class WindowsGdiGlyphMaskRasterizer
: IIdentifiedGlyphMaskRasterizer, IDisposable
{
private const uint GgoGray4Bitmap = 5;
private const uint GdiError = 0xffffffff;
private const byte DefaultCharset = 1;
private static readonly Encoding Cp932 = CreateCp932();
private readonly object _gate = new();
private readonly SafeDeviceContextHandle _displayIc;
private readonly BoundedLruCache<FontKey, SafeGdiObjectHandle> _fonts;
private bool _disposed;
private readonly record struct FontKey(
string Face, int PixelHeight, int RequestedWidth, int Weight);
public WindowsGdiGlyphMaskRasterizer(int fontCacheCapacity = 16)
{
if (fontCacheCapacity <= 0)
throw new ArgumentOutOfRangeException(nameof(fontCacheCapacity));
if (!TryGetAvailability(out string reason))
throw new PlatformNotSupportedException(reason);
IntPtr dc = NativeMethods.CreateICA("DISPLAY", null, null, IntPtr.Zero);
if (dc == IntPtr.Zero) ThrowWin32("CreateICA(\"DISPLAY\") failed");
_displayIc = new SafeDeviceContextHandle(dc);
_fonts = new BoundedLruCache<FontKey, SafeGdiObjectHandle>(
fontCacheCapacity, font => font.Dispose());
}
public static bool TryGetAvailability(out string reason)
{
if (!OperatingSystem.IsWindows())
{
reason = "Windows GDI is unavailable on this operating system.";
return false;
}
uint activeCodePage = NativeMethods.GetACP();
if (activeCodePage != 932)
{
reason =
$"Exact AGE ANSI rasterization requires Windows system code page 932; active ACP is {activeCodePage}.";
return false;
}
reason = "Windows GDI DISPLAY information context with Japanese ANSI code page 932.";
return true;
}
public GlyphRasterizerBackendInfo BackendInfo => new(
"windows-gdi-gray4",
"Windows GDI Gray-4 (AGE reference)",
GlyphRasterPolicy.NativeCp932Gray4,
NativePixelExact: true,
Detail: "CreateICA(\"DISPLAY\") + CreateFontIndirectA + GetGlyphOutlineA(GGO_GRAY4_BITMAP), ACP 932");
public int FontCacheCount => _fonts.Count;
public int FontCacheCapacity => _fonts.Capacity;
public GlyphMask Rasterize(GlyphRasterRequest request)
{
ArgumentNullException.ThrowIfNull(request);
if (request.Policy != GlyphRasterPolicy.NativeCp932Gray4 || request.Cp932Code == null)
throw new ArgumentException(
"The Windows GDI reference backend accepts only native CP932 gray-4 requests.",
nameof(request));
lock (_gate)
{
ObjectDisposedException.ThrowIf(_disposed, this);
SafeGdiObjectHandle font = GetOrCreateFont(request);
IntPtr previous = NativeMethods.SelectObject(
_displayIc.DangerousGetHandle(), font.DangerousGetHandle());
if (previous == IntPtr.Zero || previous == new IntPtr(-1))
ThrowWin32("SelectObject(font) failed");
try
{
return RasterizeSelectedFont(request);
}
finally
{
NativeMethods.SelectObject(_displayIc.DangerousGetHandle(), previous);
}
}
}
public void Dispose()
{
lock (_gate)
{
if (_disposed) return;
_disposed = true;
_fonts.Clear();
_displayIc.Dispose();
}
GC.SuppressFinalize(this);
}
private GlyphMask RasterizeSelectedFont(GlyphRasterRequest request)
{
ushort code = request.Cp932Code!.Value;
if (code == 0) throw new ArgumentException("CP932 NUL is not a drawable glyph.", nameof(request));
Mat2 identity = Mat2.Identity;
uint size = NativeMethods.GetGlyphOutlineA(
_displayIc.DangerousGetHandle(), code, GgoGray4Bitmap,
out GlyphMetrics metrics, 0, null, ref identity);
if (size == GdiError) ThrowWin32($"GetGlyphOutlineA query failed for CP932 0x{code:x4}");
int width = checked((int)metrics.BlackBoxX);
int height = checked((int)metrics.BlackBoxY);
int stride = checked((width + 3) & ~3);
int expectedBytes = checked(stride * height);
if (size != expectedBytes)
throw new InvalidOperationException(
$"GDI gray-4 buffer size {size} disagrees with {width}x{height}, stride {stride}.");
byte[] coverage = new byte[expectedBytes];
if (size > 0)
{
identity = Mat2.Identity;
uint written = NativeMethods.GetGlyphOutlineA(
_displayIc.DangerousGetHandle(), code, GgoGray4Bitmap,
out GlyphMetrics secondMetrics, size, coverage, 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.");
}
byte[] encoded = EncodeCp932Code(code);
if (!NativeMethods.GetTextExtentPoint32A(
_displayIc.DangerousGetHandle(), encoded, encoded.Length, out NativeSize cell))
ThrowWin32($"GetTextExtentPoint32A failed for CP932 0x{code:x4}");
return new GlyphMask(
width, height, stride,
metrics.GlyphOrigin.X, metrics.GlyphOrigin.Y,
metrics.CellIncrementX, metrics.CellIncrementY,
cell.Width, cell.Height,
coverage);
}
private SafeGdiObjectHandle GetOrCreateFont(GlyphRasterRequest request)
{
var key = new FontKey(
request.FontFace, request.PixelHeight, request.RequestedWidth, request.Weight);
if (_fonts.TryGetValue(key, out SafeGdiObjectHandle? cached)) return cached;
var logFont = new LogFontA
{
Height = -request.PixelHeight,
Width = request.RequestedWidth,
Weight = request.Weight,
CharSet = DefaultCharset,
FaceName = EncodeFaceName(request.FontFace),
};
IntPtr raw = NativeMethods.CreateFontIndirectA(ref logFont);
if (raw == IntPtr.Zero)
ThrowWin32($"CreateFontIndirectA failed for '{request.FontFace}'");
var created = new SafeGdiObjectHandle(raw);
_fonts.Set(key, created);
return created;
}
private static byte[] EncodeFaceName(string face)
{
byte[] encoded;
try
{
encoded = Cp932.GetBytes(face);
}
catch (EncoderFallbackException error)
{
throw new ArgumentException($"Font face '{face}' is not representable in CP932.", nameof(face), error);
}
if (encoded.Length > 31)
throw new ArgumentException("A LOGFONTA face name cannot exceed 31 encoded bytes.", nameof(face));
var result = new byte[32];
encoded.CopyTo(result, 0);
return result;
}
private static byte[] EncodeCp932Code(ushort code)
=> code <= byte.MaxValue
? [(byte)code]
: [(byte)(code >> 8), (byte)code];
private static Encoding CreateCp932()
{
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
return Encoding.GetEncoding(
932, EncoderFallback.ExceptionFallback, DecoderFallback.ExceptionFallback);
}
[DoesNotReturn]
private static void ThrowWin32(string message)
=> throw new Win32Exception(Marshal.GetLastPInvokeError(), message);
[StructLayout(LayoutKind.Sequential)]
private struct NativePoint : IEquatable<NativePoint>
{
public int X;
public int Y;
public readonly bool Equals(NativePoint other) => X == other.X && Y == other.Y;
public override readonly bool Equals(object? obj) => obj is NativePoint other && Equals(other);
public override readonly int GetHashCode() => HashCode.Combine(X, Y);
}
[StructLayout(LayoutKind.Sequential)]
private struct NativeSize
{
public int Width;
public int Height;
}
[StructLayout(LayoutKind.Sequential)]
private struct GlyphMetrics : IEquatable<GlyphMetrics>
{
public uint BlackBoxX;
public uint BlackBoxY;
public NativePoint GlyphOrigin;
public short CellIncrementX;
public short CellIncrementY;
public readonly bool Equals(GlyphMetrics other)
=> BlackBoxX == other.BlackBoxX
&& BlackBoxY == other.BlackBoxY
&& GlyphOrigin.Equals(other.GlyphOrigin)
&& CellIncrementX == other.CellIncrementX
&& CellIncrementY == other.CellIncrementY;
public override readonly bool Equals(object? obj)
=> obj is GlyphMetrics other && Equals(other);
public override readonly int GetHashCode()
=> HashCode.Combine(
BlackBoxX, BlackBoxY, GlyphOrigin, CellIncrementX, CellIncrementY);
}
[StructLayout(LayoutKind.Sequential)]
private struct Fixed
{
public ushort Fraction;
public short Value;
public static Fixed One => new() { Value = 1 };
}
[StructLayout(LayoutKind.Sequential)]
private struct Mat2
{
public Fixed M11;
public Fixed M12;
public Fixed M21;
public Fixed M22;
public static Mat2 Identity => new() { M11 = Fixed.One, M22 = Fixed.One };
}
[StructLayout(LayoutKind.Sequential)]
private struct LogFontA
{
public int Height;
public int Width;
public int Escapement;
public int Orientation;
public int Weight;
public byte Italic;
public byte Underline;
public byte StrikeOut;
public byte CharSet;
public byte OutPrecision;
public byte ClipPrecision;
public byte Quality;
public byte PitchAndFamily;
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 32)]
public byte[] FaceName;
}
private sealed class SafeDeviceContextHandle : SafeHandleZeroOrMinusOneIsInvalid
{
public SafeDeviceContextHandle(IntPtr handle) : base(ownsHandle: true) => SetHandle(handle);
protected override bool ReleaseHandle() => NativeMethods.DeleteDC(handle);
}
private sealed class SafeGdiObjectHandle : SafeHandleZeroOrMinusOneIsInvalid
{
public SafeGdiObjectHandle(IntPtr handle) : base(ownsHandle: true) => SetHandle(handle);
protected override bool ReleaseHandle() => NativeMethods.DeleteObject(handle);
}
private static class NativeMethods
{
[DllImport("kernel32.dll")]
public static extern uint GetACP();
[DllImport("gdi32.dll", EntryPoint = "CreateICA", CharSet = CharSet.Ansi, SetLastError = true)]
public static extern IntPtr CreateICA(
string driver, string? device, string? output, IntPtr initializationData);
[DllImport("gdi32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool DeleteDC(IntPtr deviceContext);
[DllImport("gdi32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool DeleteObject(IntPtr gdiObject);
[DllImport("gdi32.dll", EntryPoint = "CreateFontIndirectA", SetLastError = true)]
public static extern IntPtr CreateFontIndirectA(ref LogFontA logFont);
[DllImport("gdi32.dll", SetLastError = true)]
public static extern IntPtr SelectObject(IntPtr deviceContext, IntPtr gdiObject);
[DllImport("gdi32.dll", EntryPoint = "GetGlyphOutlineA", SetLastError = true)]
public static extern uint GetGlyphOutlineA(
IntPtr deviceContext,
uint character,
uint format,
out GlyphMetrics metrics,
uint bufferSize,
[Out] byte[]? buffer,
ref Mat2 transform);
[DllImport("gdi32.dll", EntryPoint = "GetTextExtentPoint32A", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool GetTextExtentPoint32A(
IntPtr deviceContext,
byte[] text,
int length,
out NativeSize size);
}
}

View File

@@ -110,3 +110,15 @@ public interface IGlyphMaskRasterizer
{
GlyphMask Rasterize(GlyphRasterRequest request);
}
public sealed record GlyphRasterizerBackendInfo(
string Id,
string DisplayName,
GlyphRasterPolicy Policy,
bool NativePixelExact,
string Detail);
public interface IIdentifiedGlyphMaskRasterizer : IGlyphMaskRasterizer
{
GlyphRasterizerBackendInfo BackendInfo { get; }
}

View File

@@ -9,6 +9,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Age.Cli", "Age.Cli\Age.Cli.
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Age.Engine.Tests", "Age.Engine.Tests\Age.Engine.Tests.csproj", "{2ED171C8-DA87-40FD-A6FB-3B3F6501029F}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Age.Engine.Text.Windows", "Age.Engine.Text.Windows\Age.Engine.Text.Windows.csproj", "{74F3B4B8-5F8D-45F4-AA6B-7C2C1A54CB90}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -30,5 +32,9 @@ Global
{2ED171C8-DA87-40FD-A6FB-3B3F6501029F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{2ED171C8-DA87-40FD-A6FB-3B3F6501029F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{2ED171C8-DA87-40FD-A6FB-3B3F6501029F}.Release|Any CPU.Build.0 = Release|Any CPU
{74F3B4B8-5F8D-45F4-AA6B-7C2C1A54CB90}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{74F3B4B8-5F8D-45F4-AA6B-7C2C1A54CB90}.Debug|Any CPU.Build.0 = Debug|Any CPU
{74F3B4B8-5F8D-45F4-AA6B-7C2C1A54CB90}.Release|Any CPU.ActiveCfg = Release|Any CPU
{74F3B4B8-5F8D-45F4-AA6B-7C2C1A54CB90}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal