using System.Numerics; using AcDream.Core.Physics.Motion; namespace AcDream.App.Rendering; /// /// Retail's toggle direction enum /// (gmBarberUI::ERotateDirection/gmCGAppearancePage::ERotateDirection /// typedef alias, acclient.h:6848-6852,6960): Invalid=0, /// Clockwise=1, CounterClockwise=2. /// internal enum ChargenRotateDirection { Invalid = 0, Clockwise = 1, CounterClockwise = 2, } /// /// Presentation-free port of gmCGAppearancePage::Rotate /// (0x0047CB50) + DoRotation (0x0047CA80) — the /// button-toggled continuous rotation retail applies to the preview /// CHARACTER's heading (CPhysicsObj::set_heading inside /// gmCG3DView::Update, pseudo-C ~0x0047eecf1), not the camera (see /// 's own doc comment on why rotation /// lives here instead). Retail drives once per frame from /// a global-message-3 tick while is set /// (gmCGAppearancePage::ListenToGlobalMessage @ 0x0047CED0); the /// CC6b page-mount half will bind the Rotate Clockwise/Counter-Clockwise /// buttons to and the render loop to . /// internal sealed class ChargenPreviewRotationController { /// /// Rotate's explicit sentinel write /// (this->m_dLastRotateTime = -1.0, pseudo-C ~0x0047cba7/0x0047cbb1 /// — the high dword 0xbff00000 paired with a zero low dword is the /// exact IEEE-754 bit pattern for -1.0) — invalidates the /// timestamp so the very next resets it to "now" /// (a zero-length first delta) instead of computing a huge jump from a /// stale or never-set value. /// private const double InvalidTimeSentinel = -1.0; private double _lastRotateTime = InvalidTimeSentinel; private ChargenRotateDirection _direction = ChargenRotateDirection.Invalid; private bool _rotating; /// /// CC6b-MOUNT: retail's true OPERATIVE starting heading — not the ctor's /// value. gmCGAppearancePage::gmCGAppearancePage @0x0047CCC0 sets /// m_fCurHeading = 0f at 0x0047CDAC, but /// gmCGAppearancePage::InitializePage @0x0047FDD0 — which always /// runs immediately afterward, before the page is ever visible — writes /// m_fCurHeading = 180f at 0x00480235 and pushes it into the /// view via gmCG3DView::SetPlayerHeading(m_p3DView, 180f) at /// 0x0048023F. 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 gmCG3DView owner does for ITS own instance: /// gmCGSummaryPage::InitializePage @0x0047BD54 (a separate /// viewport/page, CC5's scope, not this one) and gmBarberUI /// corroborate 180 TWICE, in two separate functions (fix round F4 /// correction — the original citation here wrongly attributed both /// writes to PostInit): gmBarberUI::PostInit @0x004de2e0 /// has its OWN m_fCurHeading = 180f write at 0x004de330 /// (no push there — PostInit ends right after that assignment); /// separately, gmBarberUI::InitializePage @0x004e0040 has its OWN /// redundant m_fCurHeading = 180f write at 0x004e03ab, /// THEN pushes it via SetPlayerHeading(m_p3DView, 180f) at /// 0x004e03b5 — the address the original citation attributed to /// PostInit. Two functions, both landing on 180, not one /// function pushing from the other's write. Since /// this controller — like retail's m_fCurHeading — 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). /// public const float RetailDefaultHeadingDegrees = 180f; public bool IsRotating => _rotating; public ChargenRotateDirection Direction => _direction; /// Defaults to /// (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 0f explicitly for simpler /// relative-delta assertions; that is a test convenience, not a second /// retail-cited default. public ChargenPreviewRotationController( float initialHeadingDegrees = RetailDefaultHeadingDegrees) { HeadingDegrees = initialHeadingDegrees; } /// Retail's m_fCurHeading, degrees — applied to the /// preview entity via MoveToMath.SetHeading /// (CPhysicsObj::set_heading's exact port). See /// for why this controller's /// parameterless-constructor default is 180, not the ctor's raw 0. /// public float HeadingDegrees { get; private set; } /// /// gmCGAppearancePage::Rotate @ 0x0047CB50: 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 m_dLastRotateTime per this class's own sentinel /// doc. /// public void Toggle(ChargenRotateDirection direction) { if (_rotating && direction == _direction) { _rotating = false; return; } _direction = direction; _lastRotateTime = InvalidTimeSentinel; _rotating = true; } /// /// gmCGAppearancePage::DoRotation @ 0x0047CA80: per-tick /// deltaDegrees = ((now - lastRotateTime) / RotationSecondsPerRevolution) /// * 360, added for /// and subtracted for every other direction (pseudo-C ~0x0047cacd: /// if (m_eRotateDir != ECG_ROTATE_CLOCKWISE) heading -= delta; else /// heading += delta;), then a SINGLE-PASS clamp back into /// [0, 360) — 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 x87_r7_1 = x87_r6_3 at 0x0047CAEB /// inside the counter-clockwise branch — reassigning the local that held /// the "now" timestamp to the just-computed delta-degrees value — which /// would make the 0x0047CB3D store into m_dLastRotateTime /// 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 /// claude-memory/feedback_bn_decomp_field_names.md names exactly /// this x87-stack-register mislabeling as a known decompiler artifact /// class), so this port stores now into _lastRotateTime /// unconditionally in BOTH directions. /// 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; } /// CPhysicsObj::set_heading's exact quaternion /// construction — the SAME shared Core primitive retail movement already /// ports (). public Quaternion ToOrientation() => MoveToMath.SetHeading(Quaternion.Identity, HeadingDegrees); }