fix(ui): retail tooltip rendering — formula-first compose and the two-pass sizing every tooltip was missing
All checks were successful
CI / linux-portable (push) Successful in 3m39s
CI / windows-gate (push) Successful in 6m41s
CI / release (push) Successful in 2m10s

Two corrections from the owner's retail-render oracle at the CA5 re-check,
both against readings the TS-85 register row had recorded as settled:

Compose (skill tooltips): retail is formula + newline + description —
GetTooltip @0x004f1fe0's operator+ has the InqSkillFormula output as the
LEFT operand; the old '"\n" + formula, no separator' reading had the
operand order backwards and produced a leading blank line with the formula
and description glued on one line. A formula-less skill (Salvaging) shows
the bare description, matching the failed-InqSkillFormula branch.

Sizing (ALL tooltips, per the owner's direction): retail sizes a tooltip
in TWO passes (StartTooltip @0x0045DE90) — measure-wrap at the max width,
resize the root through the authored ResizeTo clamps, then
RecalculateGlyphList RE-WRAPS the text at its final clamped width and a
second resize grows the root's HEIGHT for the extra lines. The branch the
register called 'a structural no-op' IS that second pass; without it a
description longer than the clamped popup stayed one clipped line, where
retail shows three. ApplyTooltipText now ports the full chain, so every
tooltip surface (items, options rows, character panel, world hover, map)
wraps and grows exactly as retail.

Pinned by BuildTooltip_FormulaFirstThenNewlineThenDescription,
BuildTooltip_FormulaLessSkillShowsBareDescription, and
LongTooltip_RewrapsAtTheClampedPopupWidth_AndGrowsHeightForTheExtraLines.
TS-85 carries both dated corrections. Owner visual re-check owed: skill
tooltip shows formula on line one, description below, long descriptions
wrapping to three-plus lines inside the parchment. Full hermetic suite
15,332 passed / 0 failed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-24 18:09:54 +02:00
parent 51a7c99b94
commit 1e8596a440
5 changed files with 174 additions and 18 deletions

File diff suppressed because one or more lines are too long

View file

@ -186,12 +186,19 @@ internal static class RetailSkillFormula
/// <summary>
/// Retail <c>SkillInfoRegion::GetTooltip @ 0x004f1fe0</c>, called once
/// from <c>SkillInfoRegion::SkillInfoRegion @ 0x004f2140</c>'s
/// <c>UIElement::SetTooltip</c> at 0x004f222f. Composition is exactly
/// <c>"\n" + formula + description</c> — retail concatenates the
/// description directly onto the formula line with NO separator between
/// them (ported verbatim, not "fixed": <c>append_n_chars</c> runs
/// immediately after the formula assignment with no intervening
/// literal). <c>SkillSystem::InqSkillDescription @ 0x005c8770</c> reads
/// <c>UIElement::SetTooltip</c> at 0x004f222f. Composition is
/// <c>formula + "\n" + description</c> — CORRECTED 2026-08-24 at the
/// CA5 gate: the original Batch-B reading ("\n" + formula, no
/// separator) had the <c>operator+</c> operand order backwards
/// (<c>PVar3 = operator+(&amp;local_c, ...)</c> — <c>local_c</c>, the
/// InqSkillFormula output, is the LEFT operand; the "\n" literal is the
/// right), and the owner's retail-client render confirms: formula on
/// its own first line, description below (wrapping to further lines
/// when the DAT text is long). A formula-less skill (Salvaging) shows
/// the bare description with no leading break, matching retail's
/// failed-<c>InqSkillFormula</c> branch, which appends the description
/// to the still-empty output.
/// <c>SkillSystem::InqSkillDescription @ 0x005c8770</c> reads
/// <c>SkillBase._description</c> — the same DAT field
/// <see cref="DatReaderWriter.Types.SkillBase.Description"/> already
/// exposes, so no hand-transcription was needed for the ~30+ skill
@ -204,7 +211,7 @@ internal static class RetailSkillFormula
string? formula = FormatFormula(skillBase.Formula);
string description = skillBase.Description.Value ?? string.Empty;
string tooltip = (formula is null ? string.Empty : "\n" + formula) + description;
string tooltip = (formula is null ? string.Empty : formula + "\n") + description;
return tooltip.Length == 0 ? null : tooltip;
}
}

View file

@ -626,15 +626,27 @@ public sealed class RetailTooltipPresenter : IDisposable
? bitmapFont.MeasureWidth
: static s => s.Length * 8f;
float wrapWidth = MathF.Max(1f, _host.EffectiveCanvasSize.X);
var wrapped = UiText.WrapWords(tooltipText, measure, wrapWidth);
text.LinesProvider = () => wrapped
.Select(line => new UiText.Line(line, text.DefaultColor))
.ToArray();
float measuredWidth = wrapped.Count == 0 ? 0f : wrapped.Max(measure);
// CA5-gate correction (2026-08-24, owner retail-render oracle):
// retail sizes a tooltip in TWO passes, and the second is what makes
// long tooltips multi-line. StartTooltip @0x0045DE90:
// 1. MEASURE — InqSizewMargins(..., UITS_MAX_WIDTH): wrap at the
// authored P0x3D max width, else the display width, producing
// the measured extent.
// 2. Resize the ROOT by the measured-vs-authored delta through
// ResizeTo, where the popup skin's authored max/min CLAMP.
// 3. RecalculateGlyphList — the text RE-WRAPS at its FINAL
// (possibly clamped) width.
// 4. A second ResizeTo grows the root's HEIGHT (width unchanged)
// when the re-wrapped glyph extent needs more than the text
// child's current height.
// The pre-correction port did only pass 1, so a description longer
// than the clamped popup stayed one clipped line.
float lineHeight = text.DatFont?.LineHeight ?? text.Font?.LineHeight ?? 14f;
float measuredHeight = wrapped.Count * lineHeight;
float measureWrapWidth = MathF.Max(1f, _host.EffectiveCanvasSize.X);
var measured = UiText.WrapWords(tooltipText, measure, measureWrapWidth);
float measuredWidth = measured.Count == 0 ? 0f : measured.Max(measure);
float measuredHeight = measured.Count * lineHeight;
float requestedWidth = root.Width + (measuredWidth - authoredTextWidth);
float requestedHeight = root.Height + (measuredHeight - authoredTextHeight);
@ -651,10 +663,40 @@ public sealed class RetailTooltipPresenter : IDisposable
if (root.AuthoredResizeMinWidth is { } minWidth && requestedWidth < minWidth)
requestedWidth = minWidth;
float authoredRootWidth = root.Width;
float authoredRootHeight = root.Height;
root.Width = requestedWidth;
root.Height = requestedHeight;
text.Width = measuredWidth;
text.Height = measuredHeight;
// The text child follows the root's ACTUAL growth (retail's
// anchored resize) — the clamp is what makes these differ from the
// measured extents.
float textFinalWidth = MathF.Max(
1f, authoredTextWidth + (requestedWidth - authoredRootWidth));
float textFinalHeight =
authoredTextHeight + (requestedHeight - authoredRootHeight);
// Pass 3: re-wrap at the final clamped width (RecalculateGlyphList).
var wrapped = UiText.WrapWords(tooltipText, measure, textFinalWidth);
text.LinesProvider = () => wrapped
.Select(line => new UiText.Line(line, text.DefaultColor))
.ToArray();
text.Width = wrapped.Count == 0 ? 0f : MathF.Min(textFinalWidth, wrapped.Max(measure));
float rewrappedHeight = wrapped.Count * lineHeight;
// Pass 4: grow the root's height by the re-wrap's overflow beyond
// the text child's post-resize height (width unchanged), through
// the same ResizeTo clamps.
if (rewrappedHeight > textFinalHeight)
{
float grownHeight = root.Height + (rewrappedHeight - textFinalHeight);
if (root.AuthoredResizeMaxHeight is { } maxH2 && grownHeight > maxH2)
grownHeight = maxH2;
if (root.AuthoredResizeMinHeight is { } minH2 && grownHeight < minH2)
grownHeight = minH2;
root.Height = grownHeight;
}
text.Height = rewrappedHeight;
}
/// <summary>Retail <c>UIElementManager::StartTooltip @0x00459700</c>: the

View file

@ -143,6 +143,48 @@ public sealed class RetailSkillFormulaTests
Assert.Equal(0u, resolver.Resolve(0x999u, new Dictionary<uint, uint>()));
}
[Fact]
public void BuildTooltip_FormulaFirstThenNewlineThenDescription()
{
// CA5 gate correction (2026-08-24): retail renders the formula on
// its own FIRST line with the description below — GetTooltip
// @0x004f1fe0's operator+ has the InqSkillFormula output as the
// LEFT operand (formula + a newline), then appends the description;
// confirmed against the owner's retail-client render. The original
// reading (newline-first + formula, glued description) produced a leading
// blank line and a single glued line.
SkillFormula formula = Formula(w: 0, x: 1, y: 1, z: 2);
formula.Attribute1 = DatReaderWriter.Enums.AttributeId.Strength;
formula.Attribute2 = DatReaderWriter.Enums.AttributeId.Coordination;
var skillBase = new SkillBase
{
Formula = formula,
Description = { Value = "Description text." },
};
string? tooltip = RetailSkillFormula.BuildTooltip(skillBase);
Assert.NotNull(tooltip);
Assert.False(tooltip!.StartsWith('\n'));
int split = tooltip.IndexOf('\n');
Assert.True(split > 0);
Assert.Equal("Description text.", tooltip[(split + 1)..]);
}
[Fact]
public void BuildTooltip_FormulaLessSkillShowsBareDescription()
{
// Retail's failed-InqSkillFormula branch (z==0) appends the
// description to a still-empty string — no leading break.
var skillBase = new SkillBase
{
Formula = Formula(w: 7, x: 1, y: 1, z: 0),
Description = { Value = "Salvage things." },
};
Assert.Equal("Salvage things.", RetailSkillFormula.BuildTooltip(skillBase));
}
private static SkillFormula Formula(int w, int x, int y, uint z) => new()
{
AdditiveBonus = w,

View file

@ -464,6 +464,69 @@ public sealed class RetailTooltipPresenterTests
return target;
}
[Fact]
public void LongTooltip_RewrapsAtTheClampedPopupWidth_AndGrowsHeightForTheExtraLines()
{
// CA5-gate correction: retail sizes tooltips in TWO passes
// (StartTooltip @0x0045DE90) — measure-wrap at the max width, resize
// the root through the authored clamps, then RE-WRAP at the final
// clamped width and grow the root's HEIGHT for the extra lines.
// Pre-correction, a description longer than the clamped popup stayed
// one clipped line (the owner's retail client shows three).
var (root, _, _) = CreateHarness();
string longText = string.Join(
" ", Enumerable.Repeat("description", 40)); // far wider than 200px
var target = new HoverTarget
{
Left = 100, Top = 100, Width = 40, Height = 20,
AuthoredTooltipEnabled = true,
AuthoredTooltipText = longText,
AuthoredTooltipRootElementId = PopupRootId,
AuthoredTooltipLayoutDid = PopupLayoutDid,
};
root.AddChild(target);
int childrenBefore = root.Children.Count;
// Clamp the popup to 200px wide via a max-width-authored skin.
HoverAndDwellWithClampedPopup(root, maxWidth: 200);
UiElement popup = root.Children[^1];
Assert.True(root.Children.Count > childrenBefore, "popup did not mount");
Assert.True(popup.Width <= 200f, $"popup width {popup.Width} escaped the clamp");
var text = FindText(popup);
Assert.NotNull(text);
var lines = text!.LinesProvider();
Assert.True(lines.Count >= 3,
$"expected the clamped width to force >=3 lines, got {lines.Count}");
// Every re-wrapped line must fit the clamped popup.
Assert.All(lines, l => Assert.True(l.Text.Length * 8f <= 200f + 8f));
// The root grew to hold the extra lines.
float lineHeight = 14f;
Assert.True(popup.Height >= lines.Count * lineHeight,
$"popup height {popup.Height} does not fit {lines.Count} lines");
}
private static void HoverAndDwellWithClampedPopup(UiRoot root, int maxWidth)
{
// A presenter whose popup skin authors ResizeMaxWidth, mirroring the
// parchment skins' bounded frames.
var presenter = new RetailTooltipPresenter(root, (_, _) =>
{
ImportedLayout layout = BuildPopup();
layout.Root.AuthoredResizeMaxWidth = maxWidth;
return layout;
});
HoverAndDwell(root);
}
private static UiText? FindText(UiElement element)
{
if (element is UiText text) return text;
foreach (UiElement child in element.Children)
if (FindText(child) is { } found) return found;
return null;
}
private static void HoverAndDwell(UiRoot root)
{
root.OnMouseMove(110, 110);