using System; using System.Numerics; using AcDream.Core.Chat; using AcDream.Core.Physics; namespace AcDream.Runtime.Gameplay; /// /// Input state for a single frame of player movement. /// public readonly record struct MovementInput( bool Forward = false, bool Backward = false, bool StrafeLeft = false, bool StrafeRight = false, bool TurnLeft = false, bool TurnRight = false, bool Run = false, float MouseDeltaX = 0f, bool Jump = false); /// /// Typed construction policy for the local movement owner. Server-authoritative /// values come from ; the fallback only /// preserves the pre-description test/login baseline and never reads process /// environment state. /// public readonly record struct PlayerMovementConstructionOptions( int RunSkill, int JumpSkill) { public const int FallbackRunSkill = 200; public const int FallbackJumpSkill = 300; public static PlayerMovementConstructionOptions Fallback => new(FallbackRunSkill, FallbackJumpSkill); public static PlayerMovementConstructionOptions From( RuntimeMovementSkillSnapshot skills) => new( skills.RunSkill >= 0 ? skills.RunSkill : FallbackRunSkill, skills.JumpSkill >= 0 ? skills.JumpSkill : FallbackJumpSkill); } /// /// Read-only presentation snapshot of retail's pending jump build. The movement /// controller remains the sole owner of charge timing; retained UI only projects /// this state into gmPowerbarUI. /// public readonly record struct JumpChargeSnapshot(bool IsCharging, float Power); /// /// Result of a single frame's movement update. /// /// /// Wire vs. local animation command. ACE's MovementData /// (ACE.Server/Network/Motion/MovementData.cs) only computes /// interpState.ForwardSpeed for raw WalkForward/ /// WalkBackwards — on every other command the else branch /// passes through command without setting speed, leaving observers with /// speed=0. The client therefore has to send WalkForward /// (with HoldKey.Run for running) and let ACE auto-upgrade to /// RunForward for broadcast. But the LOCAL view wants the run /// cycle immediately, so we carry a separate /// for the player's own renderer. /// /// /// — true when the player is holding Shift to run. /// Used by the GameWindow when building the outbound MoveToState's /// CURRENT_HOLD_KEY (2=Run) vs (1=None). /// /// public readonly record struct MovementResult( Vector3 Position, Vector3 RenderPosition, uint CellId, bool IsOnGround, bool MotionStateChanged, uint? ForwardCommand, // wire-side command (WalkForward / WalkBackward / …) uint? SidestepCommand, uint? TurnCommand, float? ForwardSpeed, float? SidestepSpeed, float? TurnSpeed, bool IsRunning = false, // K-fix5 (2026-04-26): cycle-pace multiplier for the LOCAL animation // sequencer. Decoupled from ForwardSpeed so the wire can keep sending // 1.0 for WalkBackward (ACE-compatible) while the animation plays at // runRate × so the cycle visually matches the run-speed velocity. // Forward+Run = runRate (same as ForwardSpeed); Backward+Run, Strafe+Run // = runRate (where ForwardSpeed is 1.0 / null); everything else = 1.0. bool JustLanded = false, // true on the single frame we transitioned airborne → grounded float? JumpExtent = null, // non-null when a jump was triggered this frame Vector3? JumpVelocity = null, // BODY-LOCAL launch velocity (forward/right/up relative to facing) — see PlayerMovementController jump path for the inverse-yaw conversion. Server rotates body→world on broadcast. // Retail updates mouse-origin turn motions continuously but sends the // resulting movement state only on ToggleMouseLook start/stop and the // CameraSet half-second cadence. Keep packet ownership separate from the // local motion-state change so mouse sampling cannot flood the server. bool ShouldSendMovementEvent = false, // CameraSet::Rotate always calls MovePlayer with holdRun=true for the turn // axis, independently of the player's ordinary walk/run toggle. bool TurnUsesRunHold = false, // CameraInstantMouseLook remaps keyboard turn to sidestep with the same // per-axis Run hold, independently of the global walk/run toggle. bool SidestepUsesRunHold = false, // The host acknowledges this clock/queue only after the MoveToState has // actually been put on the wire. bool IsMouseLookMovementEvent = false, // CommandInterpreter::SendMovementEvent @ 0x006B4680 passes the // MovementManager's complete RawMotionState into MoveToStatePack. An // absent style bit unpacks as NonCombat, so the canonical raw style must // travel with every input-boundary snapshot sent to ACE. uint CurrentStyle = 0x8000003Du); /// /// Portal-space state for the player movement controller. /// PortalSpace freezes all movement input while the server is moving the /// player through a portal — resumed once the destination UpdatePosition /// arrives and the player is snapped to the new location. /// While in PortalSpace, Update returns immediately with a zero-movement /// result so no WASD input or physics is processed. /// public enum PlayerState { InWorld, PortalSpace } internal enum PlayerMovementControllerPublicationLifecycle { StandalonePublished, CandidatePreparing, CandidateSealed, RuntimeOwnedDormant, RuntimePublished, RuntimeRetired, Discarded, } /// /// Per-frame player movement controller. Reads input, drives the /// ported PhysicsBody + MotionInterpreter, tracks motion state for /// animation + server messages. /// /// Architecture: /// - PhysicsBody owns integration: gravity, friction, sub-stepping, /// velocity clamping — all from the decompiled retail client. /// - MotionInterpreter owns the motion state machine: walk/run/jump /// validation, state tracking, speed constants from the retail dat. /// - PhysicsEngine.ResolveWithTransition is still used each frame to snap /// the player to terrain/cell floor Z and detect ground contact (C5a, /// 2026-08-05: the legacy PhysicsEngine.Resolve this note used to name /// is deleted, zero production callers; ResolveWithTransition's /// sphere-sweep resolver is, and always was, the real per-frame path). /// public sealed class PlayerMovementController { private readonly PhysicsEngine _physics; private readonly PhysicsBody _body; private readonly MotionInterpreter _motion; private readonly PlayerWeenie _weenie; private readonly AcDream.Core.Physics.Motion.MovementManager _movementManager; private float _stepUpHeight = 0.4f; private float _stepDownHeight = 0.4f; private ObjectInfoState _ownPvpFlags = ObjectInfoState.None; private System.Collections.Immutable.ImmutableArray _sphereList; private float _objectScale = 1f; private PlayerState _state = PlayerState.InWorld; private uint _localEntityId; private AcDream.Core.Physics.Motion.PositionManager? _positionManager; /// /// Campaign CH slice CH2: reports a client-locally-detected jump refusal /// (retail's CommenceJump @0x0056AF90 / DoJump @0x0056B110 /// WeenieError family — research doc §4.2/§6.4) to the SpewBox router. /// Wired by RuntimeLocalPlayerMovementState, whose /// OnInterfaceText setter applies to every controller it installs /// (including this one, at commit time). Never invoked directly for the /// jump physics/charge behaviour itself — this is a REPORT of an /// already-decided refusal, not a gate. /// public Action? OnInterfaceText { get; set; } /// /// Maximum Z increase per movement step before the move is rejected. /// /// /// #338 (2026-08-07) — this comment previously claimed retail's /// step_up_height for humans "is ~0.4 m" and that the value is /// "set in GameWindow.cs at world-entry time". Both were false, and a /// third false claim on the SphereList doc named a /// PlayerModeController.ApplyStepHeights that has never existed /// in the tree. The measured truth: the human Setup 0x02000001 authors /// 0.600 up / 1.500 down; retail's fallback when NOT on /// walkable ground is 0.04 (CTransition::step_up /// @0x0050b655), and 0.4 appears nowhere in retail. The authoritative /// writer is RuntimeSetPositionMoverPreparation (Setup-derived, /// x scale) via RuntimeLocalPlayerPhysicsPublicationState's /// candidate, adopted by CommitRuntimeOwnedController. The 0.4f /// construction default below survives only until that adoption — a /// seconds-long window shared with AD-68's remote residency placeholder. /// Live capture: 111,248 authored-pair resolves vs 358 placeholder ones. /// /// public float StepUpHeight { get => _stepUpHeight; set { EnsureConfigurationMutable(); _stepUpHeight = value; } } /// /// L.2.3a (2026-04-29): how far below the foot the step-down probe /// reaches when transitioning between surfaces. (The original "retail's /// step_down_height is ~0.4 m" claim here was wrong — the human Setup /// authors 1.500; see 's #338 note. The /// historical observation stands: with the very first 4 cm hardcoded /// value, walking off a stair onto ground 25 cm below produced a /// one-frame contact-plane gap and a falling-animation flicker.) /// public float StepDownHeight { get => _stepDownHeight; set { EnsureConfigurationMutable(); _stepDownHeight = value; } } /// /// TS-23 (Campaign P Slice P3, 2026-07-30): the local player's own /// PK/PKLite/Impenetrable bits, decoded /// from PublicWeenieDesc._bitfield (retail OBJECTINFO::init /// 0x0050cf30 `state |= w->vtable->IsPK()/IsPKLite()/IsImpenetrable() /// ? 0x800/0x1000/0x80`). Set at world-entry and refreshed reactively /// whenever the player's own ClientObject.PublicWeenieBitfield /// changes (same trigger set as 's /// server-skill pushes). Default /// (no PK/PKLite/Impenetrable) is bit-identical to every pre-P3 caller's /// hardcoded IsPlayer | EdgeSlide — the non-PK invariant this /// port must not break. /// public ObjectInfoState OwnPvpFlags { get => _ownPvpFlags; set { EnsureConfigurationMutable(); _ownPvpFlags = value; } } /// /// TS-46 (2026-07-30): the player's own Setup ≤2-sphere list (dat /// CSphere Origin+Radius), verbatim per retail /// CPhysicsObj::transition (0x00512dc0) → /// SPHEREPATH::init_sphere (0x0050c670). Set alongside /// / by /// RuntimeLocalPlayerPhysicsPublicationState's publication /// candidate (#338: the previously-named /// PlayerModeController.ApplyStepHeights never existed). Default /// (empty) falls back to ResolveWithTransition's legacy /// (0.48, 1.835) two-scalar capsule reconstruction — the human Setup /// 0x02000001's authored spheres are (0,0,0.475) r=.48 and /// (0,0,1.350) r=.48, a 5 mm improvement over the reconstruction's /// (0,0,0.48) + (0,0,1.355). /// public System.Collections.Immutable.ImmutableArray SphereList { get => _sphereList; set { EnsureConfigurationMutable(); _sphereList = value; } } /// /// Retail CPhysicsObj::m_scale. Grounded CSequence root /// displacement is multiplied by this value before PositionManager /// composition (UpdatePositionInternal @ 0x00512C30). /// public float ObjectScale { get => _objectScale; set { EnsureConfigurationMutable(); _objectScale = value; } } /// /// Current portal-space state. Set to PortalSpace when the server sends /// PlayerTeleport (0xF751); set back to InWorld once the destination /// UpdatePosition arrives and the player is snapped to the new cell. /// While in PortalSpace, Update returns immediately with a zero-movement /// result so no WASD input or physics is processed. /// public PlayerState State { get => _state; set { EnsurePublishedForRuntimeOperation(); _state = value; } } /// /// Horizontal projection of the authoritative body quaternion. Assigning /// a yaw is the explicit retail Frame::set_heading seam and therefore /// intentionally replaces pitch/roll; ordinary object ticks never rebuild /// the quaternion from this projection. /// public float Yaw { get => AcDream.Core.Physics.Motion.MoveToMath.YawFromHeading( AcDream.Core.Physics.Motion.MoveToMath.GetHeading(_body.Orientation)); set { EnsurePublishedForRuntimeOperation(); float wrapped = value; while (wrapped > MathF.PI) wrapped -= 2f * MathF.PI; while (wrapped < -MathF.PI) wrapped += 2f * MathF.PI; _body.Orientation = AcDream.Core.Physics.Motion.MoveToMath.SetHeading( _body.Orientation, AcDream.Core.Physics.Motion.MoveToMath.HeadingFromYaw(wrapped)); } } public Vector3 Position => _body.Position; public Vector3 RenderPosition => ComputeRenderPosition(); public uint CellId { get; private set; } public AcDream.Core.Physics.Position CellPosition => _body.CellPosition; /// /// True only when the most recent visible or Hidden object update admitted /// at least one complete retail quantum. Presentation uses this to rebuild /// a Hidden part pose after HandleEnterWorld without doing per-render-frame /// work while the object clock is retaining a fragment. /// internal bool AdvancedObjectQuantumLastTick { get; private set; } /// /// Returns retail's canonical outbound Position: the physics body's /// carried cell id plus its landblock-local frame origin. Retail /// CommandInterpreter::SendMovementEvent @ 0x006B4680 and /// SendPositionEvent @ 0x006B4770 serialize /// CPhysicsObj::m_position directly; render/streaming origins are not /// part of the protocol frame. /// internal bool TryGetOutboundPosition( out AcDream.Core.Physics.Position outboundPosition) { EnsurePublishedForRuntimeOperation(); AcDream.Core.Physics.Position canonical = _body.CellPosition; outboundPosition = new AcDream.Core.Physics.Position( canonical.ObjCellId, canonical.Frame.Origin, _body.Orientation); return PositionFrameValidation.IsValid( outboundPosition.ObjCellId, outboundPosition.Frame.Origin, outboundPosition.Frame.Orientation); } /// /// Local-player entity id used to skip self-collision in the /// airborne sweep. GameWindow updates this whenever the local /// `+Acdream` entity (re)spawns. Default 0 = no filter (matches /// retail's CObjCell::find_obj_collisions self-skip when the /// caller's OBJECTINFO::object pointer is null). Without this the /// sweep collides with its own ShadowEntry registered at /// GameWindow.cs:2545 — see #42. /// public uint LocalEntityId { get => _localEntityId; set { // A same-value re-assertion is not a mutation: // RuntimeLocalPlayerFrameController.AdvanceBeforeNetwork // re-asserts the resolved id every advance tick, which on the // headless host reached a still-dormant/sealed controller and // quarantined the whole session (jump-probe repro, 2026-08-10). // A DIFFERENT id while sealed remains the error it always was. if (value == _localEntityId) return; EnsureConfigurationMutable(); _localEntityId = value; } } /// /// Applies the canonical server PhysicsState to the local body. Retail's /// CPhysicsObj::SetState replaces these persistent bits before its /// next acceleration/integration decision. /// public void ApplyPhysicsState(PhysicsStateFlags state) { EnsureConfigurationMutable(); _body.State = state; _body.calc_acceleration(); } /// /// C3c-F1 (2026-08-02): the lifecycle-deciding inbound-SetState entry /// for the local player. Live states apply the exact /// body; the dormant window drops the /// push because the activation transaction owns the dormant body's /// physics state exclusively ( /// re-reads the canonical record's FinalPhysicsState at both activation /// phases, and while the accepted SetState is queued behind the initial /// residence the App-side push carries that same unchanged record value /// — the drop is value-preserving by construction); terminal states are /// displaced pushes (J3.6 displaced-callback-rejection), never a fault. /// internal RuntimeServerPhysicsStateApplication ApplyServerPhysicsState( PhysicsStateFlags state) { switch (_publicationLifecycle) { case PlayerMovementControllerPublicationLifecycle.StandalonePublished: case PlayerMovementControllerPublicationLifecycle.CandidatePreparing: case PlayerMovementControllerPublicationLifecycle.RuntimePublished: _body.State = state; _body.calc_acceleration(); return RuntimeServerPhysicsStateApplication.AppliedLive; case PlayerMovementControllerPublicationLifecycle.RuntimeOwnedDormant: return RuntimeServerPhysicsStateApplication .DroppedDormantActivationOwned; default: return RuntimeServerPhysicsStateApplication .DroppedDisplacedController; } } public bool IsAirborne => !_body.OnWalkable; /// /// Current vertical (Z-axis) velocity of the physics body. /// Positive = rising, negative = falling. Exposed for tests and HUD. /// public float VerticalVelocity => _body.Velocity.Z; /// Full 3D world-space velocity of the physics body. Exposed for diagnostic logging. public Vector3 BodyVelocity => _body.Velocity; /// /// 2026-05-16 — current contact plane (normal + distance) for the /// physics body. Exposed so the network outbound layer can stamp /// it into for retail's diff-driven /// AP cadence: SendPositionEvent re-sends if cell OR contact-plane /// changed since last_sent, per /// acclient_2013_pseudo_c.txt:700233 ShouldSendPositionEvent. /// public System.Numerics.Plane ContactPlane => _body.ContactPlane; // Jump charge state. private bool _jumpCharging; private float _jumpExtent; // Campaign CH user-gate round 1 (item A, #329 sibling finding): previous // frame's raw Jump input, so an airborne jump press can be reported on // its RISING edge only — retail's jump_is_allowed (called from // ClientCombatSystem::DoJump @0x0056B110) refuses once per press, not // once per frame the key is held. private bool _prevJumpHeld; /// /// Current retail jump-powerbar state. Power is always zero when no jump is /// pending and otherwise lies in [0,1]. /// public JumpChargeSnapshot JumpCharge => new(_jumpCharging, _jumpCharging ? _jumpExtent : 0f); // Matching v11.4186 x86 resolves GetPowerBarLevel's collapsed x87 // operands: ATTACK_POWERUP_TIME=1.0 s, DUAL_WIELD_POWERUP_TIME=0.8 s. // Jump uses the same shared powerbar function, so its normal fill rate is // 1 extent/s and DualWieldCombat is 1/0.8 = 1.25 extent/s. private const float JumpChargeRate = 1f / (float)AcDream.Core.Combat.CombatInputPlanner.AttackPowerUpSeconds; private const float DualWieldJumpChargeRate = 1f / (float)AcDream.Core.Combat.CombatInputPlanner.DualWieldPowerUpSeconds; // Airborne → grounded transition detection. Flipped on every frame where // the body transitions from airborne to on-walkable; used by the GameWindow // to drive the landing animation cycle. private bool _wasAirborneLastFrame; // Previous frame's motion commands for change detection (wire cadence). private uint? _prevForwardCmd; private uint? _prevSidestepCmd; private uint? _prevTurnCmd; private float? _prevForwardSpeed; private bool _prevRunHold; // R3-W6: previous frame's HELD-KEY state — the edge detector feeding // retail's DoMotion/StopMotion/set_hold_run calls (retail's // CommandInterpreter altitude: motion dispatch happens on key EDGES, // never level-triggered per-frame). private bool _prevForwardHeld; private bool _prevBackwardHeld; private bool _prevStrafeLeftHeld; private bool _prevStrafeRightHeld; private bool _prevTurnLeftHeld; private bool _prevTurnRightHeld; private bool _prevRunHeld; private bool _hasInputSnapshot; // Retail CameraSet::ToggleMouseLook / Rotate (0x00457490 / 0x00458310). // Mouse look owns a transient turn command in the SAME MotionInterpreter // as keyboard and server turns; it never writes Yaw directly. The latest // filtered sample replaces the preceding sample, matching CameraSet's // per-mouse-message MovePlayer call. private bool _mouseLookActive; private bool _mouseTurnSamplePending; private float _mouseTurnAdjustment; private uint? _activeInputTurnCommand; private float _activeInputTurnSpeed; private bool _activeInputTurnFromMouse; private uint? _activeInputSidestepCommand; private bool _activeInputSidestepUsesRunHold; private bool _mouseMovementEventCandidate; private bool _mouseMovementEventPending; private float _lastMouseMovementEventTime; private bool _controlledByServer = true; /// /// Retail's mouse-look movement report interval from /// CameraSet::Rotate and MouseLookHandler. /// public const float MouseMovementEventInterval = 0.5f; private const float MouseTurnDeadZone = 0.02f; private const float MouseTurnSpeedScale = 2.0f; private const float MouseTurnMaximumSpeed = 1.5f; // Position-event comparison interval. Retail does not send an idle packet // every second: after the interval elapses it compares the complete frame // and sends only when the cell, origin, or orientation changed. /// /// 2026-05-16 — retail-faithful AP cadence. Matches retail's /// CommandInterpreter::ShouldSendPositionEvent (acclient_2013_pseudo_c.txt /// at address 0x006b45e0). Inside the interval it reacts to cell/contact /// changes; after the interval it compares cell plus the complete frame. /// `time_between_position_events` constant at 0x006b3efb = 1.0 sec. /// /// Old model: a 1 Hz idle / 10 Hz active flat accumulator. That /// missed retail's per-frame-while-moving behaviour and forced the /// four B.6 workarounds (arrival margin, re-send on arrival, AP /// flush, retry flag) to compensate for the lag in ACE's server-side /// WithinUseRadius poll. Replaced by diff-driven cadence below. /// public const float HeartbeatInterval = 1.0f; // retail 0x006b3efb private AcDream.Core.Physics.Position _lastSentPosition; private System.Numerics.Plane _lastSentContactPlane; private float _lastSentTime; private bool _lastSentInitialized; private float _simTimeSeconds; /// Sim-time accumulator (advanced by dt at the top of Update). /// Exposed for the network outbound layer to stamp NotePositionSent. public float SimTimeSeconds => _simTimeSeconds; // R6 retail complete-object scheduler (2026-07-19). // // Retail's CPhysicsObj::update_object subdivides elapsed time into // MaxQuantum-sized complete object updates plus a remainder, retaining // that remainder while it is at or below MinQuantum. The debugger trace // confirmed this: UpdatePhysicsInternal fires only ~61% as often as // update_object — i.e., retail's effective physics tick rate is 30Hz // even when the renderer runs at 60+Hz. // // Without this gate our acdream integrates at the full render rate // (60+Hz), which compresses bounce-energy / gravity-tangent // accumulation into half the time. Per-frame V grows ~2x faster than // retail's. On a steep-slope tangent that produces the wedge: V grows // tangent + huge while position reverts each frame, body locks in // place. Retail's slower integration cadence (and larger per-tick // position deltas) lets the body geometrically escape the tangent. // // Source: retail debugger trace 2026-04-30 // update_object = 40,960 calls // UpdatePhysicsInternal = 25,087 calls (61%) // ratio implies 39% of frames return early via the MinQuantum gate. // // ACE: PhysicsObj.UpdateObject (Physics.cs). // Named-retail: CPhysicsObj::update_object (acclient_2013_pseudo_c.txt:283950). private RetailObjectQuantumClock _objectClock; private PlayerMovementControllerPublicationLifecycle _publicationLifecycle; private Vector3 _prevPhysicsPos; private Vector3 _currPhysicsPos; private Action? _advanceAnimationRootMotion; private Action? _processAnimationHooks; private readonly AcDream.Core.Physics.Motion.MotionDeltaFrame _animationRootMotionScratch = new(); private readonly AcDream.Core.Physics.Motion.MotionDeltaFrame _positionManagerDeltaScratch = new(); private bool _externalMovementEventPending; // ── R4-V5: the verbatim retail MoveToManager replaces B.6 auto-walk ── // The B.6 DriveServerAutoWalk overlay (synthesized turn-first phase, // 30° walk-while-turning band, one-shot walk/run decision, invented // arrival epsilon — registers AD-26/AD-8-local) is DELETED; server // MoveTos for the local player now run through the SAME verbatim // MoveToManager remotes use (R4-V4), bound below by GameWindow's // EnterPlayerModeNow beside the R3-W6 DefaultSink bind. /// /// R5-V5: retail CPhysicsObj::movement_manager (acclient.h /// /* 3463 */) — the ONE owner of this controller's /// + pair. Constructed in the /// ctor around the interp; the MoveToManager side arrives via /// MoveToFactory + MakeMoveToManager() in /// GameWindow.EnterPlayerModeNow (the same facade shape /// EnsureRemoteMotionBindings gives remotes). /// ticks UseTime() (0x005242f0) at the slot the deleted /// DriveServerAutoWalk occupied and relays HitGround() /// (0x00524300, minterp first then moveto). /// public AcDream.Core.Physics.Motion.MovementManager Movement { get { EnsureConfigurationMutable(); return _movementManager; } } /// /// R4-V5: the local player's verbatim retail MoveToManager /// (decomp 0x00529010-0x0052a987), constructed + seam-bound by /// GameWindow.EnterPlayerModeNow against this controller's /// /body/Yaw (the same wiring shape /// EnsureRemoteMotionBindings uses for remotes). GameWindow /// routes inbound mt 6-9 movement events through /// . /// User input cancels a moveto through the retail chain: key edge → /// DoMotion (ctor-default params, CancelMoveTo bit set) → /// → /// CancelMoveTo(ActionCancelled) (register TS-36 retired). /// R5-V5: a view of 's moveto child; the setter is /// sugar over the facade's factory path (kept for the /// PlayerMoveToCutoverTests rig and any pre-facade bind shape). /// public AcDream.Core.Physics.Motion.MoveToManager? MoveTo { get => Movement.MoveTo; set { EnsureConfigurationMutable(); var mtm = value ?? throw new ArgumentNullException(nameof(value)); Movement.MoveToFactory = () => mtm; Movement.MakeMoveToManager(); } } /// /// R5-V3 (#171): the player's PositionManager facade (retail /// CPhysicsObj::position_manager — owned by the player's /// EntityPhysicsHost, handed here by EnterPlayerModeNow). /// drives it at the two retail per-tick points: /// AdjustOffset inside the physics-tick block (retail /// UpdatePositionInternal @0x00512d0e, BEFORE /// UpdatePhysicsInternal so the sticky steer is part of the swept /// motion) and UseTime after the completed-motions sweep (retail /// UpdateObjectInternal tail @0x005159b3 — the sticky 1 s lease /// watchdog). tears any /// stick down (retail teleport_hook @0x00514eee) and, as of /// Campaign P P5 (#167), also tears down and immediately re-arms the /// constraint leash (UnConstrain then ConstrainTo); /// 's ForcePosition route /// does NOT touch the leash at all — retail's FORCE_POSITION branch of /// SmartBox::HandleReceivedPosition (@0x00453FD0) returns at /// 0x0045409D, before every ConstrainTo call (C4 route 2 review /// fix, 2026-08-03 — the deleted BlipPosition's unconditional /// leash re-arm here was an unbacked deviation for that exact branch; /// see docs/research/2026-08-03-c4-route-2-implementation-plan.md §1b). /// public AcDream.Core.Physics.Motion.PositionManager? PositionManager { get { EnsureConfigurationMutable(); return _positionManager; } set { EnsureConfigurationMutable(); _positionManager = value; } } public PlayerMovementController( PhysicsEngine physics, RetailObjectQuantumClock? objectClock = null) : this( physics, objectClock, PlayerMovementConstructionOptions.Fallback, PlayerMovementControllerPublicationLifecycle.StandalonePublished) { } public PlayerMovementController( PhysicsEngine physics, RetailObjectQuantumClock? objectClock, PlayerMovementConstructionOptions options) : this( physics, objectClock, options, PlayerMovementControllerPublicationLifecycle.StandalonePublished) { } private PlayerMovementController( PhysicsEngine physics, RetailObjectQuantumClock? objectClock, PlayerMovementConstructionOptions options, PlayerMovementControllerPublicationLifecycle publicationLifecycle) { _physics = physics; _objectClock = objectClock ?? new RetailObjectQuantumClock(); _publicationLifecycle = publicationLifecycle; _body = new PhysicsBody { State = PhysicsStateFlags.Gravity | PhysicsStateFlags.ReportCollisions, }; // Default skills — tuned toward mid-retail feel. Real characters' // skills come from PlayerDescription (0xF7B0/0x0013) — GameWindow // Runtime supplies them through typed construction options and // updates them via SetCharacterSkills when later authoritative // values arrive. // K-fix6 (2026-04-26): bumped default jump skill from 200 → 300. // Retail formula: height = (skill/(skill+1300))*22.2 + 0.05 (extent=1): // skill=200 → 3.01m max (felt too low — user complaint) // skill=300 → 4.21m max (closer to a typical retail mid-tier // character's "I can clear that fence" hop) // Until PlayerDescription supplies both values, retain the typed // construction baseline. RuntimeMovementSkillState updates this same // PlayerWeenie after authoritative character data arrives. _weenie = new PlayerWeenie( runSkill: options.RunSkill, jumpSkill: options.JumpSkill); _motion = new MotionInterpreter(_body, _weenie); // R5-V5: the MovementManager facade owns the interp from birth // (retail CPhysicsObj::movement_manager); the moveto child binds // later via MoveToFactory (EnterPlayerModeNow / the test rigs). _movementManager = new AcDream.Core.Physics.Motion.MovementManager(_motion); _movementManager.ActivatePhysicsObject = ActivateFromMovement; // R3-W4 (A3): the local player's movement is input-driven — // movement_is_autonomous true so apply_current_movement's dual // dispatch routes apply_raw_movement (IsThePlayer && autonomous). // R3-W6 refines this per-motion (server-controlled MoveTo clears it). _body.LastMoveWasAutonomous = true; } internal static PlayerMovementController CreatePublicationCandidate( PhysicsEngine physics, PlayerMovementConstructionOptions options) => new( physics, new RetailObjectQuantumClock(), options, PlayerMovementControllerPublicationLifecycle.CandidatePreparing); internal PhysicsBody PhysicsBody { get { EnsureConfigurationMutable(); return _body; } } internal bool OwnsPhysicsBody(PhysicsBody body) => ReferenceEquals(_body, body); internal bool IsSealedPublicationCandidate => _publicationLifecycle is PlayerMovementControllerPublicationLifecycle.CandidateSealed; internal bool IsRuntimeOwnedDormant => _publicationLifecycle is PlayerMovementControllerPublicationLifecycle.RuntimeOwnedDormant; internal bool IsRuntimePublished => _publicationLifecycle is PlayerMovementControllerPublicationLifecycle.RuntimePublished; /// /// True when this controller may execute a live movement operation — the /// same predicate throws /// on. Exposed so LIFECYCLE callers (window focus loss, session teardown) /// can skip the operation instead of faulting: a focus change can arrive at /// any moment, including before the controller is published during login /// and after it is retired on logout, and neither is an error. /// public bool CanExecuteLiveMovement => _publicationLifecycle is PlayerMovementControllerPublicationLifecycle.StandalonePublished or PlayerMovementControllerPublicationLifecycle.RuntimePublished; private bool _dormantSetPositionGroundPhase; internal void BeginDormantSetPositionGroundPhase() { if (!IsRuntimeOwnedDormant || _dormantSetPositionGroundPhase) throw new InvalidOperationException( "Dormant SetPosition ground phase requires one dormant Runtime owner."); _dormantSetPositionGroundPhase = true; } internal void EndDormantSetPositionGroundPhase() { if (!_dormantSetPositionGroundPhase) throw new InvalidOperationException( "Dormant SetPosition ground phase is not active."); _body.TransientState &= ~TransientStateFlags.Active; _dormantSetPositionGroundPhase = false; } internal bool IsDormantSetPositionGroundPhaseActive => _dormantSetPositionGroundPhase; internal void RefreshDormantRuntimePhysicsState( PhysicsStateFlags state, bool recalculateAcceleration) { if (!IsRuntimeOwnedDormant || _dormantSetPositionGroundPhase) throw new InvalidOperationException( "Only an idle dormant Runtime owner can refresh physics state."); _body.State = state; if (recalculateAcceleration) _body.calc_acceleration(); } internal void RefreshDormantRuntimeVector( Vector3? velocity, Vector3? omega) { if (!IsRuntimeOwnedDormant || _dormantSetPositionGroundPhase) throw new InvalidOperationException( "Only an idle dormant Runtime owner can refresh vector state."); if (velocity is { } liveVelocity) _body.set_velocity(liveVelocity); if (omega is { } liveOmega) _body.Omega = liveOmega; _body.TransientState &= ~TransientStateFlags.Active; } internal void SealPublicationCandidate() { if (_publicationLifecycle is not PlayerMovementControllerPublicationLifecycle .CandidatePreparing) { throw new InvalidOperationException( "Only a preparing Runtime movement candidate can be sealed."); } _publicationLifecycle = PlayerMovementControllerPublicationLifecycle .CandidateSealed; } internal void CommitRuntimeOwnership(RetailObjectQuantumClock objectClock) { ArgumentNullException.ThrowIfNull(objectClock); if (_publicationLifecycle is not PlayerMovementControllerPublicationLifecycle.CandidateSealed) { throw new InvalidOperationException( "Only a sealed Runtime movement candidate can be published."); } _objectClock = objectClock; _publicationLifecycle = PlayerMovementControllerPublicationLifecycle .RuntimeOwnedDormant; } internal void ActivateRuntimePublication() { if (_publicationLifecycle is not PlayerMovementControllerPublicationLifecycle .RuntimeOwnedDormant || _dormantSetPositionGroundPhase) { throw new InvalidOperationException( "Only a dormant Runtime-owned movement controller can be activated."); } _publicationLifecycle = PlayerMovementControllerPublicationLifecycle .RuntimePublished; } /// /// Installs the already-committed Runtime SetPosition frame into the /// controller's interpolation/cell sidecar without invoking the public /// teleport path. The canonical body is written by Runtime first; this /// method only makes the controller's private frame agree while it is /// still dormant. It deliberately does not touch CellGraph, movement, /// PositionManager, the object clock, or callbacks. /// internal void CommitRuntimeActivationFrame() { if (_publicationLifecycle is not PlayerMovementControllerPublicationLifecycle .RuntimeOwnedDormant || _dormantSetPositionGroundPhase) { throw new InvalidOperationException( "Only a dormant Runtime-owned movement controller can accept its activation frame."); } _prevPhysicsPos = _body.Position; _currPhysicsPos = _body.Position; CellId = _body.CellPosition.ObjCellId; } internal void DiscardRuntimeCandidate() { if (_publicationLifecycle is PlayerMovementControllerPublicationLifecycle.CandidatePreparing or PlayerMovementControllerPublicationLifecycle.CandidateSealed) { _publicationLifecycle = PlayerMovementControllerPublicationLifecycle .Discarded; } } internal void RetireRuntimePublication() { if (_publicationLifecycle is PlayerMovementControllerPublicationLifecycle.RuntimeOwnedDormant or PlayerMovementControllerPublicationLifecycle.RuntimePublished) { _publicationLifecycle = PlayerMovementControllerPublicationLifecycle .RuntimeRetired; } } private void EnsureConfigurationMutable() { if (_publicationLifecycle is PlayerMovementControllerPublicationLifecycle.StandalonePublished or PlayerMovementControllerPublicationLifecycle .CandidatePreparing or PlayerMovementControllerPublicationLifecycle.RuntimePublished || IsRuntimeOwnedDormant && _dormantSetPositionGroundPhase) { return; } throw new InvalidOperationException( "A sealed, retired, or discarded Runtime movement controller cannot be mutated."); } /// /// Campaign CH slice CH2: retail-exact CommenceJump/DoJump /// refusal-text dispatch (research doc §4.2, verified directly against /// the decomp — NOT the HandleFailureEvent/WeenieErrorMessages /// table, which is a DIFFERENT switch that happens to share three of /// these same string globals). /// ClientCombatSystem::CommenceJump @0x0056AF90 and /// ClientCombatSystem::DoJump @0x0056B110 each explicitly handle /// only 0x24/0x48/0x49; every other code — /// including 0x47 GeneralMovementFailure (fully constrained or /// can't afford the jump's stamina cost, ) /// and 0x08 NoPhysicsObject — falls through their dispatch with /// NO AddTextToScroll call at all. Confirmed via DoJump's /// compiled switch(eax_7) (raw 376288-376312): exactly 4 real /// case targets (0, 0x24, 0x48, 0x49), every /// other index routed to the function's silent fall-through end. A /// fully-constrained or out-of-stamina jump refusal is therefore /// SILENT in retail — no on-screen text, the player just doesn't jump. /// private void ReportJumpRefusal(WeenieError result) { // Campaign CH user-gate round 2, item 1 (TEMPORARY): print // UNCONDITIONALLY, before the OnInterfaceText null-check, so the // probe distinguishes "ReportJumpRefusal was never called with a // reportable result" from "it was called but OnInterfaceText was // null" from "the callback fired but downstream dropped the line." if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeJumpEnabled) { Console.WriteLine( $"[jump] ReportJumpRefusal result={result} " + $"hasCallback={OnInterfaceText is not null} " + $"onWalkable={_body.OnWalkable} " + $"prevJumpHeld={_prevJumpHeld}"); } if (OnInterfaceText is null) return; string? text = result switch { WeenieError.NotGrounded => ClientTextRefusals.CantJumpInAir, // 0x24 WeenieError.YouCantJumpFromThisPosition => ClientTextRefusals.CantJumpPosition, // 0x48 WeenieError.CantJumpLoadedDown => ClientTextRefusals.CantJumpLoad, // 0x49 _ => null, }; if (text is not null) OnInterfaceText(text, RetailLogTextType.ClientLocal); } private void EnsurePublishedForRuntimeOperation() { if (_publicationLifecycle is PlayerMovementControllerPublicationLifecycle.StandalonePublished or PlayerMovementControllerPublicationLifecycle.RuntimePublished) { return; } throw new InvalidOperationException( "An unpublished or retired Runtime movement controller cannot execute live movement operations."); } /// /// Host half of retail MovementManager::PerformMovement's /// unconditional CPhysicsObj::set_active(1) head. Static objects /// reject activation; an inactive ordinary object rebases its canonical /// object clock before setting the body's transient bit. /// private void ActivateFromMovement() { EnsureConfigurationMutable(); if ((_body.State & PhysicsStateFlags.Static) != 0) return; if (IsRuntimeOwnedDormant && _dormantSetPositionGroundPhase) return; _objectClock.Activate(); _body.TransientState |= TransientStateFlags.Active; } /// /// Applies retail's parent/cell-less/Frozen early gate: clear Active and /// advance only wall-clock presentation time. The next eligible frame /// reactivates through set_active(1) and rebases the object clock. /// internal void SuspendObjectUpdate(float elapsedSeconds) { EnsurePublishedForRuntimeOperation(); AdvancedObjectQuantumLastTick = false; if (float.IsFinite(elapsedSeconds) && elapsedSeconds > 0f) _simTimeSeconds += elapsedSeconds; _objectClock.Deactivate(); _body.TransientState &= ~TransientStateFlags.Active; } /// /// Begins retail instant mouse-look. CameraSet::ToggleMouseLook /// (0x00457490) sends a movement event on both transitions, even before a /// horizontal turn sample arrives. /// public bool BeginMouseLook(MovementInput input) { EnsurePublishedForRuntimeOperation(); if (_mouseLookActive || State != PlayerState.InWorld) return false; _mouseLookActive = true; // ToggleMouseLook stores the new mode before MovePlayer enters // TakeControlFromServer, so its ApplyCurrentMovement observes the // remapped turn→sidestep channels immediately. TakeControlFromServer(input); _mouseTurnSamplePending = false; _mouseTurnAdjustment = 0f; ApplyMouseLookToggleMovement(input, entering: true); return true; } /// /// Supplies the latest signed, filtered horizontal mouse adjustment. /// Negative means TurnRight in acdream's yaw convention; positive means /// TurnLeft. Retail CameraSet::Rotate doubles the magnitude, applies /// a 0.02 dead zone, and caps the raw turn speed at 1.5. /// public void SubmitMouseTurnAdjustment( float signedAdjustment, MovementInput input) { EnsurePublishedForRuntimeOperation(); if (!_mouseLookActive || !float.IsFinite(signedAdjustment)) return; TakeControlFromServer(input); _mouseTurnAdjustment = signedAdjustment; _mouseTurnSamplePending = true; _mouseMovementEventCandidate = true; } /// /// Retail's delayed zero-input MouseLookHandler calls /// CommandInterpreter::StopDrift @ 0x006B4510 before filtering the /// zero sample. It stops both turn directions and participates in the /// same half-second movement-report cadence. /// public void StopMouseDrift(MovementInput input) { EnsurePublishedForRuntimeOperation(); if (!_mouseLookActive) return; TakeControlFromServer(input); var p = MouseAxisParameters(_activeInputTurnSpeed == 0f ? 1f : _activeInputTurnSpeed); StopMotionAtPhysicsObjectBoundary(MotionCommand.TurnRight, p); StopMotionAtPhysicsObjectBoundary(MotionCommand.TurnLeft, p); _activeInputTurnCommand = null; _activeInputTurnSpeed = 0f; _activeInputTurnFromMouse = false; _mouseTurnSamplePending = false; _mouseTurnAdjustment = 0f; _mouseMovementEventCandidate = true; } /// /// Ends retail instant mouse-look. The next update restores any held /// keyboard turn (or stops the mouse-origin turn) and publishes the final /// heading through MoveToState. /// public bool EndMouseLook(MovementInput input) { EnsurePublishedForRuntimeOperation(); if (!_mouseLookActive && !_activeInputTurnFromMouse) return false; _mouseLookActive = false; // Retail clears the mode before TakeControlFromServer/ApplyCurrentMovement // so a held sidestep-remapped turn is restored directly as turn. TakeControlFromServer(input); _mouseTurnSamplePending = false; _mouseTurnAdjustment = 0f; ApplyMouseLookToggleMovement(input, entering: false); return true; } private void ApplyMouseLookToggleMovement(MovementInput input, bool entering) { // CameraSet::ToggleMouseLook @ 0x00457490 routes the pseudo-command // CameraInstantMouseLook through CommandInterpreter::MovePlayer // @ 0x006B3F40. While entering, held keyboard turns move to the // sidestep channel. On exit, MovePlayer reverses that mapping and // ApplyCurrentMovement replays every currently held channel before the // synchronous movement packet is captured. (uint? sidestep, bool useRunHold) = DesiredInputSidestep(input, entering); ApplyInputSidestep(sidestep, useRunHold, reapply: !entering); uint? turn = entering ? null : input.TurnRight ? MotionCommand.TurnRight : input.TurnLeft ? MotionCommand.TurnLeft : null; ApplyInputTurn(turn, speed: 1f, fromMouse: false, reapply: !entering); // TakeControlFromServer::ApplyCurrentMovement and both sides of // ToggleMouseLook replay the complete current input, not merely the // turn/sidestep channels affected by CameraInstantMouseLook. bool runHeld = _motion.RawState.CurrentHoldKey == HoldKey.Run; if (runHeld != input.Run) _motion.set_hold_run(input.Run, interrupt: true); uint? desiredForward = input.Forward ? MotionCommand.WalkForward : input.Backward ? MotionCommand.WalkBackward : null; uint rawForward = _motion.RawState.ForwardCommand; uint? activeForward = rawForward == RawMotionState.Default.ForwardCommand ? null : rawForward; if (activeForward is { } active && active != desiredForward) { StopMotionAtPhysicsObjectBoundary( active, new AcDream.Core.Physics.Motion.MovementParameters()); } if (desiredForward is { } command && (activeForward != desiredForward || !entering)) DoMotionAtPhysicsObjectBoundary( command, new AcDream.Core.Physics.Motion.MovementParameters()); _hasInputSnapshot = true; _prevForwardHeld = input.Forward; _prevBackwardHeld = input.Backward; _prevStrafeLeftHeld = input.StrafeLeft; _prevStrafeRightHeld = input.StrafeRight; _prevTurnLeftHeld = input.TurnLeft; _prevTurnRightHeld = input.TurnRight; _prevRunHeld = input.Run; _prevRunHold = input.Run; _body.LastMoveWasAutonomous = true; } private static (uint? Command, bool UseRunHold) DesiredInputSidestep( MovementInput input, bool mouseLookActive) { if (input.StrafeRight) return (MotionCommand.SideStepRight, false); if (input.StrafeLeft) return (MotionCommand.SideStepLeft, false); if (mouseLookActive && input.TurnRight) return (MotionCommand.SideStepRight, true); if (mouseLookActive && input.TurnLeft) return (MotionCommand.SideStepLeft, true); return (null, false); } private bool ApplyInputSidestep( uint? desired, bool useRunHold, bool reapply = false) { bool changed = desired != _activeInputSidestepCommand; if (_activeInputSidestepCommand is { } active && (changed || reapply)) { StopMotionAtPhysicsObjectBoundary( active, _activeInputSidestepUsesRunHold ? MouseAxisParameters(1f) : new()); } if (desired is { } command && (changed || reapply)) { DoMotionAtPhysicsObjectBoundary( command, useRunHold ? MouseAxisParameters(1f) : new()); } _activeInputSidestepCommand = desired; _activeInputSidestepUsesRunHold = desired.HasValue && useRunHold; return changed || (reapply && desired.HasValue); } private bool ApplyInputTurn( uint? desired, float speed, bool fromMouse, bool reapply = false) { bool commandChanged = desired != _activeInputTurnCommand; bool bothPresent = desired.HasValue && _activeInputTurnCommand.HasValue; bool speedChanged = bothPresent && MathF.Abs(speed - _activeInputTurnSpeed) >= 0.0001f; bool ownerChanged = bothPresent && fromMouse != _activeInputTurnFromMouse; bool apply = commandChanged || speedChanged || ownerChanged || (reapply && desired.HasValue); if (_activeInputTurnCommand is { } active && apply) { StopMotionAtPhysicsObjectBoundary( active, _activeInputTurnFromMouse ? MouseAxisParameters(_activeInputTurnSpeed) : new()); } if (desired is { } command && apply) { DoMotionAtPhysicsObjectBoundary( command, fromMouse ? MouseAxisParameters(speed) : new()); } _activeInputTurnCommand = desired; _activeInputTurnSpeed = desired.HasValue ? speed : 0f; _activeInputTurnFromMouse = desired.HasValue && fromMouse; return apply; } private static AcDream.Core.Physics.Motion.MovementParameters MouseAxisParameters( float speed) => new() { Speed = speed, SetHoldKey = false, HoldKeyToApply = HoldKey.Run, }; /// /// Retail CommandInterpreter::TakeControlFromServer /// (0x006B32D0). A genuine user movement first terminates the server-owned /// movement state, marks subsequent movement autonomous, and lets the next /// input update re-apply every key that is still physically held. /// private void TakeControlFromServer(MovementInput? currentInput = null) { if (!_controlledByServer) return; _controlledByServer = false; _body.LastMoveWasAutonomous = true; StopCompletelyAtPhysicsObjectBoundary(); _activeInputTurnCommand = null; _activeInputTurnSpeed = 0f; _activeInputTurnFromMouse = false; _activeInputSidestepCommand = null; _activeInputSidestepUsesRunHold = false; // Retail follows StopCompletely with ApplyCurrentMovement. Our key // snapshot is supplied to Update, so clearing the edge history makes // that same held input re-enter through the normal DoMotion boundary. _prevForwardHeld = false; _prevBackwardHeld = false; _prevStrafeLeftHeld = false; _prevStrafeRightHeld = false; _prevTurnLeftHeld = false; _prevTurnRightHeld = false; _prevRunHeld = _motion.RawState.CurrentHoldKey == HoldKey.Run; // MouseLookHandler runs before the normal per-frame input edge pass. // When it is the action that takes control, replay the device levels // synchronously just as retail ApplyCurrentMovement does; otherwise a // StopCompletely would erase held movement while the edge history is // simultaneously advanced past it. if (currentInput is { } input) ApplyMouseLookToggleMovement(input, entering: _mouseLookActive); } /// /// Retail ClientCombatSystem::StartAttackRequest (0x0056C040) /// invokes CommandInterpreter::MaybeStopCompletely before building /// the attack. This removes a mouse-origin drift immediately and schedules /// the final authoritative heading ahead of the eventual attack send. /// public bool PrepareForAttackRequest() { EnsurePublishedForRuntimeOperation(); // CommandInterpreter::MaybeStopCompletely @ 0x006B3B90 is a no-op // while an authoritative server movement owns the player. if (_controlledByServer) return false; StopCompletelyAtPhysicsObjectBoundary(); _activeInputTurnCommand = null; _activeInputTurnSpeed = 0f; _activeInputTurnFromMouse = false; _activeInputSidestepCommand = null; _activeInputSidestepUsesRunHold = false; _mouseTurnSamplePending = false; _mouseTurnAdjustment = 0f; _body.LastMoveWasAutonomous = true; return true; } /// /// Applies a command-originated retail posture through the same /// CPhysicsObj movement boundary used by keyboard input. The next admitted /// object turn emits the resulting raw movement state exactly once. /// public bool RequestPosture(uint motion) { EnsurePublishedForRuntimeOperation(); if (motion is not ( MotionCommand.Ready or MotionCommand.Crouch or MotionCommand.Sitting or MotionCommand.Sleeping)) { return false; } TakeControlFromServer(); var parameters = new AcDream.Core.Physics.Motion.MovementParameters(); if (DoMotionAtPhysicsObjectBoundary(motion, parameters) != WeenieError.None) { return false; } _externalMovementEventPending = true; return true; } public void SetCharacterSkills(int runSkill, int jumpSkill) { EnsureConfigurationMutable(); _weenie.SetSkills(runSkill, jumpSkill); } /// /// Campaign P Slice P1 (2026-07-30): pushes the retail /// InqLoad-equivalent burden ratio computed by Runtime (Strength /// + augmentation property 0xE6 + EncumbranceVal property 5) into the /// player's — wires the previously-dead /// PlayerWeenie.SetBurden setter (TS-5 retired). /// public void SetCharacterBurden(float burden) { EnsureConfigurationMutable(); _weenie.SetBurden(burden); } /// /// Campaign P Slice P1 (2026-07-30): pushes the current-stamina vital /// reading. A negative value restores PlayerWeenie's "unknown, don't /// gate" sentinel; any non-negative value including 0 gates /// InqRunRate/InqJumpVelocity's effective-skill-zeroing per /// retail's CACQualities::InqRunRate/InqJumpVelocity. /// public void SetCharacterStamina(int currentStamina) { EnsureConfigurationMutable(); _weenie.SetStamina(currentStamina < 0 ? null : (uint)currentStamina); } /// /// TS-23 §12b (Campaign P Slice P3, 2026-07-30): pushes the raw /// PlayerKillerStatus/LastPkAttackTimestamp pair into /// 's PK-timer bump. A /// negative restores "never /// pushed" (matches 's own sentinel /// convention); is null /// when the property is absent. /// public void SetCharacterPkStatus(int playerKillerStatus, float? lastPkAttackTimestamp) { EnsureConfigurationMutable(); _weenie.SetPlayerKillerStatus( playerKillerStatus < 0 ? null : playerKillerStatus, lastPkAttackTimestamp); } /// /// C3c-F1 (2026-08-02): the lifecycle-deciding half of the Runtime /// movement-stats application seam /// (). /// The publication owner — not any App caller — decides whether a /// server stat recompute may land: /// /// , /// , and /// /// apply immediately — byte-identical to the deleted /// RuntimeMovementSkillProjection.ApplyTo direct path. /// /// ALSO applies immediately: the dormant window (publication committed, /// activation deferred on cell streaming — /// RuntimeLocalPlayerFirstEntryState.AdvanceCore's /// AwaitingActivation loop) spans inbound pumps, and this exact instance /// is the controller that ActivateRuntimePublication later makes /// live, so the write must land here (same discipline as /// / /// : accepted server facts /// arriving mid-dormancy land on the dormant owner). These writes touch /// only fields and the mover-flag latch — /// no body/world/currency state the activation envelope validates. /// , /// , and /// /// report the typed displaced-write outcome (J3.6 /// displaced-callback-rejection): a stat write against a terminal /// controller is meaningless by design — the next login re-derives from /// PlayerDescription. A sealed candidate is additionally unreachable /// through the seam in production: it is never installed into /// (Prepare requires the /// movement owner empty and Commit installs it already-dormant in the /// same synchronous Advance step). /// /// internal RuntimeMovementStatsApplication ApplyCharacterMovementStats( in RuntimeMovementSkillSnapshot snapshot) { switch (_publicationLifecycle) { case PlayerMovementControllerPublicationLifecycle.StandalonePublished: case PlayerMovementControllerPublicationLifecycle.CandidatePreparing: case PlayerMovementControllerPublicationLifecycle.RuntimePublished: ApplyCharacterMovementStatsCore(snapshot); return RuntimeMovementStatsApplication.AppliedLive; case PlayerMovementControllerPublicationLifecycle.RuntimeOwnedDormant: ApplyCharacterMovementStatsCore(snapshot); return RuntimeMovementStatsApplication.AppliedDormant; default: return RuntimeMovementStatsApplication.DroppedDisplacedController; } } /// /// The exact application body of the deleted /// RuntimeMovementSkillProjection.ApplyTo (same fields, same /// order, same conversions) — moved behind the lifecycle switch so the /// dormant window can share it without routing through the /// -gated public setters. /// Campaign P Slice P1 (2026-07-30): burden/stamina ride the SAME seam /// run/jump skill already used — see the pseudocode doc §9. TS-23 /// (Campaign P Slice P3, 2026-07-30): the player's own /// PK/PKLite/Impenetrable collision-exemption bits and the /// PlayerKillerStatus/LastPkAttackTimestamp pair the jump-cost PK-timer /// bump reads — see EntityCollisionFlagsExt.ToMoverState and /// PlayerWeenie.JumpStaminaCost. /// private void ApplyCharacterMovementStatsCore( in RuntimeMovementSkillSnapshot snapshot) { _weenie.SetSkills(snapshot.RunSkill, snapshot.JumpSkill); _weenie.SetBurden(snapshot.Burden); _weenie.SetStamina( snapshot.CurrentStamina < 0 ? null : (uint)snapshot.CurrentStamina); _ownPvpFlags = EntityCollisionFlagsExt .FromPwdBitfield(snapshot.OwnPwdBitfield) .ToMoverState(); _weenie.SetPlayerKillerStatus( snapshot.PlayerKillerStatus < 0 ? null : snapshot.PlayerKillerStatus, snapshot.LastPkAttackTimestamp); } /// /// C3c-F1: the stamina-exhaustion EVENT dispatch /// (retail CommandInterpreter::HandleExhaustion @ 0x006b3c70 → /// CPhysicsObj::report_exhaustion), routed through the owner so /// App never touches the gated surface. Fires only /// on a live controller: a dormant owner has no in-flight movement to /// re-dispatch (retail's handler is a no-op for a player not in world; /// activation dispatches movement fresh from the already-current /// stamina gate), and a terminal owner is a /// displaced callback. /// internal bool ReportExhaustionAtMovementBoundary() { if (_publicationLifecycle is PlayerMovementControllerPublicationLifecycle.StandalonePublished or PlayerMovementControllerPublicationLifecycle.CandidatePreparing or PlayerMovementControllerPublicationLifecycle.RuntimePublished) { _motion.ReportExhaustion(); return true; } return false; } /// /// R3-W2 (r3-port-plan.md §4): the player's /// — GameWindow binds the player sequencer's MotionDone seam to it so the /// pending_motions queue pops in step with animation completion, same /// path remotes use. R3-W6 widens this into the full local-player /// unification. /// internal MotionInterpreter Motion { get { EnsureConfigurationMutable(); return _motion; } } /// /// Retail CPhysicsObj::StopCompletely (0x00510180) enters through /// MovementManager::PerformMovement (0x005240D0), not directly /// through CMotionInterp::StopCompletely. The facade's type-5 path /// performs the required zero-duration animation completion sweep after /// the interpreter has queued its matching pending-motion node. /// internal WeenieError StopCompletelyAtPhysicsObjectBoundary() { EnsureConfigurationMutable(); return _movementManager.PerformMovement(new MovementStruct { Type = MovementType.StopCompletely, }); } /// /// Retail CPhysicsObj::DoMotion (0x00510020) constructs a type-1 /// MovementStruct carrying the original parameters pointer and /// routes it through MovementManager::PerformMovement. That outer /// boundary is observable: CMotionInterp::PerformMovement /// (0x00528E80) performs the synchronous completed-animation sweep after /// the motion dispatch. /// private WeenieError DoMotionAtPhysicsObjectBoundary( uint motion, AcDream.Core.Physics.Motion.MovementParameters parameters) { EnsureConfigurationMutable(); _body.LastMoveWasAutonomous = true; return Movement.PerformMovement(new MovementStruct { Type = MovementType.RawCommand, Motion = motion, Params = parameters, }); } /// /// Retail CPhysicsObj::StopMotion (0x005100D0), the type-3 peer of /// . Player input must use /// this facade; MoveToManager's private _DoMotion/_StopMotion path stays /// directly on CMotionInterp, matching retail. /// private WeenieError StopMotionAtPhysicsObjectBoundary( uint motion, AcDream.Core.Physics.Motion.MovementParameters parameters) { EnsureConfigurationMutable(); _body.LastMoveWasAutonomous = true; return Movement.PerformMovement(new MovementStruct { Type = MovementType.StopRawCommand, Motion = motion, Params = parameters, }); } /// R4-V5: CONTACT transient-state bit (retail /// transient_state & 1) — the manager's /// UseTime tick gate reads exactly this bit (decomp §6a @307781; a /// strict subset of the funnel's Contact+OnWalkable gate). internal bool BodyInContact => _body.InContact; /// Retail SendPositionEvent admission gate: both Contact /// and OnWalkable must be present on the local physics body. internal bool CanSendPositionEvent => _body.InContact && _body.OnWalkable; /// R4-V5: complete body orientation for the /// manager's position seam. is /// only its horizontal projection. internal Quaternion BodyOrientation => _body.Orientation; /// /// Install a complete authoritative frame rotation (spawn, teleport, or /// server correction) without collapsing it through the yaw projection. /// public void SetBodyOrientation(Quaternion orientation) { EnsureConfigurationMutable(); _body.Orientation = AcDream.Core.Physics.Motion.FrameOps.SetRotate( _body.Position, _body.Orientation, orientation); } /// R4-V5 wedge fix — the P1 unpack store /// (CPhysics::SetObjectMovement @00509730: /// last_move_was_autonomous = arg7, written on the unpack path /// for every applied movement event). GameWindow's local-player 0xF74C /// branch stores the wire autonomous byte here (always false — the P1 /// gate drops autonomous echoes); the speculative use install stores /// false too (it models the wire mt-6 ACE sends moments later). Routes /// the per-tick pump's A3 dual dispatch to the INTERPRETED branch /// during a server moveto so apply_raw can't clobber the manager's /// dispatched motions with the idle raw state. internal void SetLastMoveWasAutonomous(bool autonomous) { EnsureConfigurationMutable(); _body.LastMoveWasAutonomous = autonomous; _controlledByServer = !autonomous; } /// /// Wire the player's AnimationSequencer current cycle velocity into /// . When attached, /// get_state_velocity uses MotionData.Velocity * speedMod /// as the primary forward-axis drive, keeping the body's world velocity /// locked to the animation's baked-in root-motion velocity. /// /// /// Without this accessor, the decompiled constant path /// (RunAnimSpeed * ForwardSpeed) is used — matches retail only /// when the character's MotionTable happens to bake Velocity=4.0 on /// RunForward, which is true for Humanoid but not for arbitrary /// creatures. See /// for the full rationale. /// /// /// /// Called once from GameWindow.CreateAnimatedEntity after the /// player's AnimatedEntity.Sequencer is constructed. /// /// public void AttachCycleVelocityAccessor(Func accessor) { EnsureConfigurationMutable(); if (accessor is null) throw new ArgumentNullException(nameof(accessor)); _motion.GetCycleVelocity = accessor; } /// /// Binds the owning PartArray/CSequence update to the local physics tick. /// The callback runs after this frame's input edges have reached the motion /// table and writes the complete local Frame emitted by /// CSequence::update. The callback runs only for admitted object /// quanta; render-frame fragments are retained by the object clock rather /// than advancing PartArray early. /// public void AttachAnimationRootMotionSource( Action advance, Action? processHooks = null) { EnsureConfigurationMutable(); _advanceAnimationRootMotion = advance ?? throw new ArgumentNullException(nameof(advance)); _processAnimationHooks = processHooks; _animationRootMotionScratch.Reset(); } /// /// Retail CommandInterpreter::SendMovementEvent (0x006B4680) /// stamps only last_sent_position_time. The carried frame and /// contact plane remain those from the last AutonomousPosition packet. /// public void NoteMovementSent(float nowSeconds, bool mouseLookEvent = false) { EnsurePublishedForRuntimeOperation(); _lastSentTime = nowSeconds; if (mouseLookEvent) { _mouseMovementEventPending = false; _lastMouseMovementEventTime = nowSeconds; } } /// /// Captures the current raw movement axes for an input-boundary /// SendMovementEvent. ToggleMouseLook sends synchronously, so the host /// cannot wait for the next physics update to reconstruct this state. /// public MovementResult CaptureMovementResult(bool mouseLookEvent) { EnsurePublishedForRuntimeOperation(); var raw = _motion.RawState; uint? forward = raw.ForwardCommand == RawMotionState.Default.ForwardCommand ? null : raw.ForwardCommand; uint? sidestep = raw.SidestepCommand == RawMotionState.Default.SidestepCommand ? null : raw.SidestepCommand; uint? turn = raw.TurnCommand == RawMotionState.Default.TurnCommand ? null : raw.TurnCommand; return new MovementResult( Position: Position, RenderPosition: RenderPosition, CellId: CellId, IsOnGround: CanSendPositionEvent, MotionStateChanged: true, ForwardCommand: forward, SidestepCommand: sidestep, TurnCommand: turn, ForwardSpeed: forward.HasValue ? raw.ForwardSpeed : null, SidestepSpeed: sidestep.HasValue ? raw.SidestepSpeed : null, TurnSpeed: turn.HasValue ? raw.TurnSpeed : null, IsRunning: raw.CurrentHoldKey == HoldKey.Run, ShouldSendMovementEvent: true, TurnUsesRunHold: turn.HasValue && raw.TurnHoldKey == HoldKey.Run, SidestepUsesRunHold: sidestep.HasValue && raw.SidestepHoldKey == HoldKey.Run, IsMouseLookMovementEvent: mouseLookEvent, CurrentStyle: raw.CurrentStyle); } /// /// Captures the current controller state for presentation without /// advancing physics or requesting an outbound movement event. /// public MovementResult CapturePresentationResult() { MovementResult current = CaptureMovementResult(mouseLookEvent: false); return current with { MotionStateChanged = false, ShouldSendMovementEvent = false, IsMouseLookMovementEvent = false, }; } /// /// Retail CommandInterpreter::SendPositionEvent (0x006B4770) /// stamps the send time, complete cell-local frame (origin and /// orientation), and contact plane after an AutonomousPosition packet. /// public void NotePositionSent(AcDream.Core.Physics.Position position, System.Numerics.Plane contactPlane, float nowSeconds) { EnsurePublishedForRuntimeOperation(); _lastSentPosition = position; _lastSentContactPlane = contactPlane; _lastSentTime = nowSeconds; _lastSentInitialized = true; } /// /// Faithful port of retail /// CommandInterpreter::ShouldSendPositionEvent (0x006B45E0). /// Before the one-second interval expires, only a cell or contact-plane /// change sends. Once it expires, the complete frame is compared, including /// orientation; this is what publishes a stationary heading change. /// internal bool ShouldSendPositionEvent( AcDream.Core.Physics.Position currentPosition, System.Numerics.Plane currentContactPlane, float nowSeconds) { EnsurePublishedForRuntimeOperation(); if (!_lastSentInitialized) return true; bool cellChanged = _lastSentPosition.ObjCellId != currentPosition.ObjCellId; if ((_lastSentTime + HeartbeatInterval) >= nowSeconds) { return cellChanged || !ApproxPlaneEqual(_lastSentContactPlane, currentContactPlane); } return cellChanged || !ApproxFrameEqual(_lastSentPosition.Frame, currentPosition.Frame); } // L.2a slice 1 (2026-05-12): centralized CellId mutation so the // [cell-transit] probe fires from a single chokepoint. Both the // server-snap path (SetPosition) and the per-frame resolver path // route through here. When PhysicsDiagnostics.ProbeCellEnabled is // off this collapses to a single bool-compare + assignment — zero // logging cost. private void UpdateCellId( uint newCellId, string reason, bool publishRenderRoot = true) { if (newCellId != CellId && PhysicsDiagnostics.ProbeCellEnabled) { var pos = _body.Position; Console.WriteLine(System.FormattableString.Invariant( $"[cell-transit] 0x{CellId:X8} -> 0x{newCellId:X8} pos=({pos.X:F3},{pos.Y:F3},{pos.Z:F3}) reason={reason}")); } CellId = newCellId; // Render root: CellGraph.CurrCell IS "the player's cell" — it roots the indoor render // (GameWindow.OnRender). Set it HERE, the single PLAYER-only chokepoint for CellId // (teleport / server snap @ SetPosition + per-frame resolver), NOT in the per-entity // PhysicsEngine.ResolveWithTransition. That ran for EVERY entity, so a Holtburg NPC // jump-looping near the cottage doorway clobbered the render root every tick → the render // rooted at the NPC's tiny connector cell → only its ~8-tri shell drew, rest = GL clear // color = the cottage doorway "blue-hole" flap (diagnosed 2026-06-03 via [flap-cam]/[shell]). if (publishRenderRoot) _physics.UpdatePlayerCurrCell(newCellId); } /// /// C5a (2026-08-05): this seed exists ONLY to place a controller directly /// in test fixtures. Production placement never calls it — the retail /// server-snap / teleport / enter-world path commits through /// followed by /// (or, for the /// already-live case, canonical SetPositionCore callers inside /// this class). is the LANDBLOCK-relative /// position (the wire's local, or world − landblock origin) which seeds /// the body's cell-relative CellPosition WITHOUT any streaming /// center (#145). /// internal void SeedPlacementForTest(Vector3 pos, uint cellId, Vector3 cellLocal) { EnsurePublishedForRuntimeOperation(); SetPositionCore( pos, cellId, cellLocal, publishSharedState: true); } /// /// Builds a new local controller's canonical body pose without publishing /// the renderer's global current cell or mutating the incarnation-stable /// PositionManager. Player-mode entry commits those shared edges only /// after camera, shadow, and host preparation succeeds. /// internal void PreparePositionForCommit( Vector3 pos, uint cellId, Vector3 cellLocal) { EnsureConfigurationMutable(); SetPositionCore( pos, cellId, cellLocal, publishSharedState: false); } /// /// C3c-R1: arms the login-entry constraint leash from the Runtime /// publication chain. The flip deleted the only login-path caller of /// (the App-side /// CommitPreparedPosition call in the old /// player-mode-entry commit, removed C5a — production placement now /// arms exclusively here and at 's /// teleport_hook teardown+rearm); the dormant activation's final commit /// (RuntimeSetPositionState.TryApplyDormantLocalActivationFinalCommit) /// is the accepted-position event that replaces it — retail arms at /// every accepted-position event (SmartBox::HandleReceivedPosition /// 0x00453FD0). The final commit has already activated this controller /// (ActivateRuntimePublication), so the published guard doubles /// as a stale-caller check. Like the pre-flip commit path, no /// UnConstrain teardown is needed: nothing can have armed the leash on /// a controller whose was created by its /// own publication candidate. /// internal void ArmConstraintLeashAtCommittedPlacement() { EnsurePublishedForRuntimeOperation(); RearmConstraintLeashAtCurrentPosition(); } /// /// #167 (Campaign P P5): retail SmartBox::HandleReceivedPosition /// (0x00453fd0) "Player, teleport-newer" branch re-arms the leash /// immediately after TeleportPlayer's teardown, anchored to the /// RECEIVED position (here, the body's just-snapped current position). /// Shared by the teleport path (after UnConstrain), the deferred /// player-mode-entry commit path (formerly the App-side /// CommitPreparedPosition caller, removed C5a; now /// 's Runtime-owned /// caller), which never ran UnConstrain because nothing could have armed /// the leash before the controller had a , /// and the C3c first-entry placement commit /// (). /// docs/research/2026-07-30-constraint-leash-constants.md §2/§3.2. /// private void RearmConstraintLeashAtCurrentPosition() { if (PositionManager is not { } positionManager) return; AcDream.Core.Physics.Position anchor = _body.CellPosition; positionManager.ConstrainTo( anchor, AcDream.Core.Physics.Motion.ConstraintDistance.GetStartConstraintDistance(anchor.ObjCellId), AcDream.Core.Physics.Motion.ConstraintDistance.GetMaxConstraintDistance(anchor.ObjCellId)); } private void SetPositionCore( Vector3 pos, uint cellId, Vector3 cellLocal, bool publishSharedState) { _body.SnapToCell(cellId, pos, cellLocal); _prevPhysicsPos = pos; _currPhysicsPos = pos; UpdateCellId( _body.CellPosition.ObjCellId, "teleport", publishSharedState); // Treat as grounded after a server-side position snap. _body.TransientState = TransientStateFlags.Contact | TransientStateFlags.OnWalkable | TransientStateFlags.Active; _body.Velocity = Vector3.Zero; // #145 Slice 7: idle the motion interpreter on a server snap / teleport arrival. // SetPosition zeros the body velocity, but the motion interpreter still holds the // PRE-teleport ForwardCommand (e.g. RunForward), so the next Update() would // reconstruct that run vector via get_state_velocity and the player would sprint // off in the old direction the instant input resumes. Resetting the forward // command to Ready makes the player arrive at rest. // R3-W6: retail's teleport idle is a FULL stop (StopCompletely // 0x00527e40: resets fwd/sidestep/turn COMMANDS, zeroes velocity, // enqueues the A9 jump-snapshot node) — not a bare DoMotion(Ready). StopCompletelyAtPhysicsObjectBoundary(); _activeInputTurnCommand = null; _activeInputTurnSpeed = 0f; _activeInputTurnFromMouse = false; _activeInputSidestepCommand = null; _activeInputSidestepUsesRunHold = false; _mouseLookActive = false; _mouseTurnSamplePending = false; _mouseTurnAdjustment = 0f; _mouseMovementEventCandidate = false; _mouseMovementEventPending = false; // R5-V3 (#171): retail teleport_hook (0x00514ed0) — PositionManager:: // UnStick (@0x00514eee) right after the moveto cancel: a teleport // tears down any active stick. (StopInterpolating has no armed // acdream counterpart — no local-player InterpolationManager.) // #167 (Campaign P P5): teleport_hook's UnConstrain (@0x00514f02) runs // right after UnStick — previously a no-op because nothing armed the // leash. Now that inbound positions arm it (both remote UpdatePosition // and this player teleport/blip path), the teardown must actually run // so a teleport doesn't inherit a stale leash from wherever the player // was constrained before. Retail's "Player, teleport-newer" branch // then immediately RE-arms the leash anchored to the new (received) // position (SmartBox::HandleReceivedPosition 0x00453fd0) — velocity is // already zeroed above by StopCompletelyAtPhysicsObjectBoundary. if (publishSharedState) { PositionManager?.UnStick(); PositionManager?.UnConstrain(); RearmConstraintLeashAtCurrentPosition(); } // Reset the edge tracker: the stop wiped the motion state, so keys // still physically held must re-fire as press edges on the next // Update (matches the pre-W6 level-triggered behavior of walking // straight out of a teleport while W stays held). _prevForwardHeld = false; _prevBackwardHeld = false; _prevStrafeLeftHeld = false; _prevStrafeRightHeld = false; _prevTurnLeftHeld = false; _prevTurnRightHeld = false; _prevRunHeld = false; _hasInputSnapshot = false; // Reset physics clock so any subsequent update_object calls start fresh. _body.LastUpdateTime = 0.0; _objectClock.ResetForEnterWorld(); } /// /// C4 route 2: the controller-local half of a ForcePosition commit whose /// body write already happened inside Runtime's canonical /// RuntimeSetPositionState.CommitCanonical (retail /// CPhysicsObj::SetPositionSimple @0x005162B0 with flags /// 0x1012, called from SmartBox::BlipPlayer @0x00453940, /// acclient_2013_pseudo_c.txt:284276/92528). Resets the render-lerp /// anchors and republishes the render-root cell — the same two /// controller-local jobs the deleted BlipPosition performed after /// its own (now-Runtime-owned) body snap. /// /// Deliberately does NOT call : /// retail's FORCE_POSITION branch of SmartBox::HandleReceivedPosition /// (@0x00453FD0) returns at 0x0045409D, before every /// CPhysicsObj::ConstrainTo call (0x00454272/0x0045418A/ /// 0x004541EC) — the deleted BlipPosition's re-arm here was an /// unbacked deviation (docs/research/2026-08-03-c4-route-2-implementation-plan.md /// §1b); this route retires it. Active motion, velocity, contact state, /// and PositionManager stick relationships are untouched, matching /// retail's BlipPlayer path exactly (Runtime's canonical commit — not /// this method — is what already wrote the body). /// internal void CommitCanonicalForcePositionFrame() { EnsurePublishedForRuntimeOperation(); _prevPhysicsPos = _body.Position; _currPhysicsPos = _body.Position; UpdateCellId(_body.CellPosition.ObjCellId, "force-position"); } /// /// C4 route 3: the controller-local half of a portal-teleport commit /// whose body write, cell install, and orientation already happened /// inside Runtime's canonical RuntimeSetPositionState.CommitCanonical /// (retail CPhysicsObj::SetPositionSimple @0x005162B0 with flags /// 0x1012, called from SmartBox::TeleportPlayer @0x00453910, /// acclient_2013_pseudo_c.txt:284276/92528). Unlike /// (which the FORCE_POSITION /// branch's early return at @0x0045409D exempts from every /// ConstrainTo), the local TELEPORT branch of /// SmartBox::HandleReceivedPosition (@0x0045415F) DOES re-arm the /// leash (@0x0045418A, anchored at the received destination) and DOES /// zero velocity (@0x004541B4) — the inversion is deliberate, not a /// missed exemption; see docs/research/2026-08-04-c4-route-3-contract.md /// §2 Inversion A. /// /// Performs every duty NOT already covered /// by the canonical commit (P1's duty map): render-lerp anchor reset, /// UpdateCellId publication, the retail teleport_hook tail /// (UnStick @0x00514eee / UnConstrain @0x00514f02 / re-arm @0x0045418A), /// the retail StopCompletely full stop (0x00527e40, zeroes velocity and /// resets fwd/sidestep/turn commands so input resumes at rest), the /// input-edge/mouse press-edge reset, and the physics-clock reset for a /// fresh update_object boundary. TransientState (Contact/OnWalkable/ /// Sliding/WaterContact) is deliberately NOT re-seeded here — the canonical /// commit's PhysicsObjUpdate.CommitSetPositionContactTransition /// already derives those bits from the SLIDE placement's OWN resolved /// contact result, which is more retail-faithful than the old /// 's unconditional /// Contact|OnWalkable|Active overwrite (that overwrite could mark a /// portal arrival grounded even when the destination placement actually /// resolved airborne). Active is untouched because a live in-world /// local player already carries it; the canonical commit only sets it on /// entry from a celless residence, which a portal arrival never is. /// /// /// A4 review fix (2026-08-05): the two inversions this method embodies /// are named, retail-cited facts on the classifier's /// RuntimeAuthoritativePositionRouteZeroVelocity and /// ConstrainPhase.AfterPositionOperation — and this method must /// actually READ them rather than assume the LocalPlayer-teleport /// branch's values are the only ones that will ever reach it. Both /// parameters are the route's own facts, passed by the one caller /// (RuntimeAcceptedPositionDriveController.ReconcileAndAcknowledgePortal); /// a future classifier edit that changes either value now changes this /// method's behaviour instead of silently disagreeing with it. /// /// Coordinator note (round-3 closeout, 2026-08-05): the two parameters /// are read, not hardcoded — but they are NOT equally load-bearing. /// is read and applied, then /// runs /// UNCONDITIONALLY on the very next line and zeroes velocity again — so /// a zeroVelocity: false sabotage changes nothing observable /// here; the field is proven read but not proven DISCRIMINATING. /// (ConstrainAfterRouting) /// has no such unconditional fallback and IS the load-bearing one — /// it alone decides whether the leash re-arms. Do not read this doc /// comment as proving both fields equally; only the leash flag is. /// /// /// /// N4 review fix (2026-08-05): before this parameter, the caller gated /// the ENTIRE method call on route.RunsTeleportHook — but retail's /// SetPositionInternal @0x00515330 does the frame/cell/stop/input- /// reset/clock work UNCONDITIONALLY; only retail's teleport_hook /// @0x00514ED0 (UnStick/UnConstrain/re-arm, mapped below) is itself /// conditional on the hook phase. Gating the whole call meant a future /// /// of None would silently skip the render-root UpdateCellId /// publish too — the doorway-FLAP class. Today the portal route always /// sets a non-None phase, so this parameter is always true in /// production and there is no live behavior change; it exists so a /// future None phase changes only the hook tail, not the frame /// commit. /// internal void CommitCanonicalTeleportFrame( bool zeroVelocity, bool rearmConstraintLeash, bool runTeleportHookTail = true) { EnsurePublishedForRuntimeOperation(); _prevPhysicsPos = _body.Position; _currPhysicsPos = _body.Position; UpdateCellId(_body.CellPosition.ObjCellId, "teleport"); // Retail set_velocity(player, 0, 1) @0x004541B4 — route.ZeroVelocity. if (zeroVelocity) _body.Velocity = Vector3.Zero; // Retail teleport idle is a FULL stop (StopCompletely 0x00527e40): // resets fwd/sidestep/turn COMMANDS and zeroes velocity again so the // motion interpreter cannot reconstruct the pre-teleport run vector // the instant input resumes. StopCompletelyAtPhysicsObjectBoundary(); _activeInputTurnCommand = null; _activeInputTurnSpeed = 0f; _activeInputTurnFromMouse = false; _activeInputSidestepCommand = null; _activeInputSidestepUsesRunHold = false; _mouseLookActive = false; _mouseTurnSamplePending = false; _mouseTurnAdjustment = 0f; _mouseMovementEventCandidate = false; _mouseMovementEventPending = false; // Retail teleport_hook @0x00514ed0 tears down any active stick/leash // unconditionally, then HandleReceivedPosition's TELEPORT branch // immediately re-arms the leash anchored to the just-committed // position ONLY when ConstrainPhase is AfterPositionOperation // (Inversion A — the opposite of // CommitCanonicalForcePositionFrame's no-re-arm rule, itself // route.ConstrainPhase.None for FORCE_POSITION). N4 review fix: this // is the ONLY part of this method retail actually conditions on the // teleport-hook phase — everything above runs unconditionally. if (runTeleportHookTail) { PositionManager?.UnStick(); PositionManager?.UnConstrain(); if (rearmConstraintLeash) RearmConstraintLeashAtCurrentPosition(); } // Reset the edge tracker: the stop wiped the motion state, so keys // still physically held must re-fire as press edges on the next // Update (matches SetPositionCore's walking-straight-out-of-a- // teleport behavior while W stays held). _prevForwardHeld = false; _prevBackwardHeld = false; _prevStrafeLeftHeld = false; _prevStrafeRightHeld = false; _prevTurnLeftHeld = false; _prevTurnRightHeld = false; _prevRunHeld = false; _hasInputSnapshot = false; // Reset physics clock so any subsequent update_object calls start fresh. _body.LastUpdateTime = 0.0; _objectClock.ResetForEnterWorld(); } private Vector3 ComputeRenderPosition() { float alpha = Math.Clamp( (float)(_objectClock.PendingSeconds / PhysicsBody.MinQuantum), 0f, 1f); return Vector3.Lerp(_prevPhysicsPos, _currPhysicsPos, alpha); } /// /// Retail Hidden slice of CPhysicsObj::UpdatePositionInternal /// (0x00512C30). Input, PartArray root motion, and physics integration are /// skipped, while PositionManager composition and the manager tail remain /// live. A composed offset still commits through CTransition so cell /// membership and contact state cannot become stale behind the hidden mesh. /// public MovementResult TickHidden(float dt, Action? handleTargeting = null) { EnsurePublishedForRuntimeOperation(); AdvancedObjectQuantumLastTick = false; if (!float.IsFinite(dt) || dt <= 0f) { return CapturePresentationResult() with { RenderPosition = _body.Position, IsOnGround = _body.OnWalkable, }; } _simTimeSeconds += dt; bool reactivated = _objectClock.Activate(); _body.TransientState |= TransientStateFlags.Active; RetailObjectQuantumBatch batch = reactivated ? default : _objectClock.Advance(dt); AdvancedObjectQuantumLastTick = batch.Count > 0; if (batch.Discarded) { _prevPhysicsPos = _body.Position; _currPhysicsPos = _body.Position; } for (int qi = 0; qi < batch.Count; qi++) { float quantum = batch.GetQuantum(qi); Vector3 previousPosition = _body.Position; bool previousContact = _body.InContact; bool previousOnWalkable = _body.OnWalkable; if (PositionManager is { } manager) { var delta = _positionManagerDeltaScratch; delta.Reset(); manager.AdjustOffset(delta, quantum); if (delta.Origin != Vector3.Zero) _body.Position += Vector3.Transform(delta.Origin, _body.Orientation); if (!delta.Orientation.IsIdentity) { _body.Orientation = AcDream.Core.Physics.Motion.FrameOps.SetRotate( _body.Position, _body.Orientation, _body.Orientation * delta.Orientation); } } // CPhysicsObj::process_hooks remains inside UpdatePositionInternal // even when Hidden suppresses PartArray and physics advancement. _processAnimationHooks?.Invoke(); if (_body.Position != previousPosition && CellId != 0 && _physics.LandblockCount > 0) { ResolveResult resolved = _physics.ResolveWithTransition( previousPosition, _body.Position, CellId, sphereRadius: 0.48f, sphereHeight: 1.835f, stepUpHeight: StepUpHeight, stepDownHeight: StepDownHeight, isOnGround: previousOnWalkable, body: _body, // TS-23: OwnPvpFlags is None for every non-PK character // (the default), so this OR is a no-op for the common // case and bit-identical to the pre-P3 hardcoded value. moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide | OwnPvpFlags, movingEntityId: LocalEntityId, // TS-46: the player's own Setup sphere list, scaled by // ObjectScale. Empty falls back to the 0.48/1.835 // reconstruction above. sphereList: SphereList, sphereScale: ObjectScale); _body.CommitTransitionPosition(resolved.CellId, resolved.Position); PhysicsObjUpdate.CommitSetPositionTransition( _body, resolved.InContact, resolved.OnWalkable, resolved.CollisionNormalValid, resolved.CollisionNormal, previousContact, previousOnWalkable, Movement.HitGround, _motion.LeaveGround); UpdateCellId(resolved.CellId, "hidden-position-manager"); } RetailObjectManagerTail.Run( handleTargeting, Movement, _motion.CheckForCompletedMotions, PositionManager); _prevPhysicsPos = _body.Position; _currPhysicsPos = _body.Position; } _prevPhysicsPos = _body.Position; _currPhysicsPos = _body.Position; _wasAirborneLastFrame = !_body.OnWalkable; return new MovementResult( Position: _body.Position, RenderPosition: _body.Position, CellId: CellId, IsOnGround: _body.OnWalkable, MotionStateChanged: false, ForwardCommand: null, SidestepCommand: null, TurnCommand: null, ForwardSpeed: null, SidestepSpeed: null, TurnSpeed: null, CurrentStyle: _motion.RawState.CurrentStyle); } public MovementResult Update( float dt, MovementInput input, Action? handleTargeting = null) { EnsurePublishedForRuntimeOperation(); AdvancedObjectQuantumLastTick = false; // Reject a malformed host-frame duration at the controller boundary. // The retail object clock cannot sanitize state that input/jump/yaw // code already mutated; in particular Infinity would never converge // in heading wrap. A rejected frame is a pure presentation read. if (!float.IsFinite(dt) || dt <= 0f) return CapturePresentationResult(); _simTimeSeconds += dt; // Portal-space guard: while teleporting, no input is processed and // no physics is resolved. Return a zero-movement result so the caller // can detect the frozen state (MotionStateChanged = false, no commands). if (State == PlayerState.PortalSpace) { return new MovementResult( Position: Position, RenderPosition: RenderPosition, CellId: CellId, IsOnGround: _body.OnWalkable, MotionStateChanged: false, ForwardCommand: null, SidestepCommand: null, TurnCommand: null, ForwardSpeed: null, SidestepSpeed: null, TurnSpeed: null, CurrentStyle: _motion.RawState.CurrentStyle); } // ── R3-W6: EDGE-DRIVEN retail input (replaces the D6.2 per-frame // RawMotionState rebuild — the level-triggered substitute for // retail's edge-triggered CommandInterpreter). Each key EDGE fires // DoMotion/StopMotion (0x00528d20/0x00528530) which mutate the // interpreter's OWN RawState via ApplyMotion/RemoveMotion and // dispatch through the funnel + DefaultSink — the SAME pipeline // remotes use. The Shift edge is retail's set_hold_run // (0x00528b70, caller 0x006b33ca shape: interrupt=true). RAW speeds // stay 1.0 (apply_run_to_command applies the run rate — pre-scaling // would double-scale; TS-22 unchanged). R4-V5: the ctor-default // params carry retail's 0x1EE0F bitfield whose CancelMoveTo bit // (0x8000) is SET — any key edge mid-moveto fires // InterruptCurrentMovement → MoveTo.CancelMoveTo(ActionCancelled), // the retail user-input cancel chain (TS-36 retired; set_hold_run's // interrupt:true is the same chain for the Shift edge). if (!_hasInputSnapshot) { // GameWindow supplies the configured default walk/run mode as a // level, not a physical key edge. Seed that first snapshot without // claiming movement control; a simultaneous directional edge below // still takes control normally. _hasInputSnapshot = true; _prevRunHeld = input.Run; _prevRunHold = input.Run; _motion.set_hold_run(input.Run, interrupt: false); } bool externallyRequestedMovementEvent = _externalMovementEventPending; _externalMovementEventPending = false; bool motionEdgeFired = false; bool movementEventRequested = externallyRequestedMovementEvent; { bool userInputEdge = input.Run != _prevRunHeld || input.Forward != _prevForwardHeld || input.Backward != _prevBackwardHeld || input.StrafeLeft != _prevStrafeLeftHeld || input.StrafeRight != _prevStrafeRightHeld || input.TurnLeft != _prevTurnLeftHeld || input.TurnRight != _prevTurnRightHeld; if (userInputEdge) TakeControlFromServer(); var p = new AcDream.Core.Physics.Motion.MovementParameters(); // Shift/run edge FIRST — retail's set_hold_run re-applies // movement, so the hold-key state is current before any // same-frame directional dispatch. if (input.Run != _prevRunHeld) { _motion.set_hold_run(input.Run, interrupt: true); motionEdgeFired = true; } // Forward channel (W / S share one raw channel — retail // RawMotionState.ApplyMotion's forward-class switch). W wins on // a same-frame double-press; releasing one key while the // opposite is still held re-issues the survivor (equivalent to // the old level-triggered build, which always reflected the // currently-held set). if (input.Forward && !_prevForwardHeld) { DoMotionAtPhysicsObjectBoundary(MotionCommand.WalkForward, p); motionEdgeFired = true; } else if (input.Backward && !_prevBackwardHeld && !input.Forward) { DoMotionAtPhysicsObjectBoundary(MotionCommand.WalkBackward, p); motionEdgeFired = true; } if (!input.Forward && _prevForwardHeld) { if (input.Backward) DoMotionAtPhysicsObjectBoundary(MotionCommand.WalkBackward, p); else StopMotionAtPhysicsObjectBoundary(MotionCommand.WalkForward, p); motionEdgeFired = true; } else if (!input.Backward && _prevBackwardHeld && !input.Forward) { StopMotionAtPhysicsObjectBoundary(MotionCommand.WalkBackward, p); motionEdgeFired = true; } // Sidestep channel. CameraInstantMouseLook remaps held keyboard // TurnLeft/TurnRight into this channel while MMB is active. (uint? desiredSidestep, bool sidestepUsesRunHold) = DesiredInputSidestep(input, _mouseLookActive); if (ApplyInputSidestep(desiredSidestep, sidestepUsesRunHold)) motionEdgeFired = true; // Everything above is an ordinary input edge and therefore owns // an immediate SendMovementEvent. Mouse-origin turn updates below // deliberately do not: CameraSet reports them on its separate // half-second cadence. movementEventRequested |= motionEdgeFired; // Turn channel. Retail CameraSet::Rotate (0x00458310) feeds mouse // input through CommandInterpreter::MovePlayer as TurnLeft / // TurnRight, so character yaw, physics orientation, raw wire state, // and ACE authority all share this one MotionInterpreter owner. bool keyboardTurnEdge = input.TurnRight != _prevTurnRightHeld || input.TurnLeft != _prevTurnLeftHeld; if (keyboardTurnEdge) movementEventRequested = true; uint? desiredTurnCommand = _mouseLookActive ? null : input.TurnRight ? MotionCommand.TurnRight : input.TurnLeft ? MotionCommand.TurnLeft : null; float desiredTurnSpeed = 1f; bool desiredTurnFromMouse = false; if (_mouseLookActive && _mouseTurnSamplePending) { float adjustment = _mouseTurnAdjustment; _mouseTurnSamplePending = false; _mouseTurnAdjustment = 0f; if (MathF.Abs(adjustment) >= MouseTurnDeadZone) { desiredTurnCommand = adjustment < 0f ? MotionCommand.TurnRight : MotionCommand.TurnLeft; desiredTurnSpeed = MathF.Min( MathF.Abs(adjustment) * MouseTurnSpeedScale, MouseTurnMaximumSpeed); desiredTurnFromMouse = true; } } else if (_mouseLookActive && _activeInputTurnFromMouse) { // Eventless render frames do not synthesize a zero. Preserve // the last Rotate motion until the delayed retail idle handler // explicitly calls StopMouseDrift. desiredTurnCommand = _activeInputTurnCommand; desiredTurnSpeed = _activeInputTurnSpeed; desiredTurnFromMouse = true; } if (ApplyInputTurn( desiredTurnCommand, desiredTurnSpeed, desiredTurnFromMouse)) motionEdgeFired = true; // Retail stores last_move_was_autonomous = 1 at the CPhysicsObj // INPUT boundary — CPhysicsObj::DoMotion @00510030 / // CPhysicsObj::StopMotion @005100e0 (per call, before routing // to MovementManager) and CommandInterpreter:: // TakeControlFromServer @006b32f4. The MoveToManager's // _DoMotion goes through CMotionInterp internals and NEVER // touches the flag; the wire unpack path stores the wire byte // (P1, 00509730). This edge block IS acdream's input boundary, // so an edge firing is exactly retail's store site. The flag // routes the per-tick pump's A3 dual dispatch (raw for // input-driven motion, interpreted for server/manager-driven). if (motionEdgeFired) { _body.LastMoveWasAutonomous = true; _controlledByServer = false; } } _prevForwardHeld = input.Forward; _prevBackwardHeld = input.Backward; _prevStrafeLeftHeld = input.StrafeLeft; _prevStrafeRightHeld = input.StrafeRight; _prevTurnLeftHeld = input.TurnLeft; _prevTurnRightHeld = input.TurnRight; _prevRunHeld = input.Run; // Retail input reaches MotionTableManager before this object's // CPartArray update. The complete-object scheduler below advances it // once per admitted retail quantum, after every input edge for this // render frame has reached the motion table. bool hasAnimationRootMotion = _advanceAnimationRootMotion is not null; // ── 1. Jump input (charged) ─────────────────────────────────────────── // Hold spacebar to charge (0→1 over JumpChargeRate seconds). // Release to execute: jump(extent) validates + sets JumpExtent, // then LeaveGround() applies the scaled velocity via GetLeaveGroundVelocity. float? outJumpExtent = null; Vector3? outJumpVelocity = null; if (input.Jump && !_jumpCharging && !_prevJumpHeld) { // Press edge — ClientCombatSystem::CommenceJump @0x0056AF90. // R3-W6 (map R1): retail's charge_jump fires at charge START // (SmartBox/input boundary 0x0056afac) — the ONLY place // StandingLongJump arms (grounded + Ready + no sidestep/turn). // // 2026-08-14 user retail gate: charge_jump @0x005281c0 has NO // grounded check — it refuses only 0x49 (CanJump encumbrance) // and 0x48 (fallen/crouch-family commands). Pressing jump while // AIRBORNE charges the bar normally; retail's 0x24 "You can't // jump while in the air" comes exclusively from the RELEASE // path's jump_is_allowed (DoJump @0x0056B110 → CMotionInterp:: // jump), so charging through the air and releasing after // landing executes a normal jump. This supersedes the CH // user-gate round 1 item A press-edge 0x24 report — CommenceJump // DOES carry an in-air fallback text, but with a faithful // charge_jump (0/0x48/0x49 only) that arm is unreachable. // // A REFUSED charge reports and never begins the bar // (jump_pending stays 0 in retail — no powerbar, no charge). WeenieError chargeResult = _motion.ChargeJump(); if (chargeResult == WeenieError.None) { _jumpCharging = true; _jumpExtent = 0f; } else { ReportJumpRefusal(chargeResult); } } if (input.Jump && _jumpCharging) { // Spacebar held — accumulate charge (grounded OR airborne). float chargeRate = _motion.InterpretedState.CurrentStyle == AcDream.Core.Combat.CombatInputPlanner.DualWieldCombatStyle ? DualWieldJumpChargeRate : JumpChargeRate; _jumpExtent = MathF.Min(_jumpExtent + dt * chargeRate, 1.0f); } else if (_jumpCharging) { // Spacebar RELEASED — fire jump (DoJump @0x0056B110). An // airborne release refuses 0x24 through jump_is_allowed below. var jumpResult = _motion.jump(_jumpExtent); if (jumpResult == WeenieError.None) { // R3-W4 (J7): the manual LeaveGround call is DELETED — // jump() clears OnWalkable, and the SAME frame's // grounded→airborne edge (section 5's transition detection) // fires _motion.LeaveGround() exactly where retail's // transition sweep does. Capture jump_v_z NOW: the edge's // LeaveGround resets JumpExtent to 0 later this frame. float jumpVz = _motion.GetJumpVZ(); outJumpExtent = _jumpExtent; // D6.2: get_state_velocity() is now correct for all directions // (apply_raw_movement normalized backward/strafe above), so the // jump-launch velocity is get_state_velocity() + the vertical jump // component. Retires the duplicated hand-mirrored formulas that // existed only until adjust_motion was ported (register TS-22). var jumpVel = _motion.get_state_velocity(); outJumpVelocity = new Vector3(jumpVel.X, jumpVel.Y, jumpVz); // Local-prediction fix: LeaveGround above wrote (0, 0, jumpZ) // to the body for backward/strafe-left (same get_state_velocity // zero-for-non-canonical-motion bug as on the wire side). // Push the corrected body-local velocity back so the local // client renders the jump in the same world direction the // server is broadcasting to observers. Same vector we just // sent in JumpAction — local + remote stay in sync. _body.set_local_velocity(outJumpVelocity.Value, autonomous: true); } else { // Campaign CH slice CH2: a refused fire used to reset the // charge state and return silently — the power bar drained // and nothing happened, with no explanation. Report it // exactly as ClientCombatSystem::DoJump @0x0056B110 does. ReportJumpRefusal(jumpResult); } _jumpCharging = false; _jumpExtent = 0f; } // Campaign CH user-gate round 2, item 1 (TEMPORARY): per-tick trace // bracketing every frame where jump is (or was) held, so the probe // can see the OnWalkable transition around a live jump attempt // without spamming every ordinary frame. if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeJumpEnabled && (input.Jump || _prevJumpHeld)) { Console.WriteLine( $"[jump-tick] input.Jump={input.Jump} prevJumpHeld={_prevJumpHeld} " + $"onWalkable={_body.OnWalkable} jumpCharging={_jumpCharging} " + $"jumpExtent={_jumpExtent:F2}"); } _prevJumpHeld = input.Jump; // ── 2. Run admitted complete-object quanta ──────────────────────────── // CPhysicsObj::update_object (0x00515D10) retains a remainder at or // below MinQuantum, splits elapsed time above MaxQuantum, and discards // stale gaps above HugeQuantum. Every admitted quantum executes the // whole object update below; animation never runs on a render-only // fragment. bool reactivated = _objectClock.Activate(); _body.TransientState |= TransientStateFlags.Active; RetailObjectQuantumBatch quantumBatch = reactivated ? default : _objectClock.Advance(dt); AdvancedObjectQuantumLastTick = quantumBatch.Count > 0; bool justLanded = false; if (quantumBatch.Discarded) { _prevPhysicsPos = _body.Position; _currPhysicsPos = _body.Position; } for (int qi = 0; qi < quantumBatch.Count; qi++) { float tickDt = quantumBatch.GetQuantum(qi); bool captureQuantum = PlayerPhysicsQuantumCapture.IsEnabled; uint captureCellBefore = CellId; PlayerPhysicsBodyTraceSnapshot captureQuantumStart = captureQuantum ? PlayerPhysicsQuantumCapture.Snapshot(_body) : default; // CPhysicsObj::UpdatePositionInternal (0x00512C30): visible objects // advance their PartArray first. The complete Frame survives; only its // origin is scaled while OnWalkable or zeroed while airborne. var pmDelta = _positionManagerDeltaScratch; pmDelta.Reset(); if (_advanceAnimationRootMotion is { } advanceRootMotion) { _animationRootMotionScratch.Reset(); advanceRootMotion(tickDt, _animationRootMotionScratch); pmDelta.Origin = _body.OnWalkable ? _animationRootMotionScratch.Origin * ObjectScale : Vector3.Zero; pmDelta.Orientation = _animationRootMotionScratch.Orientation; } else if (_motion.InterpretedState.TurnCommand == MotionCommand.TurnRight) { // AP-77: explicit no-PartArray/headless fallback. Production // humanoids consume the DAT-authored complete CSequence Frame. Yaw -= 1.5f * _motion.InterpretedState.TurnSpeed * tickDt; } // #265/#166 (2026-07-30, docs/research/2026-07-30-265-capture-bisect.md // §4): retail CPhysicsObj::UpdatePositionInternal (0x00512C30) composes // BOTH channels every quantum -- the root-motion Frame just written into // pmDelta.Origin above (commanded locomotion) AND the integrated physics // Velocity (residual momentum: jump arcs, landing slides) -- via the SAME // candidate position that PhysicsBody.UpdatePhysicsInternal's Euler step // (calc_friction + v*dt, below) and the ResolveWithTransition sweep both // see. This block used to hand-zero Velocity.X/Y to EXACTLY zero on every // single grounded tick whenever animation root motion drives the walk (the // production graphical local-player path since R6) -- regardless of why // the body was OnWalkable. That discarded any horizontal momentum a fall // or collision had just left on the body (retail settles it via // calc_friction over subsequent ticks; PhysicsBody.GroundNormal is now // synced to the real contact-plane normal by PhysicsEngine so calc_friction // has real slope data to act on) before the integrator ever ran -- a mover // that landed on a walkable roof/slope with residual horizontal velocity // had that velocity vanish the very next tick and never moved again. Root // motion still fully owns COMMANDED locomotion (walking/running // displacement comes from pmDelta.Origin above, not from Velocity), so // this does not reintroduce command- or packet-cadence-derived grounded // translation -- it only stops DESTROYING whatever Velocity already holds. // Ordinary walking is unaffected: Velocity is already ~0 while grounded // with no fall/collision in flight (nothing else writes it), so removing // this zero is a no-op on that path -- see // GroundedRootMotion_FrictionThreshold_DoesNotHammerLocomotionTests // (PhysicsBodyTests.cs) and Update_AnimationRootMotion_WalkSpeedUnaffected // ByResidualVelocityFix (PlayerMovementControllerTests.cs). // // The headless/test-controller fallback below (no animation runtime -- // get_state_velocity's doc comment) is unchanged: it still directly // writes the commanded state velocity into the body every grounded tick, // exactly as before -- that model has no separate root-motion channel to // compose with, so overwriting IS its correct per-tick behavior. if (_body.OnWalkable && !hasAnimationRootMotion) { float savedWorldVz = _body.Velocity.Z; Vector3 stateVelocity = _motion.get_state_velocity(); _body.set_local_velocity( new Vector3(stateVelocity.X, stateVelocity.Y, savedWorldVz), autonomous: _body.LastMoveWasAutonomous); } var preIntegratePos = _body.Position; Vector3 oldTickEndPos = _currPhysicsPos; // PositionManager::adjust_offset (0x00512D0E) mutates the SAME // complete Frame after PartArray. Interpolation may replace it; // Sticky/Constraint then compose according to their retail rules. PositionManager?.AdjustOffset(pmDelta, tickDt); // #167 (Campaign P P5): push the read side of TS-35's // jump_is_allowed gate. MotionInterpreter only has a PhysicsBody // (no host reference), so the per-tick pump — the single owner of // this write, right beside the taper call it mirrors — is the seam // that keeps the stub property current for the local player. _body.IsFullyConstrained = PositionManager?.IsFullyConstrained() ?? false; if (pmDelta.Origin != Vector3.Zero) _body.Position += Vector3.Transform(pmDelta.Origin, _body.Orientation); if (!pmDelta.Orientation.IsIdentity) { _body.Orientation = AcDream.Core.Physics.Motion.FrameOps.SetRotate( _body.Position, _body.Orientation, _body.Orientation * pmDelta.Orientation); } _body.calc_acceleration(); PlayerPhysicsBodyTraceSnapshot capturePreIntegration = captureQuantum ? PlayerPhysicsQuantumCapture.Snapshot(_body) : default; _body.UpdatePhysicsInternal(tickDt); PlayerPhysicsBodyTraceSnapshot capturePostIntegration = captureQuantum ? PlayerPhysicsQuantumCapture.Snapshot(_body) : default; // Retail process_hooks is the final UpdatePositionInternal step: // after physics, before the transition and manager tail. _processAnimationHooks?.Invoke(); var postIntegratePos = _body.Position; // retail UpdateObjectInternal (pc:283657): the transition + handle_all_collisions are // reached ONLY when the integrated candidate actually MOVED off m_position. This gate // is load-bearing for the #182 bleed: after fsf>1 zeros a blocked jump's velocity, the // next frame integrates zero motion (velMag2==0 → no position step, just v += gravity), // so the candidate hasn't moved yet — handle_all_collisions MUST be skipped that frame // or it re-zeros the gravity velocity and the body re-wedges instead of falling off. bool candidateMoved = postIntegratePos != preIntegratePos; // ── 3. Collision resolution via CTransition sphere-sweep ───────────── // The Transition system subdivides the movement from pre→post into // sphere-radius steps, testing terrain collision at each step. // Falls back to simple Z-snap if transition fails. var resolveResult = _physics.ResolveWithTransition( preIntegratePos, postIntegratePos, CellId, sphereRadius: 0.48f, // human Setup 0x02000001 sphere radius (dat: 0.480) // #137 window climb (2026-07-06): sphereHeight is the CAPSULE TOP // (InitPath places the head sphere center at height − radius). The // dat human Setup 0x02000001 has Spheres[1].Origin.Z = 1.350 // (top 1.830) and Height = 1.835 — retail collides with that // sphere list verbatim (CPhysicsObj::transition 0x00512dc0 → // init_sphere(GetNumSphere, GetSphere, scale)). The old 1.2f put // the head sphere center at 0.72 — the top 0.63 m of the // character had NO collision, letting the player climb into a // 1.3 m window alcove head-through-lintel. Register TS-46. sphereHeight: 1.835f, stepUpHeight: StepUpHeight, stepDownHeight: StepDownHeight, // L.2.3a: from Setup.StepDownHeight isOnGround: _body.OnWalkable, body: _body, // persist ContactPlane across frames for slope tracking // L.2c 2026-04-30: retail PhysicsGlobals.DefaultState includes // EdgeSlide, and PhysicsObj.get_object_info copies that bit into // OBJECTINFO. Keep it explicit here so edge/cliff handling runs // under the same flag profile as retail player movement. // // Commit C 2026-04-29 — local player is always IsPlayer. // TS-23 (2026-07-30): OwnPvpFlags carries the real // PK/PKLite/Impenetrable bits, decoded from the player's own // PublicWeenieDesc._bitfield (default None — a no-op OR for // every non-PK character, bit-identical to the pre-P3 value; // non-PK pair still walks through other non-PK players, // retail's default for ACE's character creation defaults). moverFlags: AcDream.Core.Physics.ObjectInfoState.IsPlayer | AcDream.Core.Physics.ObjectInfoState.EdgeSlide | OwnPvpFlags, // Fix #42: skip self in FindObjCollisions. Wired by GameWindow // when the local player entity spawns (or stays 0 in tests, in // which case there's no registered ShadowEntry to collide with // anyway). movingEntityId: LocalEntityId, // TS-46 (2026-07-30): the player's own Setup sphere list // (0x02000001: (0,0,0.475) r=.48 + (0,0,1.350) r=.48), // scaled by ObjectScale. Empty (unset/no Setup resolved yet) // falls back to the sphereRadius/sphereHeight reconstruction // above. sphereList: SphereList, sphereScale: ObjectScale); // L.4-diag (2026-04-30): trace position transitions so we can see // whether the body is actually moving frame-to-frame on the steep // roof, or whether it's frozen at the impact point. if (AcDream.Core.Physics.PhysicsDiagnostics.DumpSteepRoofEnabled && resolveResult.CollisionNormalValid) { Console.WriteLine( $"[steep-roof] FRAME pre=({preIntegratePos.X:F2},{preIntegratePos.Y:F2},{preIntegratePos.Z:F2}) " + $"post=({postIntegratePos.X:F2},{postIntegratePos.Y:F2},{postIntegratePos.Z:F2}) " + $"resolved=({resolveResult.Position.X:F2},{resolveResult.Position.Y:F2},{resolveResult.Position.Z:F2}) " + $"isOnGround={resolveResult.IsOnGround}"); } // ── Apply the resolve: retail UpdateObjectInternal (0x005156b0) → // SetPositionInternal (0x00515330) → handle_all_collisions (0x00514780). // #182 verbatim rebuild: this REPLACES the ad-hoc airborne-only bounce with the // frames_stationary_fall-driven velocity model — the airborne-stuck bleed // (fsf>1 → velocity zeroed) that lets a blocked jump fall/glide off a monster crowd. // cached_velocity = realized displacement / dt (retail's SEPARATE reporting/DR value, // pc:005158cb-5158ff; not fed back into the integrator velocity). _body.CachedVelocity = candidateMoved ? (resolveResult.Position - preIntegratePos) / tickDt : Vector3.Zero; // Capture prev contact/walkable BEFORE committing the new state — retail // SetPositionInternal reads transient_state at entry for handle_all_collisions' args. bool prevContact = _body.InContact; bool prevOnWalkable = _body.OnWalkable; _body.CommitTransitionPosition(resolveResult.CellId, resolveResult.Position); _prevPhysicsPos = oldTickEndPos; _currPhysicsPos = _body.Position; // Landing-bounce family (#265, 2026-07-30, // docs/research/2026-07-30-landing-bounce-family.md): retail // SetPositionInternal (0x00515330) commits contact PURELY from the // transition's contact plane — no velocity-sign gate, no velocity // zeroing. The former AD-25 gate (Velocity.Z<=0) existed because // the resolver glued ascending movers to the ground; the // check_contact seed (PhysicsEngine, retail 0x0050f5b0) now stops // that at the source — an ascending jump finds no contact plane and // goes airborne naturally. The former Velocity.Z hand-zero // explicitly defeated handle_all_collisions' landing reflect; // deleting it restores retail's landing bounce // (v += -(v·n)(elasticity+1)·n, DEFAULT_ELASTICITY 0.05): the flat // landing pop, the downhill bounce chain, and the uphill // into-slope velocity kill all come from that reflect. // // Retail reaches SetPositionInternal only when the transition // succeeded (CPhysicsObj::transition 0x00512DC0 discards the // CTransition on find_valid_position failure) — contact state stays // untouched on failure frames. fsf-wedge frames are OK frames // (ValidateTransition manufactures the UP contact), so the fsf>1 // bleed below remains reachable exactly as before. // The WHOLE commit is additionally gated on candidateMoved: retail // UpdateObjectInternal (pc:283657) only runs the transition + // SetPositionInternal when the integrated candidate MOVED — a // standing body's contact state is never re-derived (a zero-move // resolve cannot "find" a contact plane because no sweep runs; the // old code masked this by echoing the caller's isOnGround back // through the seeded oi flags). This same gate is what keeps a // post-bleed no-move frame from re-zeroing the rebuilding gravity // velocity (#182). bool landedThisQuantum = false; if (resolveResult.Ok && candidateMoved) { if (resolveResult.InContact) _body.TransientState |= TransientStateFlags.Contact; else _body.TransientState &= ~TransientStateFlags.Contact; _body.calc_acceleration(); // pc:283442 (post-contact-bit) if (resolveResult.InContact && resolveResult.OnWalkable) { bool wasAirborne = !_body.OnWalkable; _body.TransientState |= TransientStateFlags.OnWalkable; if (wasAirborne) { // R4-V5 → R5-V5: retail order — minterp then moveto // (MovementManager::HitGround 0x00524300). Re-arms a // moveto suspended by the airborne UseTime contact gate. // LeaveGround has NO moveto side (§2e). Movement.HitGround(); landedThisQuantum = true; } } else { _body.TransientState &= ~TransientStateFlags.OnWalkable; } _body.calc_acceleration(); // pc:283475/283490 (set_on_walkable tail) // handle_all_collisions (0x005154FE): reflect the into-surface // velocity (fsf≤1 — v += -(v·n)(elasticity+1)·n, the landing // bounce) or ZERO it entirely (fsf>1 — THE airborne-stuck // fix). The Stationary* bit round-trip is owned by the Core // resolve writeback. PhysicsObjUpdate.HandleAllCollisions( _body, resolveResult.CollisionNormalValid, resolveResult.CollisionNormal, prevContact, prevOnWalkable, nowOnWalkable: _body.OnWalkable); } // R3-W4 (J7/J8): the grounded→airborne EDGE fires retail's LeaveGround (0x00528b00) — // jump launches and walk-off-a-ledge both route here. The landing branch above fires // HitGround on the opposite edge. if (!_body.OnWalkable && !_wasAirborneLastFrame) _motion.LeaveGround(); _wasAirborneLastFrame = !_body.OnWalkable; justLanded |= landedThisQuantum; UpdateCellId(resolveResult.CellId, "resolver"); // Named retail CPhysicsObj::UpdateObjectInternal (0x005156B0): // process_hooks has already completed inside UpdatePositionInternal, // then the transition commits, and only then does the ordered manager // tail runs in its exact Detection/Target/Movement/PartArray/ // Position order. DetectionManager is not ported, so its slot is // presently null. RetailObjectManagerTail.Run( handleTargeting, Movement, _motion.CheckForCompletedMotions, PositionManager); if (captureQuantum) { PlayerPhysicsQuantumCapture.Log( tickDt, captureCellBefore, CellId, input, capturePreIntegration.Position - captureQuantumStart.Position, candidateMoved, captureQuantumStart, capturePreIntegration, capturePostIntegration, new PlayerPhysicsResolveTraceSnapshot( resolveResult.Position, resolveResult.CellId, resolveResult.Ok, resolveResult.IsOnGround, resolveResult.InContact, resolveResult.OnWalkable, resolveResult.CollisionNormalValid, resolveResult.CollisionNormal), PlayerPhysicsQuantumCapture.Snapshot(_body)); } } // ── 4. Determine outbound motion commands ───────────────────────────── uint? outForwardCmd = null; float? outForwardSpeed = null; uint? outSidestepCmd = null; float? outSidestepSpeed = null; uint? outTurnCmd = null; float? outTurnSpeed = null; // Retail-faithful wire commands — the wire carries the RAW motion state: // - Forward (walk): WalkForward @ 1.0 // - Forward (run): WalkForward @ 1.0 + HoldKey.Run // - Backward: WalkBackward @ 1.0 // D6.2b (echo-test 2026-07-01): ACE RECOMPUTES the broadcast run speed // from the character's run skill and auto-upgrades WalkForward+HoldKey.Run // → RunForward for observers. Sending the raw forward_speed=1.0 (omitted by // default-difference packing) still broadcasts RunForward @ runRate — a // retail observer saw +Acdream run at full pace. // R3-W6: the LOCAL animation no longer needs a separate // LocalAnimationCommand — the walk→run promotion happens inside the // ported machinery (apply_raw_movement → apply_run_to_command // promotes WalkForward+HoldKey.Run → RunForward @ my_run_rate on the // interpreted side, which the DefaultSink dispatch plays). // NOTE (R7 scope): the wire values below stay derived from input — // the L.2b-verified byte stream is untouched; sourcing them from // _motion.RawState needs the cdb CommandInterpreter-boundary capture // the W6 map's §2b TODO flags, and R7 owns outbound anyway. if (input.Forward) { outForwardCmd = MotionCommand.WalkForward; outForwardSpeed = 1.0f; // RAW — ACE recomputes the broadcast speed } else if (input.Backward) { outForwardCmd = MotionCommand.WalkBackward; outForwardSpeed = 1.0f; } else if (_motion.RawState.ForwardCommand is ( MotionCommand.Crouch or MotionCommand.Sitting or MotionCommand.Sleeping)) { outForwardCmd = _motion.RawState.ForwardCommand; outForwardSpeed = _motion.RawState.ForwardSpeed; } // Source the outbound axis from the canonical input-owned raw channel. // CameraInstantMouseLook maps keyboard TurnLeft/TurnRight into this // same channel, so reconstructing it from only physical strafe keys // would send ACE an idle sidestep while the local body moved. if (_activeInputSidestepCommand is { } activeInputSidestep) { outSidestepCmd = activeInputSidestep; outSidestepSpeed = _motion.RawState.SidestepSpeed; } // Turn commands come from the current user-owned turn channel. This is // keyboard A/D or CameraSet's transient mouse-origin turn, never a // server-owned MoveTo turn. The raw speed is the exact value passed to // MotionInterpreter before hold-run adjustment. if (_activeInputTurnCommand is { } activeInputTurn) { outTurnCmd = activeInputTurn; outTurnSpeed = _activeInputTurnSpeed; } // ── 7. Detect motion state change ───────────────────────────────────── // ForwardCommand can stay WalkForward while only the run-hold bit changes // (walk W held, then Shift → run). Since D6.2b the wire forward_speed is // always the RAW 1.0 (ACE recomputes the broadcast run speed), so the // walk↔run toggle is detected via the HoldKey (runHold) and the // LocalAnimationCommand change (Walk↔Run cycle), NOT via forward_speed. A // fresh MoveToState on that toggle lets ACE's BroadcastMovement re-pick // WalkForward vs RunForward (via HoldKey) and recompute the run speed for // observers. The forward_speed comparison below is retained (harmless — it // never fires now that forward_speed is constant) for defensiveness. bool runHold = input.Run; // R3-W6: the localAnimCmd leg is deleted with the synthesis layer; // motionEdgeFired (any DoMotion/StopMotion/set_hold_run edge this // frame) is OR-ed in — by construction an edge IS a state change // (retail dispatches only on edges), keeping the wire cadence // identical to the old output-comparison. bool changed = outForwardCmd != _prevForwardCmd || outSidestepCmd != _prevSidestepCmd || outTurnCmd != _prevTurnCmd || !FloatsEqual(outForwardSpeed, _prevForwardSpeed) || runHold != _prevRunHold || motionEdgeFired || externallyRequestedMovementEvent; bool mouseMovementEventDue = _mouseMovementEventPending || (_mouseMovementEventCandidate && _simTimeSeconds > _lastMouseMovementEventTime + MouseMovementEventInterval); _mouseMovementEventCandidate = false; if (mouseMovementEventDue) _mouseMovementEventPending = true; bool shouldSendMovementEvent = movementEventRequested || mouseMovementEventDue; _prevForwardCmd = outForwardCmd; _prevSidestepCmd = outSidestepCmd; _prevTurnCmd = outTurnCmd; _prevForwardSpeed = outForwardSpeed; _prevRunHold = runHold; static bool FloatsEqual(float? a, float? b) { if (a.HasValue != b.HasValue) return false; if (!a.HasValue || !b.HasValue) return true; return System.Math.Abs(a.Value - b.Value) < 1e-4f; } // R3-W6: the K-fix5 LocalAnimationSpeed synthesis is DELETED — the // run pacing now comes from the ported machinery itself // (apply_run_to_command scales the interpreted speed by my_run_rate; // the DefaultSink dispatch plays the cycle at that speed — the same // source remotes use). // R4-V5: the #69 auto-walk turn-cycle edge synthesizer is DELETED — // the MoveToManager's own aux-turn steering and TurnToHeading nodes // dispatch TurnRight/TurnLeft through _DoMotion (the retail // mechanism), so turn cycles during a moveto come from the same // pipeline as everything else. return new MovementResult( Position: Position, RenderPosition: RenderPosition, CellId: CellId, IsOnGround: _body.OnWalkable, MotionStateChanged: changed, ForwardCommand: outForwardCmd, SidestepCommand: outSidestepCmd, TurnCommand: outTurnCmd, ForwardSpeed: outForwardSpeed, SidestepSpeed: outSidestepSpeed, TurnSpeed: outTurnSpeed, // CurrentHoldKey is a level independent of active axes; every // active ordinary axis inherits it during wire projection. This // preserves idle run/walk toggles as well as strafe/backward run. IsRunning: input.Run, JustLanded: justLanded, JumpExtent: outJumpExtent, JumpVelocity: outJumpVelocity, ShouldSendMovementEvent: shouldSendMovementEvent, TurnUsesRunHold: _activeInputTurnFromMouse && outTurnCmd.HasValue, SidestepUsesRunHold: _activeInputSidestepUsesRunHold && outSidestepCmd.HasValue, IsMouseLookMovementEvent: mouseMovementEventDue, CurrentStyle: _motion.RawState.CurrentStyle); } /// /// Retail Frame::is_equal (0x00424C30) compares both the origin /// and all four quaternion components with a 0.0002-unit epsilon. It does /// not treat the mathematically equivalent q/-q pair as the /// same stored frame. /// private static bool ApproxFrameEqual(CellFrame a, CellFrame b) { const float Epsilon = 0.000199999995f; return MathF.Abs(a.Origin.X - b.Origin.X) <= Epsilon && MathF.Abs(a.Origin.Y - b.Origin.Y) <= Epsilon && MathF.Abs(a.Origin.Z - b.Origin.Z) <= Epsilon && MathF.Abs(a.Orientation.W - b.Orientation.W) < Epsilon && MathF.Abs(a.Orientation.X - b.Orientation.X) < Epsilon && MathF.Abs(a.Orientation.Y - b.Orientation.Y) < Epsilon && MathF.Abs(a.Orientation.Z - b.Orientation.Z) < Epsilon; } /// /// 2026-05-16. Contact-plane-equality test for retail's /// sub-interval AP gate. Retail's SendPositionEvent stores /// last_sent_contact_plane and ShouldSendPositionEvent re-sends /// during the sub-interval window if the plane has changed (e.g., /// player stepped onto stairs / a hill — same cell but different /// contact normal). Tiny epsilon on normal + distance covers /// floating-point noise from the physics integration. /// private static bool ApproxPlaneEqual( System.Numerics.Plane a, System.Numerics.Plane b) { const float Epsilon = 0.000199999995f; return MathF.Abs(a.Normal.X - b.Normal.X) <= Epsilon && MathF.Abs(a.Normal.Y - b.Normal.Y) <= Epsilon && MathF.Abs(a.Normal.Z - b.Normal.Z) <= Epsilon && MathF.Abs(a.D - b.D) < Epsilon; } }