fix(app,runtime,headless): Campaign CC slice CC4 review-fix round — F1-F12

Dual-lens review of CC4 (0e71d3b8) returned architectural FAIL (F1, F6)
and retail-fidelity PASS-with-reservations (F2, F3, F4), plus LOW
findings F5, F7-F12. F13 (TS-82's merge collision with campaign-cc6a) is
merge mechanics for the orchestrator, not addressed here.

F1 (HIGH, blocking): CharacterCreationUiController never released
UiRoot.FixedCanvasSize, on a FALSE premise that CharacterManagementUi-
Controller does a per-tick set (it does not — it sets once on activation
and nulls on Deactivate/Dispose). Root cause: RuntimeCharacterCreation-
State had no CompleteEnter() analogue to RuntimeCharacterSelectionState's,
so the creation view reported IsActive=true for an entire in-world
session. Added CompleteEnter(), wired at both LiveSessionController
in-world edges (StartCore, EnterHighlightedCore); made Open/Close/
Deactivate/Dispose set/null the canvas symmetrically; corrected the false
comment and ledger claim; added FixedCanvasSize test coverage.

F2 (MEDIUM-HIGH, blocking): the attribute-slider scalar mapping was not
retail's. Fixed display to value/100f (UpdateAttributeValues @
0x0048251d) and the drag inverse to truncate+clamp-low-only, no rescale
(ListenToElementMessage @ 0x004829c0, independently re-verified against
the decomp). Added tests at scalar 0.5/0.0 plus a display-direction test.

F3 (MEDIUM, blocking): ported the unported heritage-button tab-restore
arm (ListenToElementMessage @ 0x004e9450) — SHOW/HIDE id sets independently
re-derived from the decomp, including the genuine Lugian (0x100005f1)
no-restore quirk, reproduced faithfully. Wired via a new HeritagePage
click callback; added restore + quirk tests.

F4 (MEDIUM): ported SetTown's (@ 0x0047c360) separate per-town page-root
state literal (Holtburg->0x10000034 etc.), independently re-derived from
the decomp's tail-merged branches; wired via the existing
IUiDatStateful.TrySetRetailState seam; added a test.

F5 (MEDIUM): softened AD-103's unmeasured pixel-equivalence claim.

F6 (MEDIUM, blocking): DECISION — install ChargenOptions in the headless
content path (chosen over marking headless creation out-of-scope).
HeadlessSessionHost now calls InstallOptions off the shared content
lease's Dats, beside the existing InstallSpellMetadata call.

F7: AP-213 already named the label format and click/double-click
substitution explicitly on inspection — no edit needed.
F8: AP-212 now names all six DoRandom primitives with a known landing site.
F9: AD-101 retirement corrected to precede CC5's Finish un-ghosting.
F10: merged ItemAppraisalTextFormatter's duplicate <summary> block.
F11: fixed TS-82's wrong AP-211 cross-reference.
F12: cached the chargen DatStringResolver once per composition instead of
per ResolveText call.

Runtime 1713/0, App 5125/13 skips (+8 new tests), Headless 165/0, full
solution Release build green. Live-DAT probes 7/7 under
ACDREAM_PROBE_LIVE_MOUNT=1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-15 18:29:49 +02:00
parent 0e71d3b829
commit ec854db045
12 changed files with 419 additions and 30 deletions

View file

@ -201,18 +201,21 @@ internal sealed class CharacterCreationUiController : IDisposable
Root.ClickThrough = false;
Root.Visible = false;
// AD-98: the same authored 800x600 fixed-canvas treatment as the
// character-management screen — see that controller's own comment.
// Both screens author the identical extent, so it is safe for both
// controllers to independently (idempotently) push the SAME value
// to the shared UiRoot.FixedCanvasSize; this controller therefore
// never NULLS it back out on close (see Deactivate/Close), leaving
// char-management's own per-tick set as the surviving owner once
// this screen is not the active one.
// character-management screen. CharacterManagementUiController sets
// UiRoot.FixedCanvasSize ONCE on its own activation edge
// (Tick's `if (!_active)` arm) and NULLS it in both Deactivate AND
// Dispose — it is NOT a per-tick set, and this controller must be
// symmetric with that exact shape (review fix round F1, 2026-08-15
// — the earlier claim here that it was safe to leave the canvas
// pinned forever was FALSE and left an 800x600-scaled canvas
// covering the in-world UI whenever this screen had been opened).
// See Open/Close/Deactivate/Dispose below for the matching set/null
// pair.
_authoredCanvas = new Vector2(
Root.Width > 0f ? Root.Width : 800f,
Root.Height > 0f ? Root.Height : 600f);
_heritagePage = new CharacterCreationHeritagePage(heritagePageRoot, bindings);
_heritagePage = new CharacterCreationHeritagePage(heritagePageRoot, bindings, ApplyHeritageTabRestore);
_professionPage = new CharacterCreationProfessionPage(professionPageRoot, bindings);
_skillsPage = new CharacterCreationSkillsPage(skillsPageRoot, bindings, templateResolver);
_townPage = new CharacterCreationTownPage(townPageRoot, bindings);
@ -354,7 +357,6 @@ internal sealed class CharacterCreationUiController : IDisposable
if (_isOpen)
{
Root.Visible = true;
_host.FixedCanvasSize = _authoredCanvas;
_host.BringToFront(Root);
}
else
@ -378,19 +380,27 @@ internal sealed class CharacterCreationUiController : IDisposable
/// <summary>Opens the screen at retail's authored default page
/// (<c>gmCharGenMainUI::gmCharGenMainUI</c>'s trailing
/// <c>SetProgressState(this, ECG_HERTAGE)</c>).</summary>
/// <c>SetProgressState(this, ECG_HERTAGE)</c>). Sets the fixed canvas
/// on this exact activation edge — matching
/// <see cref="CharacterManagementUiController"/>'s own one-shot set —
/// not per-tick; <see cref="Close"/>/<see cref="Deactivate"/>/
/// <see cref="Dispose"/> null it back out symmetrically.</summary>
internal void Open()
{
if (_disposed)
return;
_isOpen = true;
_host.FixedCanvasSize = _authoredCanvas;
ApplyProgressState(Page.Heritage);
}
private void Close()
{
if (!_isOpen)
return;
_isOpen = false;
Root.Visible = false;
_host.FixedCanvasSize = null;
}
public void Dispose()
@ -404,6 +414,10 @@ internal sealed class CharacterCreationUiController : IDisposable
}
finally
{
// Matches CharacterManagementUiController.Dispose's own
// unconditional null — defends against disposing while _isOpen
// (Close() is not otherwise called on this path).
_host.FixedCanvasSize = null;
_back.OnClick = null;
_next.OnClick = null;
_finish.OnClick = null;
@ -609,6 +623,60 @@ internal sealed class CharacterCreationUiController : IDisposable
stateful.TrySetRetailState(stateId);
}
// ── Heritage tab-restore (gmCharGenMainUI::ListenToElementMessage @ ────
// ── 0x004e9450, the heritage-button bubble arm) ─────────────────────
/// <summary>SHOW ids (label_4e9673, three <c>SetVisible(1)</c> calls) —
/// verbatim off the decompiled switch's case list at
/// <c>0x004e9450</c>.</summary>
private static readonly IReadOnlySet<uint> HeritageTabShowButtonIds = new HashSet<uint>
{
0x100003BFu, 0x100003C1u, 0x100003C2u, 0x100003C3u,
0x10000590u, 0x10000591u, 0x100005A9u, 0x100005BFu,
0x100005C4u, 0x100005E8u,
};
/// <summary>HIDE ids (@0x004e96b9, three <c>SetVisible(0)</c> calls) —
/// the Olthoi/OlthoiAcid heritage buttons.</summary>
private static readonly IReadOnlySet<uint> HeritageTabHideButtonIds = new HashSet<uint>
{
0x100005C7u, 0x100005C8u,
};
/// <summary>
/// Ports <c>gmCharGenMainUI::ListenToElementMessage @ 0x004e9450</c>'s
/// heritage-button tab-restore arm: heritage-button clicks bubble to
/// the master shell and SYNCHRONOUSLY show/hide the Profession/Skills/
/// Town tabs, independent of <see cref="ApplyProgressState"/>'s own
/// tab-visibility recompute at page-switch time (that recompute only
/// runs when Back/Next/a tab is clicked — not on every heritage pick).
/// Retail quirk reproduced faithfully: Lugian's button id
/// (<c>0x100005f1</c>) sits OUTSIDE both the SHOW and HIDE case lists
/// in the decompiled switch, so clicking Lugian neither restores nor
/// hides the tabs — a genuine retail bug (the tabs stay in whatever
/// state the PREVIOUS heritage selection left them), not an acdream
/// omission. Review fix round F3 (2026-08-15): this arm was entirely
/// unported — before this fix, selecting a human heritage right after
/// Olthoi/OlthoiAcid left the tabs hidden until the next Back/Next/tab
/// click recomputed them.
/// </summary>
private void ApplyHeritageTabRestore(uint buttonElementId)
{
if (HeritageTabShowButtonIds.Contains(buttonElementId))
{
_professionTab.Visible = true;
_skillsTab.Visible = true;
_townTab.Visible = true;
}
else if (HeritageTabHideButtonIds.Contains(buttonElementId))
{
_professionTab.Visible = false;
_skillsTab.Visible = false;
_townTab.Visible = false;
}
// Else (including Lugian, 0x100005f1): no-op, matching retail.
}
private void ReconcileDialogs(RuntimeCharacterCreationSnapshot snapshot)
{
// Local-refusal / rejection surfacing is CC5's Summary-page job
@ -622,9 +690,8 @@ internal sealed class CharacterCreationUiController : IDisposable
if (_active)
{
_active = false;
_isOpen = false;
_openOnStartConsumed = false;
Root.Visible = false;
Close();
}
CloseAllDialogs(suppressCallbacks: true);
}