diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md index 475fbaf9..fecdc1a7 100644 --- a/docs/architecture/acdream-architecture.md +++ b/docs/architecture/acdream-architecture.md @@ -206,6 +206,7 @@ src/ LiveEntityRuntime.cs -> canonical identity/state/body/projectile/spatial ownership LiveEntityPresentationController.cs -> Hidden/NoDraw/effect/collision presentation LiveEntityTeardown.cs -> failure-isolated multi-owner lifecycle drain + RetailLiveFrameCoordinator.cs -> object/physics-before-network frame barrier Rendering/ GameWindow.cs -> still owns too much runtime wiring TerrainModernRenderer.cs -> mandatory bindless+MDI terrain path @@ -642,7 +643,19 @@ canonical local physics controller and chase-camera handoff. The destination residency gate supplies retail's `EndTeleportAnimation` edge once asynchronous streaming is ready. Player placement remains authoritative and separate from presentation; arrival resets camera viewer state but does -not reset the character animation sequence. See +not reset the character animation sequence. `RetailLiveFrameCoordinator` +matches `SmartBox::UseTime @ 0x00455410`: it advances local and remote object/ +projectile runtime, composes final animated/equipped poses, drains animation +hooks, then advances particles before owner scripts and inbound network dispatch. +`RetailLocalPlayerFrameController` owns the player's exactly-once tick and sends +input-originated movement/jump output on the object side of that barrier. After +inbound dispatch, the retail command-interpreter phase evaluates periodic +AutonomousPosition from current authoritative state, then a non-advancing spatial +reconciliation updates equipped children and live effect/light anchors. Consequently a +teleport Hidden transition cannot freeze the last fraction of recall and replay +it after UnHide, and ForcePosition/F751 cannot combine old one-shot input with a new cell. +The draw pass consumes that completed update snapshot and never advances live +animation independently. See `docs/research/2026-07-15-retail-portal-space-pseudocode.md`. ## Roadmap Model diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index e5d538da..99948acb 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -593,7 +593,7 @@ Research: R9 + R12 + R13. - **✓ SHIPPED — G.1 — Sky + weather + day-night.** Deterministic client-side from Portal Year time. Sky dome geometry + keyframe gradients + rain/snow particles. See `r12-weather-daynight.md`. Full data + visual stack shipped: Region dat loader, keyframe interp, WeatherSystem with 5-kind PDF + transitions + storm flashes, WorldSession→WorldTimeService sync via ConnectRequest+TimeSync, SkyRenderer with sky-object arcs + UV scroll, rain/snow billboard renderer, F7/F10 debug cycle keys. - **✓ SHIPPED — G.2 — Dynamic lighting.** 8-light D3D-style fixed pipeline. Hard-cutoff at Range, no attenuation inside. Cell ambient. Shader UBO per frame. See `r13-dynamic-lighting.md`. SceneLightingUbo std140 at binding=1 feeds terrain + mesh + mesh_instanced + sky shaders. LightingHookSink auto-registers Setup.Lights at entity stream-in, flips IsLit on SetLightHook, unregisters on landblock unload. - **Indoor portal-based cell tracking (follow-up to Indoor walking Phase 1 / issue #87).** Replace `PhysicsDataCache.TryFindContainingCell` AABB containment with retail's `CObjMaint::HandleObjectEnterCell` portal traversal. When the player crosses a cell portal boundary, `CellId` propagates through the `CEnvCell` portal connectivity graph. Prerequisite for wall collision from outside (#85) and the remaining #84 threshold symptom. PDB symbols and `acclient.h` `CCellStructure` refs are in place (see #87). **Unblocks G.3.** -- **✓ SHIPPED — G.3 — Dungeon streaming + portal space.** `EnvCellStreamer`, portal-visibility BFS, `PlayerTeleport (0xF751)` handling with post-transition `LoginComplete`, and the retail DAT-authored portal-space CreatureMode viewport (Setup `0x02000306`, animation `0x030005AC`, exact camera/light/timing/view-plane transitions, forward-axis roll, direct portal→world viewport switch, projection warp, and destination viewer reset; corrective visual gate pending 2026-07-15). Dungeons render, stream, teleport-in, collide, light, and their doors work — see the shipped-table rows (G.3, G.3a, #137 collision, A7.L1 lighting) below and the M1.5 section of `docs/plans/2026-05-12-milestones.md` for current gate status (one recorded end-to-end round-trip user gate outstanding; live residual = far-town teleport-OUT arrival cascade, #145-residual REOPENED). See `r09-dungeon-portal-space.md` and `docs/research/2026-07-15-retail-portal-space-pseudocode.md`. +- **✓ SHIPPED — G.3 — Dungeon streaming + portal space.** `EnvCellStreamer`, portal-visibility BFS, `PlayerTeleport (0xF751)` handling with post-transition `LoginComplete`, and the retail DAT-authored portal-space CreatureMode viewport (Setup `0x02000306`, animation `0x030005AC`, exact camera/light/timing/view-plane transitions, forward-axis roll, direct portal→world viewport switch, projection warp, destination viewer reset, object/physics-before-network frame barrier, pre-network input movement/jump output, post-inbound AutonomousPosition, and non-advancing spatial reconciliation; recall-tail corrective visual gate pending 2026-07-16). Dungeons render, stream, teleport-in, collide, light, and their doors work — see the shipped-table rows (G.3, G.3a, #137 collision, A7.L1 lighting) below and the M1.5 section of `docs/plans/2026-05-12-milestones.md` for current gate status (one recorded end-to-end dungeon round-trip user gate outstanding; live residual = far-town teleport-OUT arrival cascade, #145-residual REOPENED). See `r09-dungeon-portal-space.md` and `docs/research/2026-07-15-retail-portal-space-pseudocode.md`. **Acceptance:** walk outside at dusk, see the sky gradient + sun moving; enter a torch-lit dungeon via portal; leave back to daylight. diff --git a/docs/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md index 6f74d445..3c71623d 100644 --- a/docs/plans/2026-05-12-milestones.md +++ b/docs/plans/2026-05-12-milestones.md @@ -312,6 +312,16 @@ a black alpha fade; both compose below retained UI, so chat, panels, toolbar, cursor, and input remain live throughout travel. See `docs/research/2026-07-15-retail-portal-space-pseudocode.md`. +The live frame barrier now also matches `SmartBox::UseTime`: local/remote +object, projectile, animation, final-pose, animation-hook, particle, and +owner-script work completes before the inbound network queue is dispatched. +Local movement/jump output is committed on that same pre-network side, while a +non-advancing post-network pass reconciles children and attached effect/light +anchors after authoritative root moves. This prevents ACE's teleport Hidden state +from freezing the final fraction of recall and replaying it after materialize, +without an arrival reset or Hidden-time animation exception. Automated recall +DAT conformance is green; destination-side visual confirmation remains. + --- #### (historical M1.5 working notes below) @@ -513,9 +523,9 @@ include dungeons. zero-time recursive chains fail diagnostically while valid timed weather loops continue. The live-pose step now publishes exact root/indexed rigid-part transforms after - animation and equipped-child composition, then drains animation hooks, - PhysicsScripts, attached emitters/lights, and particle simulation in fixed - frame order. World-released particles remain at their birth positions while + animation and equipped-child composition, advances existing FP hooks, drains + animation hooks, refreshes attached emitters/lights, advances particles, then + ticks PhysicsScripts in fixed retail frame order. World-released particles remain at their birth positions while parent-local particles follow; normal/blocking/anonymous logical emitter semantics are exact; pending projections skip particle updates/draw while retaining absolute creation times, the same emitter, and its logical ID, so diff --git a/docs/research/2026-07-13-retail-projectile-vfx-pseudocode.md b/docs/research/2026-07-13-retail-projectile-vfx-pseudocode.md index 6cd796c8..e0eedb10 100644 --- a/docs/research/2026-07-13-retail-projectile-vfx-pseudocode.md +++ b/docs/research/2026-07-13-retail-projectile-vfx-pseudocode.md @@ -844,8 +844,17 @@ Repository links: 4. No client-side impact, explosion, damage, or deletion is synthesized. 5. Presentation frame ordering is fixed: advance animation/root motion, publish indexed parts, compose equipped children, drain animation hooks, - tick owner PhysicsScripts, refresh attached emitters/lights, then advance - particle simulation and draw. + refresh attached emitters/lights, advance particle simulation, tick owner + PhysicsScripts, dispatch inbound network events, run the post-inbound + command-interpreter position check, reconcile spatial derivatives without + advancing time, then draw. The explicit network boundary ports + `SmartBox::UseTime @ 0x00455410`: `CPhysics::UseTime` updates every object + (including its particle and script managers) before SmartBox drains its + inbound queue, after which `CommandInterpreter::UseTime @ 0x006B3BF0` + evaluates AutonomousPosition. Within each object, + `CPhysicsObj::UpdateObjectInternal @ 0x005156B0` updates + `ParticleManager` before `ScriptManager`, so a particle created by a script + begins simulation on the following object frame. Retail stages animation hooks inside `CSequence::update`, then executes them from `CPhysicsObj::process_hooks` before the new root is committed. The same diff --git a/docs/research/2026-07-15-retail-portal-space-pseudocode.md b/docs/research/2026-07-15-retail-portal-space-pseudocode.md index 47fd5ef7..2aba5560 100644 --- a/docs/research/2026-07-15-retail-portal-space-pseudocode.md +++ b/docs/research/2026-07-15-retail-portal-space-pseudocode.md @@ -276,10 +276,45 @@ The viewer reset copies the player's complete position into both `viewer` and boom outward through the same damped and swept path used after camera collision. Reusing the source-world viewer is not retail behavior. +## Recall completion versus Hidden + The retail teleport hook does not clear the character's animation sequence. -The arrival presentation is instead hidden by the portal scene and its final -world view-plane transition while the normal motion/update stream settles. Therefore acdream -must not add an arrival-only animation reset. +Recall completion is instead guaranteed by the enclosing frame order: + +```text +Client.UseTime: + Timer.update_time() + ClientNet.UseTime() + process packet controller/cache/UI work + SmartBox.UseTime() + SmartBox.Draw() + +SmartBox.UseTime: + CObjectMaint.UseTime() + CPhysics.UseTime() // advances PartArray, particles, scripts + GameTime/LScape/Ambient.UseTime() + while inbound queue not empty: + DispatchSmartBoxEvent() // SetState/Hidden lands here + CommandInterpreter.UseTime() // ShouldSendPositionEvent -> AP +``` + +The exact call sites are `Client::UseTime` `0x00411C40` and +`SmartBox::UseTime` `0x00455410`, and `CommandInterpreter::UseTime` +`0x006B3BF0`. `SmartBox::DoSetState` `0x004520D0` +applies Hidden directly and does not advance or reset animation. Separately, +`CPhysicsObj::UpdatePositionInternal` `0x00512C30` skips `CPartArray::Update` +while Hidden. Therefore the ordering boundary is load-bearing: the current +frame's animation must advance before an inbound Hidden transition can freeze +it. + +ACE schedules lifestone recall teleport after the MotionTable-reported action +length. With the installed human DATs, the boundary is about 15.06024 seconds; +floating-point conversion can leave acdream at frame 149.999998 of the +0..149 recall node if it dispatches Hidden before the current object tick. On +UnHide that microscopic remainder becomes a visible destination-side recall +tail. Retail's object-before-network order retires the node first. The faithful +fix is that frame barrier, never an arrival-only Ready/reset command and never +advancing PartArray while Hidden. ## acdream integration translation @@ -303,6 +338,23 @@ must not add an arrival-only animation reset. strand the transit, while canonical physics retains its own retail gates. - Destination placement resets the retail chase camera's published and sought positions to the player before the normal update path resumes. + - `RetailLiveFrameCoordinator` owns the `SmartBox::UseTime` phase barrier: + local/remote object and projectile advancement, final pose/hook publication, + attached emitters/lights, particle simulation, and PhysicsScripts all precede + inbound session dispatch in that retail order; rendering consumes the resulting + coherent snapshot. + - `RetailLocalPlayerFrameController` owns the local player's exactly-once + object tick. Input-originated movement and jump packets are serialized from + its pre-network result; F751 or ForcePosition received later in the frame + cannot relocate or replay those one-shot commands. After inbound dispatch, + `CommandInterpreter::UseTime @ 0x006B3BF0` evaluates AutonomousPosition from + the current controller frame, matching its exact retail slot. + - After inbound dispatch, a non-time-advancing reconciliation composes equipped + children and refreshes emitter/light anchors from the authoritative root. It + does not rerun animation, scripts, particles, or fades. A player first created + by that inbound pass is likewise projected without receiving a late physics + tick; its first normal object tick occurs on the next update. + This is update-loop ordering, not portal-specific animation logic. - Destination residency remains acdream's asynchronous adaptation. It supplies the same `EndTeleportAnimation` edge retail receives when its blocking cell load completes; it does not alter presentation ordering. diff --git a/src/AcDream.App/Input/LocalPlayerOutboundController.cs b/src/AcDream.App/Input/LocalPlayerOutboundController.cs new file mode 100644 index 00000000..b6a6d1cf --- /dev/null +++ b/src/AcDream.App/Input/LocalPlayerOutboundController.cs @@ -0,0 +1,209 @@ +using System.Numerics; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; +using AcDream.Core.Physics; + +namespace AcDream.App.Input; + +/// +/// Serializes input-originated output from the object-phase result and the +/// periodic position report from post-inbound controller state. +/// +public sealed class LocalPlayerOutboundController +{ + private readonly Func _toWireRotation; + private readonly Action _diagnostic; + + public LocalPlayerOutboundController( + Func toWireRotation, + Action diagnostic) + { + _toWireRotation = toWireRotation + ?? throw new ArgumentNullException(nameof(toWireRotation)); + _diagnostic = diagnostic + ?? throw new ArgumentNullException(nameof(diagnostic)); + } + + /// + /// Sends input-originated movement and jump packets before inbound + /// dispatch. Retail emits these from input/object processing rather than + /// from CommandInterpreter::UseTime. + /// + public void SendPreNetworkActions( + WorldSession? session, + PlayerMovementController controller, + MovementResult movement, + bool hidden) + { + if (session is null || hidden) + return; + + Quaternion wireRotation = _toWireRotation(controller.Yaw); + if (!controller.TryGetOutboundPosition( + wireRotation, + out uint wireCellId, + out Vector3 wirePosition)) + { + return; + } + + if (movement.ShouldSendMovementEvent) + TrySendMovement(session, controller, movement); + + if (movement.JumpExtent.HasValue && movement.JumpVelocity.HasValue) + { + uint sequence = session.NextGameActionSequence(); + byte[] body = JumpAction.Build( + gameActionSequence: sequence, + extent: movement.JumpExtent.Value, + velocity: movement.JumpVelocity.Value, + cellId: wireCellId, + position: wirePosition, + rotation: wireRotation, + instanceSequence: session.InstanceSequence, + serverControlSequence: session.ServerControlSequence, + teleportSequence: session.TeleportSequence, + forcePositionSequence: session.ForcePositionSequence); + session.SendGameAction(body); + } + } + + /// + /// Ports the position-send slot in CommandInterpreter::UseTime + /// (0x006B3BF0), which SmartBox calls after draining inbound + /// events. The predicate and serialized frame therefore observe any + /// accepted ForcePosition or teleport state from this update. + /// + public void SendPostNetworkPosition( + WorldSession? session, + PlayerMovementController controller, + bool hidden) + { + if (session is null || hidden) + return; + + Quaternion wireRotation = _toWireRotation(controller.Yaw); + if (!controller.TryGetOutboundPosition( + wireRotation, + out uint wireCellId, + out Vector3 wirePosition)) + { + return; + } + + var position = new Position(wireCellId, wirePosition, wireRotation); + if (!controller.ShouldSendPositionEvent( + position, + controller.ContactPlane, + controller.SimTimeSeconds) + || !controller.CanSendPositionEvent) + { + return; + } + + MovementResult movement = controller.CapturePresentationResult(); + byte contactByte = movement.IsOnGround ? (byte)1 : (byte)0; + uint sequence = session.NextGameActionSequence(); + byte[] body = AutonomousPosition.Build( + gameActionSequence: sequence, + cellId: wireCellId, + position: wirePosition, + rotation: wireRotation, + instanceSequence: session.InstanceSequence, + serverControlSequence: session.ServerControlSequence, + teleportSequence: session.TeleportSequence, + forcePositionSequence: session.ForcePositionSequence, + lastContact: contactByte); + _diagnostic( + "AP", + sequence, + movement, + wirePosition, + wireCellId, + contactByte); + session.SendGameAction(body); + controller.NotePositionSent( + position, + controller.ContactPlane, + controller.SimTimeSeconds); + } + + public bool TrySendMovement( + WorldSession? session, + PlayerMovementController? controller, + MovementResult movement) + { + if (session is null || controller is null) + return false; + + Quaternion wireRotation = _toWireRotation(controller.Yaw); + if (!controller.TryGetOutboundPosition( + wireRotation, + out uint wireCellId, + out Vector3 wirePosition)) + { + return false; + } + + byte contactByte = movement.IsOnGround ? (byte)1 : (byte)0; + RawMotionState rawMotionState = BuildRawMotionState(movement); + uint sequence = session.NextGameActionSequence(); + byte[] body = MoveToState.Build( + gameActionSequence: sequence, + rawMotionState: rawMotionState, + cellId: wireCellId, + position: wirePosition, + rotation: wireRotation, + instanceSequence: session.InstanceSequence, + serverControlSequence: session.ServerControlSequence, + teleportSequence: session.TeleportSequence, + forcePositionSequence: session.ForcePositionSequence, + contact: contactByte != 0, + standingLongjump: false); + _diagnostic( + "MTS", + sequence, + movement, + wirePosition, + wireCellId, + contactByte); + session.SendGameAction(body); + controller.NoteMovementSent( + controller.SimTimeSeconds, + movement.IsMouseLookMovementEvent); + return true; + } + + public static RawMotionState BuildRawMotionState(MovementResult movement) + { + HoldKey axisHoldKey = movement.IsRunning ? HoldKey.Run : HoldKey.None; + return new RawMotionState + { + CurrentHoldKey = axisHoldKey, + ForwardCommand = movement.ForwardCommand + ?? RawMotionState.Default.ForwardCommand, + ForwardHoldKey = movement.ForwardCommand.HasValue + ? axisHoldKey : HoldKey.Invalid, + ForwardSpeed = movement.ForwardSpeed + ?? RawMotionState.Default.ForwardSpeed, + SidestepCommand = movement.SidestepCommand + ?? RawMotionState.Default.SidestepCommand, + SidestepHoldKey = movement.SidestepCommand.HasValue + ? movement.SidestepUsesRunHold + ? HoldKey.Run + : axisHoldKey + : HoldKey.Invalid, + SidestepSpeed = movement.SidestepSpeed + ?? RawMotionState.Default.SidestepSpeed, + TurnCommand = movement.TurnCommand + ?? RawMotionState.Default.TurnCommand, + TurnHoldKey = movement.TurnCommand.HasValue + ? movement.TurnUsesRunHold + ? HoldKey.Run + : axisHoldKey + : HoldKey.Invalid, + TurnSpeed = movement.TurnSpeed + ?? RawMotionState.Default.TurnSpeed, + }; + } +} diff --git a/src/AcDream.App/Input/LocalPlayerProjectionController.cs b/src/AcDream.App/Input/LocalPlayerProjectionController.cs new file mode 100644 index 00000000..9b851bb8 --- /dev/null +++ b/src/AcDream.App/Input/LocalPlayerProjectionController.cs @@ -0,0 +1,68 @@ +using AcDream.Core.World; + +namespace AcDream.App.Input; + +/// +/// Projects the canonical local physics body into world rendering and spatial +/// buckets without advancing it. +/// +public sealed class LocalPlayerProjectionController +{ + private readonly Func _resolveEntity; + private readonly Func _liveCenterX; + private readonly Func _liveCenterY; + private readonly Action _syncShadow; + private readonly Action _rebucket; + + public LocalPlayerProjectionController( + Func resolveEntity, + Func liveCenterX, + Func liveCenterY, + Action syncShadow, + Action rebucket) + { + _resolveEntity = resolveEntity ?? throw new ArgumentNullException(nameof(resolveEntity)); + _liveCenterX = liveCenterX ?? throw new ArgumentNullException(nameof(liveCenterX)); + _liveCenterY = liveCenterY ?? throw new ArgumentNullException(nameof(liveCenterY)); + _syncShadow = syncShadow ?? throw new ArgumentNullException(nameof(syncShadow)); + _rebucket = rebucket ?? throw new ArgumentNullException(nameof(rebucket)); + } + + public void Project( + PlayerMovementController controller, + MovementResult movement, + bool hidden) + { + WorldEntity? entity = _resolveEntity(); + if (entity is null) + return; + + entity.SetPosition(movement.RenderPosition); + entity.ParentCellId = movement.CellId; + entity.Rotation = System.Numerics.Quaternion.CreateFromAxisAngle( + System.Numerics.Vector3.UnitZ, + controller.Yaw - MathF.PI / 2f); + if (!hidden) + _syncShadow(entity, movement.CellId); + + uint currentLandblock; + if (movement.CellId != 0 && (movement.CellId & 0xFFFFu) >= 0x0100u) + { + // Indoor positions belong to the cell's landblock. Dungeon frame + // origins can be negative, so render XYZ is not a landblock key. + currentLandblock = (movement.CellId & 0xFFFF0000u) | 0xFFFFu; + } + else + { + System.Numerics.Vector3 position = controller.Position; + int landblockX = _liveCenterX() + (int)Math.Floor(position.X / 192f); + int landblockY = _liveCenterY() + (int)Math.Floor(position.Y / 192f); + currentLandblock = (uint)((landblockX << 24) | (landblockY << 16) | 0xFFFF); + } + + // The teleport owner alone projects the destination while the local + // controller deliberately retains its frozen source cell. + if (controller.State != PlayerState.PortalSpace) + _rebucket(entity.ServerGuid, currentLandblock); + } +} diff --git a/src/AcDream.App/Input/PlayerMovementController.cs b/src/AcDream.App/Input/PlayerMovementController.cs index ddace4ec..b09f2036 100644 --- a/src/AcDream.App/Input/PlayerMovementController.cs +++ b/src/AcDream.App/Input/PlayerMovementController.cs @@ -916,6 +916,21 @@ public sealed class PlayerMovementController IsMouseLookMovementEvent: mouseLookEvent); } + /// + /// 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 diff --git a/src/AcDream.App/Input/RetailLocalPlayerFrameController.cs b/src/AcDream.App/Input/RetailLocalPlayerFrameController.cs new file mode 100644 index 00000000..cf0e393c --- /dev/null +++ b/src/AcDream.App/Input/RetailLocalPlayerFrameController.cs @@ -0,0 +1,155 @@ +namespace AcDream.App.Input; + +/// +/// Owns the local player's once-per-frame retail object phase. +/// +/// +/// Retail advances the player and sends input-originated movement/jump output +/// before SmartBox::UseTime drains inbound events. Its later +/// CommandInterpreter::UseTime position check uses current post-inbound +/// state. The cached input result is presentation-only after the barrier: its +/// one-shot commands are never replayed against a later authoritative cell. +/// +public sealed class RetailLocalPlayerFrameController +{ + private readonly Func _canPresentPlayer; + private readonly Func _getController; + private readonly Func _captureInput; + private readonly Func _resolveLocalEntityId; + private readonly Action _handleTargeting; + private readonly Func _isHidden; + private readonly Action _project; + private readonly Action _sendPreNetwork; + private readonly Action _sendPostNetwork; + + private AdvancedFrame? _advancedFrame; + + private readonly record struct AdvancedFrame( + PlayerMovementController Controller, + MovementResult Movement); + + public readonly record struct PresentationFrame( + MovementResult Movement, + bool Hidden, + bool AdvancedBeforeNetwork); + + public RetailLocalPlayerFrameController( + Func canPresentPlayer, + Func getController, + Func captureInput, + Func resolveLocalEntityId, + Action handleTargeting, + Func isHidden, + Action project, + Action sendPreNetwork, + Action sendPostNetwork) + { + _canPresentPlayer = canPresentPlayer + ?? throw new ArgumentNullException(nameof(canPresentPlayer)); + _getController = getController + ?? throw new ArgumentNullException(nameof(getController)); + _captureInput = captureInput + ?? throw new ArgumentNullException(nameof(captureInput)); + _resolveLocalEntityId = resolveLocalEntityId + ?? throw new ArgumentNullException(nameof(resolveLocalEntityId)); + _handleTargeting = handleTargeting + ?? throw new ArgumentNullException(nameof(handleTargeting)); + _isHidden = isHidden + ?? throw new ArgumentNullException(nameof(isHidden)); + _project = project + ?? throw new ArgumentNullException(nameof(project)); + _sendPreNetwork = sendPreNetwork + ?? throw new ArgumentNullException(nameof(sendPreNetwork)); + _sendPostNetwork = sendPostNetwork + ?? throw new ArgumentNullException(nameof(sendPostNetwork)); + } + + /// + /// Advances the existing local object exactly once on the object side of + /// the inbound-network barrier. + /// + public void AdvanceBeforeNetwork(float deltaSeconds) + { + _advancedFrame = null; + PlayerMovementController? controller = _getController(); + if (!_canPresentPlayer() || controller is null) + return; + + controller.LocalEntityId = _resolveLocalEntityId(); + _handleTargeting(); + + bool hidden = _isHidden(); + MovementResult movement = hidden + ? controller.TickHidden(deltaSeconds) + : controller.Update(deltaSeconds, _captureInput()); + + _project(controller, movement, hidden); + _sendPreNetwork(controller, movement, hidden); + _advancedFrame = new AdvancedFrame(controller, movement); + } + + /// + /// Runs retail's post-inbound command-interpreter phase. The split + /// controller/render projection is reconciled without advancing physics, + /// then AutonomousPosition is evaluated from that current state. + /// + public void RunPostNetworkCommandPhase() + { + PlayerMovementController? controller = _getController(); + if (!_canPresentPlayer() || controller is null) + return; + + bool hidden = _isHidden(); + if (_advancedFrame is { } advanced + && ReferenceEquals(advanced.Controller, controller)) + { + MovementResult movement = RefreshSpatialFields( + advanced.Movement, + controller); + _project(controller, movement, hidden); + _advancedFrame = new AdvancedFrame(controller, movement); + } + + _sendPostNetwork(controller, hidden); + } + + /// + /// Produces the post-network presentation sample without advancing + /// physics. An owner first created by the inbound pass is seeded from its + /// current state and receives its first real object tick next frame. + /// + public bool TryGetPresentationAfterNetwork(out PresentationFrame frame) + { + frame = default; + PlayerMovementController? controller = _getController(); + if (!_canPresentPlayer() || controller is null) + return false; + + bool hidden = _isHidden(); + if (_advancedFrame is { } advanced + && ReferenceEquals(advanced.Controller, controller)) + { + MovementResult movement = RefreshSpatialFields( + advanced.Movement, + controller); + frame = new PresentationFrame(movement, hidden, AdvancedBeforeNetwork: true); + return true; + } + + MovementResult initial = controller.CapturePresentationResult(); + _project(controller, initial, hidden); + frame = new PresentationFrame(initial, hidden, AdvancedBeforeNetwork: false); + return true; + } + + private static MovementResult RefreshSpatialFields( + MovementResult movement, + PlayerMovementController controller) => + movement with + { + Position = controller.Position, + RenderPosition = controller.RenderPosition, + CellId = controller.CellId, + IsOnGround = controller.CanSendPositionEvent, + }; +} diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 38c227a0..ed34c4bd 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -163,6 +163,10 @@ public sealed class GameWindow : IDisposable // once the injected shared helpers exist as method groups (GetSetupCylinder, // ApplyServerControlledVelocityCycle). See RemotePhysicsUpdater. private readonly AcDream.App.Physics.RemotePhysicsUpdater _remotePhysicsUpdater; + private readonly AcDream.App.Input.LocalPlayerProjectionController _localPlayerProjection; + private readonly AcDream.App.Input.LocalPlayerOutboundController _localPlayerOutbound; + private readonly AcDream.App.Input.RetailLocalPlayerFrameController _localPlayerFrame; + private readonly AcDream.App.World.RetailLiveFrameCoordinator _liveFrameCoordinator; private AcDream.App.Physics.RemoteTeleportController? _remoteTeleportController; // Step 7 projectile presentation. The controller owns no identity map; // each runtime component is stored on the canonical LiveEntityRecord. @@ -1182,6 +1186,51 @@ public sealed class GameWindow : IDisposable // handler) stay on GameWindow and are injected as delegates. _remotePhysicsUpdater = new AcDream.App.Physics.RemotePhysicsUpdater( _physicsEngine, GetSetupCylinder, ApplyServerControlledVelocityCycle); + _localPlayerProjection = new AcDream.App.Input.LocalPlayerProjectionController( + resolveEntity: () => + _entitiesByServerGuid.TryGetValue(_playerServerGuid, out var entity) + ? entity + : null, + liveCenterX: () => _liveCenterX, + liveCenterY: () => _liveCenterY, + syncShadow: (entity, cellId) => SyncLocalPlayerShadow(entity, cellId), + rebucket: (serverGuid, landblockId) => + _liveEntities?.RebucketLiveEntity(serverGuid, landblockId)); + _localPlayerOutbound = new AcDream.App.Input.LocalPlayerOutboundController( + YawToAcQuaternion, + DumpMovementTruthOutbound); + _localPlayerFrame = new AcDream.App.Input.RetailLocalPlayerFrameController( + canPresentPlayer: CanAdvanceLocalPlayer, + getController: () => _playerController, + captureInput: () => + { + _playerMouseDeltaX = 0f; + return CaptureMovementInput(); + }, + resolveLocalEntityId: () => + _entitiesByServerGuid.TryGetValue(_playerServerGuid, out var entity) + ? entity.Id + : 0u, + handleTargeting: () => _playerHost?.HandleTargetting(), + isHidden: () => _liveEntities?.IsHidden(_playerServerGuid) == true, + project: (controller, movement, hidden) => + _localPlayerProjection.Project(controller, movement, hidden), + sendPreNetwork: (controller, movement, hidden) => + _localPlayerOutbound.SendPreNetworkActions( + _liveSession, + controller, + movement, + hidden), + sendPostNetwork: (controller, hidden) => + _localPlayerOutbound.SendPostNetworkPosition( + _liveSession, + controller, + hidden)); + _liveFrameCoordinator = new AcDream.App.World.RetailLiveFrameCoordinator( + AdvanceLiveObjectRuntime, + () => _liveSessionController?.Tick(), + _localPlayerFrame.RunPostNetworkCommandPhase, + ReconcileLiveObjectSpatialPresentation); } public void Run() @@ -7066,6 +7115,7 @@ public sealed class GameWindow : IDisposable // camera path re-extends the boom. Updating directly from the stale // source-world viewer made the destination bend across the reveal. _retailChaseCamera?.ResetViewerToPlayer(snappedPos, _playerController.Yaw); + ReconcileLiveObjectSpatialPresentation(); AcDream.Core.Physics.PhysicsDiagnostics.LogTeleport( "PLACED", resolved.CellId, $"forced={forced}"); @@ -8764,11 +8814,15 @@ public sealed class GameWindow : IDisposable { using var _updStage = _frameProfiler.BeginStage(AcDream.App.Diagnostics.FrameStage.Update); + double frameSeconds = + AcDream.App.World.RetailLiveFrameCoordinator.NormalizeDeltaSeconds(dt); + float frameDelta = (float)frameSeconds; + // Retail ScriptManager::AddScriptInternal (0x0051B310) stamps // Timer::cur_time at the packet/default-script call site. Publish this // update frame's clock before streaming and network dispatch, then // reuse the same timestamp for animation hooks and the later drain. - _physicsScriptGameTime += Math.Max(0.0, dt); + _physicsScriptGameTime += frameSeconds; _scriptRunner?.PublishTime(_physicsScriptGameTime); // [FRAME-DIAG]: applies run later this frame inside _streamingController.Tick; @@ -8955,12 +9009,64 @@ public sealed class GameWindow : IDisposable } } + // Input callbacks feed the current object tick. Packets already read + // by Core.Net remain queued until the retail object/physics phase has + // consumed that input and published its final pose. + _inputDispatcher?.Tick(); + + // Phase K.2 — mouse-look is an input source for this object's + // movement tick, so sample it before the retail CPhysics/network + // barrier just like held keyboard actions. + if (_mouseLook is not null) + { + bool wantCaptureMouse = IsUiCapturingMouse(); + if (wantCaptureMouse != _lastWantCaptureMouse) + { + if (wantCaptureMouse && _mouseLook.Active) + EndMouseLookAndRestoreCursor(); + _mouseLook.OnWantCaptureMouseChanged(wantCaptureMouse); + _lastWantCaptureMouse = wantCaptureMouse; + } + + float nowSeconds = (float)(Environment.TickCount64 / 1000.0); + if (_mouseLook.TryTakeRawSample(nowSeconds, out float rawX, out float rawY)) + { + // GetInput synthesizes (0,0) only after >0.2 s idle. + // MouseLookHandler stops drift before filtering that sample; + // the filter tail may then start a smaller Rotate again. + if (rawX == 0f && rawY == 0f) + _playerController?.StopMouseDrift(CaptureMovementInput()); + + (float filteredX, float filteredY) = + AcDream.Core.Rendering.CameraDiagnostics.UseRetailChaseCamera + && _retailChaseCamera is not null + ? _retailChaseCamera.FilterMouseDelta( + rawX, + rawY, + weight: 0.5f, + nowSec: nowSeconds) + : (rawX, rawY); + _mouseLook.ApplyDelta(filteredX, _sensChase); + if (_retailChaseCamera is not null) + _retailChaseCamera.AdjustPitch(filteredY * 0.003f * _sensChase); + else + _chaseCamera?.AdjustPitch(filteredY * 0.003f * _sensChase); + } + } + + _combatAttackController?.Tick(); + // Drain pending live-session traffic AFTER streaming so any incoming // CreateObject events find their landblock already loaded in // GpuWorldState. Non-blocking — returns immediately if no datagrams // are in the kernel buffer. Fires EntitySpawned events synchronously. // Step 2: routed through the controller; functionally identical. - _liveSessionController?.Tick(); + // Retail SmartBox::UseTime (0x00455410) advances CObjectMaint and + // CPhysics before it drains the inbound event queue. Keep the complete + // live-object phase on that side of the barrier too. In particular, + // the recall action retires before ACE's Hidden SetState freezes its + // PartArray at the teleport boundary. + _liveFrameCoordinator.Tick(frameDelta); // Usually F751 activates immediately. This no-op convergence check // covers the session edge where the canonical player exists before @@ -8980,7 +9086,7 @@ public sealed class GameWindow : IDisposable bool ready = haveDest && TeleportWorldReady(_pendingTeleportCell); if (haveDest && !ready) { - _teleportHoldSeconds += (float)dt; + _teleportHoldSeconds += frameDelta; if (_teleportHoldSeconds >= TeleportMaxHoldSeconds) { ready = true; @@ -8989,7 +9095,7 @@ public sealed class GameWindow : IDisposable } int tunnelFrame = _portalTunnel?.CurrentAnimationFrame ?? 0; - var (snap, evts) = _teleportAnim.Tick((float)dt, ready, tunnelFrame); + var (snap, evts) = _teleportAnim.Tick(frameDelta, ready, tunnelFrame); _teleportViewPlane.Update(snap); foreach (var e in evts) @@ -9027,62 +9133,16 @@ public sealed class GameWindow : IDisposable } } - _portalTunnel?.Tick((float)dt); + _portalTunnel?.Tick(frameDelta); } // Phase K.1a — tick the input dispatcher so Hold-type bindings // re-fire while their chord is held. K.1b adds the subscribers // that actually consume the events. - _inputDispatcher?.Tick(); - _combatAttackController?.Tick(); - // Phase D.5.3a — advance the selected-object overlay flash (0.25s green pulse // on selection, then revert). No-op when nothing is flashing. // Retained panel-local timers advance through RetailUiRuntime.Tick on the draw seam. - // Phase K.2 — re-evaluate WantCaptureMouse for the MMB - // mouse-look state machine. Detect rising/falling edges so the - // state suspends correctly when ImGui claims the cursor while - // MMB is held (e.g. a tooltip pop-up over the cursor). When the - // suspend deactivates an active session, restore the cursor so - // it doesn't get stuck hidden under a panel. - if (_mouseLook is not null) - { - bool wcm = IsUiCapturingMouse(); - if (wcm != _lastWantCaptureMouse) - { - if (wcm && _mouseLook.Active) - EndMouseLookAndRestoreCursor(); - _mouseLook.OnWantCaptureMouseChanged(wcm); - _lastWantCaptureMouse = wcm; - } - - float nowSeconds = (float)(Environment.TickCount64 / 1000.0); - if (_mouseLook.TryTakeRawSample(nowSeconds, out float rawX, out float rawY)) - { - // GetInput synthesizes (0,0) only after >0.2 s idle. - // MouseLookHandler stops drift before filtering that sample; - // the filter tail may then start a smaller Rotate again. - if (rawX == 0f && rawY == 0f) - _playerController?.StopMouseDrift(CaptureMovementInput()); - - (float filteredX, float filteredY) = - AcDream.Core.Rendering.CameraDiagnostics.UseRetailChaseCamera - && _retailChaseCamera is not null - ? _retailChaseCamera.FilterMouseDelta( - rawX, - rawY, - weight: 0.5f, - nowSec: nowSeconds) - : (rawX, rawY); - _mouseLook.ApplyDelta(filteredX, _sensChase); - if (_retailChaseCamera is not null) - _retailChaseCamera.AdjustPitch(filteredY * 0.003f * _sensChase); - else - _chaseCamera?.AdjustPitch(filteredY * 0.003f * _sensChase); - } - } - // Phase K.2 — auto-enter player mode at login. The guard // returns true on the firing tick (one-shot); subsequent ticks // are no-ops. Skipped offline (no _liveSession → IsLiveInWorld @@ -9116,7 +9176,7 @@ public sealed class GameWindow : IDisposable // delta drives fly heading instead). if (_inputDispatcher is null) return; _cameraController.Fly.Update( - dt, + frameSeconds, w: _inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.MovementForward), a: _inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.MovementTurnLeft), s: _inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.MovementBackup), @@ -9142,7 +9202,7 @@ public sealed class GameWindow : IDisposable // retail chase is selected; legacy camera ignores these. if (AcDream.Core.Rendering.CameraDiagnostics.UseRetailChaseCamera && _retailChaseCamera is not null) { - float adj = AcDream.Core.Rendering.CameraDiagnostics.CameraAdjustmentSpeed * (float)dt; + float adj = AcDream.Core.Rendering.CameraDiagnostics.CameraAdjustmentSpeed * frameDelta; if (_inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.CameraZoomIn)) _retailChaseCamera.AdjustDistance(-adj); if (_inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.CameraZoomOut)) @@ -9161,82 +9221,17 @@ public sealed class GameWindow : IDisposable // until Q is pressed again. Handled in OnInputAction; here // we just OR _autoRunActive into the Forward flag. // * Mouse never drives character yaw (K.1b regression-prevention). - var input = CaptureMovementInput(); + if (!_localPlayerFrame.TryGetPresentationAfterNetwork(out var playerFrame)) + return; - // Fix #42 (2026-05-05): keep PlayerMovementController's - // LocalEntityId in sync with the live local player entity so - // FindObjCollisions skips its own ShadowEntry. Re-fetched per - // tick so re-spawns / character switches don't leave a stale - // id on the controller. Pre-spawn or between-character it - // stays 0 (no filter), which is harmless because there's no - // ShadowEntry registered yet. - _playerController.LocalEntityId = - _entitiesByServerGuid.TryGetValue(_playerServerGuid, out var localEnt) - ? localEnt.Id - : 0u; - - // R5-V2: tick the player's TargetManager BEFORE Update ticks - // MoveToManager.UseTime (retail UpdateObjectInternal order). This is - // the load-bearing call for creature-chase: the player, as a watched - // target, pushes its position to its voyeurs (the NPCs moving-to it) - // here — that push lands in each NPC's HandleUpdateTarget the same - // frame, ahead of the NPCs' own UseTime in the per-remote loop - // (which runs after this block). Replaces the AP-79 player poll. - _playerHost?.HandleTargetting(); - - bool localPlayerHidden = _liveEntities?.IsHidden(_playerServerGuid) == true; - var result = localPlayerHidden - ? _playerController.TickHidden((float)dt) - : _playerController.Update((float)dt, input); - - // Update the player entity's position + rotation so it renders at - // the physics-resolved location each frame. - if (_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var pe)) + AcDream.App.Input.MovementResult result = playerFrame.Movement; + bool localPlayerHidden = playerFrame.Hidden; + if (!playerFrame.AdvancedBeforeNetwork) { - pe.SetPosition(result.RenderPosition); // A.5 T18: SetPosition propagates AabbDirty - pe.ParentCellId = result.CellId; - pe.Rotation = System.Numerics.Quaternion.CreateFromAxisAngle( - System.Numerics.Vector3.UnitZ, _playerController.Yaw - MathF.PI / 2f); - if (!localPlayerHidden) - SyncLocalPlayerShadow(pe, result.CellId); - - // Move the player entity to its current landblock in GpuWorldState - // so it doesn't get frustum-culled when the player walks away from - // the spawn landblock. Without this, the entity stays in the spawn - // landblock's entity list and disappears when that landblock is culled. - uint currentLb; - if (result.CellId != 0 && (result.CellId & 0xFFFFu) >= 0x0100u) - { - // Indoor cell (dungeon/building EnvCell): the entity's landblock is - // the CELL's landblock. Dungeon EnvCells sit at arbitrary "ocean" - // world coords with negative local-Y, so floor(pp.Y/192) lands one - // landblock off (the Bug-A class) — relocating the player into the - // landblock the dungeon collapse unloaded, making the avatar - // invisible. The cell id is authoritative. - currentLb = (result.CellId & 0xFFFF0000u) | 0xFFFFu; - } - else - { - var pp = _playerController.Position; - int plx = _liveCenterX + (int)System.Math.Floor(pp.X / 192f); - int ply = _liveCenterY + (int)System.Math.Floor(pp.Y / 192f); - currentLb = (uint)((plx << 24) | (ply << 16) | 0xFFFF); - } - // #138-B (2026-06-24): do NOT relocate the avatar while a teleport - // is in transit (PortalSpace). The controller's cell is still the - // FROZEN SOURCE until PlaceTeleportArrival materializes the - // destination, so relocating here drags the avatar — which the - // teleport's rescue/re-inject (GpuWorldState.DrainRescued) already - // placed at the destination center — back into the now-UNLOADED - // SOURCE landblock's pending bucket, where nothing recovers it - // (the old relocate path only scanned _loaded). Net: the avatar vanishes - // after teleporting out. The teleport machinery owns the avatar's - // landblock during transit; per-frame relocation resumes at - // FireLoginComplete (InWorld). Probe-confirmed: launch4 line 561 - // APPEND guid=player lb=0x0007FFFF(source dungeon) -> PENDING -> - // DRAWSET ABSENT, never recovered. - if (_playerController.State != AcDream.App.Input.PlayerState.PortalSpace) - _liveEntities?.RebucketLiveEntity(pe.ServerGuid, currentLb); + // The player was materialized by this frame's inbound pass. + // Its non-advancing initial projection must publish child and + // effect anchors before the first draw too. + ReconcileLiveObjectSpatialPresentation(); } // Update chase camera(s). The CameraController exposes whichever @@ -9249,7 +9244,7 @@ public sealed class GameWindow : IDisposable // low-stiffness damping). _chaseCamera.Update(result.RenderPosition, _playerController.Yaw, isOnGround: result.IsOnGround, - dt: (float)dt); + dt: frameDelta); // RetailChaseCamera: heading is the player's facing direction // projected onto the contact plane when grounded, or the // world XY plane when airborne. The contact plane normal @@ -9263,98 +9258,26 @@ public sealed class GameWindow : IDisposable playerVelocity: _playerController.BodyVelocity, isOnGround: result.IsOnGround, contactPlaneNormal: _playerController.ContactPlane.Normal, - dt: (float)dt, + dt: frameDelta, cellId: _playerController.CellId, selfEntityId: _playerController.LocalEntityId, trackedTargetPoint: trackedCombatTarget); - // Send outbound movement messages to the live server. - if (_liveSession is not null - && !localPlayerHidden - && _playerController.TryGetOutboundPosition( - YawToAcQuaternion(_playerController.Yaw), - out uint wireCellId, - out System.Numerics.Vector3 wirePos)) - { - // Retail serializes CPhysicsObj::m_position directly. The - // controller owns this canonical (cell, landblock-local origin) - // pair; the render/streaming origin never enters wire coordinates. - var wireRot = YawToAcQuaternion(_playerController.Yaw); - byte contactByte = result.IsOnGround ? (byte)1 : (byte)0; - var wirePosition = new AcDream.Core.Physics.Position( - wireCellId, - wirePos, - wireRot); - - // Snapshot the AP predicate before this path mutates its send - // tracker. Retail's UseTime calls ShouldSendPositionEvent then - // SendPositionEvent; MTS originates in separate input handlers, - // so their relative same-tick callback order remains TS-33. - bool positionEventDue = _playerController.ShouldSendPositionEvent( - wirePosition, - _playerController.ContactPlane, - _playerController.SimTimeSeconds); - - // 2026-05-16 (issue #75) / R4-V5: user-MoveToState packets - // are ONLY for user-initiated motion intent — retail's - // architectural split between user-input motion and - // server-driven motion. Post-V5 the split holds BY - // By construction, ShouldSendMovementEvent derives from user - // input edges plus CameraSet's mouse-look start/stop/0.5-second - // cadence. MoveToManager dispatches never touch either source, - // so server-driven movement cannot echo a MoveToState. A user - // edge cancels the moveto and legitimately takes control. - if (result.ShouldSendMovementEvent) - TrySendPlayerMovementEvent(result); - - // SendPositionEvent itself admits only Contact+OnWalkable. - if (positionEventDue && _playerController.CanSendPositionEvent) - { - var seq = _liveSession.NextGameActionSequence(); - var body = AcDream.Core.Net.Messages.AutonomousPosition.Build( - gameActionSequence: seq, - cellId: wireCellId, - position: wirePos, - rotation: wireRot, - instanceSequence: _liveSession.InstanceSequence, - serverControlSequence: _liveSession.ServerControlSequence, - teleportSequence: _liveSession.TeleportSequence, - forcePositionSequence: _liveSession.ForcePositionSequence, - lastContact: contactByte); - DumpMovementTruthOutbound( - "AP", seq, result, wirePos, wireCellId, contactByte); - _liveSession.SendGameAction(body); - _playerController.NotePositionSent( - position: wirePosition, - contactPlane: _playerController.ContactPlane, - nowSeconds: _playerController.SimTimeSeconds); - } - - if (result.JumpExtent.HasValue && result.JumpVelocity.HasValue) - { - // D4/L.2b (2026-06-30): JumpPack::Pack (0x00516d10) packs the - // full Position, not an objectGuid/spellId — pass the same - // wireCellId/wirePos/wireRot the MoveToState send above uses. - var seq = _liveSession.NextGameActionSequence(); - var jumpBody = AcDream.Core.Net.Messages.JumpAction.Build( - gameActionSequence: seq, - extent: result.JumpExtent.Value, - velocity: result.JumpVelocity.Value, - cellId: wireCellId, - position: wirePos, - rotation: wireRot, - instanceSequence: _liveSession.InstanceSequence, - serverControlSequence: _liveSession.ServerControlSequence, - teleportSequence: _liveSession.TeleportSequence, - forcePositionSequence: _liveSession.ForcePositionSequence); - _liveSession.SendGameAction(jumpBody); - } - } // Update the player entity's animation cycle to match current motion. } } + private bool CanAdvanceLocalPlayer() => + _cameraController is not null + && _input is not null + && !_cameraController.IsFlyMode + && _playerMode + && _playerController is not null + && _chaseCamera is not null + && _inputDispatcher is not null + && !(DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureKeyboard); + private void DumpMovementTruthOutbound( string kind, uint sequence, @@ -9589,40 +9512,6 @@ public sealed class GameWindow : IDisposable if (DevToolsEnabled && _imguiBootstrap is not null) _imguiBootstrap.BeginFrame((float)deltaSeconds); - // Phase 6.4: advance per-entity animation playback before drawing - // so the renderer always sees the up-to-date per-part transforms. - // PhysicsScript timed hooks and animation hooks read the same current - // retail update-frame clock published before network dispatch. Drain - // the script pass later after final root/part poses are composed. - // Re-publish without advancing so an extra render between updates still - // retains retail's animation-hook versus object-hook phase barrier. - _scriptRunner?.PublishTime(_physicsScriptGameTime); - if (_liveEntities is { } liveEntities) - { - _remotePhysicsUpdater.TickHiddenEntities( - liveEntities, - _playerServerGuid, - (float)deltaSeconds, - entity => _effectPoses.UpdateRoot(entity)); - } - _projectileController?.Tick( - _physicsScriptGameTime, - _liveCenterX, - _liveCenterY, - _entitiesByServerGuid.TryGetValue(_playerServerGuid, out var projectileViewer) - ? projectileViewer.Position - : null); - if (_animatedEntities.Count > 0) - TickAnimations((float)deltaSeconds); - _equippedChildRenderer?.Tick(); - _animationHookFrames?.Drain(); - - // #188 — advance translucency fades UNCONDITIONALLY (not gated on - // _animatedEntities.Count): a one-shot open-cycle animation can - // finish and drop its entity from _animatedEntities before the - // fade's own Time has elapsed, but the ramp must keep running. - _translucencyFades.AdvanceAll((float)deltaSeconds); - // Phase G.1: weather state machine — deterministic per-day roll // + transitions + lightning flash. var cal = WorldTime.CurrentCalendar; @@ -9784,12 +9673,6 @@ public sealed class GameWindow : IDisposable // debug-only and disabled for normal retail rendering. if (_options.EnableSkyPesDebug) UpdateSkyPes((float)WorldTime.DayFraction, _activeDayGroup, camPos, cameraInsideCell); - _entityEffects?.RefreshLiveOwnerPoses(); - _scriptRunner?.Tick(_physicsScriptGameTime); - _particleSink?.RefreshAttachedEmitters(); - _liveEntityLights?.Refresh(); - _particleSystem?.Tick((float)deltaSeconds); - // Phase G.1/G.2: feed the sun, tick LightManager, build + upload // the scene-lighting UBO once per frame. Every shader that // consumes binding=1 reads the same data for the rest of the @@ -10737,6 +10620,71 @@ public sealed class GameWindow : IDisposable ReportCollisionEnd: () => _physicsEngine.ShadowObjects.Suspend(localEntityId))); } + /// + /// Advances the live object's retail UseTime phase before inbound packet + /// dispatch. Final root/part poses and their animation hooks are published + /// together so rendering consumes one coherent update snapshot. + /// + private void AdvanceLiveObjectRuntime(float dt) + { + // The local body participates in the same retail CPhysics phase as + // remote objects. Its outbound movement snapshot is completed here, + // before SmartBox dispatch can apply F751/ForcePosition. + _localPlayerFrame.AdvanceBeforeNetwork(dt); + _scriptRunner?.PublishTime(_physicsScriptGameTime); + if (_liveEntities is { } liveEntities) + { + _remotePhysicsUpdater.TickHiddenEntities( + liveEntities, + _playerServerGuid, + dt, + entity => _effectPoses.UpdateRoot(entity)); + } + + _projectileController?.Tick( + _physicsScriptGameTime, + _liveCenterX, + _liveCenterY, + _entitiesByServerGuid.TryGetValue(_playerServerGuid, out var projectileViewer) + ? projectileViewer.Position + : null); + if (_animatedEntities.Count > 0) + TickAnimations(dt); + _equippedChildRenderer?.Tick(); + + // Advance already-active FP hooks before routing hooks emitted by + // this PartArray update. A newly created translucency hook begins at + // its authored Start value this frame; it does not consume dt twice. + _translucencyFades.AdvanceAll(dt); + _animationHookFrames?.Drain(); + _entityEffects?.RefreshLiveOwnerPoses(); + _particleSink?.RefreshAttachedEmitters(); + _liveEntityLights?.Refresh(); + + // Retail CPhysicsObj::UpdateObjectInternal (0x005156B0) advances + // ParticleManager before ScriptManager. A particle created by a PES + // hook therefore begins simulation on the following object frame. + _particleSystem?.Tick(dt); + _scriptRunner?.Tick(_physicsScriptGameTime); + } + + /// + /// Re-composes spatial derivatives after authoritative packet handlers + /// move roots. This pass advances no time, animation, scripts, particles, + /// or fades; it only makes children and live anchors agree with the root + /// that the same frame will draw. + /// + private void ReconcileLiveObjectSpatialPresentation() + { + // Authoritative handlers mutate WorldEntity roots directly. Publish + // those roots before children read their parent's pose; child Tick + // then publishes each newly composed child pose for attached effects. + _entityEffects?.RefreshLiveOwnerPoses(); + _equippedChildRenderer?.Tick(); + _particleSink?.RefreshAttachedEmitters(); + _liveEntityLights?.Refresh(); + } + /// /// Phase 6.4: advance every animated entity's frame counter by /// * Framerate, wrapping around the cycle's @@ -12921,79 +12869,14 @@ public sealed class GameWindow : IDisposable } internal static AcDream.Core.Physics.RawMotionState BuildOutboundRawMotionState( - AcDream.App.Input.MovementResult result) - { - var axisHoldKey = result.IsRunning - ? AcDream.Core.Physics.HoldKey.Run - : AcDream.Core.Physics.HoldKey.None; - return new AcDream.Core.Physics.RawMotionState - { - CurrentHoldKey = axisHoldKey, - ForwardCommand = result.ForwardCommand - ?? AcDream.Core.Physics.RawMotionState.Default.ForwardCommand, - ForwardHoldKey = result.ForwardCommand.HasValue - ? axisHoldKey : AcDream.Core.Physics.HoldKey.Invalid, - ForwardSpeed = result.ForwardSpeed - ?? AcDream.Core.Physics.RawMotionState.Default.ForwardSpeed, - SidestepCommand = result.SidestepCommand - ?? AcDream.Core.Physics.RawMotionState.Default.SidestepCommand, - SidestepHoldKey = result.SidestepCommand.HasValue - ? result.SidestepUsesRunHold - ? AcDream.Core.Physics.HoldKey.Run - : axisHoldKey - : AcDream.Core.Physics.HoldKey.Invalid, - SidestepSpeed = result.SidestepSpeed - ?? AcDream.Core.Physics.RawMotionState.Default.SidestepSpeed, - TurnCommand = result.TurnCommand - ?? AcDream.Core.Physics.RawMotionState.Default.TurnCommand, - TurnHoldKey = result.TurnCommand.HasValue - ? result.TurnUsesRunHold - ? AcDream.Core.Physics.HoldKey.Run - : axisHoldKey - : AcDream.Core.Physics.HoldKey.Invalid, - TurnSpeed = result.TurnSpeed - ?? AcDream.Core.Physics.RawMotionState.Default.TurnSpeed, - }; - } + AcDream.App.Input.MovementResult result) => + AcDream.App.Input.LocalPlayerOutboundController.BuildRawMotionState(result); private bool TrySendPlayerMovementEvent(AcDream.App.Input.MovementResult result) - { - if (_liveSession is not { } session - || _playerController is not { } controller) - return false; - - var wireRot = YawToAcQuaternion(controller.Yaw); - if (!controller.TryGetOutboundPosition( - wireRot, - out uint wireCellId, - out System.Numerics.Vector3 wirePos)) - return false; - - byte contactByte = result.IsOnGround ? (byte)1 : (byte)0; - AcDream.Core.Physics.RawMotionState rawMotionState = - BuildOutboundRawMotionState(result); - - uint seq = session.NextGameActionSequence(); - byte[] body = AcDream.Core.Net.Messages.MoveToState.Build( - gameActionSequence: seq, - rawMotionState: rawMotionState, - cellId: wireCellId, - position: wirePos, - rotation: wireRot, - instanceSequence: session.InstanceSequence, - serverControlSequence: session.ServerControlSequence, - teleportSequence: session.TeleportSequence, - forcePositionSequence: session.ForcePositionSequence, - contact: contactByte != 0, - standingLongjump: false); - DumpMovementTruthOutbound( - "MTS", seq, result, wirePos, wireCellId, contactByte); - session.SendGameAction(body); - controller.NoteMovementSent( - controller.SimTimeSeconds, - result.IsMouseLookMovementEvent); - return true; - } + => _localPlayerOutbound.TrySendMovement( + _liveSession, + _playerController, + result); private uint? GetSelectedOrClosestCombatTarget() { diff --git a/src/AcDream.App/World/RetailLiveFrameCoordinator.cs b/src/AcDream.App/World/RetailLiveFrameCoordinator.cs new file mode 100644 index 00000000..b625e18b --- /dev/null +++ b/src/AcDream.App/World/RetailLiveFrameCoordinator.cs @@ -0,0 +1,53 @@ +namespace AcDream.App.World; + +/// +/// Owns retail's live-object, inbound-dispatch, and command-interpreter frame +/// phases. +/// +/// +/// Retail SmartBox::UseTime (0x00455410) advances +/// CObjectMaint::UseTime and CPhysics::UseTime before draining +/// SmartBox::in_queue, then calls CommandInterpreter::UseTime. +/// Keeping both boundaries explicit prevents an inbound Hidden transition +/// from freezing an action due to complete this frame while ensuring the +/// periodic AutonomousPosition check observes accepted inbound state. +/// +public sealed class RetailLiveFrameCoordinator +{ + private readonly Action _advanceObjectRuntime; + private readonly Action _dispatchInboundNetwork; + private readonly Action _runPostNetworkCommands; + private readonly Action _reconcileSpatialPresentation; + + public RetailLiveFrameCoordinator( + Action advanceObjectRuntime, + Action dispatchInboundNetwork, + Action runPostNetworkCommands, + Action reconcileSpatialPresentation) + { + _advanceObjectRuntime = advanceObjectRuntime + ?? throw new ArgumentNullException(nameof(advanceObjectRuntime)); + _dispatchInboundNetwork = dispatchInboundNetwork + ?? throw new ArgumentNullException(nameof(dispatchInboundNetwork)); + _runPostNetworkCommands = runPostNetworkCommands + ?? throw new ArgumentNullException(nameof(runPostNetworkCommands)); + _reconcileSpatialPresentation = reconcileSpatialPresentation + ?? throw new ArgumentNullException(nameof(reconcileSpatialPresentation)); + } + + public void Tick(float deltaSeconds) + { + float frameDelta = (float)NormalizeDeltaSeconds(deltaSeconds); + _advanceObjectRuntime(frameDelta); + _dispatchInboundNetwork(); + _runPostNetworkCommands(); + _reconcileSpatialPresentation(); + } + + public static double NormalizeDeltaSeconds(double deltaSeconds) => + double.IsFinite(deltaSeconds) + && deltaSeconds > 0.0 + && deltaSeconds <= float.MaxValue + ? deltaSeconds + : 0.0; +} diff --git a/tests/AcDream.App.Tests/Input/RetailLocalPlayerFrameControllerTests.cs b/tests/AcDream.App.Tests/Input/RetailLocalPlayerFrameControllerTests.cs new file mode 100644 index 00000000..29929f19 --- /dev/null +++ b/tests/AcDream.App.Tests/Input/RetailLocalPlayerFrameControllerTests.cs @@ -0,0 +1,151 @@ +using System.Numerics; +using AcDream.App.Input; +using AcDream.App.World; +using AcDream.Core.Physics; + +namespace AcDream.App.Tests.Input; + +public sealed class RetailLocalPlayerFrameControllerTests +{ + [Fact] + public void F751BetweenPhases_CannotRelocateOrReplayCapturedOutboundMovement() + { + PlayerMovementController controller = CreateController(); + var calls = new List(); + var preNetwork = new List<(PlayerState State, MovementResult Movement)>(); + var postNetwork = new List(); + var local = new RetailLocalPlayerFrameController( + canPresentPlayer: () => true, + getController: () => controller, + captureInput: () => new MovementInput(Forward: true), + resolveLocalEntityId: () => 7u, + handleTargeting: () => calls.Add("target"), + isHidden: () => false, + project: (_, _, _) => calls.Add("project"), + sendPreNetwork: (owner, movement, _) => + { + calls.Add("pre-outbound"); + preNetwork.Add((owner.State, movement)); + }, + sendPostNetwork: (owner, _) => + { + calls.Add("post-position"); + postNetwork.Add(owner.State); + }); + var live = new RetailLiveFrameCoordinator( + dt => + { + calls.Add("objects"); + local.AdvanceBeforeNetwork(dt); + }, + () => + { + calls.Add("f751"); + controller.State = PlayerState.PortalSpace; + }, + () => + { + calls.Add("commands"); + local.RunPostNetworkCommandPhase(); + }, + () => calls.Add("spatial")); + + live.Tick(1f / 60f); + Assert.True(local.TryGetPresentationAfterNetwork(out var presentation)); + + Assert.Equal( + [ + "objects", "target", "project", "pre-outbound", "f751", + "commands", "project", "post-position", "spatial", + ], + calls); + Assert.Single(preNetwork); + Assert.Equal(PlayerState.InWorld, preNetwork[0].State); + Assert.True(preNetwork[0].Movement.ShouldSendMovementEvent); + Assert.Equal([PlayerState.PortalSpace], postNetwork); + Assert.Equal(PlayerState.PortalSpace, controller.State); + Assert.True(presentation.AdvancedBeforeNetwork); + } + + [Fact] + public void OwnerCreatedByInboundPass_IsProjectedWithoutPhysicsOrOutboundTick() + { + PlayerMovementController? controller = null; + int projections = 0; + int preNetwork = 0; + int postNetwork = 0; + var local = new RetailLocalPlayerFrameController( + canPresentPlayer: () => controller is not null, + getController: () => controller, + captureInput: () => new MovementInput(Forward: true), + resolveLocalEntityId: () => 9u, + handleTargeting: () => { }, + isHidden: () => false, + project: (_, _, _) => projections++, + sendPreNetwork: (_, _, _) => preNetwork++, + sendPostNetwork: (_, _) => postNetwork++); + + local.AdvanceBeforeNetwork(1f / 60f); + controller = CreateController(); + float timeBefore = controller.SimTimeSeconds; + + Assert.True(local.TryGetPresentationAfterNetwork(out var presentation)); + + Assert.False(presentation.AdvancedBeforeNetwork); + Assert.Equal(timeBefore, controller.SimTimeSeconds); + Assert.Equal(1, projections); + Assert.Equal(0, preNetwork); + Assert.Equal(0, postNetwork); + Assert.False(presentation.Movement.ShouldSendMovementEvent); + } + + [Fact] + public void OrdinaryInboundRootMutation_IsReconciledWithoutSecondPhysicsTick() + { + PlayerMovementController controller = CreateController(); + Vector3 projectedRoot = default; + var local = new RetailLocalPlayerFrameController( + canPresentPlayer: () => true, + getController: () => controller, + captureInput: () => new MovementInput(), + resolveLocalEntityId: () => 7u, + handleTargeting: () => { }, + isHidden: () => false, + project: (_, movement, _) => projectedRoot = movement.RenderPosition, + sendPreNetwork: (_, _, _) => { }, + sendPostNetwork: (_, _) => { }); + var live = new RetailLiveFrameCoordinator( + local.AdvanceBeforeNetwork, + () => projectedRoot = new Vector3(999f, 999f, 999f), + local.RunPostNetworkCommandPhase, + () => { }); + + live.Tick(1f / 60f); + float timeAfterOneTick = controller.SimTimeSeconds; + + Assert.Equal(controller.RenderPosition, projectedRoot); + Assert.Equal(1f / 60f, timeAfterOneTick); + } + + private static PlayerMovementController CreateController() + { + var engine = new PhysicsEngine(); + var heights = new byte[81]; + Array.Fill(heights, (byte)50); + var heightTable = new float[256]; + for (int i = 0; i < heightTable.Length; i++) + heightTable[i] = i; + + engine.AddLandblock( + 0xA9B4FFFFu, + new TerrainSurface(heights, heightTable), + Array.Empty(), + Array.Empty(), + worldOffsetX: 0f, + worldOffsetY: 0f); + + var controller = new PlayerMovementController(engine); + controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001u); + return controller; + } +} diff --git a/tests/AcDream.App.Tests/World/RecallTeleportAnimationTests.cs b/tests/AcDream.App.Tests/World/RecallTeleportAnimationTests.cs new file mode 100644 index 00000000..2340d529 --- /dev/null +++ b/tests/AcDream.App.Tests/World/RecallTeleportAnimationTests.cs @@ -0,0 +1,118 @@ +using AcDream.App.Rendering; +using AcDream.App.World; +using AcDream.Content.Vfx; +using AcDream.Core.Physics; +using DatReaderWriter; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Options; +using DatReaderWriter.Types; +using Xunit.Sdk; + +namespace AcDream.App.Tests.World; + +public sealed class RecallTeleportAnimationTests +{ + private const uint HumanSetup = 0x02000001u; + private const uint HumanMotionTable = 0x09000001u; + private const uint NonCombat = 0x8000003Du; + private const uint Ready = 0x41000003u; + private const uint LifestoneRecall = 0x10000153u; + + [Fact] + public void LiveFrame_AdvancesObjectRuntimeBeforeInboundNetwork() + { + var calls = new List(); + var frame = new RetailLiveFrameCoordinator( + _ => calls.Add("objects"), + () => calls.Add("network"), + () => calls.Add("commands"), + () => calls.Add("spatial")); + + frame.Tick(1f / 60f); + + Assert.Equal(["objects", "network", "commands", "spatial"], calls); + } + + [Fact] + public void LiveFrame_InvalidHostDeltaCannotBlockNetworkDispatch() + { + var deltas = new List(); + int networkDispatches = 0; + var frame = new RetailLiveFrameCoordinator( + deltas.Add, + () => networkDispatches++, + () => { }, + () => { }); + + frame.Tick(float.NaN); + frame.Tick(float.PositiveInfinity); + frame.Tick(-1f); + + Assert.Equal([0f, 0f, 0f], deltas); + Assert.Equal(3, networkDispatches); + Assert.Equal(0.0, RetailLiveFrameCoordinator.NormalizeDeltaSeconds(double.NaN)); + Assert.Equal(0.0, RetailLiveFrameCoordinator.NormalizeDeltaSeconds(double.NegativeInfinity)); + Assert.Equal(0.0, RetailLiveFrameCoordinator.NormalizeDeltaSeconds(double.PositiveInfinity)); + Assert.Equal(0.0, RetailLiveFrameCoordinator.NormalizeDeltaSeconds(double.MaxValue)); + } + + [Fact] + public void InstalledRecall_RetiresBeforeTeleportHiddenStateIsDispatched() + { + string datDir = @"C:\Turbine\Asheron's Call"; + if (!File.Exists(Path.Combine(datDir, "client_portal.dat"))) + throw SkipException.ForSkip("Installed retail DATs are required."); + + using var dats = new DatCollection(datDir, DatAccessType.Read); + Setup setup = Assert.IsType(dats.Get(HumanSetup)); + MotionTable table = Assert.IsType(dats.Get(HumanMotionTable)); + var loader = new RetailAnimationLoader(dats); + var sequencer = new AnimationSequencer(setup, table, loader); + sequencer.SetCycle(NonCombat, Ready); + sequencer.PlayAction(LifestoneRecall); + + int readyKey = unchecked((int)((NonCombat << 16) | (Ready & 0xFFFFFFu))); + MotionData action = table.Links[readyKey].MotionData[unchecked((int)LifestoneRecall)]; + double aceDuration = 0.0; + foreach (var anim in action.Anims) + { + Animation loaded = Assert.IsType(loader.LoadAnimation((uint)anim.AnimId)); + int high = anim.HighFrame == -1 ? loaded.PartFrames.Count : anim.HighFrame; + aceDuration += (high - anim.LowFrame) / Math.Abs(anim.Framerate); + } + + const float updateStep = 1f / 60f; + double elapsed = 0.0; + bool hidden = false; + bool hiddenPacketReady = false; + var frame = new RetailLiveFrameCoordinator( + dt => + { + if (!hidden) + sequencer.Advance(dt); + }, + () => + { + if (hiddenPacketReady) + hidden = true; + }, + () => { }, + () => { }); + + // The recall motion is dispatched after an object's UseTime phase. + // On the first later frame whose timestamp reaches ACE's scheduled + // teleport boundary, retail advances the PartArray and only then + // consumes the Hidden SetState from the inbound queue. + while (elapsed < aceDuration) + { + hiddenPacketReady = elapsed + updateStep >= aceDuration; + frame.Tick(updateStep); + elapsed += updateStep; + } + + Assert.True(hidden); + Assert.True( + sequencer.CurrentNodeDiag.IsLooping, + "Recall must reach the Ready cyclic tail before Hidden freezes PartArray playback."); + } +}