fix(world): complete recall before teleport hide
Match retail's update ordering so object animation, particles, and scripts advance before inbound teleport state is applied. Separate input-originated movement from post-network autonomous position output, and reconcile presentation without a second physics tick so recall cannot resume after arrival. Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
parent
dded9e6b17
commit
75acae02d6
13 changed files with 1068 additions and 332 deletions
|
|
@ -206,6 +206,7 @@ src/
|
||||||
LiveEntityRuntime.cs -> canonical identity/state/body/projectile/spatial ownership
|
LiveEntityRuntime.cs -> canonical identity/state/body/projectile/spatial ownership
|
||||||
LiveEntityPresentationController.cs -> Hidden/NoDraw/effect/collision presentation
|
LiveEntityPresentationController.cs -> Hidden/NoDraw/effect/collision presentation
|
||||||
LiveEntityTeardown.cs -> failure-isolated multi-owner lifecycle drain
|
LiveEntityTeardown.cs -> failure-isolated multi-owner lifecycle drain
|
||||||
|
RetailLiveFrameCoordinator.cs -> object/physics-before-network frame barrier
|
||||||
Rendering/
|
Rendering/
|
||||||
GameWindow.cs -> still owns too much runtime wiring
|
GameWindow.cs -> still owns too much runtime wiring
|
||||||
TerrainModernRenderer.cs -> mandatory bindless+MDI terrain path
|
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
|
The destination residency gate supplies retail's `EndTeleportAnimation` edge
|
||||||
once asynchronous streaming is ready. Player placement remains authoritative
|
once asynchronous streaming is ready. Player placement remains authoritative
|
||||||
and separate from presentation; arrival resets camera viewer state but does
|
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`.
|
`docs/research/2026-07-15-retail-portal-space-pseudocode.md`.
|
||||||
|
|
||||||
## Roadmap Model
|
## Roadmap Model
|
||||||
|
|
|
||||||
|
|
@ -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.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.
|
- **✓ 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.**
|
- **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.
|
**Acceptance:** walk outside at dusk, see the sky gradient + sun moving; enter a torch-lit dungeon via portal; leave back to daylight.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
chat, panels, toolbar, cursor, and input remain live throughout travel. See
|
||||||
`docs/research/2026-07-15-retail-portal-space-pseudocode.md`.
|
`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)
|
#### (historical M1.5 working notes below)
|
||||||
|
|
@ -513,9 +523,9 @@ include dungeons.
|
||||||
zero-time recursive chains fail diagnostically while valid timed weather loops
|
zero-time recursive chains fail diagnostically while valid timed weather loops
|
||||||
continue. The
|
continue. The
|
||||||
live-pose step now publishes exact root/indexed rigid-part transforms after
|
live-pose step now publishes exact root/indexed rigid-part transforms after
|
||||||
animation and equipped-child composition, then drains animation hooks,
|
animation and equipped-child composition, advances existing FP hooks, drains
|
||||||
PhysicsScripts, attached emitters/lights, and particle simulation in fixed
|
animation hooks, refreshes attached emitters/lights, advances particles, then
|
||||||
frame order. World-released particles remain at their birth positions while
|
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
|
parent-local particles follow; normal/blocking/anonymous logical emitter
|
||||||
semantics are exact; pending projections skip particle updates/draw while
|
semantics are exact; pending projections skip particle updates/draw while
|
||||||
retaining absolute creation times, the same emitter, and its logical ID, so
|
retaining absolute creation times, the same emitter, and its logical ID, so
|
||||||
|
|
|
||||||
|
|
@ -844,8 +844,17 @@ Repository links:
|
||||||
4. No client-side impact, explosion, damage, or deletion is synthesized.
|
4. No client-side impact, explosion, damage, or deletion is synthesized.
|
||||||
5. Presentation frame ordering is fixed: advance animation/root motion,
|
5. Presentation frame ordering is fixed: advance animation/root motion,
|
||||||
publish indexed parts, compose equipped children, drain animation hooks,
|
publish indexed parts, compose equipped children, drain animation hooks,
|
||||||
tick owner PhysicsScripts, refresh attached emitters/lights, then advance
|
refresh attached emitters/lights, advance particle simulation, tick owner
|
||||||
particle simulation and draw.
|
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
|
Retail stages animation hooks inside `CSequence::update`, then executes them
|
||||||
from `CPhysicsObj::process_hooks` before the new root is committed. The same
|
from `CPhysicsObj::process_hooks` before the new root is committed. The same
|
||||||
|
|
|
||||||
|
|
@ -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
|
boom outward through the same damped and swept path used after camera
|
||||||
collision. Reusing the source-world viewer is not retail behavior.
|
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 retail teleport hook does not clear the character's animation sequence.
|
||||||
The arrival presentation is instead hidden by the portal scene and its final
|
Recall completion is instead guaranteed by the enclosing frame order:
|
||||||
world view-plane transition while the normal motion/update stream settles. Therefore acdream
|
|
||||||
must not add an arrival-only animation reset.
|
```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
|
## 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.
|
strand the transit, while canonical physics retains its own retail gates.
|
||||||
- Destination placement resets the retail chase camera's published and sought
|
- Destination placement resets the retail chase camera's published and sought
|
||||||
positions to the player before the normal update path resumes.
|
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
|
- Destination residency remains acdream's asynchronous adaptation. It supplies
|
||||||
the same `EndTeleportAnimation` edge retail receives when its blocking cell
|
the same `EndTeleportAnimation` edge retail receives when its blocking cell
|
||||||
load completes; it does not alter presentation ordering.
|
load completes; it does not alter presentation ordering.
|
||||||
|
|
|
||||||
209
src/AcDream.App/Input/LocalPlayerOutboundController.cs
Normal file
209
src/AcDream.App/Input/LocalPlayerOutboundController.cs
Normal file
|
|
@ -0,0 +1,209 @@
|
||||||
|
using System.Numerics;
|
||||||
|
using AcDream.Core.Net;
|
||||||
|
using AcDream.Core.Net.Messages;
|
||||||
|
using AcDream.Core.Physics;
|
||||||
|
|
||||||
|
namespace AcDream.App.Input;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Serializes input-originated output from the object-phase result and the
|
||||||
|
/// periodic position report from post-inbound controller state.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class LocalPlayerOutboundController
|
||||||
|
{
|
||||||
|
private readonly Func<float, Quaternion> _toWireRotation;
|
||||||
|
private readonly Action<string, uint, MovementResult, Vector3, uint, byte> _diagnostic;
|
||||||
|
|
||||||
|
public LocalPlayerOutboundController(
|
||||||
|
Func<float, Quaternion> toWireRotation,
|
||||||
|
Action<string, uint, MovementResult, Vector3, uint, byte> diagnostic)
|
||||||
|
{
|
||||||
|
_toWireRotation = toWireRotation
|
||||||
|
?? throw new ArgumentNullException(nameof(toWireRotation));
|
||||||
|
_diagnostic = diagnostic
|
||||||
|
?? throw new ArgumentNullException(nameof(diagnostic));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sends input-originated movement and jump packets before inbound
|
||||||
|
/// dispatch. Retail emits these from input/object processing rather than
|
||||||
|
/// from <c>CommandInterpreter::UseTime</c>.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ports the position-send slot in <c>CommandInterpreter::UseTime</c>
|
||||||
|
/// (<c>0x006B3BF0</c>), which SmartBox calls after draining inbound
|
||||||
|
/// events. The predicate and serialized frame therefore observe any
|
||||||
|
/// accepted ForcePosition or teleport state from this update.
|
||||||
|
/// </summary>
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
68
src/AcDream.App/Input/LocalPlayerProjectionController.cs
Normal file
68
src/AcDream.App/Input/LocalPlayerProjectionController.cs
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
using AcDream.Core.World;
|
||||||
|
|
||||||
|
namespace AcDream.App.Input;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Projects the canonical local physics body into world rendering and spatial
|
||||||
|
/// buckets without advancing it.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class LocalPlayerProjectionController
|
||||||
|
{
|
||||||
|
private readonly Func<WorldEntity?> _resolveEntity;
|
||||||
|
private readonly Func<int> _liveCenterX;
|
||||||
|
private readonly Func<int> _liveCenterY;
|
||||||
|
private readonly Action<WorldEntity, uint> _syncShadow;
|
||||||
|
private readonly Action<uint, uint> _rebucket;
|
||||||
|
|
||||||
|
public LocalPlayerProjectionController(
|
||||||
|
Func<WorldEntity?> resolveEntity,
|
||||||
|
Func<int> liveCenterX,
|
||||||
|
Func<int> liveCenterY,
|
||||||
|
Action<WorldEntity, uint> syncShadow,
|
||||||
|
Action<uint, uint> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -916,6 +916,21 @@ public sealed class PlayerMovementController
|
||||||
IsMouseLookMovementEvent: mouseLookEvent);
|
IsMouseLookMovementEvent: mouseLookEvent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Captures the current controller state for presentation without
|
||||||
|
/// advancing physics or requesting an outbound movement event.
|
||||||
|
/// </summary>
|
||||||
|
public MovementResult CapturePresentationResult()
|
||||||
|
{
|
||||||
|
MovementResult current = CaptureMovementResult(mouseLookEvent: false);
|
||||||
|
return current with
|
||||||
|
{
|
||||||
|
MotionStateChanged = false,
|
||||||
|
ShouldSendMovementEvent = false,
|
||||||
|
IsMouseLookMovementEvent = false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Retail <c>CommandInterpreter::SendPositionEvent</c> (0x006B4770)
|
/// Retail <c>CommandInterpreter::SendPositionEvent</c> (0x006B4770)
|
||||||
/// stamps the send time, complete cell-local frame (origin and
|
/// stamps the send time, complete cell-local frame (origin and
|
||||||
|
|
|
||||||
155
src/AcDream.App/Input/RetailLocalPlayerFrameController.cs
Normal file
155
src/AcDream.App/Input/RetailLocalPlayerFrameController.cs
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
namespace AcDream.App.Input;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Owns the local player's once-per-frame retail object phase.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Retail advances the player and sends input-originated movement/jump output
|
||||||
|
/// before <c>SmartBox::UseTime</c> drains inbound events. Its later
|
||||||
|
/// <c>CommandInterpreter::UseTime</c> 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.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class RetailLocalPlayerFrameController
|
||||||
|
{
|
||||||
|
private readonly Func<bool> _canPresentPlayer;
|
||||||
|
private readonly Func<PlayerMovementController?> _getController;
|
||||||
|
private readonly Func<MovementInput> _captureInput;
|
||||||
|
private readonly Func<uint> _resolveLocalEntityId;
|
||||||
|
private readonly Action _handleTargeting;
|
||||||
|
private readonly Func<bool> _isHidden;
|
||||||
|
private readonly Action<PlayerMovementController, MovementResult, bool> _project;
|
||||||
|
private readonly Action<PlayerMovementController, MovementResult, bool> _sendPreNetwork;
|
||||||
|
private readonly Action<PlayerMovementController, bool> _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<bool> canPresentPlayer,
|
||||||
|
Func<PlayerMovementController?> getController,
|
||||||
|
Func<MovementInput> captureInput,
|
||||||
|
Func<uint> resolveLocalEntityId,
|
||||||
|
Action handleTargeting,
|
||||||
|
Func<bool> isHidden,
|
||||||
|
Action<PlayerMovementController, MovementResult, bool> project,
|
||||||
|
Action<PlayerMovementController, MovementResult, bool> sendPreNetwork,
|
||||||
|
Action<PlayerMovementController, bool> 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));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Advances the existing local object exactly once on the object side of
|
||||||
|
/// the inbound-network barrier.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -163,6 +163,10 @@ public sealed class GameWindow : IDisposable
|
||||||
// once the injected shared helpers exist as method groups (GetSetupCylinder,
|
// once the injected shared helpers exist as method groups (GetSetupCylinder,
|
||||||
// ApplyServerControlledVelocityCycle). See RemotePhysicsUpdater.
|
// ApplyServerControlledVelocityCycle). See RemotePhysicsUpdater.
|
||||||
private readonly AcDream.App.Physics.RemotePhysicsUpdater _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;
|
private AcDream.App.Physics.RemoteTeleportController? _remoteTeleportController;
|
||||||
// Step 7 projectile presentation. The controller owns no identity map;
|
// Step 7 projectile presentation. The controller owns no identity map;
|
||||||
// each runtime component is stored on the canonical LiveEntityRecord.
|
// 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.
|
// handler) stay on GameWindow and are injected as delegates.
|
||||||
_remotePhysicsUpdater = new AcDream.App.Physics.RemotePhysicsUpdater(
|
_remotePhysicsUpdater = new AcDream.App.Physics.RemotePhysicsUpdater(
|
||||||
_physicsEngine, GetSetupCylinder, ApplyServerControlledVelocityCycle);
|
_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()
|
public void Run()
|
||||||
|
|
@ -7066,6 +7115,7 @@ public sealed class GameWindow : IDisposable
|
||||||
// camera path re-extends the boom. Updating directly from the stale
|
// camera path re-extends the boom. Updating directly from the stale
|
||||||
// source-world viewer made the destination bend across the reveal.
|
// source-world viewer made the destination bend across the reveal.
|
||||||
_retailChaseCamera?.ResetViewerToPlayer(snappedPos, _playerController.Yaw);
|
_retailChaseCamera?.ResetViewerToPlayer(snappedPos, _playerController.Yaw);
|
||||||
|
ReconcileLiveObjectSpatialPresentation();
|
||||||
|
|
||||||
AcDream.Core.Physics.PhysicsDiagnostics.LogTeleport(
|
AcDream.Core.Physics.PhysicsDiagnostics.LogTeleport(
|
||||||
"PLACED", resolved.CellId, $"forced={forced}");
|
"PLACED", resolved.CellId, $"forced={forced}");
|
||||||
|
|
@ -8764,11 +8814,15 @@ public sealed class GameWindow : IDisposable
|
||||||
{
|
{
|
||||||
using var _updStage = _frameProfiler.BeginStage(AcDream.App.Diagnostics.FrameStage.Update);
|
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
|
// Retail ScriptManager::AddScriptInternal (0x0051B310) stamps
|
||||||
// Timer::cur_time at the packet/default-script call site. Publish this
|
// Timer::cur_time at the packet/default-script call site. Publish this
|
||||||
// update frame's clock before streaming and network dispatch, then
|
// update frame's clock before streaming and network dispatch, then
|
||||||
// reuse the same timestamp for animation hooks and the later drain.
|
// reuse the same timestamp for animation hooks and the later drain.
|
||||||
_physicsScriptGameTime += Math.Max(0.0, dt);
|
_physicsScriptGameTime += frameSeconds;
|
||||||
_scriptRunner?.PublishTime(_physicsScriptGameTime);
|
_scriptRunner?.PublishTime(_physicsScriptGameTime);
|
||||||
|
|
||||||
// [FRAME-DIAG]: applies run later this frame inside _streamingController.Tick;
|
// [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
|
// Drain pending live-session traffic AFTER streaming so any incoming
|
||||||
// CreateObject events find their landblock already loaded in
|
// CreateObject events find their landblock already loaded in
|
||||||
// GpuWorldState. Non-blocking — returns immediately if no datagrams
|
// GpuWorldState. Non-blocking — returns immediately if no datagrams
|
||||||
// are in the kernel buffer. Fires EntitySpawned events synchronously.
|
// are in the kernel buffer. Fires EntitySpawned events synchronously.
|
||||||
// Step 2: routed through the controller; functionally identical.
|
// 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
|
// Usually F751 activates immediately. This no-op convergence check
|
||||||
// covers the session edge where the canonical player exists before
|
// covers the session edge where the canonical player exists before
|
||||||
|
|
@ -8980,7 +9086,7 @@ public sealed class GameWindow : IDisposable
|
||||||
bool ready = haveDest && TeleportWorldReady(_pendingTeleportCell);
|
bool ready = haveDest && TeleportWorldReady(_pendingTeleportCell);
|
||||||
if (haveDest && !ready)
|
if (haveDest && !ready)
|
||||||
{
|
{
|
||||||
_teleportHoldSeconds += (float)dt;
|
_teleportHoldSeconds += frameDelta;
|
||||||
if (_teleportHoldSeconds >= TeleportMaxHoldSeconds)
|
if (_teleportHoldSeconds >= TeleportMaxHoldSeconds)
|
||||||
{
|
{
|
||||||
ready = true;
|
ready = true;
|
||||||
|
|
@ -8989,7 +9095,7 @@ public sealed class GameWindow : IDisposable
|
||||||
}
|
}
|
||||||
|
|
||||||
int tunnelFrame = _portalTunnel?.CurrentAnimationFrame ?? 0;
|
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);
|
_teleportViewPlane.Update(snap);
|
||||||
|
|
||||||
foreach (var e in evts)
|
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
|
// Phase K.1a — tick the input dispatcher so Hold-type bindings
|
||||||
// re-fire while their chord is held. K.1b adds the subscribers
|
// re-fire while their chord is held. K.1b adds the subscribers
|
||||||
// that actually consume the events.
|
// that actually consume the events.
|
||||||
_inputDispatcher?.Tick();
|
|
||||||
_combatAttackController?.Tick();
|
|
||||||
|
|
||||||
// Phase D.5.3a — advance the selected-object overlay flash (0.25s green pulse
|
// Phase D.5.3a — advance the selected-object overlay flash (0.25s green pulse
|
||||||
// on selection, then revert). No-op when nothing is flashing.
|
// on selection, then revert). No-op when nothing is flashing.
|
||||||
// Retained panel-local timers advance through RetailUiRuntime.Tick on the draw seam.
|
// 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
|
// Phase K.2 — auto-enter player mode at login. The guard
|
||||||
// returns true on the firing tick (one-shot); subsequent ticks
|
// returns true on the firing tick (one-shot); subsequent ticks
|
||||||
// are no-ops. Skipped offline (no _liveSession → IsLiveInWorld
|
// are no-ops. Skipped offline (no _liveSession → IsLiveInWorld
|
||||||
|
|
@ -9116,7 +9176,7 @@ public sealed class GameWindow : IDisposable
|
||||||
// delta drives fly heading instead).
|
// delta drives fly heading instead).
|
||||||
if (_inputDispatcher is null) return;
|
if (_inputDispatcher is null) return;
|
||||||
_cameraController.Fly.Update(
|
_cameraController.Fly.Update(
|
||||||
dt,
|
frameSeconds,
|
||||||
w: _inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.MovementForward),
|
w: _inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.MovementForward),
|
||||||
a: _inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.MovementTurnLeft),
|
a: _inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.MovementTurnLeft),
|
||||||
s: _inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.MovementBackup),
|
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.
|
// retail chase is selected; legacy camera ignores these.
|
||||||
if (AcDream.Core.Rendering.CameraDiagnostics.UseRetailChaseCamera && _retailChaseCamera is not null)
|
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))
|
if (_inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.CameraZoomIn))
|
||||||
_retailChaseCamera.AdjustDistance(-adj);
|
_retailChaseCamera.AdjustDistance(-adj);
|
||||||
if (_inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.CameraZoomOut))
|
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
|
// until Q is pressed again. Handled in OnInputAction; here
|
||||||
// we just OR _autoRunActive into the Forward flag.
|
// we just OR _autoRunActive into the Forward flag.
|
||||||
// * Mouse never drives character yaw (K.1b regression-prevention).
|
// * 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
|
AcDream.App.Input.MovementResult result = playerFrame.Movement;
|
||||||
// LocalEntityId in sync with the live local player entity so
|
bool localPlayerHidden = playerFrame.Hidden;
|
||||||
// FindObjCollisions skips its own ShadowEntry. Re-fetched per
|
if (!playerFrame.AdvancedBeforeNetwork)
|
||||||
// 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))
|
|
||||||
{
|
{
|
||||||
pe.SetPosition(result.RenderPosition); // A.5 T18: SetPosition propagates AabbDirty
|
// The player was materialized by this frame's inbound pass.
|
||||||
pe.ParentCellId = result.CellId;
|
// Its non-advancing initial projection must publish child and
|
||||||
pe.Rotation = System.Numerics.Quaternion.CreateFromAxisAngle(
|
// effect anchors before the first draw too.
|
||||||
System.Numerics.Vector3.UnitZ, _playerController.Yaw - MathF.PI / 2f);
|
ReconcileLiveObjectSpatialPresentation();
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update chase camera(s). The CameraController exposes whichever
|
// Update chase camera(s). The CameraController exposes whichever
|
||||||
|
|
@ -9249,7 +9244,7 @@ public sealed class GameWindow : IDisposable
|
||||||
// low-stiffness damping).
|
// low-stiffness damping).
|
||||||
_chaseCamera.Update(result.RenderPosition, _playerController.Yaw,
|
_chaseCamera.Update(result.RenderPosition, _playerController.Yaw,
|
||||||
isOnGround: result.IsOnGround,
|
isOnGround: result.IsOnGround,
|
||||||
dt: (float)dt);
|
dt: frameDelta);
|
||||||
// RetailChaseCamera: heading is the player's facing direction
|
// RetailChaseCamera: heading is the player's facing direction
|
||||||
// projected onto the contact plane when grounded, or the
|
// projected onto the contact plane when grounded, or the
|
||||||
// world XY plane when airborne. The contact plane normal
|
// world XY plane when airborne. The contact plane normal
|
||||||
|
|
@ -9263,98 +9258,26 @@ public sealed class GameWindow : IDisposable
|
||||||
playerVelocity: _playerController.BodyVelocity,
|
playerVelocity: _playerController.BodyVelocity,
|
||||||
isOnGround: result.IsOnGround,
|
isOnGround: result.IsOnGround,
|
||||||
contactPlaneNormal: _playerController.ContactPlane.Normal,
|
contactPlaneNormal: _playerController.ContactPlane.Normal,
|
||||||
dt: (float)dt,
|
dt: frameDelta,
|
||||||
cellId: _playerController.CellId,
|
cellId: _playerController.CellId,
|
||||||
selfEntityId: _playerController.LocalEntityId,
|
selfEntityId: _playerController.LocalEntityId,
|
||||||
trackedTargetPoint: trackedCombatTarget);
|
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.
|
// 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(
|
private void DumpMovementTruthOutbound(
|
||||||
string kind,
|
string kind,
|
||||||
uint sequence,
|
uint sequence,
|
||||||
|
|
@ -9589,40 +9512,6 @@ public sealed class GameWindow : IDisposable
|
||||||
if (DevToolsEnabled && _imguiBootstrap is not null)
|
if (DevToolsEnabled && _imguiBootstrap is not null)
|
||||||
_imguiBootstrap.BeginFrame((float)deltaSeconds);
|
_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
|
// Phase G.1: weather state machine — deterministic per-day roll
|
||||||
// + transitions + lightning flash.
|
// + transitions + lightning flash.
|
||||||
var cal = WorldTime.CurrentCalendar;
|
var cal = WorldTime.CurrentCalendar;
|
||||||
|
|
@ -9784,12 +9673,6 @@ public sealed class GameWindow : IDisposable
|
||||||
// debug-only and disabled for normal retail rendering.
|
// debug-only and disabled for normal retail rendering.
|
||||||
if (_options.EnableSkyPesDebug)
|
if (_options.EnableSkyPesDebug)
|
||||||
UpdateSkyPes((float)WorldTime.DayFraction, _activeDayGroup, camPos, cameraInsideCell);
|
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
|
// Phase G.1/G.2: feed the sun, tick LightManager, build + upload
|
||||||
// the scene-lighting UBO once per frame. Every shader that
|
// the scene-lighting UBO once per frame. Every shader that
|
||||||
// consumes binding=1 reads the same data for the rest of the
|
// 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)));
|
ReportCollisionEnd: () => _physicsEngine.ShadowObjects.Suspend(localEntityId)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Phase 6.4: advance every animated entity's frame counter by
|
/// Phase 6.4: advance every animated entity's frame counter by
|
||||||
/// <paramref name="dt"/> * Framerate, wrapping around the cycle's
|
/// <paramref name="dt"/> * Framerate, wrapping around the cycle's
|
||||||
|
|
@ -12921,79 +12869,14 @@ public sealed class GameWindow : IDisposable
|
||||||
}
|
}
|
||||||
|
|
||||||
internal static AcDream.Core.Physics.RawMotionState BuildOutboundRawMotionState(
|
internal static AcDream.Core.Physics.RawMotionState BuildOutboundRawMotionState(
|
||||||
AcDream.App.Input.MovementResult result)
|
AcDream.App.Input.MovementResult result) =>
|
||||||
{
|
AcDream.App.Input.LocalPlayerOutboundController.BuildRawMotionState(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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private bool TrySendPlayerMovementEvent(AcDream.App.Input.MovementResult result)
|
private bool TrySendPlayerMovementEvent(AcDream.App.Input.MovementResult result)
|
||||||
{
|
=> _localPlayerOutbound.TrySendMovement(
|
||||||
if (_liveSession is not { } session
|
_liveSession,
|
||||||
|| _playerController is not { } controller)
|
_playerController,
|
||||||
return false;
|
result);
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
private uint? GetSelectedOrClosestCombatTarget()
|
private uint? GetSelectedOrClosestCombatTarget()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
53
src/AcDream.App/World/RetailLiveFrameCoordinator.cs
Normal file
53
src/AcDream.App/World/RetailLiveFrameCoordinator.cs
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
namespace AcDream.App.World;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Owns retail's live-object, inbound-dispatch, and command-interpreter frame
|
||||||
|
/// phases.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Retail <c>SmartBox::UseTime</c> (<c>0x00455410</c>) advances
|
||||||
|
/// <c>CObjectMaint::UseTime</c> and <c>CPhysics::UseTime</c> before draining
|
||||||
|
/// <c>SmartBox::in_queue</c>, then calls <c>CommandInterpreter::UseTime</c>.
|
||||||
|
/// 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.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class RetailLiveFrameCoordinator
|
||||||
|
{
|
||||||
|
private readonly Action<float> _advanceObjectRuntime;
|
||||||
|
private readonly Action _dispatchInboundNetwork;
|
||||||
|
private readonly Action _runPostNetworkCommands;
|
||||||
|
private readonly Action _reconcileSpatialPresentation;
|
||||||
|
|
||||||
|
public RetailLiveFrameCoordinator(
|
||||||
|
Action<float> 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;
|
||||||
|
}
|
||||||
|
|
@ -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<string>();
|
||||||
|
var preNetwork = new List<(PlayerState State, MovementResult Movement)>();
|
||||||
|
var postNetwork = new List<PlayerState>();
|
||||||
|
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<CellSurface>(),
|
||||||
|
Array.Empty<PortalPlane>(),
|
||||||
|
worldOffsetX: 0f,
|
||||||
|
worldOffsetY: 0f);
|
||||||
|
|
||||||
|
var controller = new PlayerMovementController(engine);
|
||||||
|
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001u);
|
||||||
|
return controller;
|
||||||
|
}
|
||||||
|
}
|
||||||
118
tests/AcDream.App.Tests/World/RecallTeleportAnimationTests.cs
Normal file
118
tests/AcDream.App.Tests/World/RecallTeleportAnimationTests.cs
Normal file
|
|
@ -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<string>();
|
||||||
|
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<float>();
|
||||||
|
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<Setup>(dats.Get<Setup>(HumanSetup));
|
||||||
|
MotionTable table = Assert.IsType<MotionTable>(dats.Get<MotionTable>(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<Animation>(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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue