From fed838847ba85419462efd5bf2b4926088d0add8 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 16 Jun 2026 15:24:37 +0200 Subject: [PATCH] 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) --- src/AcDream.Cli/FontAtlasDump.cs | 182 +++++++++++++++++++++++++++++++ src/AcDream.Cli/Program.cs | 14 +++ 2 files changed, 196 insertions(+) create mode 100644 src/AcDream.Cli/FontAtlasDump.cs diff --git a/src/AcDream.Cli/FontAtlasDump.cs b/src/AcDream.Cli/FontAtlasDump.cs new file mode 100644 index 00000000..f9f49161 --- /dev/null +++ b/src/AcDream.Cli/FontAtlasDump.cs @@ -0,0 +1,182 @@ +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; + +/// +/// Headless inspection of a retail dat Font (DB_TYPE_FONT, 0x40000000…). Writes: +/// • <out>-fg.png — foreground (fill) atlas, alpha→luminance (white on black) +/// • <out>-bg.png — background (outline) atlas, alpha→luminance +/// • <out>-sample.png — a sample string composited EXACTLY the way +/// UiRenderContext.DrawStringDat 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. +/// +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(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(); + 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; + } + + /// Composite the sample string with the two-pass outline+fill model, + /// blitting atlas sub-rects 1:1. adds a fractional + /// line origin; selects the FIX (snap the line baseline + /// to a whole pixel once) vs the BUG (round each glyph's Y independently). + private static Image RenderSample( + string text, Dictionary 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(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 native, string outBase) + { + using var zoom = native.Clone(c => c.Resize(native.Width * 6, native.Height * 6, KnownResamplers.NearestNeighbor)); + zoom.SaveAsPng($"{outBase}-6x.png"); + } + + /// Alpha-blend one glyph's atlas sub-rect onto the canvas using its alpha + /// as coverage, tinted by . 1:1 (no scaling), so this is the + /// pixel-exact result GL_NEAREST + native-size quad produces. + private static void BlitGlyph(Image 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); + } + } + } + + /// 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. + private static Image AlphaLuma(DecodedTexture t) + { + var img = new Image(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(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; + } +} diff --git a/src/AcDream.Cli/Program.cs b/src/AcDream.Cli/Program.cs index 44094b55..5e0e03be 100644 --- a/src/AcDream.Cli/Program.cs +++ b/src/AcDream.Cli/Program.cs @@ -68,6 +68,20 @@ if (args.Length >= 1 && args[0] == "dump-sprite-sheet") return VitalsMockup.ExportSheet(dssDir, dssIds, dssOut); } +if (args.Length >= 1 && args[0] == "dump-font-atlas") +{ + string? dfaDir = args.ElementAtOrDefault(1) ?? Env.GetEnvironmentVariable("ACDREAM_DAT_DIR"); + string? dfaFont = args.ElementAtOrDefault(2); // 0xFontId (default 0x40000000) + string? dfaSample = args.ElementAtOrDefault(3); // sample string + string dfaOut = args.ElementAtOrDefault(4) ?? "font-atlas"; + if (string.IsNullOrWhiteSpace(dfaDir)) + { + Console.Error.WriteLine("usage: AcDream.Cli dump-font-atlas [0xFontId] [sample] [outBase]"); + return 2; + } + return FontAtlasDump.Run(dfaDir, dfaFont, dfaSample, dfaOut); +} + if (args.Length >= 1 && args[0] == "export-ui-sprite") { string? eusDatDir = args.ElementAtOrDefault(1) ?? Env.GetEnvironmentVariable("ACDREAM_DAT_DIR");