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

@ -312,6 +312,192 @@ public sealed class CharacterCreationUiControllerTests
Assert.Equal(2, environment.Runtime.LastSelectedStartArea);
}
/// <summary>Review fix round F4 (2026-08-15): <c>gmCGTownPage::SetTown
/// @ 0x0047c360</c> also sets the TOWN PAGE'S OWN retail state via a
/// literal per-town map — Holtburg-&gt;0x10000034, Yaraq-&gt;0x10000036 —
/// SEPARATE from the master page's per-page-index cycling
/// (<c>0x10000025+page</c>, already covered by the page-switch tests
/// above).</summary>
[Fact]
public void TownButton_Refresh_SetsThePagesOwnRetailStateLiteral()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
environment.TabButton(CharacterCreationUiController.TownTabElementId)
.OnClick!();
var pageRoot = Assert.IsType<UiDatElement>(
environment.Page(CharacterCreationUiController.TownPageElementId));
environment.Button(0x1000040Du).OnClick!(); // Holtburg -> startArea 0
BumpRevisionAndTick(environment);
Assert.Equal("Holtburg", pageRoot.ActiveState);
environment.Button(0x1000040Eu).OnClick!(); // Yaraq -> startArea 2
BumpRevisionAndTick(environment);
Assert.Equal("Yaraq", pageRoot.ActiveState);
}
/// <summary>Review fix round F2 (2026-08-15), the display direction:
/// <c>gmCGProfessionPage::UpdateAttributeValues @ 0x0048251d</c> sets
/// the slider's scalar position to <c>value * 0.00999999978f</c>
/// (value/100), not a [10,100]-to-[0,1] rescale.</summary>
[Fact]
public void ProfessionSlider_Refresh_DisplaysScalarAsValueOverOneHundred()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
environment.Runtime.SelectHeritageDirect(AluvianId);
environment.TabButton(CharacterCreationUiController.ProfessionTabElementId)
.OnClick!();
UiElement strengthContainer = Assert.IsAssignableFrom<UiElement>(
environment.Screen.FindElement(0x100003E6u));
var slider = Assert.IsType<UiScrollbar>(
UiElement.FindDescendant(strengthContainer, 0x100002EEu));
RuntimeCharacterCreationSnapshot snapshot = environment.Runtime.View.Snapshot;
environment.Runtime.View.Snapshot = snapshot with
{
Revision = snapshot.Revision + 1,
Attributes = snapshot.Attributes with { Strength = 55 },
};
environment.Controller.Tick();
Assert.Equal(0.55f, slider.ScalarPosition);
}
/// <summary>Review fix round F2 (2026-08-15), the drag-inverse
/// direction: <c>ListenToElementMessage @ 0x004829c0</c>'s scrollbar-
/// drag case truncates <c>scalar*100</c> and clamps LOW only to 10 —
/// NOT the [10,100]&lt;-&gt;[0,1] rescale the previous (wrong) formula
/// used, which only coincidentally agreed with the correct one at
/// scalar=1 (the pre-existing
/// <see cref="ProfessionSlider_ScalarChange_SetsTheAttribute"/> case).</summary>
[Theory]
[InlineData(0.5f, 50)]
[InlineData(0f, 10)]
public void ProfessionSlider_ScalarChange_TruncatesAndClampsLowOnly(
float scalar,
int expectedValue)
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
environment.Runtime.SelectHeritageDirect(AluvianId);
environment.TabButton(CharacterCreationUiController.ProfessionTabElementId)
.OnClick!();
UiElement strengthContainer = Assert.IsAssignableFrom<UiElement>(
environment.Screen.FindElement(0x100003E6u));
var slider = Assert.IsType<UiScrollbar>(
UiElement.FindDescendant(strengthContainer, 0x100002EEu));
slider.ScalarChanged!(scalar);
Assert.Equal(ChargenAttributeId.Strength, environment.Runtime.LastAttributeSet);
Assert.Equal(expectedValue, environment.Runtime.LastAttributeValue);
}
/// <summary>Review fix round F3 (2026-08-15):
/// <c>gmCharGenMainUI::ListenToElementMessage @ 0x004e9450</c>'s
/// heritage-button bubble arm shows/hides the Profession/Skills/Town
/// tabs SYNCHRONOUSLY at click time — independent of
/// <see cref="OlthoiHeritage_HidesProfessionSkillsAndTownTabs"/>'s
/// page-switch-time recompute (no tab/Back/Next click happens in this
/// test at all). Lugian (<c>0x100005f1</c>) sits outside BOTH the SHOW
/// and HIDE case lists in the decompiled switch — a genuine retail
/// quirk, reproduced faithfully.</summary>
[Fact]
public void HeritageButtonClick_RestoresHiddenTabsAtClickTime_ExceptLugian()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
environment.Button(0x100005C7u).OnClick!(); // Olthoi -> HIDE
Assert.False(environment.TabButton(
CharacterCreationUiController.ProfessionTabElementId).Visible);
Assert.False(environment.TabButton(
CharacterCreationUiController.SkillsTabElementId).Visible);
Assert.False(environment.TabButton(
CharacterCreationUiController.TownTabElementId).Visible);
environment.Button(0x100005F1u).OnClick!(); // Lugian -> no-op quirk
Assert.False(environment.TabButton(
CharacterCreationUiController.ProfessionTabElementId).Visible);
Assert.False(environment.TabButton(
CharacterCreationUiController.SkillsTabElementId).Visible);
Assert.False(environment.TabButton(
CharacterCreationUiController.TownTabElementId).Visible);
environment.Button(0x100003BFu).OnClick!(); // Aluvian -> SHOW
Assert.True(environment.TabButton(
CharacterCreationUiController.ProfessionTabElementId).Visible);
Assert.True(environment.TabButton(
CharacterCreationUiController.SkillsTabElementId).Visible);
Assert.True(environment.TabButton(
CharacterCreationUiController.TownTabElementId).Visible);
}
/// <summary>Review fix round F1 (2026-08-15): <c>Open()</c> sets
/// <c>UiRoot.FixedCanvasSize</c> once on the activation edge (matching
/// <c>CharacterManagementUiController</c>'s real, non-per-tick shape);
/// <c>Close()</c>/<c>Deactivate()</c>/<c>Dispose()</c> null it back out
/// symmetrically. Before this fix nothing ever nulled it, so an
/// 800x600-scaled canvas silently covered the in-world UI for the rest
/// of the session once this screen had ever been opened.</summary>
[Fact]
public void Open_SetsFixedCanvas_ExitConfirmClosesAndNullsIt()
{
using var environment = new EnvironmentHarness();
Assert.Null(environment.Host.FixedCanvasSize);
environment.Controller.Open();
Assert.Equal(new Vector2(800f, 600f), environment.Host.FixedCanvasSize);
environment.Button(CharacterCreationUiController.ExitElementId).OnClick!();
environment.ConfirmActiveDialog(confirmed: true);
Assert.Null(environment.Host.FixedCanvasSize);
}
[Fact]
public void Deactivate_NullsFixedCanvas_AndClosesTheScreen()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();
Assert.NotNull(environment.Host.FixedCanvasSize);
// Runtime reporting the view inactive/gone (e.g. entering the
// world) must Deactivate -- previously nothing drove this because
// RuntimeCharacterCreationState had no CompleteEnter() analogue;
// this test exercises the CONTROLLER side of that fix directly by
// simulating the view disappearing.
environment.Runtime.ProvideView = false;
environment.Controller.Tick();
Assert.False(environment.Controller.Root.Visible);
Assert.Null(environment.Host.FixedCanvasSize);
}
[Fact]
public void Dispose_NullsFixedCanvas()
{
var environment = new EnvironmentHarness();
environment.Controller.Open();
Assert.NotNull(environment.Host.FixedCanvasSize);
environment.Dispose();
Assert.Null(environment.Host.FixedCanvasSize);
}
private static void BumpRevisionAndTick(EnvironmentHarness environment)
{
RuntimeCharacterCreationSnapshot snapshot = environment.Runtime.View.Snapshot;
environment.Runtime.View.Snapshot = snapshot with { Revision = snapshot.Revision + 1 };
environment.Controller.Tick();
}
private static IEnumerable<UiElement> Descendants(UiElement root)
{
yield return root;
@ -680,6 +866,7 @@ public sealed class CharacterCreationUiControllerTests
};
page.Children.Add(ButtonInfo(0x100003BFu)); // Aluvian
page.Children.Add(ButtonInfo(0x100005C7u)); // Olthoi
page.Children.Add(ButtonInfo(0x100005F1u)); // Lugian (F3 quirk: no tab-restore/hide)
page.Children.Add(TextInfo(0x100003C4u));
return page;
}
@ -749,6 +936,14 @@ public sealed class CharacterCreationUiControllerTests
page.Children.Add(ButtonInfo(0x1000040Eu)); // Yaraq
page.Children.Add(ButtonInfo(0x1000040Fu)); // Shoushi
page.Children.Add(TextInfo(0x10000409u));
// F4: the page ROOT's own retail state literal map
// (gmCGTownPage::SetTown @ 0x0047c360), a separate state machine
// from the master page's per-page-index cycling.
page.States[0x10000034u] = new UiStateInfo { Id = 0x10000034u, Name = "Holtburg" };
page.States[0x10000035u] = new UiStateInfo { Id = 0x10000035u, Name = "Sanamar" };
page.States[0x10000036u] = new UiStateInfo { Id = 0x10000036u, Name = "Yaraq" };
page.States[0x10000037u] = new UiStateInfo { Id = 0x10000037u, Name = "Shoushi" };
return page;
}