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");