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:
parent
0e71d3b829
commit
ec854db045
12 changed files with 419 additions and 30 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -643,6 +643,19 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
|
|||
d.DebugFont,
|
||||
controls,
|
||||
iconComposer);
|
||||
// Review fix round F12 (2026-08-15): constructed ONCE per
|
||||
// composition and captured by the ResolveText closure below,
|
||||
// rather than a fresh DatStringResolver per lookup. The
|
||||
// Heritage/Town pages' description composers each call
|
||||
// ResolveText several times per Refresh, and CharacterCreation-
|
||||
// UiController.ApplyProgressState forces a full refresh on
|
||||
// every page switch (`_lastRevision = long.MinValue`) — so an
|
||||
// unchached resolver meant several fresh allocations + DatLock
|
||||
// acquisitions per click. DatStringResolver's own constructor
|
||||
// does no DAT I/O (only .Resolve reads), so building it here
|
||||
// outside the lock matches this file's existing pattern
|
||||
// elsewhere (construct once, lock only around Resolve calls).
|
||||
var characterCreationStrings = new DatStringResolver(d.Dats);
|
||||
var bindings = new RetailUiRuntimeBindings(
|
||||
Host: host,
|
||||
Assets: assets,
|
||||
|
|
@ -987,7 +1000,7 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
|
|||
{
|
||||
lock (d.DatLock)
|
||||
{
|
||||
return new DatStringResolver(d.Dats).Resolve(
|
||||
return characterCreationStrings.Resolve(
|
||||
0x23000002u,
|
||||
DatStringResolver.ComputeHash(key));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -73,21 +73,35 @@ internal sealed class CharacterCreationHeritagePage : IDisposable
|
|||
};
|
||||
|
||||
private readonly CharacterCreationRuntimeBindings _bindings;
|
||||
private readonly Action<uint> _onButtonClicked;
|
||||
private readonly Dictionary<UiButton, uint> _buttons = [];
|
||||
private readonly UiText? _description;
|
||||
private bool _disposed;
|
||||
|
||||
/// <param name="onButtonClicked">Review fix round F3 (2026-08-15):
|
||||
/// invoked with the RAW button element id (not the resolved heritage
|
||||
/// id) on every heritage-button click, before <see cref="Select"/>
|
||||
/// runs — mirrors retail's message bubbling from
|
||||
/// <c>gmCGHeritagePage::ListenToElementMessage</c> up to
|
||||
/// <c>gmCharGenMainUI::ListenToElementMessage</c>'s own tab-restore
|
||||
/// arm, which is keyed on the same raw id.</param>
|
||||
internal CharacterCreationHeritagePage(
|
||||
UiElement pageRoot,
|
||||
CharacterCreationRuntimeBindings bindings)
|
||||
CharacterCreationRuntimeBindings bindings,
|
||||
Action<uint> onButtonClicked)
|
||||
{
|
||||
_bindings = bindings;
|
||||
_onButtonClicked = onButtonClicked;
|
||||
foreach ((uint buttonId, uint heritageId) in HeritageByButtonId)
|
||||
{
|
||||
if (UiElement.FindDescendant(pageRoot, buttonId) is not UiButton button)
|
||||
continue;
|
||||
_buttons[button] = heritageId;
|
||||
button.OnClick = () => Select(heritageId);
|
||||
button.OnClick = () =>
|
||||
{
|
||||
_onButtonClicked(buttonId);
|
||||
Select(heritageId);
|
||||
};
|
||||
}
|
||||
|
||||
_description = UiElement.FindDescendant(pageRoot, 0x100003C4u) as UiText;
|
||||
|
|
|
|||
|
|
@ -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 < 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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -40,7 +40,32 @@ internal sealed class CharacterCreationTownPage : IDisposable
|
|||
[3] = "ID_CharGen_SanamarText",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Start-area index -> the page's OWN retail state literal — a
|
||||
/// SEPARATE state machine from <c>CharacterCreationUiController</c>'s
|
||||
/// master-page per-page-index cycling
|
||||
/// (<c>0x10000025 + (page - 1)</c>). <c>gmCGTownPage::SetTown @
|
||||
/// 0x0047c360</c> calls <c>this->vtable->SetState(...)</c> (the
|
||||
/// gmCGTownPage/page-root object itself) with these four literals
|
||||
/// verbatim, alongside the per-button highlight state — note these do
|
||||
/// NOT sit in button/startArea numeric order: Holtburg->0x10000034,
|
||||
/// Shoushi->0x10000037, Yaraq->0x10000036, Sanamar->0x10000035.
|
||||
/// Re-asserted directly (inlined, bypassing SetTown) at the Sanamar
|
||||
/// click site @0x0047c518. Review fix round F4 (2026-08-15): only the
|
||||
/// master page's state cycling was ported — this page's own state was
|
||||
/// missed entirely.
|
||||
/// </summary>
|
||||
private static readonly IReadOnlyDictionary<int, uint> PageStateByStartArea =
|
||||
new Dictionary<int, uint>
|
||||
{
|
||||
[0] = 0x10000034u, // Holtburg
|
||||
[1] = 0x10000037u, // Shoushi
|
||||
[2] = 0x10000036u, // Yaraq
|
||||
[3] = 0x10000035u, // Sanamar
|
||||
};
|
||||
|
||||
private readonly CharacterCreationRuntimeBindings _bindings;
|
||||
private readonly UiElement _pageRoot;
|
||||
private readonly Dictionary<UiButton, int> _buttons = [];
|
||||
private readonly UiText? _description;
|
||||
private bool _disposed;
|
||||
|
|
@ -50,6 +75,7 @@ internal sealed class CharacterCreationTownPage : IDisposable
|
|||
CharacterCreationRuntimeBindings bindings)
|
||||
{
|
||||
_bindings = bindings;
|
||||
_pageRoot = pageRoot;
|
||||
foreach ((uint buttonId, int startArea) in StartAreaByButtonId)
|
||||
{
|
||||
if (UiElement.FindDescendant(pageRoot, buttonId) is not UiButton button)
|
||||
|
|
@ -68,6 +94,12 @@ internal sealed class CharacterCreationTownPage : IDisposable
|
|||
foreach ((UiButton button, int startArea) in _buttons)
|
||||
button.Selected = startArea == snapshot.StartArea;
|
||||
|
||||
if (PageStateByStartArea.TryGetValue(snapshot.StartArea, out uint pageStateId)
|
||||
&& _pageRoot is IUiDatStateful stateful)
|
||||
{
|
||||
stateful.TrySetRetailState(pageStateId);
|
||||
}
|
||||
|
||||
if (_description is null)
|
||||
return;
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1715,10 +1715,10 @@ public static class ItemAppraisalTextFormatter
|
|||
_ => string.Empty,
|
||||
};
|
||||
|
||||
/// <summary><c>AppraisalSystem::SkillToString @ 0x005B4A30</c>.</summary>
|
||||
/// <summary>Retail skill-id -> display-name table. Made <c>internal</c>
|
||||
/// (Campaign CC slice CC4) so the chargen Skills page can reuse the
|
||||
/// same names instead of duplicating this table.</summary>
|
||||
/// <summary><c>AppraisalSystem::SkillToString @ 0x005B4A30</c> — retail
|
||||
/// skill-id -> display-name table. Made <c>internal</c> (Campaign CC
|
||||
/// slice CC4) so the chargen Skills page can reuse the same names
|
||||
/// instead of duplicating this table.</summary>
|
||||
internal static string SkillName(int skill) => skill switch
|
||||
{
|
||||
1 => "Axe",
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ using AcDream.Headless.Credentials;
|
|||
using AcDream.Headless.Diagnostics;
|
||||
using AcDream.Headless.Plugins;
|
||||
using AcDream.Headless.Policies;
|
||||
using AcDream.Content.CharGen;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Runtime;
|
||||
|
|
@ -310,6 +311,22 @@ internal sealed class HeadlessSessionHost : IDisposable
|
|||
{
|
||||
runtime.CharacterOwner.InstallSpellMetadata(
|
||||
content.MagicCatalog.SpellTable);
|
||||
// Review fix round F6 (2026-08-15): mirrors the spell-
|
||||
// metadata install directly above — without this, a
|
||||
// content-bearing headless host's ChargenOptions stayed
|
||||
// ChargenOptions.Empty (LiveSessionController's own
|
||||
// construction default) forever, so
|
||||
// RuntimeCharacterCreationState refused every chargen
|
||||
// command (TrySelectHeritage etc. all validate against
|
||||
// Options) even though CharacterCreated/CreationFailed were
|
||||
// already wired below. A content-less host (contentLease is
|
||||
// null, e.g. a bot that never needs to create a character)
|
||||
// is still a validated-legal configuration per the R9 note
|
||||
// near _contentLease's other reads — it simply cannot issue
|
||||
// chargen commands, matching a content-less host's existing
|
||||
// inability to resolve spell/collision data either.
|
||||
runtime.Session.CharacterCreationState.InstallOptions(
|
||||
ChargenTableReader.Load(content.Dats));
|
||||
}
|
||||
gameplay.Bind(
|
||||
runtime,
|
||||
|
|
|
|||
|
|
@ -915,6 +915,9 @@ public sealed class LiveSessionController
|
|||
_inWorld = true;
|
||||
_activeSelection = selection;
|
||||
CharacterSelectionState.CompleteEnter(selection.CharacterId);
|
||||
// CC4 review-fix F1: same in-world edge as selection's own
|
||||
// CompleteEnter above.
|
||||
CharacterCreationState.CompleteEnter();
|
||||
host.ApplyEnteredWorld(selection);
|
||||
if (!IsCurrent(scope, generation))
|
||||
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
|
||||
|
|
@ -1206,6 +1209,10 @@ public sealed class LiveSessionController
|
|||
_inWorld = true;
|
||||
_activeSelection = selection;
|
||||
CharacterSelectionState.CompleteEnter(character.CharacterId);
|
||||
// CC4 review-fix F1: covers BOTH callers of this shared core
|
||||
// (EnterSelectedCore and EnterCreatedCharacterCore) — the same
|
||||
// in-world edge as selection's own CompleteEnter above.
|
||||
CharacterCreationState.CompleteEnter();
|
||||
scope.Host.ApplyEnteredWorld(selection);
|
||||
if (!IsCurrent(scope, generation))
|
||||
return CharacterSelectionResult(RuntimeCommandStatus.Inactive);
|
||||
|
|
|
|||
|
|
@ -400,6 +400,39 @@ public sealed class RuntimeCharacterCreationState : IDisposable
|
|||
Publish(RuntimeCharacterCreationDeltaKind.Reset);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign CC slice CC4 review-fix round (F1): the character-creation
|
||||
/// analogue of <see cref="RuntimeCharacterSelectionState.CompleteEnter"/>.
|
||||
/// Unlike selection (whose <c>IsActive</c> is computed from a
|
||||
/// <c>Lifecycle</c> enum that already has an <c>InWorld</c> state),
|
||||
/// creation has no lifecycle enum — this flips <see cref="_active"/>
|
||||
/// (and therefore <see cref="RuntimeCharacterCreationSnapshot.IsActive"/>)
|
||||
/// straight to <see langword="false"/>, mirroring selection's OBSERVABLE
|
||||
/// effect at the same call sites (<c>LiveSessionController.StartCore</c>
|
||||
/// and <c>EnterHighlightedCore</c>, both already call
|
||||
/// <c>CharacterSelectionState.CompleteEnter</c> at the exact point the
|
||||
/// session transitions in-world). Session field data (heritage/gender/
|
||||
/// name/etc.) is left untouched — only <see cref="Reset"/> clears it,
|
||||
/// matching selection's own CompleteEnter, which does not clear its
|
||||
/// roster either. Before this fix, nothing ever cleared
|
||||
/// <see cref="_active"/> between <see cref="Begin"/> and the NEXT
|
||||
/// <see cref="Reset"/>/<see cref="Dispose"/>, so the creation view
|
||||
/// reported active for an entire in-world session — the CC4 review's
|
||||
/// F1 finding (a permanently re-pinned <c>UiRoot.FixedCanvasSize</c>
|
||||
/// once the chargen screen had ever been opened).
|
||||
/// </summary>
|
||||
internal void CompleteEnter()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_disposed || !_active)
|
||||
return;
|
||||
_active = false;
|
||||
_revision++;
|
||||
}
|
||||
Publish(RuntimeCharacterCreationDeltaKind.StateChanged);
|
||||
}
|
||||
|
||||
internal void Reset(RuntimeGenerationToken generation)
|
||||
{
|
||||
lock (_gate)
|
||||
|
|
|
|||
|
|
@ -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->0x10000034, Yaraq->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]<->[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;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue