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
File diff suppressed because one or more lines are too long
|
|
@ -1,4 +1,3 @@
|
|||
using System.Globalization;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.Core.CharGen;
|
||||
using AcDream.Runtime;
|
||||
|
|
@ -175,6 +174,7 @@ internal sealed class CharacterCreationAppearancePage : IDisposable
|
|||
private readonly UiButton? _rotateCounterClockwise;
|
||||
private readonly UiButton? _zoomIn;
|
||||
private readonly UiButton? _zoomOut;
|
||||
private readonly UiElement? _gradCircle;
|
||||
|
||||
private Choice _currentChoice = Choice.Face;
|
||||
private Part _currentPart = Part.Hair;
|
||||
|
|
@ -242,6 +242,8 @@ internal sealed class CharacterCreationAppearancePage : IDisposable
|
|||
if (_shadeScroll is not null)
|
||||
_shadeScroll.ScalarChanged = SetShadeFromScalar;
|
||||
|
||||
_gradCircle = Find<UiElement>(pageRoot, GradCircleId);
|
||||
|
||||
Viewport = Find<UiViewport>(pageRoot, ViewportId);
|
||||
|
||||
_rotateClockwise = Find<UiButton>(pageRoot, RotateClockwiseId);
|
||||
|
|
@ -327,8 +329,8 @@ internal sealed class CharacterCreationAppearancePage : IDisposable
|
|||
}
|
||||
ApplyChoiceVisibility();
|
||||
|
||||
if (TryGetGender(view, snapshot, out ChargenGenderOptions? gender))
|
||||
RefreshSpins(gender, snapshot.Appearance);
|
||||
// GF-6: heritage-flavored, index-independent — no gender needed.
|
||||
RefreshSpinCaptions(snapshot.HeritageId);
|
||||
|
||||
RefreshColorAndShadeControls(view, snapshot);
|
||||
RebuildPreview(view, snapshot);
|
||||
|
|
@ -677,6 +679,37 @@ internal sealed class CharacterCreationAppearancePage : IDisposable
|
|||
overlay.Visible = colorSlot is not null && currentColor == (uint)i;
|
||||
}
|
||||
|
||||
// AP-216 (Campaign CC gate round 1 Batch C, PARTIAL): retail's
|
||||
// DoColorSpots @0x0047d850 blits ACTUAL-color art for each valid
|
||||
// swatch and BLANK art for any swatch beyond the current part's
|
||||
// real color count. Painting each swatch with its own represented
|
||||
// color needs a PalSet/Palette-id -> RGB resolution pipeline this
|
||||
// batch does not add (no chargen page currently reads DAT palette
|
||||
// pixels at runtime) — register AP-216 stays open for that half.
|
||||
// This ships the cheap, fully-evidenced half: hiding a swatch a
|
||||
// part's color list doesn't actually have (closest faithful
|
||||
// rendering the existing pipeline supports — Visible=false is the
|
||||
// acdream equivalent of "blit nothing").
|
||||
int colorCount = colorSlot is not null
|
||||
&& TryGetGender(view, snapshot, out ChargenGenderOptions? swatchGender)
|
||||
? ColorCount(_currentPart, swatchGender)
|
||||
: 0;
|
||||
for (int i = 0; i < _swatches.Length; i++)
|
||||
{
|
||||
if (_swatches[i] is { } swatch)
|
||||
swatch.Visible = colorSlot is not null && i < colorCount;
|
||||
}
|
||||
|
||||
// AP-217 (PARTIAL): gmCGAppearancePage::DoGradDisk @0x0047da90
|
||||
// blits the blank "grad plug" for Eyes (DoGradDisk(this, 1),
|
||||
// called from SetSelection @0x0047e85d) and a gradient graphic
|
||||
// TINTED with the current part's color otherwise — the tinted
|
||||
// repaint needs the same palette-to-RGB pipeline AP-216's open
|
||||
// half needs, so it stays open too. This ships the evidenced
|
||||
// Eyes-blank half only.
|
||||
if (_gradCircle is not null)
|
||||
_gradCircle.Visible = _currentPart != Part.Eyes;
|
||||
|
||||
ChargenShadeSlot? shadeSlot = ShadeSlotFor(_currentPart);
|
||||
if (_shadeScroll is null)
|
||||
return;
|
||||
|
|
@ -694,36 +727,57 @@ internal sealed class CharacterCreationAppearancePage : IDisposable
|
|||
}
|
||||
}
|
||||
|
||||
// ── Spin labels ──────────────────────────────────────────────────
|
||||
// ── Spin captions ────────────────────────────────────────────────
|
||||
|
||||
private void RefreshSpins(ChargenGenderOptions gender, RuntimeCharacterCreationAppearance a)
|
||||
/// <summary>
|
||||
/// GF-6/AP-218 (Campaign CC gate round 1 Batch C):
|
||||
/// <c>gmCGAppearancePage::Update @ 0x0047e8f0</c> writes the Hair/Eyes/
|
||||
/// Skin spins' caption to a heritage-flavored STATIC string via
|
||||
/// <c>UIElement_Text::SetStringInfoWithFont</c> — never an index or a
|
||||
/// style name. Normal heritage: <c>ID_CharGen_HairStyle</c>/
|
||||
/// <c>_Eyes</c>/<c>_Skin</c> (@0x0047ebad/0x0047ebe3/0x0047ec6a).
|
||||
/// Gearknight (heritage 6): <c>ID_CharGen_GearText_HairButton</c>/
|
||||
/// <c>_EyesButton</c>/<c>_SkinButton</c> (@0x0047e9ef/0x0047ea25/
|
||||
/// 0x0047eaa9). Olthoi/OlthoiAcid (heritage 0xc/0xd):
|
||||
/// <c>ID_CharGen_OlthoiText_HairButton</c>/<c>_EyesButton</c>/
|
||||
/// <c>_SkinButton</c> (@0x0047ed5b/0x0047ed91/0x0047ee15). The other
|
||||
/// six spins (Nose/Mouth/Headgear/Shirt/Trousers/Footwear) are NEVER
|
||||
/// touched by <c>Update</c> — their DAT-authored static caption
|
||||
/// (already resolved at build time by
|
||||
/// <c>DatWidgetFactory.BuildButton</c>'s own P0x17 lift) is left
|
||||
/// alone. Retail shows NO per-style index or name anywhere on this
|
||||
/// page — the live 3D preview is the player's only feedback for which
|
||||
/// style/gear is currently selected; acdream's own prior "1-based
|
||||
/// ordinal"/gear-name substitution here was never a retail behavior
|
||||
/// (register AP-218, retired by this fix; AP-215's own icon-thumbnail
|
||||
/// item stays open — a DIFFERENT gap, see that row's own text).
|
||||
/// </summary>
|
||||
private void RefreshSpinCaptions(uint heritageId)
|
||||
{
|
||||
SetStyleSpinLabel(Part.Hair, gender.HairStyles.Count, a.HairStyle);
|
||||
SetStyleSpinLabel(Part.Eyes, gender.EyeStrips.Count, a.EyesStrip);
|
||||
SetStyleSpinLabel(Part.Nose, gender.NoseStrips.Count, a.NoseStrip);
|
||||
SetStyleSpinLabel(Part.Mouth, gender.MouthStrips.Count, a.MouthStrip);
|
||||
SetGearSpinLabel(Part.Headgear, gender.Headgears, a.HeadgearStyle);
|
||||
SetGearSpinLabel(Part.Shirt, gender.Shirts, a.ShirtStyle);
|
||||
SetGearSpinLabel(Part.Trousers, gender.Pants, a.TrousersStyle);
|
||||
SetGearSpinLabel(Part.Footwear, gender.Footwear, a.FootwearStyle);
|
||||
(string hairKey, string eyesKey, string skinKey) = heritageId switch
|
||||
{
|
||||
(uint)ChargenHeritageGroup.Gearknight => (
|
||||
"ID_CharGen_GearText_HairButton",
|
||||
"ID_CharGen_GearText_EyesButton",
|
||||
"ID_CharGen_GearText_SkinButton"),
|
||||
(uint)ChargenHeritageGroup.Olthoi or (uint)ChargenHeritageGroup.OlthoiAcid => (
|
||||
"ID_CharGen_OlthoiText_HairButton",
|
||||
"ID_CharGen_OlthoiText_EyesButton",
|
||||
"ID_CharGen_OlthoiText_SkinButton"),
|
||||
_ => ("ID_CharGen_HairStyle", "ID_CharGen_Eyes", "ID_CharGen_Skin"),
|
||||
};
|
||||
|
||||
SetSpinCaption(Part.Hair, hairKey);
|
||||
SetSpinCaption(Part.Eyes, eyesKey);
|
||||
SetSpinCaption(Part.Skin, skinKey);
|
||||
}
|
||||
|
||||
private void SetStyleSpinLabel(Part part, int count, uint index)
|
||||
private void SetSpinCaption(Part part, string key)
|
||||
{
|
||||
if (!_spins.TryGetValue(part, out UiButton? spin))
|
||||
return;
|
||||
spin.Label = index != Unset && index < (uint)count
|
||||
? (index + 1).ToString(CultureInfo.InvariantCulture)
|
||||
: "-";
|
||||
}
|
||||
|
||||
private void SetGearSpinLabel(Part part, IReadOnlyList<ChargenGearOption> options, uint index)
|
||||
{
|
||||
if (!_spins.TryGetValue(part, out UiButton? spin))
|
||||
return;
|
||||
spin.Label = index != Unset && index < (uint)options.Count
|
||||
? options[(int)index].Name
|
||||
: "None";
|
||||
if (_bindings.ResolveText?.Invoke(key) is { } text)
|
||||
spin.Label = text;
|
||||
}
|
||||
|
||||
// ── Preview rebuild ──────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
using System.Numerics;
|
||||
using AcDream.Core.CharGen;
|
||||
using AcDream.Runtime;
|
||||
using AcDream.Runtime.Session;
|
||||
|
|
@ -72,10 +73,38 @@ internal sealed class CharacterCreationHeritagePage : IDisposable
|
|||
[(uint)ChargenHeritageGroup.Undead] = "ID_CharGen_UndText_BonusSkills_Trained",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Root 1d (Campaign CC gate round 1 Batch C): the page's own backdrop
|
||||
/// element (<c>0x100003be</c>, live-DAT-measured 13 authored states)
|
||||
/// switches per selected heritage — <c>gmCGHeritagePage::Update
|
||||
/// @0x00483210</c>'s per-case <c>m_pBackground->SetState(...)</c>
|
||||
/// calls (heritages 5/Shadowbound and 10/Penumbraen share literals
|
||||
/// <c>0x10000058</c>/<c>0x10000059</c> via a shared jump target, every
|
||||
/// other heritage has its own distinct state).
|
||||
/// </summary>
|
||||
private static readonly IReadOnlyDictionary<uint, uint> BackdropStateByHeritage =
|
||||
new Dictionary<uint, uint>
|
||||
{
|
||||
[(uint)ChargenHeritageGroup.Aluvian] = 0x10000021u,
|
||||
[(uint)ChargenHeritageGroup.Gharundim] = 0x10000022u,
|
||||
[(uint)ChargenHeritageGroup.Sho] = 0x10000023u,
|
||||
[(uint)ChargenHeritageGroup.Viamontian] = 0x10000024u,
|
||||
[(uint)ChargenHeritageGroup.Shadowbound] = 0x10000058u,
|
||||
[(uint)ChargenHeritageGroup.Gearknight] = 0x1000005Au,
|
||||
[(uint)ChargenHeritageGroup.Tumerok] = 0x1000005Fu,
|
||||
[(uint)ChargenHeritageGroup.Lugian] = 0x10000060u,
|
||||
[(uint)ChargenHeritageGroup.Empyrean] = 0x1000005Cu,
|
||||
[(uint)ChargenHeritageGroup.Penumbraen] = 0x10000059u,
|
||||
[(uint)ChargenHeritageGroup.Undead] = 0x1000005Bu,
|
||||
[(uint)ChargenHeritageGroup.Olthoi] = 0x1000005Du,
|
||||
[(uint)ChargenHeritageGroup.OlthoiAcid] = 0x1000005Eu,
|
||||
};
|
||||
|
||||
private readonly CharacterCreationRuntimeBindings _bindings;
|
||||
private readonly Action<uint> _onButtonClicked;
|
||||
private readonly Dictionary<UiButton, uint> _buttons = [];
|
||||
private readonly UiText? _description;
|
||||
private readonly UiElement? _backdrop;
|
||||
private bool _disposed;
|
||||
|
||||
/// <param name="onButtonClicked">Review fix round F3 (2026-08-15):
|
||||
|
|
@ -105,6 +134,7 @@ internal sealed class CharacterCreationHeritagePage : IDisposable
|
|||
}
|
||||
|
||||
_description = UiElement.FindDescendant(pageRoot, 0x100003C4u) as UiText;
|
||||
_backdrop = UiElement.FindDescendant(pageRoot, 0x100003BEu);
|
||||
}
|
||||
|
||||
internal void Refresh(
|
||||
|
|
@ -114,12 +144,24 @@ internal sealed class CharacterCreationHeritagePage : IDisposable
|
|||
foreach ((UiButton button, uint heritageId) in _buttons)
|
||||
button.Selected = heritageId == snapshot.HeritageId;
|
||||
|
||||
// Root 1d: switch the backdrop art per selected heritage. Retail
|
||||
// runs this unconditionally alongside the button highlight/text
|
||||
// composition below — no heritage-unset guard exists in the decomp
|
||||
// beyond the dictionary lookup itself (heritageId 0 simply has no
|
||||
// entry, so TryGetValue leaves the backdrop at whatever state it
|
||||
// last held, matching retail's own "no case 0" switch shape).
|
||||
if (_backdrop is IUiDatStateful backdropStateful
|
||||
&& BackdropStateByHeritage.TryGetValue(snapshot.HeritageId, out uint backdropState))
|
||||
{
|
||||
backdropStateful.TrySetRetailState(backdropState);
|
||||
}
|
||||
|
||||
if (_description is null)
|
||||
return;
|
||||
|
||||
string composed = ComposeDescription(view, snapshot.HeritageId, _bindings.ResolveText);
|
||||
_description.LinesProvider = () =>
|
||||
[new UiText.Line(composed, _description.DefaultColor)];
|
||||
IReadOnlyList<DatRichText.Segment> segments = ComposeSegments(
|
||||
_description, view, snapshot.HeritageId, _bindings.ResolveText);
|
||||
_description.LinesProvider = () => DatRichText.Compose(_description, segments);
|
||||
}
|
||||
|
||||
internal void Randomize(RuntimeCharacterCreationSnapshot snapshot)
|
||||
|
|
@ -168,38 +210,45 @@ internal sealed class CharacterCreationHeritagePage : IDisposable
|
|||
/// body, the bonus-skills header, then — only once a heritage is
|
||||
/// selected — that heritage's own bonus-skills line (absent for
|
||||
/// Lugian/Olthoi/OlthoiAcid; see <see cref="BonusSkillsKeyByHeritage"/>).
|
||||
/// <paramref name="resolveText"/> is the DAT string lookup
|
||||
/// (<c>RetailUiRuntime</c>'s <c>DatStringResolver</c> over table
|
||||
/// <c>0x23000002</c>) threaded through the bindings record; a missing
|
||||
/// resolver or a missing key degrades to skipping that segment rather
|
||||
/// than throwing.
|
||||
/// Header segments use <c>SetStringInfoWithFont</c>'s own font-index
|
||||
/// argument (<c>1</c> — palette index 1, live-DAT-measured GREEN);
|
||||
/// body/bonus-body segments use index <c>0</c> (white). <paramref
|
||||
/// name="resolveText"/> is the DAT string lookup (<c>RetailUiRuntime</c>'s
|
||||
/// <c>DatStringResolver</c> over table <c>0x23000002</c>) threaded
|
||||
/// through the bindings record; a missing resolver or a missing key
|
||||
/// degrades to skipping that segment rather than throwing.
|
||||
/// </summary>
|
||||
private static string ComposeDescription(
|
||||
private static IReadOnlyList<DatRichText.Segment> ComposeSegments(
|
||||
UiText description,
|
||||
IRuntimeCharacterCreationView view,
|
||||
uint heritageId,
|
||||
Func<string, string?>? resolveText)
|
||||
{
|
||||
Vector4 headerColor = DatRichText.PaletteColor(description, 1, new Vector4(0f, 1f, 0f, 1f));
|
||||
Vector4 bodyColor = DatRichText.PaletteColor(description, 0, Vector4.One);
|
||||
|
||||
if (resolveText is null)
|
||||
{
|
||||
return view.Options.TryGetHeritage(heritageId, out ChargenHeritageOptions? named)
|
||||
string name = view.Options.TryGetHeritage(heritageId, out ChargenHeritageOptions? named)
|
||||
? named.Name
|
||||
: string.Empty;
|
||||
return [new DatRichText.Segment(name, bodyColor)];
|
||||
}
|
||||
|
||||
var parts = new List<string>();
|
||||
var segments = new List<DatRichText.Segment>();
|
||||
if (resolveText("ID_CharGen_Heritage_StartingSkills_Header") is { } header)
|
||||
parts.Add(header);
|
||||
segments.Add(new(header, headerColor));
|
||||
if (resolveText("ID_CharGen_Heritage_StartingSkills") is { } body)
|
||||
parts.Add(body);
|
||||
segments.Add(new(body, bodyColor));
|
||||
if (resolveText("ID_CharGen_Heritage_BonusSkills_Trained_Header") is { } bonusHeader)
|
||||
parts.Add(bonusHeader);
|
||||
segments.Add(new(bonusHeader, headerColor));
|
||||
if (heritageId != 0
|
||||
&& BonusSkillsKeyByHeritage.TryGetValue(heritageId, out string? bonusKey)
|
||||
&& resolveText(bonusKey) is { } bonusBody)
|
||||
{
|
||||
parts.Add(bonusBody);
|
||||
segments.Add(new(bonusBody, bodyColor));
|
||||
}
|
||||
return string.Join("\n\n", parts);
|
||||
return segments;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
|
|
|
|||
|
|
@ -69,6 +69,65 @@ internal sealed class CharacterCreationProfessionPage : IDisposable
|
|||
|
||||
private sealed record SliderWidgets(UiButton? Lock, UiScrollbar? Slider, UiField? Value);
|
||||
|
||||
/// <summary>
|
||||
/// GF-4b: the six slider containers' name-label CHILD, relative id
|
||||
/// <c>0x100002ed</c> — <c>gmCGProfessionPage::InitializePage
|
||||
/// @0x00482e1a-0x00482f1d</c> writes <c>CharGenState::GetAttributeName
|
||||
/// @0x005C3A20</c>'s literal ONCE at page construction (no per-refresh
|
||||
/// rewrite anywhere in the decomp — <c>UpdateAttributeValues</c> only
|
||||
/// touches <c>pSlider</c>/<c>pAttribValue</c>, never this id). Live-DAT-
|
||||
/// measured: this child resolves as Type 1 (<c>UIElement_Button</c>),
|
||||
/// matching retail's own declared <c>UIElement_Button*</c> field type
|
||||
/// that still accepts <c>UIElement_Text::SetText</c> — retail's button
|
||||
/// class carries the same text-rendering capability
|
||||
/// <see cref="UiButton.Label"/> already is in this port.
|
||||
/// </summary>
|
||||
private const uint SliderNameRelativeId = 0x100002EDu;
|
||||
|
||||
/// <summary>
|
||||
/// GF-3: the description textbox — <c>gmCGProfessionPage::InitializePage
|
||||
/// @0x00483068</c>'s <c>m_pTextBox</c>.
|
||||
/// </summary>
|
||||
private const uint DescriptionTextId = 0x100003E0u;
|
||||
|
||||
/// <summary>
|
||||
/// Root 1d: the page's own backdrop (<c>0x100003d8</c>, live-DAT-
|
||||
/// measured 7 authored states) switches per selected template —
|
||||
/// <c>gmCGProfessionPage::UpdateProfession @ 0x004821b0</c>'s per-case
|
||||
/// <c>eax_2->SetState(...)</c> calls, keyed by <c>ChargenTemplate</c>
|
||||
/// index (0=Custom..6=Soldier), NOT the button-id map above.
|
||||
/// </summary>
|
||||
private static readonly IReadOnlyDictionary<uint, uint> BackdropStateByTemplate =
|
||||
new Dictionary<uint, uint>
|
||||
{
|
||||
[0u] = 0x1000002Bu, // Custom / Adventurer
|
||||
[1u] = 0x1000002Cu, // Bow Hunter
|
||||
[2u] = 0x10000031u, // Swashbuckler
|
||||
[3u] = 0x1000002Du, // Life Caster
|
||||
[4u] = 0x1000002Eu, // War Caster
|
||||
[5u] = 0x1000002Fu, // Wayfarer
|
||||
[6u] = 0x10000030u, // Soldier
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// GF-3: per-template description string id —
|
||||
/// <c>gmCGProfessionPage::UpdateProfession @0x00482203-0048233d</c>'s
|
||||
/// per-case <c>var_a4_1</c> literal, resolved through
|
||||
/// <c>UIElement_Text::SetStringInfo</c> (NOT ...WithFont — a single
|
||||
/// plain string, no per-run palette color).
|
||||
/// </summary>
|
||||
private static readonly IReadOnlyDictionary<uint, string> DescriptionKeyByTemplate =
|
||||
new Dictionary<uint, string>
|
||||
{
|
||||
[0u] = "ID_CharGen_CustomText",
|
||||
[1u] = "ID_CharGen_BowText",
|
||||
[2u] = "ID_CharGen_SwashText",
|
||||
[3u] = "ID_CharGen_LifeText",
|
||||
[4u] = "ID_CharGen_WarText",
|
||||
[5u] = "ID_CharGen_WayText",
|
||||
[6u] = "ID_CharGen_SoldierText",
|
||||
};
|
||||
|
||||
private readonly CharacterCreationRuntimeBindings _bindings;
|
||||
private readonly Dictionary<UiButton, uint> _templateButtons = [];
|
||||
private readonly Dictionary<ChargenAttributeId, SliderWidgets> _sliders = [];
|
||||
|
|
@ -76,6 +135,8 @@ internal sealed class CharacterCreationProfessionPage : IDisposable
|
|||
private readonly UiButton? _healthValue;
|
||||
private readonly UiButton? _staminaValue;
|
||||
private readonly UiButton? _manaValue;
|
||||
private readonly UiText? _description;
|
||||
private readonly UiElement? _backdrop;
|
||||
private bool _disposed;
|
||||
|
||||
internal CharacterCreationProfessionPage(
|
||||
|
|
@ -118,22 +179,29 @@ internal sealed class CharacterCreationProfessionPage : IDisposable
|
|||
value.OnSubmit = text => SetAttributeFromText(capturedAttribute, text);
|
||||
}
|
||||
|
||||
// GF-4b: the name-label child is static per attribute — retail
|
||||
// writes it exactly once (InitializePage), never on refresh.
|
||||
if (UiElement.FindDescendant(container, SliderNameRelativeId) is UiButton nameLabel)
|
||||
nameLabel.Label = AttributeName(attribute);
|
||||
|
||||
_sliders[attribute] = new SliderWidgets(lockButton, slider, value);
|
||||
}
|
||||
|
||||
// Live-DAT probe (CharacterCreationLiveDatTests): every one of the
|
||||
// four display containers (0x100003e2..e5) authors as a Button
|
||||
// whose Type-12 value child (0x100002f1/0x100002f3) is swallowed by
|
||||
// UiButton.ConsumesDatChildren — the same "consumed child -> use
|
||||
// the button's own Label" substitution the Skills page's credits
|
||||
// meter needed (see CharacterCreationSkillsPage's ctor comment).
|
||||
// Retail's own DynamicCast(0xc) on the CHILD (not the container)
|
||||
// still stands as ground truth for the container's ROLE; only
|
||||
// acdream's widget-level addressability differs (register AD-103).
|
||||
// GF-4a (Campaign CC gate round 1 Batch C): every one of the four
|
||||
// display buttons (0x100003e2..e5) authors its CAPTION directly as
|
||||
// its own P0x17 and carries a SEPARATE, media-less Type-12 value
|
||||
// child (0x100002f1/0x100002f3 — gmCGProfessionPage::InitializePage
|
||||
// @0x00482f90-0x00483062). DatWidgetFactory.BuildButton now surfaces
|
||||
// that child as UiButton.ValueLabel, coexisting with the authored
|
||||
// Label caption — see that method's own doc comment. Retiring the
|
||||
// prior Label-clobber substitution (register AD-103).
|
||||
_availableValue = UiElement.FindDescendant(pageRoot, 0x100003E2u) as UiButton;
|
||||
_healthValue = UiElement.FindDescendant(pageRoot, 0x100003E3u) as UiButton;
|
||||
_staminaValue = UiElement.FindDescendant(pageRoot, 0x100003E4u) as UiButton;
|
||||
_manaValue = UiElement.FindDescendant(pageRoot, 0x100003E5u) as UiButton;
|
||||
|
||||
_description = UiElement.FindDescendant(pageRoot, DescriptionTextId) as UiText;
|
||||
_backdrop = UiElement.FindDescendant(pageRoot, 0x100003D8u);
|
||||
}
|
||||
|
||||
internal void Refresh(
|
||||
|
|
@ -173,6 +241,23 @@ internal sealed class CharacterCreationProfessionPage : IDisposable
|
|||
SetDisplay(_healthValue, endurance / 2);
|
||||
SetDisplay(_staminaValue, endurance);
|
||||
SetDisplay(_manaValue, snapshot.Attributes.Self);
|
||||
|
||||
// Root 1d: backdrop art per selected template.
|
||||
if (_backdrop is IUiDatStateful backdropStateful
|
||||
&& BackdropStateByTemplate.TryGetValue(snapshot.Template, out uint backdropState))
|
||||
{
|
||||
backdropStateful.TrySetRetailState(backdropState);
|
||||
}
|
||||
|
||||
// GF-3: description textbox — one plain segment (SetStringInfo,
|
||||
// not ...WithFont), so a single DefaultColor run.
|
||||
if (_description is not null
|
||||
&& DescriptionKeyByTemplate.TryGetValue(snapshot.Template, out string? key))
|
||||
{
|
||||
string? text = _bindings.ResolveText?.Invoke(key);
|
||||
var segments = new[] { new DatRichText.Segment(text, _description.DefaultColor) };
|
||||
_description.LinesProvider = () => DatRichText.Compose(_description, segments);
|
||||
}
|
||||
}
|
||||
|
||||
internal void Randomize(RuntimeCharacterCreationSnapshot snapshot)
|
||||
|
|
@ -205,9 +290,27 @@ internal sealed class CharacterCreationProfessionPage : IDisposable
|
|||
{
|
||||
if (display is null)
|
||||
return;
|
||||
display.Label = value.ToString(CultureInfo.InvariantCulture);
|
||||
// GF-4a: the button's OWN P0x17 caption ("Attribute Credits" etc.)
|
||||
// stays in Label; the live number goes in the coexisting value
|
||||
// slot DatWidgetFactory.BuildButton surfaced from the button's
|
||||
// media-less Type-12 child.
|
||||
display.ValueLabel = value.ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
/// <summary>Ports <c>CharGenState::GetAttributeName @ 0x005C3A20</c>
|
||||
/// verbatim — retail hardcodes these six literals directly (not a
|
||||
/// DAT/localization lookup), so this port does too.</summary>
|
||||
private static string AttributeName(ChargenAttributeId id) => id switch
|
||||
{
|
||||
ChargenAttributeId.Strength => "Strength",
|
||||
ChargenAttributeId.Endurance => "Endurance",
|
||||
ChargenAttributeId.Quickness => "Quickness",
|
||||
ChargenAttributeId.Coordination => "Coordination",
|
||||
ChargenAttributeId.Focus => "Focus",
|
||||
ChargenAttributeId.Self => "Self",
|
||||
_ => string.Empty,
|
||||
};
|
||||
|
||||
private void SelectTemplate(uint templateIndex)
|
||||
{
|
||||
if (_disposed)
|
||||
|
|
|
|||
|
|
@ -126,10 +126,13 @@ internal sealed class CharacterCreationSkillsPage : IDisposable
|
|||
// the mechanism our factory already uses to surface a consumed
|
||||
// Type-12 child's text (register AD-103).
|
||||
//
|
||||
// GF-5 note: this clobbers the button's authored "Available Skill
|
||||
// Credits" caption (retail's own m_pCreditsMeter is a SEPARATE
|
||||
// widget from any caption text) — left as-is per the gate-round
|
||||
// scope (Batch C owns the caption fix).
|
||||
// GF-4a (Campaign CC gate round 1 Batch C): this used to clobber
|
||||
// the button's authored "Available Skill Credits" caption (retail's
|
||||
// own m_pCreditsMeter, 0x100002f3, is a SEPARATE widget from the
|
||||
// caption text — gmCGSkillsPage::InitializePage @0x00481e1c).
|
||||
// DatWidgetFactory.BuildButton now surfaces that media-less Type-12
|
||||
// child as UiButton.ValueLabel, coexisting with Label — see
|
||||
// Refresh below.
|
||||
_credits = UiElement.FindDescendant(pageRoot, 0x100003F9u) as UiButton;
|
||||
_infoTitle = UiElement.FindDescendant(pageRoot, 0x100003FBu) as UiText;
|
||||
_infoText = UiElement.FindDescendant(pageRoot, 0x100003FCu) as UiText;
|
||||
|
|
@ -150,7 +153,7 @@ internal sealed class CharacterCreationSkillsPage : IDisposable
|
|||
RefreshRowValues(row, view, snapshot);
|
||||
|
||||
if (_credits is { } credits)
|
||||
credits.Label = snapshot.RemainingSkillCredits.ToString(CultureInfo.InvariantCulture);
|
||||
credits.ValueLabel = snapshot.RemainingSkillCredits.ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private void RebuildRows(IRuntimeCharacterCreationView view, uint heritageId)
|
||||
|
|
|
|||
|
|
@ -103,9 +103,22 @@ internal sealed class CharacterCreationTownPage : IDisposable
|
|||
if (_description is null)
|
||||
return;
|
||||
|
||||
// GF-11a: gmCGTownPage::SetTownString @ 0x0047c1f0 concatenates
|
||||
// howTo + a compiled "\n\n%s\n" literal format around the town
|
||||
// text (the ONE composition site in this batch where retail's OWN
|
||||
// code — not the authored DAT string content — inserts the blank
|
||||
// line, confirmed via the format string's raw bytes,
|
||||
// 0x0079c2d2 = u"\n\n%s\n") into ONE plain SetText — no per-run
|
||||
// font/color argument, unlike Heritage's WithFont calls. The
|
||||
// string composition itself was already byte-correct before this
|
||||
// fix; what was missing was routing it through the same
|
||||
// escape-normalize + word-wrap path every other description box
|
||||
// needed (a single un-wrapped line meant the town-specific suffix
|
||||
// rendered past the clipped viewport, which is why switching towns
|
||||
// looked like the text never changed).
|
||||
string composed = ComposeDescription(snapshot.StartArea, _bindings.ResolveText);
|
||||
_description.LinesProvider = () =>
|
||||
[new UiText.Line(composed, _description.DefaultColor)];
|
||||
var segments = new[] { new DatRichText.Segment(composed, _description.DefaultColor) };
|
||||
_description.LinesProvider = () => DatRichText.Compose(_description, segments);
|
||||
}
|
||||
|
||||
internal void Randomize(IRuntimeCharacterCreationView view)
|
||||
|
|
|
|||
98
src/AcDream.App/UI/Layout/DatRichText.cs
Normal file
98
src/AcDream.App/UI/Layout/DatRichText.cs
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Shared multi-segment rich-text composer for the chargen description
|
||||
/// boxes (Campaign CC gate round 1 Batch C — GF-2/GF-3/GF-11a, and the
|
||||
/// Summary how-to text). Ports retail's
|
||||
/// <c>UIElement_Text::SetStringInfoWithFont</c> /
|
||||
/// <c>AppendStringInfoWithFont @ 0x00469D70</c> composition model: a text
|
||||
/// box is built from an ORDERED list of string segments, each carrying its
|
||||
/// OWN font-color palette index
|
||||
/// (<c>UIElement_Text::AppendStringInfoWithFont</c>'s
|
||||
/// <c>SetFontColorHelper</c> -> <c>InqProperty(0x1B)</c> array lookup —
|
||||
/// see <see cref="AcDream.App.UI.UiText.FontColorPalette"/>).
|
||||
///
|
||||
/// <para>
|
||||
/// The description pages used to bypass this entirely: they assigned a raw
|
||||
/// <c>LinesProvider</c> lambda returning ONE unwrapped <see cref="AcDream.App.UI.UiText.Line"/>
|
||||
/// per composed string, with no escape-normalize and no word-wrap. Two
|
||||
/// concrete symptoms this caused: literal two-character <c>"\n"</c>
|
||||
/// escapes rendered as backslash-n instead of a real line break (the DAT
|
||||
/// stores that literal escape — <c>DatWidgetFactory.BuildText</c>'s own
|
||||
/// authored-string path already normalizes it for single-element authored
|
||||
/// captions; this helper reproduces the SAME normalize for
|
||||
/// runtime-composed multi-segment text), and — for the Town page
|
||||
/// specifically — an unwrapped single line meant the town-specific SUFFIX
|
||||
/// of the composed string rendered far outside the box's clipped viewport,
|
||||
/// so switching towns looked like "the text never changes" even though the
|
||||
/// underlying string genuinely did (only its INVISIBLE tail differed).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static class DatRichText
|
||||
{
|
||||
/// <summary>One composed segment: text plus the color it should render
|
||||
/// in. A null or empty <see cref="Text"/> is silently skipped (mirrors
|
||||
/// retail's own null-string-info no-op guards throughout this text
|
||||
/// composition family).</summary>
|
||||
public readonly record struct Segment(string? Text, Vector4 Color);
|
||||
|
||||
/// <summary>
|
||||
/// Escape-normalizes and word-wraps every segment (independently, so
|
||||
/// each segment's wrapped lines keep ITS OWN color), then concatenates
|
||||
/// the results in order. No separator is inserted between segments —
|
||||
/// retail's own composition calls concatenate directly
|
||||
/// (<c>AppendStringInfoWithFont</c>/<c>append_n_chars</c> with no
|
||||
/// interposed literal), so any blank-line spacing between sections
|
||||
/// comes from the authored DAT string content itself, not from code
|
||||
/// here.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<UiText.Line> Compose(
|
||||
UiText target,
|
||||
IReadOnlyList<Segment> segments)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(target);
|
||||
ArgumentNullException.ThrowIfNull(segments);
|
||||
|
||||
var lines = new List<UiText.Line>();
|
||||
float maximumWidth = MathF.Max(1f, target.Width - 2f * target.Padding);
|
||||
Func<string, float> measure = target.DatFont is { } font
|
||||
? font.MeasureWidth
|
||||
: static value => value.Length * 8f;
|
||||
|
||||
foreach (Segment segment in segments)
|
||||
{
|
||||
if (string.IsNullOrEmpty(segment.Text))
|
||||
continue;
|
||||
|
||||
// The installed DAT stores the LITERAL two-character escape
|
||||
// "\n" (0x5C 0x6E), not a real line break — same normalize
|
||||
// DatWidgetFactory.BuildText's authored-string path already
|
||||
// applies for single-element authored captions.
|
||||
string normalized = segment.Text
|
||||
.Replace("\\n", "\n")
|
||||
.Replace("\r", string.Empty);
|
||||
|
||||
foreach (string wrapped in UiText.WrapWords(normalized, measure, maximumWidth))
|
||||
lines.Add(new UiText.Line(wrapped, segment.Color));
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves <paramref name="target"/>'s own authored font-color
|
||||
/// palette (dat property <c>0x1B</c>) entry at <paramref name="index"/>,
|
||||
/// falling back to <paramref name="fallback"/> when the palette is
|
||||
/// absent or too short. Mirrors the same fallback shape
|
||||
/// <c>CharacterStatController.BuildSelectedTitleRuns</c> already uses
|
||||
/// for its own palette-indexed colors.
|
||||
/// </summary>
|
||||
public static Vector4 PaletteColor(UiText target, int index, Vector4 fallback) =>
|
||||
index >= 0 && index < target.FontColorPalette.Count
|
||||
? target.FontColorPalette[index]
|
||||
: fallback;
|
||||
}
|
||||
|
|
@ -933,6 +933,39 @@ public static class DatWidgetFactory
|
|||
ElementReader.BuildPerStateColorMap(labelInfo, 0x1Bu),
|
||||
ElementReader.BuildPerStateBoolMap(labelInfo, 0x21u));
|
||||
|
||||
// GF-4a (Campaign CC gate round 1 Batch C): retail's chargen
|
||||
// display buttons author the caption directly as THEIR OWN P0x17
|
||||
// (so `label` above resolved from `info` itself, not a lifted
|
||||
// child) AND carry a SEPARATE, media-less Type-12 child for the
|
||||
// live value (gmCGProfessionPage::InitializePage
|
||||
// @0x00482f90-0x00483062, gmCGSkillsPage::InitializePage
|
||||
// @0x00481e1c — live-DAT-measured: exactly one Type-12 child, zero
|
||||
// StateMedia entries). Gated tightly to that exact shape so this
|
||||
// stays a no-op for every other button (a lifted-caption button
|
||||
// never reaches here with labelInfo==info; a button with an icon/
|
||||
// face child instead of a value child has no media-less Type-12
|
||||
// child to find).
|
||||
if (ReferenceEquals(labelInfo, info) && label is not null)
|
||||
{
|
||||
ElementInfo? valueChild = info.Children.FirstOrDefault(
|
||||
child => child.Type == 12u && child.StateMedia.Count == 0);
|
||||
if (valueChild is not null)
|
||||
{
|
||||
button.ValueBox = (valueChild.X, valueChild.Y, valueChild.Width, valueChild.Height);
|
||||
button.ValueFont = valueChild.FontDid != 0u && fontResolve is not null
|
||||
? fontResolve(valueChild.FontDid) ?? elementFont
|
||||
: elementFont;
|
||||
button.ValueColor = valueChild.FontColor ?? System.Numerics.Vector4.One;
|
||||
button.ValueAlign = valueChild.HJustify == HJustify.Left
|
||||
? UiButton.LabelAlignment.Left
|
||||
: UiButton.LabelAlignment.Center;
|
||||
// Seed with whatever the child itself authors (typically
|
||||
// blank) so an unbound button doesn't draw stray leftover
|
||||
// text before a controller writes a real value.
|
||||
button.ValueLabel = ResolveAuthoredString(valueChild, stringResolve);
|
||||
}
|
||||
}
|
||||
|
||||
return button;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -179,6 +179,43 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
|
|||
/// </summary>
|
||||
public (float X, float Y, float Width, float Height)? LabelBox { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// GF-4a (Campaign CC gate round 1 Batch C): optional secondary VALUE
|
||||
/// text, coexisting with <see cref="Label"/> (the authored CAPTION).
|
||||
/// Retail's chargen display buttons (Attribute/Skill Credits, Health,
|
||||
/// Stamina, Mana — <c>0x100003e2-e5</c>, <c>0x100003f9</c>) author the
|
||||
/// caption directly as this element's own dat property <c>0x17</c>
|
||||
/// AND carry a SEPARATE, media-less Type-12 child for the live value
|
||||
/// (<c>gmCGProfessionPage::InitializePage @0x00482f90-0x00483062</c>,
|
||||
/// <c>gmCGSkillsPage::InitializePage @0x00481e1c</c>) —
|
||||
/// <see cref="UiButton"/> consumes ALL of its dat children
|
||||
/// (<see cref="ConsumesDatChildren"/>), which used to mean a page
|
||||
/// controller had nowhere faithful to put the value except
|
||||
/// overwriting <see cref="Label"/> itself, destroying the caption.
|
||||
/// <see cref="Layout.DatWidgetFactory.BuildButton"/> now surfaces that
|
||||
/// child's geometry/font/color here instead. Null (default) draws
|
||||
/// nothing extra — every pre-existing button that only ever wrote
|
||||
/// <see cref="Label"/> is unaffected.
|
||||
/// </summary>
|
||||
public string? ValueLabel { get; set; }
|
||||
|
||||
/// <summary>Dat font for <see cref="ValueLabel"/>.</summary>
|
||||
public UiDatFont? ValueFont { get; set; }
|
||||
|
||||
/// <summary>Color for <see cref="ValueLabel"/> (default white).</summary>
|
||||
public Vector4 ValueColor { get; set; } = Vector4.One;
|
||||
|
||||
/// <summary>Authored rectangle for <see cref="ValueLabel"/>, LOCAL to
|
||||
/// this button — the lifted value child's own rect
|
||||
/// (<see cref="Layout.DatWidgetFactory.BuildButton"/> sets this). Null
|
||||
/// (no value child found) means <see cref="ValueLabel"/> is never set
|
||||
/// either, so this is never read in that case.</summary>
|
||||
public (float X, float Y, float Width, float Height)? ValueBox { get; set; }
|
||||
|
||||
/// <summary>Horizontal alignment of <see cref="ValueLabel"/> within
|
||||
/// <see cref="ValueBox"/> — the lifted child's own authored justify.</summary>
|
||||
public LabelAlignment ValueAlign { get; set; } = LabelAlignment.Center;
|
||||
|
||||
/// <summary>Label horizontal alignment options.</summary>
|
||||
public enum LabelAlignment { Center, Left }
|
||||
|
||||
|
|
@ -453,6 +490,19 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
|
|||
ctx.DrawStringDat(lf, label, tx, ty, LabelColor, Outline, OutlineColor);
|
||||
}
|
||||
|
||||
if (ValueLabel is { Length: > 0 } value && ValueFont is { } vf)
|
||||
{
|
||||
float boxX = ValueBox?.X ?? 0f;
|
||||
float boxY = ValueBox?.Y ?? 0f;
|
||||
float boxWidth = ValueBox?.Width ?? Width;
|
||||
float boxHeight = ValueBox?.Height ?? Height;
|
||||
float vx = ValueAlign == LabelAlignment.Left
|
||||
? boxX + LabelOffsetX
|
||||
: boxX + (boxWidth - vf.MeasureWidth(value)) * 0.5f;
|
||||
float vy = boxY + (boxHeight - vf.LineHeight) * 0.5f;
|
||||
ctx.DrawStringDat(vf, value, vx, vy, ValueColor, Outline, OutlineColor);
|
||||
}
|
||||
|
||||
uint dragSprite = _itemDragAcceptance switch
|
||||
{
|
||||
ItemDragAcceptance.Accept => ItemDragAcceptSprite,
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
128
tests/AcDream.App.Tests/UI/Layout/DatRichTextTests.cs
Normal file
128
tests/AcDream.App.Tests/UI/Layout/DatRichTextTests.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue