fix(chargen): Campaign CC gate round 1 Batch C — rich text + labels + backdrops

Commit 1/3: chargen-scoped, low blast-radius fixes.

- New DatRichText helper: escape-normalize + word-wrap + per-segment
  palette color, porting UIElement_Text::SetStringInfoWithFont /
  AppendStringInfoWithFont's composition model. Routes the Heritage
  (GF-2), Town (GF-11a), and Profession (GF-3) description boxes
  through it instead of a raw unwrapped single-Line LinesProvider.
  Heritage headers use font-color palette index 1 (green), bodies
  index 0 (white), matching AppendStringInfoWithFont's own font-index
  argument. Town's diagnosed GF-11a root cause: a single un-wrapped
  line meant the town-specific suffix rendered past the clipped
  viewport, so switching towns looked like "text never changes" even
  though the underlying composed string genuinely differed.

- GF-3: bind the Profession page's description textbox (0x100003e0,
  gmCGProfessionPage::InitializePage @0x00483068) and compose its
  per-template text (UpdateProfession @0x004821b0's CustomText/
  BowText/SwashText/LifeText/WarText/WayText/SoldierText, plain
  SetStringInfo — no palette).

- GF-4: UiButton gains a coexisting ValueLabel/ValueBox/ValueFont/
  ValueColor slot alongside Label. Retail's chargen display buttons
  (avail/health/stamina/mana credits, 0x100003e2-e5/0x100003f9)
  author their caption directly on P0x17 AND carry a separate,
  media-less Type-12 value child that UiButton.ConsumesDatChildren
  used to drop entirely — pages substituted the button's own Label,
  destroying the caption. DatWidgetFactory.BuildButton now surfaces
  that child (gated on ReferenceEquals(labelInfo, info) — own-caption
  buttons only) instead. The six Profession slider name labels
  (0x100002ed, CharGenState::GetAttributeName @0x005C3A20's six
  hardcoded literals) resolve as UiButton in this port (live-DAT-
  measured Type 1 — retail's UIElement_Button is DynamicCast(0xc)-
  compatible with UIElement_Text) and are written once at
  construction, matching retail's own single InitializePage write.

- GF-6/AP-218: gmCGAppearancePage::Update writes a heritage-flavored
  STATIC caption to the Hair/Eyes/Skin spins (plain / GearText_* /
  OlthoiText_* variants) — never an index. Removed the prior 1-based-
  ordinal/gear-name substitution entirely; the other six spins keep
  their DAT-authored caption untouched, matching retail exactly.

- Root 1d: wire the Heritage (0x100003be, 13 states) and Profession
  (0x100003d8, 7 states) backdrop SetState cascades
  (gmCGHeritagePage::Update / gmCGProfessionPage::UpdateProfession).

- AP-216/AP-217 (partial, register updated honestly): swatches beyond
  the current part's real color count now hide (DoColorSpots' blank-
  blit half); the GradCircle now blanks for Eyes (DoGradDisk's blank-
  plug half). The "paint with the actual represented/current color"
  halves stay open — they need a PalSet/Palette-id -> RGB pipeline no
  chargen page reads at runtime yet, judged disproportionate to add
  alongside this batch's other ~10 fixes.

Register: AP-215 rewritten (item 2's "ordinal" framing is stale after
GF-6; restated as the icon-thumbnail gap), AP-216/AP-217 rewritten
(partially closed), AP-218 retired, AD-103 retired (the swallowed-
child Label substitution AD-103 tracked is replaced by ValueLabel's
own-geometry surfacing).

22 new tests (DatRichText unit tests, UiButton/DatWidgetFactory
ValueLabel tests, live-DAT structural pins, controller behavioral
tests) — all green. Full App suite (Release, live-DAT):
5300 passed / 1 pre-existing unrelated flake (PortalProjectionTests
allocation test, passes in isolation) / 3 skipped, up from the
baseline 5282/3.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-16 12:18:53 +02:00
parent 7d09821fdc
commit 0591b9a026
13 changed files with 1018 additions and 66 deletions

View file

@ -1053,6 +1053,160 @@ public sealed class CharacterCreationLiveDatTests
Assert.True(okButton.Width > 0f && okButton.Height > 0f);
}
/// <summary>
/// Campaign CC gate round 1 Batch C (GF-4a). Live-DAT-measured: each of
/// the four Profession display buttons (avail/health/stamina/mana
/// credits) and the Skills credits button author the CAPTION directly
/// as their OWN P0x17 property and carry exactly ONE Type-12 child with
/// NO state media of its own — the live VALUE slot
/// (<c>gmCGProfessionPage::InitializePage @0x00482f90-0x00483062</c>,
/// <c>gmCGSkillsPage::InitializePage @0x00481e1c</c>). Pins the shape
/// <see cref="DatWidgetFactory.BuildButton"/>'s <c>ValueLabel</c>
/// detection depends on.
/// </summary>
[InstalledDatFact]
public void ProfessionAndSkillsDisplayButtons_OwnCaptionPlusOneMediaLessValueChild()
{
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));
(uint Button, uint ValueChild)[] shapes =
[
(0x100003E2u, 0x100002F1u), // Profession available attribute credits
(0x100003E3u, 0x100002F3u), // Profession health
(0x100003E4u, 0x100002F3u), // Profession stamina
(0x100003E5u, 0x100002F3u), // Profession mana
(0x100003F9u, 0x100002F3u), // Skills credits
];
foreach ((uint buttonId, uint valueChildId) in shapes)
{
ElementInfo button = Assert.IsType<ElementInfo>(FindInfo(rootInfo, buttonId));
Assert.Equal(1u, button.Type);
Assert.True(
button.TryGetEffectiveProperty(0x17u, out UiPropertyValue caption)
&& caption.Kind == UiPropertyKind.StringInfo,
$"button 0x{buttonId:X8} must author its own P0x17 caption.");
ElementInfo singleChild = Assert.Single(button.Children);
Assert.Equal(valueChildId, singleChild.Id);
Assert.Equal(12u, singleChild.Type);
Assert.Empty(singleChild.StateMedia);
}
}
/// <summary>
/// GF-4b: the Profession page's six slider containers each carry a
/// name-label CHILD at the SAME relative id (<c>0x100002ed</c>) —
/// live-DAT-measured as Type 1 (<c>UIElement_Button</c>), matching
/// retail's own declared pointer type
/// (<c>class UIElement_Button* m_pHairSpin</c>-shaped fields
/// throughout <c>gmCGAppearancePage</c>/<c>gmCGProfessionPage</c> that
/// still receive <c>UIElement_Text::SetText</c> calls — retail's
/// <c>UIElement_Button</c> is DynamicCast-compatible with
/// <c>UIElement_Text</c> (id <c>0xc</c>), i.e. buttons carry their own
/// text-rendering capability). acdream's <c>UiButton.Label</c> is that
/// exact capability, so this element resolves as <see cref="UiButton"/>
/// in our port too, not <see cref="UiText"/>.
/// </summary>
[InstalledDatFact]
public void ProfessionPage_SliderContainers_HaveNameLabelButtonChild()
{
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
uint layoutId = RetailDataIdResolver.Resolve(
dats, CharacterCreationUiController.RootEnum, 5u);
ImportedLayout screen = BuildSelected(
dats, layoutId, CharacterCreationUiController.RootElementId);
UiElement professionRoot = Assert.IsAssignableFrom<UiElement>(
screen.FindElement(CharacterCreationUiController.ProfessionPageElementId));
foreach (uint containerId in new[]
{
0x100003E6u, 0x100003E7u, 0x100003E8u,
0x100003E9u, 0x100003EAu, 0x100003EBu,
})
{
UiElement container = Assert.IsAssignableFrom<UiElement>(
UiElement.FindDescendant(professionRoot, containerId));
Assert.IsType<UiButton>(UiElement.FindDescendant(container, 0x100002EDu));
}
}
/// <summary>
/// Root 1d (Campaign CC gate round 1 Batch C): the Heritage
/// (<c>0x100003be</c>, 13 states) and Profession (<c>0x100003d8</c>,
/// 7 states) backdrops, live-DAT-measured against
/// <c>gmCGHeritagePage::Update</c>'s <c>m_pBackground-&gt;SetState</c>
/// literals and <c>gmCGProfessionPage::UpdateProfession</c>'s
/// per-template <c>eax_2-&gt;SetState</c> literals.
/// </summary>
[InstalledDatFact]
public void HeritageAndProfessionBackdrops_AuthorEveryRetailState()
{
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 heritageBackdrop = Assert.IsType<ElementInfo>(FindInfo(rootInfo, 0x100003BEu));
uint[] heritageStates =
[
0x10000021u, 0x10000022u, 0x10000023u, 0x10000024u, 0x10000058u,
0x10000059u, 0x1000005Au, 0x1000005Bu, 0x1000005Cu, 0x1000005Du,
0x1000005Eu, 0x1000005Fu, 0x10000060u,
];
foreach (uint stateId in heritageStates)
Assert.True(heritageBackdrop.States.ContainsKey(stateId), $"heritage backdrop missing state 0x{stateId:X8}");
ElementInfo professionBackdrop = Assert.IsType<ElementInfo>(FindInfo(rootInfo, 0x100003D8u));
uint[] professionStates =
[
0x1000002Bu, 0x1000002Cu, 0x1000002Du,
0x1000002Eu, 0x1000002Fu, 0x10000030u, 0x10000031u,
];
foreach (uint stateId in professionStates)
Assert.True(professionBackdrop.States.ContainsKey(stateId), $"profession backdrop missing state 0x{stateId:X8}");
}
/// <summary>
/// GF-3: the Profession page's description textbox
/// (<c>0x100003e0</c>) resolves as <see cref="UiText"/> and (Commit-2
/// scope, pinned here for completeness) carries the SAME eight
/// gold-frame child ids the Town description (<c>0x10000409</c>) and
/// Summary how-to (<c>0x10000404</c>) boxes carry — one shared box
/// template reused across pages.
/// </summary>
[InstalledDatFact]
public void DescriptionTextboxes_ShareTheSameGoldFrameChildTemplate()
{
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));
uint[] frameChildIds =
[
0x100002DEu, 0x100002DFu, 0x100002E0u, 0x100002E1u,
0x100000E8u, 0x100002E2u, 0x100002E3u, 0x100000EAu,
];
foreach (uint boxId in new[] { 0x100003E0u, 0x10000409u, 0x10000404u })
{
ElementInfo box = Assert.IsType<ElementInfo>(FindInfo(rootInfo, boxId));
Assert.Equal(12u, box.Type);
foreach (uint frameChildId in frameChildIds)
Assert.Contains(box.Children, c => c.Id == frameChildId);
}
// The Summary how-to box additionally carries a linked scrollbar.
ElementInfo summaryHowTo = Assert.IsType<ElementInfo>(FindInfo(rootInfo, 0x10000404u));
Assert.Contains(summaryHowTo.Children, c => c.Id == 0x100002E7u);
}
private static void AssertButton(ImportedLayout layout, uint elementId) =>
Assert.IsType<UiButton>(layout.FindElement(elementId));

View file

@ -1505,6 +1505,164 @@ public sealed class CharacterCreationUiControllerTests
public void RotateCounterClockwise() => RotateCounterClockwiseCalls++;
}
// ── Campaign CC gate round 1 Batch C ────────────────────────────────
/// <summary>
/// GF-2: the composed description routes through the shared rich-text
/// helper — header segments (palette index 1) render in a DIFFERENT
/// color than body segments (index 0), and each segment's own escape
/// sequence is normalized. The fixture's description element carries
/// no authored <c>FontColorPalette</c>, so this also exercises
/// <see cref="DatRichText.PaletteColor"/>'s fallback (green header /
/// white body).
/// </summary>
[Fact]
public void HeritageDescription_ComposesGreenHeaderAndWhiteBodySegments()
{
using var environment = new EnvironmentHarness();
environment.Runtime.ResolvedStrings["ID_CharGen_Heritage_StartingSkills_Header"] = "Trained Starting Skills:";
environment.Runtime.ResolvedStrings["ID_CharGen_Heritage_StartingSkills"] = "Line one\\nLine two";
environment.Controller.Open();
environment.Runtime.SelectHeritageDirect(AluvianId);
BumpRevisionAndTick(environment);
UiText description = Assert.IsType<UiText>(environment.Screen.FindElement(0x100003C4u));
var lines = description.LinesProvider().ToList();
Assert.Contains(lines, l => l.Text == "Trained Starting Skills:" && l.Color == new Vector4(0f, 1f, 0f, 1f));
// The literal "\n" escape in the body segment must become TWO
// separate lines, not render as a literal backslash-n.
Assert.Contains(lines, l => l.Text == "Line one" && l.Color == Vector4.One);
Assert.Contains(lines, l => l.Text == "Line two" && l.Color == Vector4.One);
Assert.DoesNotContain(lines, l => l.Text.Contains("\\n"));
}
/// <summary>GF-11a: switching towns changes the RENDERED (wrapped)
/// lines, not just an internal string that never becomes visible —
/// the diagnosed root cause of "description does not change" was a
/// single un-wrapped line whose differing suffix rendered past the
/// clipped viewport.</summary>
[Fact]
public void TownDescription_ChangesRenderedLinesWhenSwitchingTowns()
{
using var environment = new EnvironmentHarness();
environment.Runtime.ResolvedStrings["ID_CharGen_TownHowTo"] = "How to pick a town.";
environment.Runtime.ResolvedStrings["ID_CharGen_HoltText"] = "Holtburg is snowy.";
environment.Runtime.ResolvedStrings["ID_CharGen_ShoushiText"] = "Shoushi is sunny.";
environment.Controller.Open();
environment.TabButton(CharacterCreationUiController.TownTabElementId).OnClick!();
environment.Button(0x1000040Du).OnClick!(); // Holtburg
BumpRevisionAndTick(environment);
UiText description = Assert.IsType<UiText>(environment.Screen.FindElement(0x10000409u));
string holtburgText = JoinedText(description);
Assert.Contains("Holtburg is snowy.", holtburgText);
environment.Button(0x1000040Fu).OnClick!(); // Shoushi
BumpRevisionAndTick(environment);
string shoushiText = JoinedText(description);
Assert.Contains("Shoushi is sunny.", shoushiText);
Assert.DoesNotContain("Holtburg is snowy.", shoushiText);
}
/// <summary>GF-3: the Profession page's description textbox
/// (<c>0x100003e0</c>) binds and switches per selected template.</summary>
[Fact]
public void ProfessionDescription_BindsAndSwitchesPerTemplate()
{
using var environment = new EnvironmentHarness();
environment.Runtime.ResolvedStrings["ID_CharGen_CustomText"] = "Custom flexible build.";
environment.Runtime.ResolvedStrings["ID_CharGen_BowText"] = "Bow hunters use ranged attacks.";
environment.Controller.Open();
environment.Runtime.SelectHeritageDirect(AluvianId);
environment.TabButton(CharacterCreationUiController.ProfessionTabElementId).OnClick!();
environment.Button(0x100003DAu).OnClick!(); // Bow Hunter = template 1
BumpRevisionAndTick(environment);
UiText description = Assert.IsType<UiText>(environment.Screen.FindElement(0x100003E0u));
Assert.Contains("Bow hunters use ranged attacks.", JoinedText(description));
}
/// <summary>GF-4a: the display buttons' authored caption survives a
/// value write — the whole point of the ValueLabel coexistence
/// mechanism.</summary>
[Fact]
public void ProfessionAndSkillsDisplayButtons_ValueWriteDoesNotClobberLabel()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
environment.Runtime.SelectHeritageDirect(AluvianId);
UiButton available = environment.Button(0x100003E2u);
available.Label = "Attribute Credits"; // fixture authors no P0x17; simulate it
UiButton credits = environment.Button(0x100003F9u);
credits.Label = "Available Skill Credits";
environment.TabButton(CharacterCreationUiController.ProfessionTabElementId).OnClick!();
BumpRevisionAndTick(environment);
Assert.Equal("Attribute Credits", available.Label);
// The fixture's default snapshot (BuildOptions' companion default)
// carries RemainingAttributeCredits=66 — an exact, non-vacuous
// pin, not just "some value got written somewhere".
Assert.Equal("66", available.ValueLabel);
environment.TabButton(CharacterCreationUiController.SkillsTabElementId).OnClick!();
BumpRevisionAndTick(environment);
Assert.Equal("Available Skill Credits", credits.Label);
Assert.Equal("50", credits.ValueLabel); // RemainingSkillCredits=50
}
/// <summary>GF-6/AP-218: the Appearance page's Hair/Eyes/Skin spins
/// show a heritage-flavored STATIC caption, never a numeric ordinal —
/// and switch to the Gearknight/Olthoi variant per heritage.</summary>
[Fact]
public void AppearanceSpinCaptions_ArePartNames_NotOrdinals_AndVaryByHeritage()
{
using var environment = new EnvironmentHarness();
environment.Runtime.ResolvedStrings["ID_CharGen_HairStyle"] = "Hair Style";
environment.Runtime.ResolvedStrings["ID_CharGen_Eyes"] = "Eyes";
environment.Runtime.ResolvedStrings["ID_CharGen_GearText_HairButton"] = "Gear Hair";
environment.Controller.Open();
environment.Runtime.SelectHeritageDirect(AluvianId);
environment.TabButton(CharacterCreationUiController.AppearanceTabElementId).OnClick!();
BumpRevisionAndTick(environment);
UiButton hairSpin = environment.Button(CharacterCreationAppearancePage.HairSpinId);
Assert.Equal("Hair Style", hairSpin.Label);
Assert.DoesNotContain(hairSpin.Label, new[] { "1", "2", "-" });
environment.Runtime.SelectHeritageDirect((uint)ChargenHeritageGroup.Gearknight);
BumpRevisionAndTick(environment);
Assert.Equal("Gear Hair", hairSpin.Label);
}
/// <summary>Root 1d: the Heritage and Profession backdrops switch
/// state per selection.</summary>
[Fact]
public void HeritageAndProfessionBackdrops_SwitchStatePerSelection()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
var heritageBackdrop = Assert.IsAssignableFrom<IUiDatStateful>(
environment.Screen.FindElement(0x100003BEu));
environment.Runtime.SelectHeritageDirect(AluvianId);
BumpRevisionAndTick(environment);
Assert.Equal(0x10000021u, heritageBackdrop.ActiveRetailStateId);
environment.Runtime.SelectHeritageDirect((uint)ChargenHeritageGroup.Gharundim);
BumpRevisionAndTick(environment);
Assert.Equal(0x10000022u, heritageBackdrop.ActiveRetailStateId);
environment.TabButton(CharacterCreationUiController.ProfessionTabElementId).OnClick!();
var professionBackdrop = Assert.IsAssignableFrom<IUiDatStateful>(
environment.Screen.FindElement(0x100003D8u));
environment.Button(0x100003DAu).OnClick!(); // Bow Hunter = template 1
BumpRevisionAndTick(environment);
Assert.Equal(0x1000002Cu, professionBackdrop.ActiveRetailStateId);
}
private static void BumpRevisionAndTick(EnvironmentHarness environment)
{
RuntimeCharacterCreationSnapshot snapshot = environment.Runtime.View.Snapshot;
@ -2113,6 +2271,20 @@ public sealed class CharacterCreationUiControllerTests
page.Children.Add(ButtonInfo(0x100005C7u)); // Olthoi
page.Children.Add(ButtonInfo(0x100005F1u)); // Lugian (F3 quirk: no tab-restore/hide)
page.Children.Add(TextInfo(0x100003C4u));
// Root 1d: the backdrop, live-DAT-measured 13 authored states
// (see CharacterCreationHeritagePage.BackdropStateByHeritage).
var backdrop = ContainerInfo(0x100003BEu);
foreach (uint stateId in new[]
{
0x10000021u, 0x10000022u, 0x10000023u, 0x10000024u, 0x10000058u,
0x10000059u, 0x1000005Au, 0x1000005Bu, 0x1000005Cu, 0x1000005Du,
0x1000005Eu, 0x1000005Fu, 0x10000060u,
})
{
backdrop.States[stateId] = new UiStateInfo { Id = stateId, Name = $"State_{stateId:X8}" };
}
page.Children.Add(backdrop);
return page;
}
@ -2138,6 +2310,21 @@ public sealed class CharacterCreationUiControllerTests
page.Children.Add(ButtonInfo(0x100003E3u)); // Health
page.Children.Add(ButtonInfo(0x100003E4u)); // Stamina
page.Children.Add(ButtonInfo(0x100003E5u)); // Mana
page.Children.Add(TextInfo(0x100003E0u)); // GF-3: description textbox
// Root 1d: the backdrop, live-DAT-measured 7 authored states (see
// CharacterCreationProfessionPage.BackdropStateByTemplate).
var backdrop = ContainerInfo(0x100003D8u);
foreach (uint stateId in new[]
{
0x1000002Bu, 0x1000002Cu, 0x1000002Du,
0x1000002Eu, 0x1000002Fu, 0x10000030u, 0x10000031u,
})
{
backdrop.States[stateId] = new UiStateInfo { Id = stateId, Name = $"State_{stateId:X8}" };
}
page.Children.Add(backdrop);
return page;
}

View file

@ -0,0 +1,128 @@
using System.Numerics;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
namespace AcDream.App.Tests.UI.Layout;
/// <summary>
/// Campaign CC gate round 1 Batch C: unit tests for the shared
/// escape-normalize + word-wrap + per-segment-color helper feeding
/// GF-2/GF-3/GF-11a and the Summary how-to text (Commit 3).
/// </summary>
public class DatRichTextTests
{
private static readonly Vector4 White = Vector4.One;
private static readonly Vector4 Green = new(0f, 1f, 0f, 1f);
private static UiText MakeTarget(float width) =>
new() { Width = width, Height = 200f };
[Fact]
public void Compose_NormalizesLiteralBackslashNEscape()
{
UiText target = MakeTarget(1000f); // wide enough that nothing wraps
var segments = new[] { new DatRichText.Segment("line one\\nline two", White) };
var lines = DatRichText.Compose(target, segments);
Assert.Equal(2, lines.Count);
Assert.Equal("line one", lines[0].Text);
Assert.Equal("line two", lines[1].Text);
}
[Fact]
public void Compose_WordWrapsToTheTargetWidth()
{
// Bitmap-font-shaped measure: 8px/char, matching BuildText's own
// authored-string fallback measure.
UiText target = MakeTarget(80f); // 10 chars per line at 8px/char
var segments = new[]
{
new DatRichText.Segment("one two three four five six seven eight", White),
};
var lines = DatRichText.Compose(target, segments);
Assert.True(lines.Count > 1, "a long segment must wrap to more than one line");
foreach (UiText.Line line in lines)
Assert.True(line.Text.Length * 8f <= 80f, $"line '{line.Text}' overflowed the target width");
}
[Fact]
public void Compose_EachSegmentKeepsItsOwnColorAcrossItsWrappedLines()
{
UiText target = MakeTarget(1000f);
var segments = new[]
{
new DatRichText.Segment("Header:", Green),
new DatRichText.Segment("Body text.", White),
};
var lines = DatRichText.Compose(target, segments);
Assert.Equal(2, lines.Count);
Assert.Equal(Green, lines[0].Color);
Assert.Equal(White, lines[1].Color);
}
[Fact]
public void Compose_NullOrEmptySegmentText_IsSkipped()
{
UiText target = MakeTarget(1000f);
var segments = new[]
{
new DatRichText.Segment(null, White),
new DatRichText.Segment(string.Empty, White),
new DatRichText.Segment("real text", White),
};
var lines = DatRichText.Compose(target, segments);
Assert.Single(lines);
Assert.Equal("real text", lines[0].Text);
}
[Fact]
public void Compose_NoSeparatorInsertedBetweenSegments()
{
// Retail's own composition calls concatenate directly
// (AppendStringInfoWithFont / append_n_chars, no interposed
// literal) — this helper must not invent one either.
UiText target = MakeTarget(1000f);
var segments = new[]
{
new DatRichText.Segment("first", White),
new DatRichText.Segment("second", White),
};
var lines = DatRichText.Compose(target, segments);
// Each segment still wraps independently (so "first"/"second" stay
// on separate output lines, not glued into "firstsecond") — but no
// BLANK line is inserted between them unless the segment's own
// text carried one.
Assert.Equal(2, lines.Count);
Assert.Equal("first", lines[0].Text);
Assert.Equal("second", lines[1].Text);
}
[Fact]
public void PaletteColor_ReturnsAuthoredPaletteEntry_WhenPresent()
{
UiText target = new()
{
FontColorPalette = [White, Green],
};
Assert.Equal(White, DatRichText.PaletteColor(target, 0, Green));
Assert.Equal(Green, DatRichText.PaletteColor(target, 1, White));
}
[Fact]
public void PaletteColor_FallsBack_WhenPaletteTooShortOrMissing()
{
UiText target = new(); // empty palette
Assert.Equal(Green, DatRichText.PaletteColor(target, 1, Green));
Assert.Equal(Green, DatRichText.PaletteColor(target, -1, Green));
}
}

View file

@ -456,6 +456,88 @@ public class DatWidgetFactoryTests
Assert.Equal(36f, button.LabelOffsetX); // face.X(0) + face.Width(32) + 4
}
/// <summary>
/// GF-4a (Campaign CC gate round 1 Batch C): retail's chargen display
/// buttons (live-DAT-measured shape) author their CAPTION directly as
/// their own P0x17 AND carry one SEPARATE, media-less Type-12 child for
/// the live value. <c>BuildButton</c> surfaces that child through
/// <see cref="UiButton.ValueBox"/>/<see cref="UiButton.ValueLabel"/>
/// instead of dropping it — coexisting with, not clobbering,
/// <see cref="UiButton.Label"/>.
/// </summary>
[Fact]
public void BuildButton_OwnCaptionPlusMediaLessTextChild_SurfacesValueSlotWithoutClobberingLabel()
{
uint captionStringId = 111u;
var info = new ElementInfo { Type = 1, Width = 80, Height = 20 };
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),
};
// The button carries its own background media (authoredFaces stays
// empty — matches the real display buttons, which have their OWN
// frame art, not a lifted face-segment child).
info.StateMedia[""] = (0x06000001u, 1);
var valueChild = new ElementInfo { Type = 12, X = 5, Y = 2, Width = 60, Height = 16 };
info.Children.Add(valueChild);
var button = Assert.IsType<UiButton>(DatWidgetFactory.Create(
info, NoTex, null,
stringResolve: value => value.StringId == captionStringId ? "Attribute Credits" : null));
Assert.Equal("Attribute Credits", button.Label);
Assert.Null(button.ValueLabel); // nothing authored on the child itself
Assert.Equal((5f, 2f, 60f, 16f), button.ValueBox);
// Writing the live value (as CharacterCreationProfessionPage.SetDisplay
// does) must not touch the caption — the whole point of this fix.
button.ValueLabel = "42";
Assert.Equal("Attribute Credits", button.Label);
Assert.Equal("42", button.ValueLabel);
}
/// <summary>
/// Negative companion: a button whose caption was LIFTED from a
/// distinct Type-12 child (the town-marker shape,
/// <c>!ReferenceEquals(labelInfo, info)</c>) must NOT pick up a
/// ValueBox even if the button happens to have another Type-12 child —
/// the gate is <c>ReferenceEquals(labelInfo, info)</c>, own-caption
/// only.
/// </summary>
[Fact]
public void BuildButton_LiftedCaption_NeverSurfacesValueSlot()
{
uint stringId = 222u;
var info = new ElementInfo { Type = 1, Width = 106, Height = 80 };
info.States[1u] = new UiStateInfo { Id = 1u, Name = "Normal" };
info.States[6u] = new UiStateInfo { Id = 6u, Name = "Highlight" };
var caption = new ElementInfo { Type = 12, X = 0, Y = 4, Width = 100, Height = 37 };
caption.States[UiStateInfo.DirectStateId] = new UiStateInfo { Id = UiStateInfo.DirectStateId };
caption.States[UiStateInfo.DirectStateId].Properties.Values[0x17u] = new UiPropertyValue
{
Kind = UiPropertyKind.StringInfo,
StringInfoValue = new UiStringInfoValue(0, stringId, 0, 0, 0, 0),
};
info.Children.Add(caption);
var marker = new ElementInfo { Type = 3, X = 36, Y = 36, Width = 38, Height = 38 };
marker.StateMedia["Normal"] = (0x06004D60u, 1);
marker.StateMedia["Highlight"] = (0x06004D61u, 1);
info.Children.Add(marker);
var button = Assert.IsType<UiButton>(DatWidgetFactory.Create(
info, NoTex, null,
stringResolve: value => value.StringId == stringId ? "Holtburg" : null));
Assert.Equal("Holtburg", button.Label);
Assert.Null(button.ValueBox);
Assert.Null(button.ValueLabel);
}
// ── Test 5b: Type 11 → UiScrollbar ──────────────────────────────────────
[Fact]