acdream/src/AcDream.Cli/FontAtlasDump.cs
Erik fed838847b chore(cli): dump-font-atlas tool for headless font inspection
A `dump-font-atlas` subcommand renders a dat Font's fg/bg atlases (alpha as
luminance) plus a sample string composited exactly the way DrawStringDat does
it (outline + fill, integer-snapped). Used to reproduce the glyph-baseline
jitter offline (fractional-origin bug vs the fix) without launching the client.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 15:24:37 +02:00

182 lines
9.1 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using AcDream.Core.Textures;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Options;
using DatReaderWriter.Types;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Processing;
namespace AcDream.Cli;
/// <summary>
/// Headless inspection of a retail dat Font (DB_TYPE_FONT, 0x40000000…). Writes:
/// • <c>&lt;out&gt;-fg.png</c> — foreground (fill) atlas, alpha→luminance (white on black)
/// • <c>&lt;out&gt;-bg.png</c> — background (outline) atlas, alpha→luminance
/// • <c>&lt;out&gt;-sample.png</c> — a sample string composited EXACTLY the way
/// <c>UiRenderContext.DrawStringDat</c> does it (black outline pass behind,
/// colored fill pass on top) onto the dark chat-panel colour, at native 1:1
/// and at 6× nearest zoom side by side.
///
/// The sample reproduces our client's glyph math deterministically so the
/// "not sharp" artifact can be judged offline: if the 1:1 sample is crisp, the
/// softness is downstream (a post-process / scale); if the sample itself is
/// soft, the cause is the atlas or the two-pass outline.
/// </summary>
public static class FontAtlasDump
{
public static int Run(string datDir, string? fontIdText, string? sampleText, string outBase)
{
if (!Directory.Exists(datDir)) { Console.Error.WriteLine($"error: dir not found: {datDir}"); return 2; }
uint fontId = string.IsNullOrWhiteSpace(fontIdText) ? 0x40000000u : ParseHex(fontIdText);
string sample = string.IsNullOrEmpty(sampleText) ? "Chat Send 12345 ghpqy" : sampleText;
using var dats = new DatCollection(datDir, DatAccessType.Read);
var font = dats.Get<Font>(fontId);
if (font is null) { Console.Error.WriteLine($"error: Font 0x{fontId:X8} not found"); return 1; }
Console.WriteLine($"Font 0x{fontId:X8}: fg=0x{font.ForegroundSurfaceDataId:X8} bg=0x{font.BackgroundSurfaceDataId:X8} " +
$"MaxCharHeight={font.MaxCharHeight} Baseline={font.BaselineOffset} glyphs={font.CharDescs.Count}");
DecodedTexture fg = DecodeRs(dats, font.ForegroundSurfaceDataId);
DecodedTexture? bg = font.BackgroundSurfaceDataId != 0 ? DecodeRs(dats, font.BackgroundSurfaceDataId) : null;
Console.WriteLine($" fg atlas {fg.Width}x{fg.Height}" + (bg is { } b ? $" bg atlas {b.Width}x{b.Height}" : " (no bg atlas)"));
AlphaLuma(fg).SaveAsPng($"{outBase}-fg.png");
Console.WriteLine($"wrote {outBase}-fg.png");
if (bg is { } bgt) { AlphaLuma(bgt).SaveAsPng($"{outBase}-bg.png"); Console.WriteLine($"wrote {outBase}-bg.png"); }
// Build a glyph lookup.
var glyphs = new Dictionary<char, FontCharDesc>();
foreach (var cd in font.CharDescs) glyphs[(char)cd.Unicode] = cd;
// Render the sample the way DrawStringDat does, onto the dark chat panel colour.
var panel = new Rgba32(28, 28, 32, 255);
var fill = new Rgba32(255, 255, 255, 255); // white fill, like System default-ish
var outline = new Rgba32(0, 0, 0, 255);
int lineH = Math.Max((int)font.MaxCharHeight, 8);
// (a) integer baseline, per-glyph round (works — like the vitals digits).
using var native = RenderSample(sample, glyphs, fg, bg, lineH, panel, fill, outline, 0f, snapOnce: false);
Save6x(native, $"{outBase}-sample");
// (b) FRACTIONAL baseline (textY=0.5, like a menu item centered in a 17px row over
// a 16px font) with the OLD per-glyph rounding → reproduces the "letters dip down"
// jitter the user reported.
using var jitter = RenderSample(sample, glyphs, fg, bg, lineH, panel, fill, outline, 0.5f, snapOnce: false);
Save6x(jitter, $"{outBase}-jitter");
// (c) Same fractional baseline, but the line baseline is snapped to a whole pixel ONCE
// before adding the integer per-glyph offsets → the fix. Should be straight again.
using var fixed_ = RenderSample(sample, glyphs, fg, bg, lineH, panel, fill, outline, 0.5f, snapOnce: true);
Save6x(fixed_, $"{outBase}-fixed");
Console.WriteLine($"wrote {outBase}-sample-6x.png (ok), {outBase}-jitter-6x.png (bug repro), {outBase}-fixed-6x.png (fix)");
return 0;
}
/// <summary>Composite the sample string with the two-pass outline+fill model,
/// blitting atlas sub-rects 1:1. <paramref name="originYExtra"/> adds a fractional
/// line origin; <paramref name="snapOnce"/> selects the FIX (snap the line baseline
/// to a whole pixel once) vs the BUG (round each glyph's Y independently).</summary>
private static Image<Rgba32> RenderSample(
string text, Dictionary<char, FontCharDesc> glyphs,
DecodedTexture fg, DecodedTexture? bg, int lineH,
Rgba32 panel, Rgba32 fill, Rgba32 outline, float originYExtra, bool snapOnce)
{
// First pass: measure pen width.
float pen = 0; float maxX = 0;
foreach (char ch in text)
if (glyphs.TryGetValue(ch, out var g)) { maxX = Math.Max(maxX, pen + g.HorizontalOffsetBefore + g.Width); pen += g.HorizontalOffsetBefore + g.Width + g.HorizontalOffsetAfter; }
int w = Math.Max(8, (int)MathF.Ceiling(Math.Max(maxX, pen)) + 4);
int h = lineH + 6;
var img = new Image<Rgba32>(w, h, panel);
float originY = 3f + originYExtra;
float baseY = MathF.Round(originY); // snapped line baseline (the fix)
pen = 2;
foreach (char ch in text)
{
if (!glyphs.TryGetValue(ch, out var g)) { continue; }
float gx = MathF.Round(pen + g.HorizontalOffsetBefore);
float gy = snapOnce
? baseY + g.VerticalOffsetBefore // fix: integer baseline + integer offset
: MathF.Round(originY + g.VerticalOffsetBefore); // bug: independent per-glyph rounding
if (g.Width > 0 && g.Height > 0)
{
if (bg is { } bgt) BlitGlyph(img, bgt, g, (int)gx, (int)gy, outline);
BlitGlyph(img, fg, g, (int)gx, (int)gy, fill);
}
pen += g.HorizontalOffsetBefore + g.Width + g.HorizontalOffsetAfter;
}
return img;
}
private static void Save6x(Image<Rgba32> native, string outBase)
{
using var zoom = native.Clone(c => c.Resize(native.Width * 6, native.Height * 6, KnownResamplers.NearestNeighbor));
zoom.SaveAsPng($"{outBase}-6x.png");
}
/// <summary>Alpha-blend one glyph's atlas sub-rect onto the canvas using its alpha
/// as coverage, tinted by <paramref name="tint"/>. 1:1 (no scaling), so this is the
/// pixel-exact result GL_NEAREST + native-size quad produces.</summary>
private static void BlitGlyph(Image<Rgba32> dst, DecodedTexture atlas, FontCharDesc g, int dx, int dy, Rgba32 tint)
{
for (int sy = 0; sy < g.Height; sy++)
{
int py = dy + sy;
if (py < 0 || py >= dst.Height) continue;
int ay = g.OffsetY + sy;
if (ay < 0 || ay >= atlas.Height) continue;
for (int sx = 0; sx < g.Width; sx++)
{
int px = dx + sx;
if (px < 0 || px >= dst.Width) continue;
int ax = g.OffsetX + sx;
if (ax < 0 || ax >= atlas.Width) continue;
int idx = (ay * atlas.Width + ax) * 4;
// Atlas is A8 expanded to (255,255,255,alpha); coverage = alpha.
float cov = atlas.Rgba8[idx + 3] / 255f;
if (cov <= 0f) continue;
var bgpx = dst[px, py];
dst[px, py] = new Rgba32(
(byte)(tint.R * cov + bgpx.R * (1 - cov)),
(byte)(tint.G * cov + bgpx.G * (1 - cov)),
(byte)(tint.B * cov + bgpx.B * (1 - cov)),
255);
}
}
}
/// <summary>Render an A8/RGBA atlas's ALPHA channel as opaque white-on-black luminance,
/// zoomed 4× nearest, so the glyph shapes are visible regardless of PNG viewer alpha.</summary>
private static Image<Rgba32> AlphaLuma(DecodedTexture t)
{
var img = new Image<Rgba32>(t.Width, t.Height);
for (int y = 0; y < t.Height; y++)
for (int x = 0; x < t.Width; x++)
{
byte a = t.Rgba8[(y * t.Width + x) * 4 + 3];
img[x, y] = new Rgba32(a, a, a, 255);
}
img.Mutate(c => c.Resize(t.Width * 4, t.Height * 4, KnownResamplers.NearestNeighbor));
return img;
}
private static DecodedTexture DecodeRs(DatCollection dats, uint id)
{
var rs = dats.Get<RenderSurface>(id);
if (rs is null) { Console.Error.WriteLine($" missing RenderSurface 0x{id:X8}"); return DecodedTexture.Magenta; }
return SurfaceDecoder.DecodeRenderSurface(rs);
}
private static uint ParseHex(string s)
{
s = s.Trim();
if (s.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) s = s[2..];
return uint.TryParse(s, System.Globalization.NumberStyles.HexNumber,
System.Globalization.CultureInfo.InvariantCulture, out var v) ? v : 0u;
}
}