From 6a8c919df1fb1f898b0714827531c1ccd9f376ca Mon Sep 17 00:00:00 2001 From: gamer147 Date: Thu, 30 Jul 2026 18:35:46 -0400 Subject: [PATCH] engine: add exact Windows GDI glyph backend --- docs/PROJECT-STRUCTURE.md | 4 +- docs/engine-re.md | 15 + docs/platform-portability.md | 2 +- docs/remake-architecture-and-roadmap.md | 15 +- .../Age.Engine.Tests/Age.Engine.Tests.csproj | 1 + .../WindowsGdiGlyphMaskRasterizerTests.cs | 272 ++++++++++++++ .../Age.Engine.Text.Windows.csproj | 14 + .../WindowsGdiGlyphMaskRasterizer.cs | 345 ++++++++++++++++++ engine/Age.Engine/Text/GlyphMaskContracts.cs | 12 + engine/AgeEngine.sln | 6 + 10 files changed, 679 insertions(+), 7 deletions(-) create mode 100644 engine/Age.Engine.Tests/WindowsGdiGlyphMaskRasterizerTests.cs create mode 100644 engine/Age.Engine.Text.Windows/Age.Engine.Text.Windows.csproj create mode 100644 engine/Age.Engine.Text.Windows/WindowsGdiGlyphMaskRasterizer.cs diff --git a/docs/PROJECT-STRUCTURE.md b/docs/PROJECT-STRUCTURE.md index 93f41b8..d6924d2 100644 --- a/docs/PROJECT-STRUCTURE.md +++ b/docs/PROJECT-STRUCTURE.md @@ -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/) diff --git a/docs/engine-re.md b/docs/engine-re.md index 54ab25f..bad38ce 100644 --- a/docs/engine-re.md +++ b/docs/engine-re.md @@ -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` diff --git a/docs/platform-portability.md b/docs/platform-portability.md index 4ad699e..5419edb 100644 --- a/docs/platform-portability.md +++ b/docs/platform-portability.md @@ -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 `MS 明朝`/`MS ゴシック` 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 `MS 明朝`/`MS ゴシック` 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 | diff --git a/docs/remake-architecture-and-roadmap.md b/docs/remake-architecture-and-roadmap.md index c75d5b9..9759730 100644 --- a/docs/remake-architecture-and-roadmap.md +++ b/docs/remake-architecture-and-roadmap.md @@ -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, diff --git a/engine/Age.Engine.Tests/Age.Engine.Tests.csproj b/engine/Age.Engine.Tests/Age.Engine.Tests.csproj index 5639a33..9bb7299 100644 --- a/engine/Age.Engine.Tests/Age.Engine.Tests.csproj +++ b/engine/Age.Engine.Tests/Age.Engine.Tests.csproj @@ -22,6 +22,7 @@ + diff --git a/engine/Age.Engine.Tests/WindowsGdiGlyphMaskRasterizerTests.cs b/engine/Age.Engine.Tests/WindowsGdiGlyphMaskRasterizerTests.cs new file mode 100644 index 0000000..e84f90b --- /dev/null +++ b/engine/Age.Engine.Tests/WindowsGdiGlyphMaskRasterizerTests.cs @@ -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( + "MS 明朝", 24, -12, 700, 0x3042, null, GlyphRasterPolicy.PortableUnicode); + Assert.Throws(() => rasterizer.Rasterize(portable)); + } + + [Theory] + [InlineData("MS 明朝", 24, -12, 0, 0x3042)] // あ + [InlineData("MS 明朝", 24, -12, 700, 0x59eb)] // 姫 + [InlineData("MS ゴシック", 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("MS 明朝", 24, -12, 0, 0x3042)); + rasterizer.Rasterize(NativeRequest("MS 明朝", 24, -12, 700, 0x3042)); + + Assert.Equal(1, rasterizer.FontCacheCount); + Assert.Equal(1, rasterizer.FontCacheCapacity); + rasterizer.Dispose(); + Assert.Throws( + () => rasterizer.Rasterize(NativeRequest("MS 明朝", 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); + } +} diff --git a/engine/Age.Engine.Text.Windows/Age.Engine.Text.Windows.csproj b/engine/Age.Engine.Text.Windows/Age.Engine.Text.Windows.csproj new file mode 100644 index 0000000..22ab971 --- /dev/null +++ b/engine/Age.Engine.Text.Windows/Age.Engine.Text.Windows.csproj @@ -0,0 +1,14 @@ + + + + net8.0 + enable + enable + false + + + + + + + diff --git a/engine/Age.Engine.Text.Windows/WindowsGdiGlyphMaskRasterizer.cs b/engine/Age.Engine.Text.Windows/WindowsGdiGlyphMaskRasterizer.cs new file mode 100644 index 0000000..ae27d76 --- /dev/null +++ b/engine/Age.Engine.Text.Windows/WindowsGdiGlyphMaskRasterizer.cs @@ -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; + +/// +/// 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. +/// +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 _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( + 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 + { + 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 + { + 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); + } +} diff --git a/engine/Age.Engine/Text/GlyphMaskContracts.cs b/engine/Age.Engine/Text/GlyphMaskContracts.cs index 5666f83..eb3d0a8 100644 --- a/engine/Age.Engine/Text/GlyphMaskContracts.cs +++ b/engine/Age.Engine/Text/GlyphMaskContracts.cs @@ -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; } +} diff --git a/engine/AgeEngine.sln b/engine/AgeEngine.sln index b8d0065..aa049d0 100644 --- a/engine/AgeEngine.sln +++ b/engine/AgeEngine.sln @@ -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