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

@ -147,8 +147,12 @@ internal sealed class CharacterCreationProfessionPage : IDisposable
foreach ((ChargenAttributeId attribute, SliderWidgets widgets) in _sliders)
{
int value = GetAttribute(snapshot.Attributes, attribute);
float scalar = (value - ChargenAttributeMath.AttributeMin)
/ (float)(ChargenAttributeMath.AttributeMax - ChargenAttributeMath.AttributeMin);
// gmCGProfessionPage::UpdateAttributeValues @ 0x0048251d:
// SetAttribute_Float(pSlider, 0x86, value * 0.00999999978f) —
// scalar = value/100, NOT (value-AttributeMin)/(AttributeMax-
// AttributeMin). Review fix round F2 (2026-08-15): the earlier
// [10,100]<->[0,1] normalization here did not match retail.
float scalar = value / 100f;
widgets.Slider?.SetScalarPosition(scalar);
widgets.Value?.SetText(value.ToString(CultureInfo.InvariantCulture));
if (widgets.Lock is { } lockButton)
@ -211,14 +215,21 @@ internal sealed class CharacterCreationProfessionPage : IDisposable
_bindings.SelectTemplate(templateIndex);
}
/// <summary>
/// gmCGProfessionPage::ListenToElementMessage @ 0x004829c0, the
/// scrollbar-drag case (relative id 0x100002ee, idMessage 0xa):
/// <c>ebx = _ftol2(param*100); if (ebx &lt; 0xa) ebx = 0xa;
/// SetAttribValue(this, parent, ebx)</c> — truncate (not round) the
/// scalar times 100, clamp LOW only to 10, with NO upper clamp/rescale.
/// Review fix round F2 (2026-08-15): the earlier
/// AttributeMin+Round(scalar*(Max-Min)) formula here did not match
/// retail (it only happened to agree with retail at scalar=1).
/// </summary>
private void SetAttributeFromScalar(ChargenAttributeId attribute, float scalar)
{
if (_disposed)
return;
int value = ChargenAttributeMath.AttributeMin
+ (int)MathF.Round(
scalar * (ChargenAttributeMath.AttributeMax - ChargenAttributeMath.AttributeMin),
MidpointRounding.AwayFromZero);
int value = Math.Max(ChargenAttributeMath.AttributeMin, (int)(scalar * 100f));
_bindings.SetAttribute(attribute, value);
}