The zero-allocation family failed about one full-suite run in three, on unchanged trees, and had been dismissed as inherent noise in `GC.GetAllocatedBytesForCurrentThread` three separate times. It is not noise. Reading the four members side by side, they share one root: **the measured window was never the warmed path.** UiDatFontTests 1 warm call, then a 10,000-iteration loop inline RenderFrameProductTests 8 warm calls, then a 1,000-iteration loop inline OracleTests 1 warm call, 1 measured call ArchRenderSceneTests warms Apply(registrations), measures Apply(updates) Two mechanisms come out of that table. A test method is JIT-compiled at tier 0 like anything else, and a long-running loop in tier-0 code gets replaced mid-flight by on-stack replacement — which compiles on the thread running the loop, so its bookkeeping is charged to the window being measured. That is the first two. And `ArchRenderSceneTests` warmed one arm of a switch and measured the other, so the measured call was the first ever into `ApplyUpdate` and paid that arm's JIT, type loads and static initialisation inside the window; `RenderFrameProductTests` warmed 8 times, below the tier-0 call-counting threshold of 30, so promotion was still pending when measurement began. That also explains the signature nobody could account for. Alone, the process is quiet and the runtime has finished before the assertion arrives. Alongside eight other test assemblies, tier-0 compilation never stops, the call-counting delay is re-armed continually, and the work slides into the window. Clean in isolation, failing under load, on a tree that changed nothing. `ZeroAllocationProbe` invokes the step many times before measuring anything, then measures windows that run the same already-warmed loop over the same already-taken path. Each window is a batch of 32 invocations and it reports the minimum across 4 of them. Both halves are load-bearing: the minimum is what excludes a one-time cost, and the batch is what keeps the assertion as strong as the loops it replaces — minimising over *single* invocations would report zero for a path that allocates every tenth call, which is a real regression made invisible. I had written it that way first and the apparatus test caught it. **The bound is untouched: exactly zero, no tolerance, no retry, no assertion relaxed.** `ZeroAllocationProbeTests` proves the apparatus can still fail — a step allocating every call reads above zero and does throw, a first-invocation cost reads as zero, a cost every tenth call is caught, and the one stated limit (the batch must cover the period) is pinned as a test rather than left as prose. Without those, a later edit could quietly make the whole family unfailable. Twelve further sites in this assembly still use the hand-rolled shape. None has been observed failing, and each needs its own repeatability analysis — several mutate state or consume monotonic sequences — so they are listed in the issue for adoption when next touched rather than converted blind at scale. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
117 lines
4 KiB
C#
117 lines
4 KiB
C#
using System.Collections.Generic;
|
|
using AcDream.App.UI;
|
|
using DatReaderWriter.Types;
|
|
|
|
namespace AcDream.App.Tests.UI;
|
|
|
|
/// <summary>
|
|
/// Pure pen-advance / MeasureWidth math for the retail dat font (no GL, no dat).
|
|
/// The advance per glyph is the retail
|
|
/// <c>HorizontalOffsetBefore + Width + HorizontalOffsetAfter</c>
|
|
/// (SurfaceWindow::DrawCharacter, acclient 0x00442c3a), accumulated across the
|
|
/// string the way the retail string loop does (0x00467ed4 edi_3 += var_98).
|
|
/// </summary>
|
|
public class UiDatFontTests
|
|
{
|
|
private static FontCharDesc Glyph(
|
|
ushort unicode, byte width,
|
|
sbyte before = 0, sbyte after = 0,
|
|
ushort offsetX = 0, ushort offsetY = 0, byte height = 16, sbyte vBefore = 0)
|
|
=> new()
|
|
{
|
|
Unicode = unicode,
|
|
Width = width,
|
|
Height = height,
|
|
OffsetX = offsetX,
|
|
OffsetY = offsetY,
|
|
HorizontalOffsetBefore = before,
|
|
HorizontalOffsetAfter = after,
|
|
VerticalOffsetBefore = vBefore,
|
|
};
|
|
|
|
[Fact]
|
|
public void GlyphAdvance_SumsBeforeWidthAfter()
|
|
{
|
|
var g = Glyph('A', width: 8, before: 1, after: 2);
|
|
Assert.Equal(11f, UiDatFont.GlyphAdvance(g));
|
|
}
|
|
|
|
[Fact]
|
|
public void GlyphAdvance_HandlesNegativeBearings()
|
|
{
|
|
// Kerned glyph: a negative left-bearing pulls it leftward; the advance
|
|
// still nets out to before + width + after.
|
|
var g = Glyph('j', width: 4, before: -1, after: 0);
|
|
Assert.Equal(3f, UiDatFont.GlyphAdvance(g));
|
|
}
|
|
|
|
[Fact]
|
|
public void MeasureWidth_SumsEachGlyphAdvance()
|
|
{
|
|
var table = new Dictionary<char, FontCharDesc>
|
|
{
|
|
['2'] = Glyph('2', width: 7, before: 1, after: 1), // advance 9
|
|
['9'] = Glyph('9', width: 7, before: 1, after: 1), // advance 9
|
|
['1'] = Glyph('1', width: 3, before: 2, after: 1), // advance 6
|
|
['/'] = Glyph('/', width: 4, before: 0, after: 1), // advance 5
|
|
};
|
|
FontCharDesc? Lookup(char c) => table.TryGetValue(c, out var g) ? g : null;
|
|
|
|
// "291/291" = 9 + 9 + 6 + 5 + 9 + 9 + 6 = 53
|
|
Assert.Equal(53f, UiDatFont.MeasureWidth("291/291", Lookup));
|
|
}
|
|
|
|
[Fact]
|
|
public void MeasureWidth_SkipsCharactersNotInFont()
|
|
{
|
|
var table = new Dictionary<char, FontCharDesc>
|
|
{
|
|
['5'] = Glyph('5', width: 6, before: 1, after: 1), // advance 8
|
|
};
|
|
FontCharDesc? Lookup(char c) => table.TryGetValue(c, out var g) ? g : null;
|
|
|
|
// 'X' has no glyph → contributes nothing; only the two '5's count.
|
|
Assert.Equal(16f, UiDatFont.MeasureWidth("5X5", Lookup));
|
|
}
|
|
|
|
[Fact]
|
|
public void MeasureWidth_EmptyOrNullIsZero()
|
|
{
|
|
FontCharDesc? Lookup(char c) => null;
|
|
Assert.Equal(0f, UiDatFont.MeasureWidth("", Lookup));
|
|
Assert.Equal(0f, UiDatFont.MeasureWidth(null, Lookup));
|
|
}
|
|
|
|
[Fact]
|
|
public void InstanceMeasureWidth_ReusesGlyphTableWithoutAllocating()
|
|
{
|
|
var glyphs = new Dictionary<char, FontCharDesc>
|
|
{
|
|
['A'] = Glyph('A', width: 8, before: 1, after: 2),
|
|
['B'] = Glyph('B', width: 7, before: 1, after: 1),
|
|
};
|
|
var font = new UiDatFont(
|
|
fgTex: 0,
|
|
fgW: 0,
|
|
fgH: 0,
|
|
bgTex: 0,
|
|
bgW: 0,
|
|
bgH: 0,
|
|
lineHeight: 16f,
|
|
baselineOffset: 12f,
|
|
glyphs);
|
|
const string Text = "ABBA";
|
|
float expected = font.MeasureWidth(Text);
|
|
float actual = 0f;
|
|
|
|
// #250: the measured window is one call into already-warmed code. The
|
|
// old shape measured a 10,000-iteration loop written inline, which is
|
|
// exactly the shape on-stack replacement rewrites mid-flight, on this
|
|
// thread, inside the window.
|
|
ZeroAllocationProbe.AssertAllocatesNothing(
|
|
"UiDatFont.MeasureWidth",
|
|
() => actual = font.MeasureWidth(Text));
|
|
|
|
Assert.Equal(expected, actual);
|
|
}
|
|
}
|