acdream/src/AcDream.App/Rendering/ChargenPreviewRotationController.cs
Erik d2a71152d2 fix(chargen): Campaign CC CC6b-MOUNT review fix round — F1-F13
Fixes every finding from the dual-lens review of 34c6fceab0 (architectural
PASS-with-items, retail-fidelity FAIL). Re-derived every decomp citation
against docs/research/named-retail/acclient_2013_pseudo_c.txt directly
rather than trusting the reviewer's transcription.

Wrap/normalize semantics (F1): CycleIndex's decrement-from-Unset landed on
0; the decomp's shared decrement tail (label_47f065/label_47f6d9, the same
switch the headgear ring was ported from) computes new=cur-1=-2 on the raw
signed int32, which wraps to count-1 — matching headgear's own ring shape.
Also ports the spin body-click normalize-and-write-back retail's cases
0xa5-0xae all share (NormalizeChoiceOnSelect), which acdream had dropped
entirely. Flips the one test that pinned the wrong expectation and adds
select-zone coverage no prior test isolated.

Heritage gate (F3): Update's Gearknight/Olthoi/OlthoiAcid branches reset
SetChoice(FACE)/SetSelection(HAIR) unconditionally, not only when Clothes
was showing — a conditional gate stranded Nose/Mouth as the current part
under a Face-tab session.

Doc corrections propagated everywhere they repeated (F4, F5, F6, plus the
plan doc's own CC6b-MOUNT ledger row for F1/F3): the gmBarberUI heading
citation conflated PostInit with InitializePage; Random's Appearance
disable was mislabeled a placeholder when it's really AP-212's unported
RandomizeAppearance/RandomizeClothing gap; the master-page doc still called
the Appearance page content-inert after this campaign made it real.

Visual substitutions widened (F2): AP-215 named only two of the Appearance
page's swatch/spin substitutions. Ports the two cheap ones directly —
current-part highlight via SetSelection's SetState(1)/SetState(6), routed
through the existing UiButtonStateMachine.Normal/Highlight ids and
IUiDatStateful.TrySetRetailState seam (installed-DAT-confirmed
ToggleBehavior=true on all nine spins); the shade scrollbar's SetVisible(0)
for Eyes vs acdream's Enabled=false. Files the other five (DoColorSpots,
the inert GradCircle, spin-caption/heritage-caption loss, the Skin-spin
MoveTo reposition, the Gearknight-boundary randomize calls) as new register
rows AP-216..AP-220 and corrects the plan doc's false claim that AP-215
already named the GradCircle.

Unlocked DAT read (F7, BLOCKER): ChargenPreviewController.Rebuild called
ChargenAppearanceFactory.TryCompose outside _datLock while the very next
line correctly locked TryBuildAnimated — CC6a's own F4 class of bug,
reintroduced at this catalog's first production call site. Wrapped in the
same lock; documented the invariant on ChargenAppearanceCatalog itself.

One-shot preview mount (F8): LivePresentationComposition reads
ChargenPreviewViewportWidget once, but its underlying mount
(CharacterCreationUiMountCoordinator) is explicitly retryable while this
GPU-resource composition pass is not — unlike PaperdollViewportWidget,
which IS eager/non-retryable, so the "mirrors Paperdoll" doc claim was
false. Retrofitting cross-frame retry here would mean restructuring this
composition's one-shot contract for every private viewport (paperdoll,
creature appraisal) and FrameRootComposition's fixed frame-group array —
out of this round's blast radius. Corrected the doc and made the failure
loud (a diagnostic log) instead of silent.

Dispose leak (F9): ChargenPreviewController.Dispose left the preview
WorldEntity referenced by the leased renderer until the renderer's own,
later disposal. Releases it on its own teardown now.

Test-quality items (F10, F11, F13): pinned the spin arrow widths
(47px, both arrows) the 174 zone boundary is derived from, plus a
controller test for the previously-uncovered select zone. Measured the
shade scrollbar's authored orientation instead of assuming it — it is
VERTICAL (33x85) — which is a real production bug: UiScrollbar only routed
scalar-mode mouse events when Horizontal was true, so the shade control
never fired in production. Added OnVerticalScalarEvent/DrawVerticalScalar
mirroring the existing horizontal scalar path. Converted
ChargenPreviewControllerTests from silent-pass [Fact] to the shared
InstalledDatFactAttribute skip-reporting pattern.

Adjudication (F12): AD-101's retirement leaves TryBeginFinish's four local
refusals (NoName/AttributeCreditsUnspent/AlreadyPending/RosterFull) with no
heritage/gender gate — currently latent since Finish stays hard-disabled
this round. Amended the campaign plan's CC5 slice scope to require BOTH a
heritage/gender refusal AND a real RandomizeCharacter port before the
connected user gate opens Finish; noted the interaction on AP-214's own
register row. No CC5 implementation in this commit.

Gates: dotnet build -c Release green across the full solution. App suite
(Release, ACDREAM_PROBE_LIVE_MOUNT=1) 5223/3 skips, Runtime suite
1713/0 — both clean across repeated runs. A full-solution run surfaced
three pre-existing, previously-documented flakes unrelated to this change
(Streaming.LandblockBuildFactoryTests/LandblockPresentationPipelineTests
#402, Core.Net.Tests.NakEmissionTests loss soak) — each confirmed passing
in isolation, consistent with their known full-suite-parallelism-timing
history; none touch any file this commit changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 22:19:02 +02:00

176 lines
8.6 KiB
C#

using System.Numerics;
using AcDream.Core.Physics.Motion;
namespace AcDream.App.Rendering;
/// <summary>
/// Retail's toggle direction enum
/// (<c>gmBarberUI::ERotateDirection</c>/<c>gmCGAppearancePage::ERotateDirection</c>
/// typedef alias, <c>acclient.h:6848-6852,6960</c>): <c>Invalid=0</c>,
/// <c>Clockwise=1</c>, <c>CounterClockwise=2</c>.
/// </summary>
internal enum ChargenRotateDirection
{
Invalid = 0,
Clockwise = 1,
CounterClockwise = 2,
}
/// <summary>
/// Presentation-free port of <c>gmCGAppearancePage::Rotate</c>
/// (<c>0x0047CB50</c>) + <c>DoRotation</c> (<c>0x0047CA80</c>) — the
/// button-toggled continuous rotation retail applies to the preview
/// CHARACTER's heading (<c>CPhysicsObj::set_heading</c> inside
/// <c>gmCG3DView::Update</c>, pseudo-C ~0x0047eecf1), not the camera (see
/// <see cref="ChargenPreviewCamera"/>'s own doc comment on why rotation
/// lives here instead). Retail drives <see cref="Tick"/> once per frame from
/// a global-message-3 tick while <see cref="IsRotating"/> is set
/// (<c>gmCGAppearancePage::ListenToGlobalMessage @ 0x0047CED0</c>); the
/// CC6b page-mount half will bind the Rotate Clockwise/Counter-Clockwise
/// buttons to <see cref="Toggle"/> and the render loop to <see cref="Tick"/>.
/// </summary>
internal sealed class ChargenPreviewRotationController
{
/// <summary>
/// <c>Rotate</c>'s explicit sentinel write
/// (<c>this->m_dLastRotateTime = -1.0</c>, pseudo-C ~0x0047cba7/0x0047cbb1
/// — the high dword <c>0xbff00000</c> paired with a zero low dword is the
/// exact IEEE-754 bit pattern for <c>-1.0</c>) — invalidates the
/// timestamp so the very next <see cref="Tick"/> resets it to "now"
/// (a zero-length first delta) instead of computing a huge jump from a
/// stale or never-set value.
/// </summary>
private const double InvalidTimeSentinel = -1.0;
private double _lastRotateTime = InvalidTimeSentinel;
private ChargenRotateDirection _direction = ChargenRotateDirection.Invalid;
private bool _rotating;
/// <summary>
/// CC6b-MOUNT: retail's true OPERATIVE starting heading — not the ctor's
/// value. <c>gmCGAppearancePage::gmCGAppearancePage @0x0047CCC0</c> sets
/// <c>m_fCurHeading = 0f</c> at <c>0x0047CDAC</c>, but
/// <c>gmCGAppearancePage::InitializePage @0x0047FDD0</c> — which always
/// runs immediately afterward, before the page is ever visible — writes
/// <c>m_fCurHeading = 180f</c> at <c>0x00480235</c> and pushes it into the
/// view via <c>gmCG3DView::SetPlayerHeading(m_p3DView, 180f)</c> at
/// <c>0x0048023F</c>. No player-visible frame of chargen's Appearance
/// preview is EVER rendered at the ctor's 0° — 180° is the only heading a
/// user actually sees. The same override, independently, is what every
/// other <c>gmCG3DView</c> owner does for ITS own instance:
/// <c>gmCGSummaryPage::InitializePage @0x0047BD54</c> (a separate
/// viewport/page, CC5's scope, not this one) and <c>gmBarberUI</c>
/// corroborate 180 TWICE, in two separate functions (fix round F4
/// correction — the original citation here wrongly attributed both
/// writes to <c>PostInit</c>): <c>gmBarberUI::PostInit @0x004de2e0</c>
/// has its OWN <c>m_fCurHeading = 180f</c> write at <c>0x004de330</c>
/// (no push there — <c>PostInit</c> ends right after that assignment);
/// separately, <c>gmBarberUI::InitializePage @0x004e0040</c> has its OWN
/// redundant <c>m_fCurHeading = 180f</c> write at <c>0x004e03ab</c>,
/// THEN pushes it via <c>SetPlayerHeading(m_p3DView, 180f)</c> at
/// <c>0x004e03b5</c> — the address the original citation attributed to
/// <c>PostInit</c>. Two functions, both landing on 180, not one
/// function pushing from the other's write. Since
/// this controller — like retail's <c>m_fCurHeading</c> — is itself the
/// PAGE-level heading owner (not the view's), matching the value every
/// real page converges on before its first frame is the retail-faithful
/// choice; requiring every future mount site to remember a separate
/// "seed to 180" call would be a trap (a forgotten seed silently faces
/// the character away from the camera).
/// </summary>
public const float RetailDefaultHeadingDegrees = 180f;
public bool IsRotating => _rotating;
public ChargenRotateDirection Direction => _direction;
/// <param name="initialHeadingDegrees">Defaults to
/// <see cref="RetailDefaultHeadingDegrees"/> (see that constant's doc for
/// the full ctor-vs-InitializePage citation) — the value every real
/// mount site should get for free. Tests that exercise the pure
/// rotation/wrap arithmetic pass <c>0f</c> explicitly for simpler
/// relative-delta assertions; that is a test convenience, not a second
/// retail-cited default.</param>
public ChargenPreviewRotationController(
float initialHeadingDegrees = RetailDefaultHeadingDegrees)
{
HeadingDegrees = initialHeadingDegrees;
}
/// <summary>Retail's <c>m_fCurHeading</c>, degrees — applied to the
/// preview entity via <c>MoveToMath.SetHeading</c>
/// (<c>CPhysicsObj::set_heading</c>'s exact port). See
/// <see cref="RetailDefaultHeadingDegrees"/> for why this controller's
/// parameterless-constructor default is 180, not the ctor's raw 0.
/// </summary>
public float HeadingDegrees { get; private set; }
/// <summary>
/// <c>gmCGAppearancePage::Rotate @ 0x0047CB50</c>: pressing the SAME
/// direction a second time while already rotating STOPS rotation
/// (retail's button-toggle UX); any other press (opposite direction, or
/// starting from stopped) sets that direction and (re)starts,
/// invalidating <c>m_dLastRotateTime</c> per this class's own sentinel
/// doc.
/// </summary>
public void Toggle(ChargenRotateDirection direction)
{
if (_rotating && direction == _direction)
{
_rotating = false;
return;
}
_direction = direction;
_lastRotateTime = InvalidTimeSentinel;
_rotating = true;
}
/// <summary>
/// <c>gmCGAppearancePage::DoRotation @ 0x0047CA80</c>: per-tick
/// <c>deltaDegrees = ((now - lastRotateTime) / RotationSecondsPerRevolution)
/// * 360</c>, added for <see cref="ChargenRotateDirection.Clockwise"/>
/// and subtracted for every other direction (pseudo-C ~0x0047cacd:
/// <c>if (m_eRotateDir != ECG_ROTATE_CLOCKWISE) heading -= delta; else
/// heading += delta;</c>), then a SINGLE-PASS clamp back into
/// <c>[0, 360)</c> — not a full modulo loop; retail's own tail only
/// adds/subtracts 360 once (pseudo-C ~0x0047caf3-0x0047cb31), which is
/// exactly enough for any realistic per-frame delta and is reproduced
/// here verbatim rather than "improved" into a `%=`. Fix round F3: Binary
/// Ninja literally renders <c>x87_r7_1 = x87_r6_3</c> at <c>0x0047CAEB</c>
/// inside the counter-clockwise branch — reassigning the local that held
/// the "now" timestamp to the just-computed delta-degrees value — which
/// would make the <c>0x0047CB3D</c> store into <c>m_dLastRotateTime</c>
/// write delta-degrees instead of the timestamp for CCW only; that is an
/// x87-FPU-stack modeling artifact of the decompiler, not real retail
/// behavior (a shipped feature where every counter-clockwise rotation
/// visibly diverges from clockwise is implausible, and
/// <c>claude-memory/feedback_bn_decomp_field_names.md</c> names exactly
/// this x87-stack-register mislabeling as a known decompiler artifact
/// class), so this port stores <c>now</c> into <c>_lastRotateTime</c>
/// unconditionally in BOTH directions.
/// </summary>
public void Tick(double now)
{
if (!_rotating)
return;
if (_lastRotateTime <= 0d)
_lastRotateTime = now;
double deltaDegrees = ((now - _lastRotateTime) / ChargenPreviewCamera.RotationSecondsPerRevolution) * 360.0;
HeadingDegrees = _direction == ChargenRotateDirection.Clockwise
? HeadingDegrees + (float)deltaDegrees
: HeadingDegrees - (float)deltaDegrees;
if (HeadingDegrees < 0f)
HeadingDegrees += 360f;
if (HeadingDegrees > 360f)
HeadingDegrees -= 360f;
_lastRotateTime = now;
}
/// <summary><c>CPhysicsObj::set_heading</c>'s exact quaternion
/// construction — the SAME shared Core primitive retail movement already
/// ports (<see cref="MoveToMath.SetHeading"/>).</summary>
public Quaternion ToOrientation() =>
MoveToMath.SetHeading(Quaternion.Identity, HeadingDegrees);
}