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; public bool IsRotating => _rotating; public ChargenRotateDirection Direction => _direction; /// Retail's m_fCurHeading, degrees, ctor default 0 — /// applied to the preview entity via MoveToMath.SetHeading /// (CPhysicsObj::set_heading's exact port). 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); }