engine: add exact Windows GDI glyph backend
This commit is contained in:
@@ -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/)
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" />
|
||||
|
||||
272
engine/Age.Engine.Tests/WindowsGdiGlyphMaskRasterizerTests.cs
Normal file
272
engine/Age.Engine.Tests/WindowsGdiGlyphMaskRasterizerTests.cs
Normal 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(
|
||||
"MS 明朝", 24, -12, 700, 0x3042, null, GlyphRasterPolicy.PortableUnicode);
|
||||
Assert.Throws<ArgumentException>(() => 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<ObjectDisposedException>(
|
||||
() => 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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
345
engine/Age.Engine.Text.Windows/WindowsGdiGlyphMaskRasterizer.cs
Normal file
345
engine/Age.Engine.Text.Windows/WindowsGdiGlyphMaskRasterizer.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user