feat(fonts): a handle for the sender and one for the meta line

Two roles need a face of their own, and they need it for opposite reasons.

The sender is meant to carry weight. The mockup says 600, and there is no bold
face anywhere in the plugin -- the bundled file is Inter-Light and the game's
Axis is a single weight. So the weight comes from rasterising the same outline
denser, via RasterizerMultiply. That only works on the delegate path: with both
font toggles off the game font handle draws and has no such knob, and the sender
falls back to leaning on channel colour alone. Deliberate limitation, not an
oversight.

The meta face goes the other way: smaller, and on a glyph range of about eighty
entries instead of the full set. Timestamps and world names are Latin in every
client FFXIV ships, so ASCII plus the middle dot covers what this face will ever
be asked to draw. A full range would have rasterised the whole CJK block a second
time for nothing. Anything translated stays on the body face.

Two things in the rebuild path had to change with them. The handles now go up
inside a SuppressAutoRebuild block -- without it, one size change meant four
separate atlas rebuilds instead of one. And FontsReady checks both new handles
unconditionally, unlike ItalicFont which is allowed to be null: a handle that is
not ready makes SimplePushedFont push nothing at all, silently, and the first
frame after a rebuild would measure the wrong face and write those heights into
the row cache.

Both font self-tests were extended to match. A handle nobody asserts is a handle
that can go missing for a release without anyone noticing.
This commit is contained in:
2026-08-19 09:46:34 +02:00
parent 6d2bb95528
commit 34e343d8f2
3 changed files with 141 additions and 5 deletions
+105 -5
View File
@@ -7,6 +7,7 @@ using Dalamud.Interface.ManagedFontAtlas;
using Dalamud.Interface.Utility;
using Dalamud.Plugin;
using HellionChat.Themes;
using HellionChat.Ui.StyleEngine;
namespace HellionChat;
@@ -40,6 +41,20 @@ public sealed class FontManager : IDisposable
internal IFontHandle? RegularFont;
internal IFontHandle? ItalicFont;
// v1.13.0: one handle per type role that needs a face of its own. Sender
// carries extra weight, Meta a smaller size on a tiny glyph range.
internal IFontHandle? SenderFont;
internal IFontHandle? MetaFont;
// The mockup asks for weight 600 on the sender. There is no bold face in the
// plugin and none in the bundled file, so the weight comes from a denser
// rasterisation of the same outline. 1.0 is the SafeFontConfig default;
// below ~1.2 the difference is not visible, above ~1.4 the glyphs smear.
//
// Not const: the smoke test compares three values side by side, and the
// widget gallery exposes it.
internal static float SenderWeight = 1.3f;
// Wired post-build (B4b-3); a Func keeps FontManager off the theme layer.
private Func<ThemeTypography?>? _typographySource;
@@ -56,7 +71,13 @@ public sealed class FontManager : IDisposable
&& AxisItalic.Available
&& FontAwesome.Available
&& RegularFont is { Available: true }
&& (ItalicFont is null || ItalicFont.Available);
&& (ItalicFont is null || ItalicFont.Available)
// Unconditional, unlike ItalicFont: these two are always built. A handle
// that is not ready yet makes SimplePushedFont push nothing at all --
// silently -- so the first frame after a rebuild would measure the wrong
// face and write those heights into the row cache.
&& SenderFont is { Available: true }
&& MetaFont is { Available: true };
private ushort[] Ranges = [];
private ushort[] JpRange = [];
@@ -66,6 +87,20 @@ public sealed class FontManager : IDisposable
// by the global font, so the fallback no longer re-merges the full Ranges array.
private ushort[] CjkFallbackGlyphRange = [];
// The meta role draws clock faces, world names and a separator. FFXIV world
// names are Latin in every client, so ASCII plus the middle dot covers it.
// A full range here would rasterise the whole CJK set a second time for
// eighty glyphs' worth of use. Anything translated -- the header's stand-in
// when no world is known -- goes through the body face instead.
private static readonly ushort[] MetaRange =
[
0x0020,
0x007E,
0x00B7,
0x00B7,
0,
];
// Report accessor for the ctor self-test: built glyph-range array lengths so
// the step can show the B1 dedup effect (a small trimmed fallback vs the large
// primary range) in its on-disk report instead of a bare Pass.
@@ -121,6 +156,9 @@ public sealed class FontManager : IDisposable
if (Plugin.Config.ItalicEnabled)
ItalicFont = BuildItalicFontHandle(atlas);
SenderFont = BuildSenderFontHandle(atlas);
MetaFont = BuildMetaFontHandle(atlas);
}
// Source is still null here, so this is the config-only baseline.
@@ -141,11 +179,23 @@ public sealed class FontManager : IDisposable
var atlas = _pluginInterface.UiBuilder.FontAtlas;
RegularFont?.Dispose();
RegularFont = BuildRegularFontHandle(atlas);
// Without the suppression each handle triggers its own atlas rebuild.
// With two handles that was tolerable; with four it is four rebuilds for
// one size change.
using (atlas.SuppressAutoRebuild())
{
RegularFont?.Dispose();
RegularFont = BuildRegularFontHandle(atlas);
ItalicFont?.Dispose();
ItalicFont = Plugin.Config.ItalicEnabled ? BuildItalicFontHandle(atlas) : null;
ItalicFont?.Dispose();
ItalicFont = Plugin.Config.ItalicEnabled ? BuildItalicFontHandle(atlas) : null;
SenderFont?.Dispose();
SenderFont = BuildSenderFontHandle(atlas);
MetaFont?.Dispose();
MetaFont = BuildMetaFontHandle(atlas);
}
_lastBuiltFingerprint = EffectiveFontFingerprint();
}
@@ -232,6 +282,54 @@ public sealed class FontManager : IDisposable
})
);
// Same outline as the body face, rasterised denser. Only works on the
// delegate path: with FontsEnabled and UseHellionFont both off the game's own
// Axis handle draws, and a game font handle has no such knob. The sender then
// leans on channel colour alone, which is a deliberate limitation.
private IFontHandle BuildSenderFontHandle(IFontAtlas atlas) =>
atlas.NewDelegateFontHandle(e =>
e.OnPreBuild(tk =>
{
var basePt = TypeScale.SizePtOf(
TypeRole.Sender,
ResolveGlobalFontPt()
);
var config = new SafeFontConfig
{
SizePt = basePt,
GlyphRanges = Ranges,
RasterizerMultiply = SenderWeight,
};
var bundledBytes = Plugin.Config.UseHellionFont ? TryGetBundledFontBytes() : null;
config.MergeFont = bundledBytes is not null
? tk.AddFontFromMemory(bundledBytes, config, "Inter-Light-Sender")
: AddFontWithFallback(tk, Plugin.Config.GlobalFontV2.FontId, config, "sender");
AddCjkAndSymbols(tk, config, basePt);
tk.Font = config.MergeFont;
})
);
private IFontHandle BuildMetaFontHandle(IFontAtlas atlas) =>
atlas.NewDelegateFontHandle(e =>
e.OnPreBuild(tk =>
{
var basePt = TypeScale.SizePtOf(
TypeRole.Meta,
ResolveGlobalFontPt()
);
var config = new SafeFontConfig { SizePt = basePt, GlyphRanges = MetaRange };
var bundledBytes = Plugin.Config.UseHellionFont ? TryGetBundledFontBytes() : null;
config.MergeFont = bundledBytes is not null
? tk.AddFontFromMemory(bundledBytes, config, "Inter-Light-Meta")
: AddFontWithFallback(tk, Plugin.Config.GlobalFontV2.FontId, config, "meta");
// No CJK merge on purpose: MetaRange cannot reach those glyphs.
tk.Font = config.MergeFont;
})
);
private IFontHandle BuildItalicFontHandle(IFontAtlas atlas) =>
atlas.NewDelegateFontHandle(e =>
e.OnPreBuild(tk =>
@@ -262,6 +360,8 @@ public sealed class FontManager : IDisposable
// lifetime, so the plugin must not dispose it.
RegularFont?.Dispose();
ItalicFont?.Dispose();
SenderFont?.Dispose();
MetaFont?.Dispose();
}
// Returns null when the embedded font resource is missing. Should not
@@ -56,6 +56,20 @@ internal sealed class FontManagerCtorSmokeStep : ISelfTestStep
return SelfTestStepResult.Fail;
}
// v1.13.0: unconditional, unlike ItalicFont. Both are always built, and a
// handle that never arrives makes SimplePushedFont push nothing silently.
if (fm.SenderFont is null)
{
ImGui.Text("SenderFont handle is null");
return SelfTestStepResult.Fail;
}
if (fm.MetaFont is null)
{
ImGui.Text("MetaFont handle is null");
return SelfTestStepResult.Fail;
}
if (fm.Axis.LoadException is { } e1)
{
ImGui.Text($"Axis load exception: {e1.Message}");
@@ -86,6 +100,18 @@ internal sealed class FontManagerCtorSmokeStep : ISelfTestStep
return SelfTestStepResult.Fail;
}
if (fm.SenderFont.LoadException is { } e6)
{
ImGui.Text($"SenderFont load exception: {e6.Message}");
return SelfTestStepResult.Fail;
}
if (fm.MetaFont.LoadException is { } e7)
{
ImGui.Text($"MetaFont load exception: {e7.Message}");
return SelfTestStepResult.Fail;
}
// B1: assert the atlas actually finished building all required handles,
// not just that the references are non-null. FontsReady is the observable
// state the trimmed-fallback rebuild must still reach; a half-built atlas
@@ -119,6 +145,8 @@ internal sealed class FontManagerCtorSmokeStep : ISelfTestStep
$"FontAwesome available: {fm.FontAwesome.Available}",
$"RegularFont available: {fm.RegularFont.Available}",
$"ItalicFont: {italicState}",
$"SenderFont available: {fm.SenderFont.Available} (weight {FontManager.SenderWeight:0.00})",
$"MetaFont available: {fm.MetaFont.Available}",
$"FontsReady: {fm.FontsReady}",
$"Glyph-range entries: primary={counts.Ranges}, jp={counts.JpRange}, "
+ $"cjk-fallback={counts.CjkFallback} (B1 trimmed)",
@@ -28,10 +28,18 @@ internal sealed class FontPushSmokeStep : ISelfTestStep
return SelfTestStepResult.Fail;
}
if (fm.SenderFont is null || fm.MetaFont is null)
{
ImGui.Text("SenderFont or MetaFont missing - see FontManager ctor smoke");
return SelfTestStepResult.Fail;
}
try
{
using (fm.RegularFont.Push()) { }
using (fm.FontAwesome.Push()) { }
using (fm.SenderFont.Push()) { }
using (fm.MetaFont.Push()) { }
}
catch (Exception e)
{