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:
parent
7d09821fdc
commit
0591b9a026
13 changed files with 1018 additions and 66 deletions
|
|
@ -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->SetState</c>
|
||||
/// literals and <c>gmCGProfessionPage::UpdateProfession</c>'s
|
||||
/// per-template <c>eax_2->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));
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue