fix(chargen): Campaign CC gate round 1 Batch E — text origin, caption escapes, value rects, scrollbars, name prefill
R2-1/R2-6 (description-box text clipped left of the frame, regressed from
Batch C's frame un-consume): root cause was never the un-consume change
itself — the Heritage/Profession/Town/Summary description boxes
(0x100003C4/0x100003E0/0x10000409/0x10000404) all author retail's four
independent text-inset margins (dat properties 0x23-0x26,
UIElement_Text::OnSetAttribute cases 0xf-0x12: margL=9/margR=26/margU=15/
margD=15), which this codebase never read at all, before or after Batch C.
Un-consuming the gold-frame children just made the pre-existing missing-
margin bug visible for the first time (the frame's own left border now
draws around the same x=0 origin text always used). Fixed end to end:
ElementInfo.MarginLeft/Right/Top/Bottom (read in
ApplyCanonicalLegacyProjection, propagated in Merge), UiText.MarginLeft/
Right/Top/Bottom (additive with the pre-existing Padding), a new pure
UiText.ContentOffsetX static consumed by the multi-line draw path's
per-line placement, and matching wrap-width shrinkage in
DatRichText.Compose and BuildText's own authored-multiline path. Scoped to
the multi-line (non-OneLine) path only.
R2-2/R2-3 (Attribute\n Credits renders the literal backslash-n; the live
credit value overlaps mid-caption): two stacked gaps. (1) UiButton
captions never escape-normalized the DAT's literal "\n" — centralized the
normalize into DatWidgetFactory's ResolveAuthoredString (the one choke
point every P0x17 resolution already shares) plus a NormalizeEscapes
helper for the per-state caption loop, so every caller normalizes
identically. (2) UiButton.Label only ever drew one line — retail's
UIElement_Button IS a UIElement_Text with OneLine=false on these buttons,
so a caption should word-wrap/stack like any other Type-12 box. Added
UiButton.DrawBlockLabel + the pure, unit-tested WrapBlockLines. The
value-overlap itself: ValueBox was never wrong (live-DAT-measured correct
child rects) — the caption was drawing unconfined across the button's
full width ("Available Skill Credits" measures 193px in a 231px button
whose value box starts at x=116). Fixed by confining the caption's own
drawable width to stop before ValueBox.X whenever a ValueLabel coexists.
R2-7a (Summary overview listbox missing its scrollbar): pure wiring gap —
the listbox authors a linked scrollbar via dat property 0x72
(ScrollbarElementId=0x10000401) that CharacterCreationSummaryPage's
constructor never resolved, unlike every other UiTemplateListBox owner in
the codebase. Fixed with the same resolve-and-wire pattern.
R2-7b (how-to box scrollbar overlaps text, no thumb): traced to a
downstream symptom of R2-1, not an independent bug — UiScrollbar only
paints its thumb when the linked model has overflow, and the pre-fix wrap
width (un-inset) produced fewer/shorter lines than fit the view. Pinned
directly against the real installed strings/font (Aluvian's how-to text)
that the margin-correct width overflows. No UiScrollbar code changed.
R2-8 (name field should show "[ Name ]"): re-checked the one hypothesis
Batch A's GF-15 closure left open — an authored initial-text string on
the field's own P0x17. Confirmed absent on every state in the installed
DAT. No code change; Batch A's closure stands, now pinned as a live-DAT
regression test.
App suite 5334/3 (was 5321/3, +13, zero regressions). Runtime 1735/0
unchanged. Full solution Release build green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
2ad805469d
commit
e24ec20882
11 changed files with 895 additions and 23 deletions
|
|
@ -164,6 +164,52 @@ public sealed class CharacterCreationLiveDatTests
|
|||
UiElement.FindDescendant(heritageRoot, 0x100003C4u));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// R2-1 (Campaign CC gate round 1 Batch E): the Heritage/Profession/
|
||||
/// Town/Summary description boxes author retail's four text-inset
|
||||
/// margins (dat properties 0x23-0x26 — live-DAT-probe-confirmed
|
||||
/// margL=9/margR=26/margU=15/margD=15 on all four, shared box
|
||||
/// template) — this codebase never read them before this fix, so every
|
||||
/// one of these boxes drew its first glyph flush against x=0 (Padding
|
||||
/// alone, always 0 for DAT-built text), under the authored gold-frame's
|
||||
/// own left border piece. Pins BOTH halves: the margins land on the
|
||||
/// built <see cref="UiText"/> (not just the raw <see cref="ElementInfo"/>),
|
||||
/// and <see cref="UiText.ContentOffsetX"/> computed with those margins
|
||||
/// places the first line's origin at the authored interior (x=9), not
|
||||
/// the box's outer edge (x=0).
|
||||
/// </summary>
|
||||
[InstalledDatFact]
|
||||
public void HeritageDescription_MarginsMatchAuthoredInset_AndFirstLineOriginRespectsThem()
|
||||
{
|
||||
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
|
||||
uint layoutId = RetailDataIdResolver.Resolve(
|
||||
dats, CharacterCreationUiController.RootEnum, 5u);
|
||||
ImportedLayout screen = BuildSelected(
|
||||
dats, layoutId, CharacterCreationUiController.RootElementId);
|
||||
|
||||
UiElement heritageRoot = Assert.IsAssignableFrom<UiElement>(
|
||||
screen.FindElement(CharacterCreationUiController.HeritagePageElementId));
|
||||
UiText description = Assert.IsType<UiText>(
|
||||
UiElement.FindDescendant(heritageRoot, 0x100003C4u));
|
||||
|
||||
Assert.Equal(9f, description.MarginLeft);
|
||||
Assert.Equal(26f, description.MarginRight);
|
||||
Assert.Equal(15f, description.MarginTop);
|
||||
Assert.Equal(15f, description.MarginBottom);
|
||||
|
||||
// First glyph of a left-justified line: Padding (0, DAT-built text
|
||||
// never sets it) + MarginLeft (9) = x=9, NOT x=0 — the exact
|
||||
// regression the user's "rained Starting Skills"/"OW HUNTERS"
|
||||
// reports describe (the leading 1-2 characters clipped under the
|
||||
// frame's left border because text used to start at x=0).
|
||||
float firstLineX = UiText.ContentOffsetX(
|
||||
description.Width, description.Padding,
|
||||
description.MarginLeft, description.MarginRight,
|
||||
lineWidth: 40f, centered: false, rightAligned: false);
|
||||
Assert.Equal(9f, firstLineX);
|
||||
Assert.NotEqual(0f, firstLineX);
|
||||
}
|
||||
|
||||
/// <summary>Seven template buttons, six attribute sliders (each with a
|
||||
/// lock button + scrollbar + value text), and the four derived
|
||||
/// displays (Profession page —
|
||||
|
|
@ -854,6 +900,52 @@ public sealed class CharacterCreationLiveDatTests
|
|||
Assert.IsType<UiText>(UiElement.FindDescendant(pairRow, 0x100002FDu));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// R2-8 (Campaign CC gate round 1 Batch E): re-checks the AUTHORED-
|
||||
/// initial-text hypothesis the user's re-test raised for the
|
||||
/// <c>[ Name ]</c> the retail screenshot shows — Batch A's GF-15
|
||||
/// closure already byte-verified retail's CODE never writes it
|
||||
/// (<c>CharGenState::RandomizeCharacter</c>,
|
||||
/// <c>gmCGSummaryPage::InitializePage</c>), but did not check whether
|
||||
/// the field's own dat property <c>0x17</c> (the SAME authored-caption
|
||||
/// mechanism <c>DatWidgetFactory.BuildText</c>/<c>BuildField</c> already
|
||||
/// reads for every other element) carries a display-only placeholder.
|
||||
/// It does not: the name field (<c>0x10000402</c>) authors NO <c>0x17</c>
|
||||
/// on its default state or on ANY of its named states in the installed
|
||||
/// EoR dat. Per this batch's own investigation contract ("if NOT
|
||||
/// authored, STOP on this item"), this pins that negative result as a
|
||||
/// durable regression check rather than leaving it as a one-off probe
|
||||
/// finding — CONFIRMS Batch A's closure honestly, it does not change
|
||||
/// acdream's behavior (the field stays genuinely empty, matching
|
||||
/// retail's own code-empty field).
|
||||
/// </summary>
|
||||
[InstalledDatFact]
|
||||
public void SummaryNameField_AuthorsNoP0x17OnAnyState()
|
||||
{
|
||||
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
|
||||
uint layoutId = RetailDataIdResolver.Resolve(
|
||||
dats, CharacterCreationUiController.RootEnum, 5u);
|
||||
ElementInfo rootInfo = Assert.IsType<ElementInfo>(
|
||||
LayoutImporter.ImportInfos(
|
||||
dats, layoutId, CharacterCreationUiController.RootElementId));
|
||||
ElementInfo nameField = Assert.IsType<ElementInfo>(
|
||||
FindInfo(rootInfo, CharacterCreationSummaryPage.NameTextId));
|
||||
|
||||
Assert.False(
|
||||
nameField.TryGetEffectiveProperty(0x17u, out _),
|
||||
"the name field must not author a P0x17 caption on its effective "
|
||||
+ "default state — if this starts failing, the DAT now carries an "
|
||||
+ "authored placeholder and R2-8 should be revisited as a real fix.");
|
||||
foreach (var (stateId, state) in nameField.States)
|
||||
{
|
||||
Assert.False(
|
||||
state.Properties.Values.TryGetValue(0x17u, out var stateCaption)
|
||||
&& stateCaption.Kind == UiPropertyKind.StringInfo,
|
||||
$"the name field's state 0x{stateId:X} ('{state.Name}') must not "
|
||||
+ "author a P0x17 caption either.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CC5 re-review residual round, R3 (2026-08-16): MEASURES the
|
||||
/// installed global SkillTable's (portal.dat <c>0x0E000004</c>)
|
||||
|
|
@ -1315,6 +1407,122 @@ public sealed class CharacterCreationLiveDatTests
|
|||
dialogs.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// R2-7a (Campaign CC gate round 1 Batch E): the Summary OVERVIEW
|
||||
/// listbox (<c>0x10000400</c>) authors a linked scrollbar via dat
|
||||
/// property <c>0x72</c> — live-DAT-probe-confirmed
|
||||
/// <c>ScrollbarElementId=0x10000401</c>, a SIBLING element under the
|
||||
/// Summary page root, not a descendant of the listbox itself.
|
||||
/// <see cref="CharacterCreationSummaryPage"/>'s constructor used to wire
|
||||
/// only the how-to box's own scrollbar (Commit 3) and never resolved
|
||||
/// this one, so the listbox never scrolled despite carrying more rows
|
||||
/// than fit its 435px-tall view. Same linkage pattern every other
|
||||
/// UiTemplateListBox owner in this codebase already uses
|
||||
/// (SocialFriendsPageController, ConfigOptionsPageController, etc).
|
||||
/// </summary>
|
||||
[InstalledDatFact]
|
||||
public void SummaryListbox_ScrollbarBuildsAndLinksToListboxScroll()
|
||||
{
|
||||
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
|
||||
uint layoutId = RetailDataIdResolver.Resolve(
|
||||
dats, CharacterCreationUiController.RootEnum, 5u);
|
||||
ImportedLayout screen = BuildSelected(
|
||||
dats, layoutId, CharacterCreationUiController.RootElementId);
|
||||
|
||||
UiElement summaryRoot = Assert.IsAssignableFrom<UiElement>(
|
||||
screen.FindElement(CharacterCreationUiController.SummaryPageElementId));
|
||||
UiTemplateListBox list = Assert.IsType<UiTemplateListBox>(
|
||||
UiElement.FindDescendant(summaryRoot, CharacterCreationSummaryPage.ListBoxId));
|
||||
Assert.Equal(CharacterCreationSummaryPage.ScrollId, list.ScrollbarElementId);
|
||||
UiScrollbar overviewScroll = Assert.IsType<UiScrollbar>(
|
||||
UiElement.FindDescendant(summaryRoot, CharacterCreationSummaryPage.ScrollId));
|
||||
|
||||
var host = new UiRoot();
|
||||
var dialogs = MakeDialogFactory(dats, host);
|
||||
var bindings = new CharacterCreationRuntimeBindings(
|
||||
() => null,
|
||||
_ => default, _ => default, _ => default, (_, _) => default, (_, _) => default,
|
||||
_ => default, _ => default, _ => default, _ => default, _ => default, () => { });
|
||||
UiElement? ResolveTemplate(uint templateLayoutId, uint templateElementId) =>
|
||||
LayoutImporter.Import(
|
||||
dats, templateLayoutId, templateElementId, _ => (0u, 0, 0), null)?.Root;
|
||||
|
||||
CharacterCreationUiController? controller =
|
||||
CharacterCreationUiController.CreateDetached(
|
||||
host, screen, ResolveTemplate, dialogs, bindings,
|
||||
new CharacterCreationUiController.DialogStrings(
|
||||
"Are you sure?", "No name", "Unspent credits", "Randomize?", "Name too long"));
|
||||
Assert.NotNull(controller);
|
||||
controller!.AttachAndTick();
|
||||
|
||||
Assert.Same(list.Scroll, overviewScroll.Model);
|
||||
|
||||
controller.Dispose();
|
||||
dialogs.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// R2-7b (Campaign CC gate round 1 Batch E): the how-to box's scrollbar
|
||||
/// THUMB only draws <c>if (m.HasOverflow)</c>
|
||||
/// (<see cref="UiScrollbar"/>'s own draw gate) — the reported "no
|
||||
/// thumb" symptom traces to R2-1's bug, not an independent defect: with
|
||||
/// the pre-fix wrap width (the box's raw Width, ignoring the authored
|
||||
/// margL=9/margR=26 inset), the Aluvian how-to text (the LONGEST
|
||||
/// composed variant — SummaryHowTo + the male name-suggestion list +
|
||||
/// SummaryHowToEnd) wrapped to fewer/shorter lines than the correctly
|
||||
/// inset width does. This pins the causal claim directly against the
|
||||
/// real installed strings/font: composing with the CORRECT (margin-
|
||||
/// inset) width produces content taller than the view, so
|
||||
/// <see cref="UiScrollable.HasOverflow"/> — which is exactly what
|
||||
/// <see cref="UiScrollbar"/> gates the thumb on — is true.
|
||||
/// </summary>
|
||||
[InstalledDatFact]
|
||||
public void SummaryHowToText_Aluvian_WithCorrectMarginInsetWidth_Overflows()
|
||||
{
|
||||
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
|
||||
var strings = new DatStringResolver(dats);
|
||||
const uint table = 0x23000002u;
|
||||
string? howTo = strings.Resolve(table, DatStringResolver.ComputeHash("ID_CharGen_SummaryHowTo"));
|
||||
string? names = strings.Resolve(table, DatStringResolver.ComputeHash("ID_CharGen_AluMaleNames"));
|
||||
string? howToEnd = strings.Resolve(table, DatStringResolver.ComputeHash("ID_CharGen_SummaryHowToEnd"));
|
||||
Assert.NotNull(howTo);
|
||||
Assert.NotNull(names);
|
||||
Assert.NotNull(howToEnd);
|
||||
string composed = howTo + names + howToEnd;
|
||||
|
||||
// Real font metrics (0x40000009 — live-DAT-probe-confirmed FontDid
|
||||
// on 0x10000404), no GL/texture needed for MeasureWidth.
|
||||
Assert.True(dats.TryGet<DatReaderWriter.DBObjs.Font>(0x40000009u, out var font) && font is not null);
|
||||
var glyphs = new Dictionary<char, DatReaderWriter.Types.FontCharDesc>(font!.CharDescs.Count);
|
||||
foreach (var cd in font.CharDescs) glyphs[(char)cd.Unicode] = cd;
|
||||
var datFont = new UiDatFont(0, 0, 0, 0, 0, 0, font.MaxCharHeight, font.BaselineOffset, glyphs);
|
||||
|
||||
// Live-DAT-measured box geometry (0x10000404): 247x380,
|
||||
// margL=9/margR=26/margU=15/margD=15.
|
||||
var target = new UiText
|
||||
{
|
||||
Width = 247f,
|
||||
Height = 380f,
|
||||
DatFont = datFont,
|
||||
MarginLeft = 9f,
|
||||
MarginRight = 26f,
|
||||
MarginTop = 15f,
|
||||
MarginBottom = 15f,
|
||||
};
|
||||
var segments = new[] { new DatRichText.Segment(composed, Vector4.One) };
|
||||
var lines = DatRichText.Compose(target, segments);
|
||||
|
||||
float viewHeight = target.Height - target.Padding - target.MarginTop - target.Padding - target.MarginBottom;
|
||||
float contentHeight = lines.Count * datFont.LineHeight;
|
||||
|
||||
Assert.True(
|
||||
contentHeight > viewHeight,
|
||||
$"expected the correctly-inset composition ({lines.Count} lines, "
|
||||
+ $"{contentHeight}px) to overflow the {viewHeight}px view — if it "
|
||||
+ "doesn't, the how-to scrollbar's thumb has nothing to gate on "
|
||||
+ "regardless of the R2-1 margin fix");
|
||||
}
|
||||
|
||||
private static void AssertButton(ImportedLayout layout, uint elementId) =>
|
||||
Assert.IsType<UiButton>(layout.FindElement(elementId));
|
||||
|
||||
|
|
|
|||
|
|
@ -105,6 +105,25 @@ public class DatRichTextTests
|
|||
Assert.Equal("second", lines[1].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compose_WordWrapsToTheTargetWidth_MinusTheFourRetailMargins()
|
||||
{
|
||||
// R2-1 (Campaign CC gate round 1 Batch E): the wrap width must
|
||||
// shrink by BOTH Padding and the four retail margins (properties
|
||||
// 0x23-0x26 — MarginLeft's own doc comment on UiText), not just the
|
||||
// element's raw Width. A 100px-wide box with margL=10/margR=10
|
||||
// leaves only 80px of usable width — one 10-char/8px-per-char word
|
||||
// ("aaaaaaaaaa", 80px) must fit on one line, but appending an 11th
|
||||
// 'a' (88px) must force a wrap.
|
||||
UiText fits = new() { Width = 100f, Height = 200f, MarginLeft = 10f, MarginRight = 10f };
|
||||
var fitsLines = DatRichText.Compose(fits, [new DatRichText.Segment("aaaaaaaaaa", White)]);
|
||||
Assert.Single(fitsLines);
|
||||
|
||||
UiText overflows = new() { Width = 100f, Height = 200f, MarginLeft = 10f, MarginRight = 10f };
|
||||
var overflowLines = DatRichText.Compose(overflows, [new DatRichText.Segment("aaaaaaaaaaa", White)]);
|
||||
Assert.True(overflowLines.Count > 1, "an 88px word in an 80px content width must wrap");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PaletteColor_ReturnsAuthoredPaletteEntry_WhenPresent()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -414,6 +414,142 @@ public class UiButtonTests
|
|||
Assert.Equal(externalColor, b.LabelColor);
|
||||
}
|
||||
|
||||
// ── R2-2/R2-3 (Campaign CC gate round 1 Batch E): WrapBlockLines ────
|
||||
|
||||
private static float BitmapMeasure(string text) => text.Length * 8f;
|
||||
|
||||
/// <summary>
|
||||
/// A single line that already fits its box draws with the SAME
|
||||
/// centered-block geometry the pre-fix unconditional one-line math
|
||||
/// produced — the fix is a strict superset for every already-working
|
||||
/// button caption.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WrapBlockLines_SingleLineThatFits_MatchesPriorOneLineGeometry()
|
||||
{
|
||||
var lines = UiButton.WrapBlockLines(
|
||||
"Health", BitmapMeasure, lineHeight: 24f,
|
||||
boxX: 0f, boxY: 0f, boxWidth: 150f, boxHeight: 50f,
|
||||
UiButton.LabelAlignment.Left, leftOffset: 3f);
|
||||
|
||||
Assert.Single(lines);
|
||||
Assert.Equal("Health", lines[0].Text);
|
||||
Assert.Equal(3f, lines[0].X); // boxX + leftOffset
|
||||
Assert.Equal((50f - 24f) * 0.5f, lines[0].Y); // vertically centered, one line
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// R2-2: an authored newline (already normalized to a real '\n' by
|
||||
/// DatWidgetFactory's ResolveAuthoredString) splits into stacked lines
|
||||
/// even when EACH half individually fits the box — "Attribute\nCredits"
|
||||
/// must become two lines, not one literal run.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WrapBlockLines_EmbeddedNewline_ProducesTwoStackedLines()
|
||||
{
|
||||
var lines = UiButton.WrapBlockLines(
|
||||
"Attribute\nCredits", BitmapMeasure, lineHeight: 24f,
|
||||
boxX: 0f, boxY: 0f, boxWidth: 90f, boxHeight: 50f,
|
||||
UiButton.LabelAlignment.Left, leftOffset: 3f);
|
||||
|
||||
Assert.Equal(2, lines.Count);
|
||||
Assert.Equal("Attribute", lines[0].Text);
|
||||
Assert.Equal("Credits", lines[1].Text);
|
||||
// Block-centered: total height 48 in a 50-tall box -> start Y = 1.
|
||||
Assert.Equal(1f, lines[0].Y);
|
||||
Assert.Equal(25f, lines[1].Y); // startY + 1*lineHeight
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// R2-3: a single-paragraph caption with NO authored newline still
|
||||
/// word-wraps when it doesn't fit the available width — the exact
|
||||
/// live-DAT shape of the Skills credits button's own "Available Skill
|
||||
/// Credits" caption (measured 193px in a 231px-wide button whose value
|
||||
/// box starts at local x=116, i.e. only ~113px of caption width is
|
||||
/// actually available once R2-2/R2-3's confinement applies).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WrapBlockLines_LongSingleParagraph_WordWrapsToFitAvailableWidth()
|
||||
{
|
||||
var lines = UiButton.WrapBlockLines(
|
||||
"Available Skill Credits", BitmapMeasure, lineHeight: 24f,
|
||||
boxX: 0f, boxY: 0f, boxWidth: 113f, boxHeight: 28f,
|
||||
UiButton.LabelAlignment.Left, leftOffset: 3f);
|
||||
|
||||
Assert.True(lines.Count > 1, "a 193px caption must wrap within a 110px available width");
|
||||
foreach (var line in lines)
|
||||
Assert.True(BitmapMeasure(line.Text) <= 110f, $"line '{line.Text}' overflowed");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// R2-2/R2-3 confinement itself, exercised through OnDraw's own gate:
|
||||
/// a button with BOTH Label and a coexisting ValueBox shrinks the
|
||||
/// caption's OWN drawable width to stop before the value box starts —
|
||||
/// this is what the two live-DAT overlap reports (R2-2 "24dits", R2-3
|
||||
/// "Credit0Credits") trace to: the caption used to draw across the
|
||||
/// WHOLE button width regardless of where the value sat.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void BuildButton_OwnCaptionWithCoexistingValueBox_ConfinesLabelWidthBeforeValueBox()
|
||||
{
|
||||
uint captionStringId = 333u;
|
||||
var info = new ElementInfo { Type = 1, Width = 231, Height = 28 };
|
||||
info.States[UiStateInfo.DirectStateId] = new UiStateInfo { Id = UiStateInfo.DirectStateId };
|
||||
info.States[UiStateInfo.DirectStateId].Properties.Values[0x17u] = new UiPropertyValue
|
||||
{
|
||||
Kind = UiPropertyKind.StringInfo,
|
||||
StringInfoValue = new UiStringInfoValue(0, captionStringId, 0, 0, 0, 0),
|
||||
};
|
||||
info.StateMedia[""] = (0x06000001u, 1);
|
||||
|
||||
var valueChild = new ElementInfo { Type = 12, X = 116, Y = 0, Width = 34, Height = 28 };
|
||||
info.Children.Add(valueChild);
|
||||
|
||||
var button = Assert.IsType<UiButton>(DatWidgetFactory.Create(
|
||||
info, NoTex, null,
|
||||
stringResolve: value => value.StringId == captionStringId ? "Available Skill Credits" : null));
|
||||
|
||||
Assert.Equal("Available Skill Credits", button.Label);
|
||||
Assert.Equal((116f, 0f, 34f, 28f), button.ValueBox);
|
||||
|
||||
// The caption's own available width for WrapBlockLines is bounded by
|
||||
// ValueBox.X (116), NOT the button's full Width (231) — reproducing
|
||||
// OnDraw's own confinement math here (private OnDraw isn't directly
|
||||
// callable, so this pins the INPUT the fix computes for it).
|
||||
float confinedWidth = System.MathF.Min(button.Width, button.ValueBox!.Value.X - 0f);
|
||||
Assert.Equal(116f, confinedWidth);
|
||||
Assert.True(confinedWidth < button.Width, "the confined width must be narrower than the full button");
|
||||
}
|
||||
|
||||
// ── R2-2 escape-normalize ────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// R2-2: BuildButton's own P0x17 caption escape-normalizes the same way
|
||||
/// BuildText's authored-string path always has — the DAT stores the
|
||||
/// LITERAL two-character escape "\n" (0x5C 0x6E), and the Profession
|
||||
/// credits button's own authored caption is exactly this shape.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void BuildButton_OwnCaption_NormalizesLiteralBackslashNEscape()
|
||||
{
|
||||
uint stringId = 444u;
|
||||
var info = new ElementInfo { Type = 1, Width = 150, Height = 50 };
|
||||
info.States[UiStateInfo.DirectStateId] = new UiStateInfo { Id = UiStateInfo.DirectStateId };
|
||||
info.States[UiStateInfo.DirectStateId].Properties.Values[0x17u] = new UiPropertyValue
|
||||
{
|
||||
Kind = UiPropertyKind.StringInfo,
|
||||
StringInfoValue = new UiStringInfoValue(0, stringId, 0, 0, 0, 0),
|
||||
};
|
||||
|
||||
var button = Assert.IsType<UiButton>(DatWidgetFactory.Create(
|
||||
info, NoTex, null,
|
||||
// The raw resolved string carries the LITERAL two characters
|
||||
// '\' and 'n', matching what the installed DAT actually stores.
|
||||
stringResolve: value => value.StringId == stringId ? "Attribute\\n Credits" : null));
|
||||
|
||||
Assert.Equal("Attribute\n Credits", button.Label);
|
||||
}
|
||||
|
||||
private static UiButton ButtonWithStates(params string[] states)
|
||||
{
|
||||
var info = ButtonInfo(states);
|
||||
|
|
|
|||
|
|
@ -278,6 +278,69 @@ public class UiTextTests
|
|||
Assert.Equal(9f, y);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// R2-1 (Campaign CC gate round 1 Batch E): with zero margins,
|
||||
/// ContentOffsetX is byte-identical to the pre-fix bare-Padding math —
|
||||
/// every existing DAT-imported multi-line box (margins default 0 unless
|
||||
/// DatWidgetFactory seeds them) is unaffected by this change.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ContentOffsetX_ZeroMargins_MatchesBarePaddingMath()
|
||||
{
|
||||
float left = UiText.ContentOffsetX(
|
||||
elementWidth: 200f, padding: 4f, marginLeft: 0f, marginRight: 0f,
|
||||
lineWidth: 30f, centered: false, rightAligned: false);
|
||||
Assert.Equal(4f, left);
|
||||
|
||||
float centered = UiText.ContentOffsetX(
|
||||
elementWidth: 200f, padding: 4f, marginLeft: 0f, marginRight: 0f,
|
||||
lineWidth: 30f, centered: true, rightAligned: false);
|
||||
Assert.Equal(Math.Max(4f, (200f - 30f) * 0.5f), centered);
|
||||
|
||||
float right = UiText.ContentOffsetX(
|
||||
elementWidth: 200f, padding: 4f, marginLeft: 0f, marginRight: 0f,
|
||||
lineWidth: 30f, centered: false, rightAligned: true);
|
||||
Assert.Equal(200f - 4f - 30f, right);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The exact live-DAT shape (Campaign CC gate round 1 Batch E, R2-1):
|
||||
/// Heritage/Profession/Town/Summary description boxes author
|
||||
/// margL=9/margR=26 — a left-justified line must start at x=9 (Padding
|
||||
/// 0 + MarginLeft 9), not x=0. This is the regression: pre-fix, every
|
||||
/// one of these boxes drew its first glyph at x=0, clipping under the
|
||||
/// authored gold-frame's left border piece.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ContentOffsetX_LeftJustified_HonorsAuthoredMarginLeft()
|
||||
{
|
||||
float x = UiText.ContentOffsetX(
|
||||
elementWidth: 265f, padding: 0f, marginLeft: 9f, marginRight: 26f,
|
||||
lineWidth: 100f, centered: false, rightAligned: false);
|
||||
Assert.Equal(9f, x);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A right-aligned line must stop before MarginRight, not at the raw
|
||||
/// element edge — the R2-1 fix's other half (the wrap width shrinks by
|
||||
/// the same inset so text no longer overflows the visible right edge
|
||||
/// either).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ContentOffsetX_RightAligned_HonorsAuthoredMarginRight()
|
||||
{
|
||||
float x = UiText.ContentOffsetX(
|
||||
elementWidth: 265f, padding: 0f, marginLeft: 9f, marginRight: 26f,
|
||||
lineWidth: 50f, centered: false, rightAligned: false);
|
||||
_ = x; // left case covered above
|
||||
|
||||
float right = UiText.ContentOffsetX(
|
||||
elementWidth: 265f, padding: 0f, marginLeft: 9f, marginRight: 26f,
|
||||
lineWidth: 50f, centered: false, rightAligned: true);
|
||||
// contentRight = 265 - 0 - 26 = 239; right-aligned x = 239 - 50 = 189.
|
||||
Assert.Equal(189f, right);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LineIntersectsViewport_PartialLineRemainsDrawable()
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue