fix(runtime): align portal and movement presentation

Port retail portal viewport projection and reveal behavior, preserve outbound combat style, drive remote and local grounded movement from authored CSequence root frames, and reuse the local prepared pose so animation hooks advance once.

User-verified portal, observer movement, combat stance, and short-tap locomotion gates. Release build passed with 5,767 tests and five intentional skips.

Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-17 08:48:27 +02:00
parent e95f55f25b
commit 124e046976
30 changed files with 1362 additions and 348 deletions

View file

@ -180,6 +180,11 @@ public sealed class LocalPlayerOutboundController
return new RawMotionState
{
CurrentHoldKey = axisHoldKey,
// CommandInterpreter::SendMovementEvent @ 0x006B4680 passes
// CPhysicsObj::InqRawMotionState directly to MoveToStatePack.
// RawMotionState::UnPack @ 0x0051EFC0 treats an absent style as
// NonCombat, so this is state—not optional observer decoration.
CurrentStyle = movement.CurrentStyle,
ForwardCommand = movement.ForwardCommand
?? RawMotionState.Default.ForwardCommand,
ForwardHoldKey = movement.ForwardCommand.HasValue

View file

@ -81,7 +81,12 @@ public readonly record struct MovementResult(
bool SidestepUsesRunHold = false,
// The host acknowledges this clock/queue only after the MoveToState has
// actually been put on the wire.
bool IsMouseLookMovementEvent = false);
bool IsMouseLookMovementEvent = false,
// CommandInterpreter::SendMovementEvent @ 0x006B4680 passes the
// MovementManager's complete RawMotionState into MoveToStatePack. An
// absent style bit unpacks as NonCombat, so the canonical raw style must
// travel with every input-boundary snapshot sent to ACE.
uint CurrentStyle = 0x8000003Du);
/// <summary>
/// Portal-space state for the player movement controller.
@ -135,6 +140,13 @@ public sealed class PlayerMovementController
/// </summary>
public float StepDownHeight { get; set; } = 0.4f;
/// <summary>
/// Retail <c>CPhysicsObj::m_scale</c>. Grounded CSequence root
/// displacement is multiplied by this value before PositionManager
/// composition (<c>UpdatePositionInternal @ 0x00512C30</c>).
/// </summary>
public float ObjectScale { get; set; } = 1f;
/// <summary>
/// Current portal-space state. Set to PortalSpace when the server sends
/// PlayerTeleport (0xF751); set back to InWorld once the destination
@ -339,6 +351,8 @@ public sealed class PlayerMovementController
private float _physicsAccum;
private Vector3 _prevPhysicsPos;
private Vector3 _currPhysicsPos;
private Func<float, Vector3>? _advanceAnimationRootMotion;
private Vector3 _pendingAnimationRootMotion;
// ── R4-V5: the verbatim retail MoveToManager replaces B.6 auto-walk ──
// The B.6 DriveServerAutoWalk overlay (synthesized turn-first phase,
@ -586,6 +600,7 @@ public sealed class PlayerMovementController
_prevTurnLeftHeld = input.TurnLeft;
_prevTurnRightHeld = input.TurnRight;
_prevRunHeld = input.Run;
_prevRunHold = input.Run;
_body.LastMoveWasAutonomous = true;
}
@ -867,6 +882,20 @@ public sealed class PlayerMovementController
_motion.GetCycleVelocity = accessor;
}
/// <summary>
/// Binds the owning PartArray/CSequence update to the local physics tick.
/// The callback runs after this frame's input edges have reached the motion
/// table and returns the complete local translation emitted by
/// <c>CSequence::update</c>. The controller accumulates sub-quantum deltas
/// and consumes them through the same PositionManager frame as retail.
/// </summary>
public void AttachAnimationRootMotionSource(Func<float, Vector3> advance)
{
_advanceAnimationRootMotion = advance
?? throw new ArgumentNullException(nameof(advance));
_pendingAnimationRootMotion = Vector3.Zero;
}
/// <summary>
/// Retail <c>CommandInterpreter::SendMovementEvent</c> (0x006B4680)
/// stamps only <c>last_sent_position_time</c>. The carried frame and
@ -913,7 +942,8 @@ public sealed class PlayerMovementController
TurnUsesRunHold: turn.HasValue && raw.TurnHoldKey == HoldKey.Run,
SidestepUsesRunHold: sidestep.HasValue
&& raw.SidestepHoldKey == HoldKey.Run,
IsMouseLookMovementEvent: mouseLookEvent);
IsMouseLookMovementEvent: mouseLookEvent,
CurrentStyle: raw.CurrentStyle);
}
/// <summary>
@ -1065,6 +1095,7 @@ public sealed class PlayerMovementController
// Reset physics clock so any subsequent update_object calls start fresh.
_body.LastUpdateTime = 0.0;
_physicsAccum = 0f;
_pendingAnimationRootMotion = Vector3.Zero;
}
/// <summary>
@ -1078,6 +1109,7 @@ public sealed class PlayerMovementController
_body.SnapToCell(cellId, pos, cellLocal);
_prevPhysicsPos = pos;
_currPhysicsPos = pos;
_pendingAnimationRootMotion = Vector3.Zero;
UpdateCellId(_body.CellPosition.ObjCellId, "force-position");
}
@ -1098,6 +1130,7 @@ public sealed class PlayerMovementController
{
_simTimeSeconds += dt;
_physicsAccum = 0f;
_pendingAnimationRootMotion = Vector3.Zero;
Vector3 previousPosition = _body.Position;
bool previousContact = _body.InContact;
bool previousOnWalkable = _body.OnWalkable;
@ -1164,7 +1197,8 @@ public sealed class PlayerMovementController
TurnCommand: null,
ForwardSpeed: null,
SidestepSpeed: null,
TurnSpeed: null);
TurnSpeed: null,
CurrentStyle: _motion.RawState.CurrentStyle);
}
public MovementResult Update(float dt, MovementInput input)
@ -1219,7 +1253,8 @@ public sealed class PlayerMovementController
TurnCommand: null,
ForwardSpeed: null,
SidestepSpeed: null,
TurnSpeed: null);
TurnSpeed: null,
CurrentStyle: _motion.RawState.CurrentStyle);
}
// ── R3-W6: EDGE-DRIVEN retail input (replaces the D6.2 per-frame
@ -1385,6 +1420,21 @@ public sealed class PlayerMovementController
_prevTurnRightHeld = input.TurnRight;
_prevRunHeld = input.Run;
// Retail input reaches MotionTableManager before this object's
// CPartArray update. Advance only after the edge dispatch above so a
// Ready-to-Walk/Run link contributes its authored displacement on the
// same object tick. Sub-quantum render frames accumulate until the
// 30 Hz physics gate consumes them below.
bool hasAnimationRootMotion = _advanceAnimationRootMotion is not null;
if (_advanceAnimationRootMotion is { } advanceRootMotion)
{
Vector3 rootDelta = advanceRootMotion(dt);
if (_body.OnWalkable)
_pendingAnimationRootMotion += rootDelta * ObjectScale;
else
_pendingAnimationRootMotion = Vector3.Zero;
}
// ── 1. Apply turning from keyboard + mouse ────────────────────────────
// 2026-05-16 — retail-faithful turn rate.
// Anchor: docs/research/named-retail/acclient_2013_pseudo_c.txt
@ -1423,41 +1473,32 @@ public sealed class PlayerMovementController
// by (Yaw - PI/2) about Z to map local +Y → world (cos Yaw, sin Yaw, 0).
_body.Orientation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, Yaw - MathF.PI / 2f);
// ── 2. Set velocity via MotionInterpreter state machine ───────────────
// R4-V5: runs during a moveto too — the manager's BeginMoveForward
// dispatched WalkForward/RunForward through _DoMotion into the SAME
// interpreted state, so this one per-frame apply turns it into body
// velocity exactly like input-driven motion (the #75 "two writers"
// hazard is gone: there is only this writer now).
// D6.2: the forward/sidestep command determination + DoMotion +
// DoInterpretedMotion moved into the apply_raw_movement call above, so
// the interpreted state (and thus get_state_velocity) is already
// populated + normalized for all directions.
// Only replace velocity with motion interpreter output when grounded.
// While airborne, the physics body's integrated velocity (from LeaveGround)
// persists — gravity pulls Z down, horizontal momentum is preserved.
// Retail AC works this way: you maintain momentum in the air.
// ── 2. Install grounded movement source ───────────────────────────────
// Retail CPhysicsObj::UpdatePositionInternal @ 0x00512C30 advances the
// PartArray into one local Frame. Ordinary grounded movement is that
// animation-authored displacement, not get_state_velocity(). The
// latter remains the explicit fallback for headless/test controllers
// that have no attached animation runtime and for jump launch below.
if (_body.OnWalkable)
{
float savedWorldVz = _body.Velocity.Z;
// D6.2: velocity for ALL directions comes from the normalized
// interpreted state — backward (WalkForward ×-0.65) and strafe-left
// (SideStepRight ×-1×1.248) are handled inside get_state_velocity now
// that adjust_motion ran above. The hand-mirrored formulas are gone
// (register TS-22 retired).
var stateVel = _motion.get_state_velocity();
// R4-V5 wedge fix: PRESERVE the flag — retail's
// last_move_was_autonomous is stored at EVENT boundaries only
// (input DoMotion/StopMotion edges above, the P1 wire-unpack
// store, LeaveGround's own autonomous=1 write), never re-stamped
// by the per-tick velocity write. V5's first cut stamped it per
// frame here, which mis-routed the pump's A3 dual dispatch on
// event-transition frames (apply_raw clobbering a just-armed
// moveto's dispatched motion with the raw Ready state).
_body.set_local_velocity(new Vector3(stateVel.X, stateVel.Y, savedWorldVz),
autonomous: _body.LastMoveWasAutonomous);
if (hasAnimationRootMotion)
{
// Do not let m_velocityVector move the body a second time.
// The pending animation delta is composed with the
// PositionManager below and swept through the normal collision
// path in the same physics quantum.
_body.Velocity = new Vector3(0f, 0f, savedWorldVz);
}
else
{
var stateVel = _motion.get_state_velocity();
// Preserve LastMoveWasAutonomous: retail stores it at motion
// event boundaries, never at this per-tick fallback write.
_body.set_local_velocity(
new Vector3(stateVel.X, stateVel.Y, savedWorldVz),
autonomous: _body.LastMoveWasAutonomous);
}
}
// ── 3. Jump (charged) ─────────────────────────────────────────────────
@ -1540,6 +1581,7 @@ public sealed class PlayerMovementController
{
// Stale frame (debugger break, GC pause). Discard accumulated dt.
_physicsAccum = 0f;
_pendingAnimationRootMotion = Vector3.Zero;
_prevPhysicsPos = _body.Position;
_currPhysicsPos = _body.Position;
}
@ -1559,18 +1601,21 @@ public sealed class PlayerMovementController
// body orientation); the heading is RELATIVE and writes Yaw —
// the authoritative facing the body quaternion is re-derived
// from every frame (a quaternion-only write would be clobbered).
// No-op while nothing is stuck (untouched delta frame).
if (PositionManager is { } ppm)
{
var pmDelta = new AcDream.Core.Physics.Motion.MotionDeltaFrame();
ppm.AdjustOffset(pmDelta, tickDt);
if (pmDelta.Origin != Vector3.Zero)
_body.Position += Vector3.Transform(pmDelta.Origin, _body.Orientation);
if (!pmDelta.Orientation.IsIdentity)
Yaw = AcDream.Core.Physics.Motion.MoveToMath.YawFromHeading(
AcDream.Core.Physics.Motion.MoveToMath.HeadingFromYaw(Yaw)
+ pmDelta.GetHeading());
}
// Seed the SAME frame that PositionManager receives. Retail's
// Sticky interpolation deliberately replaces PartArray root
// displacement rather than adding a second movement source.
var pmDelta = new AcDream.Core.Physics.Motion.MotionDeltaFrame();
if (hasAnimationRootMotion && _body.OnWalkable)
pmDelta.Origin = _pendingAnimationRootMotion;
_pendingAnimationRootMotion = Vector3.Zero;
PositionManager?.AdjustOffset(pmDelta, tickDt);
if (pmDelta.Origin != Vector3.Zero)
_body.Position += Vector3.Transform(pmDelta.Origin, _body.Orientation);
if (!pmDelta.Orientation.IsIdentity)
Yaw = AcDream.Core.Physics.Motion.MoveToMath.YawFromHeading(
AcDream.Core.Physics.Motion.MoveToMath.HeadingFromYaw(Yaw)
+ pmDelta.GetHeading());
_body.calc_acceleration();
_body.UpdatePhysicsInternal(tickDt);
@ -1868,7 +1913,8 @@ public sealed class PlayerMovementController
TurnUsesRunHold: _activeInputTurnFromMouse && outTurnCmd.HasValue,
SidestepUsesRunHold: _activeInputSidestepUsesRunHold
&& outSidestepCmd.HasValue,
IsMouseLookMovementEvent: mouseMovementEventDue);
IsMouseLookMovementEvent: mouseMovementEventDue,
CurrentStyle: _motion.RawState.CurrentStyle);
}
/// <summary>

View file

@ -92,9 +92,18 @@ internal sealed class RemotePhysicsUpdater
/// body. <c>serverGuid</c> + the entity id derive from
/// <paramref name="ae"/>.Entity; <paramref name="liveCenterX"/>/<paramref name="liveCenterY"/>
/// are passed per-call (they change on streaming recentre — never snapshot
/// them in the constructor).
/// them in the constructor). <paramref name="rootMotionLocalDelta"/> is
/// the complete local displacement produced by this frame's preceding
/// <c>CSequence::update</c>, matching retail's
/// <c>CPartArray::Update → PositionManager::adjust_offset</c> order.
/// </summary>
public void Tick(RemoteMotion rm, AnimatedEntity ae, float dt, int liveCenterX, int liveCenterY)
public void Tick(
RemoteMotion rm,
AnimatedEntity ae,
float dt,
System.Numerics.Vector3 rootMotionLocalDelta,
int liveCenterX,
int liveCenterY)
{
uint serverGuid = ae.Entity.ServerGuid;
// R5-V2: retail UpdateObjectInternal ticks TargetManager::
@ -124,6 +133,16 @@ internal sealed class RemotePhysicsUpdater
{
double nowSec = (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds;
// Retail CPhysicsObj::UpdatePositionInternal @ 0x00512C30 scales
// the CSequence root displacement by m_scale only while the body
// is OnWalkable; otherwise it clears the displacement. Grounded
// remotes below are explicitly OnWalkable and airborne remotes use
// their authoritative velocity/gravity arc, so this is the same
// branch expressed through our retained runtime state.
System.Numerics.Vector3 scaledRootMotionLocalDelta = !rm.Airborne
? rootMotionLocalDelta * ae.Scale
: System.Numerics.Vector3.Zero;
// Step 1: re-apply current motion commands → body.Velocity.
// Forces OnWalkable + Contact so the gate in apply_current_movement
// always succeeds (remotes are server-authoritative; we don't
@ -293,8 +312,6 @@ internal sealed class RemotePhysicsUpdater
AcDream.Core.Physics.Motion.MotionDeltaFrame pmDelta;
if (!rm.Airborne)
{
System.Numerics.Vector3 seqVelNpc = ae.Sequencer?.CurrentVelocity
?? System.Numerics.Vector3.Zero;
float maxSpeedNpc = rm.Motion.GetMaxSpeed();
System.Numerics.Vector3? terrainNormalNpc =
_physicsEngine.SampleTerrainNormal(
@ -302,7 +319,7 @@ internal sealed class RemotePhysicsUpdater
System.Numerics.Vector3 offsetNpc = rm.Position.ComputeOffset(
dt: (double)dt,
currentBodyPosition: rm.Body.Position,
seqVel: seqVelNpc,
rootMotionLocalDelta: scaledRootMotionLocalDelta,
ori: rm.Body.Orientation,
interp: rm.Interp,
maxSpeed: maxSpeedNpc,
@ -324,8 +341,6 @@ internal sealed class RemotePhysicsUpdater
{
// No PositionManager host yet (pre-binding): apply the catch-up
// directly, matching Path A's fallback (:10202).
System.Numerics.Vector3 seqVelNpc = ae.Sequencer?.CurrentVelocity
?? System.Numerics.Vector3.Zero;
float maxSpeedNpc = rm.Motion.GetMaxSpeed();
System.Numerics.Vector3? terrainNormalNpc =
_physicsEngine.SampleTerrainNormal(
@ -333,7 +348,7 @@ internal sealed class RemotePhysicsUpdater
rm.Body.Position += rm.Position.ComputeOffset(
dt: (double)dt,
currentBodyPosition: rm.Body.Position,
seqVel: seqVelNpc,
rootMotionLocalDelta: scaledRootMotionLocalDelta,
ori: rm.Body.Orientation,
interp: rm.Interp,
maxSpeed: maxSpeedNpc,

View file

@ -303,6 +303,15 @@ public sealed class GameWindow : IDisposable
public float CurrFrame; // monotonically increasing within [LowFrame, HighFrame]
public AcDream.Core.Physics.AnimationSequencer? Sequencer;
// The local player's CPartArray is advanced from
// PlayerMovementController before its 30 Hz physics integration so
// the exact authored root delta enters the collision sweep. Retain
// that same advance's part pose for TickAnimations; advancing the
// sequencer a second time would double both animation time and hooks.
public IReadOnlyList<AcDream.Core.Physics.PartTransform>? PreparedSequenceFrames;
public bool SequenceAdvancedBeforeAnimationPass;
public readonly DatReaderWriter.Types.Frame RootMotionScratch = new();
/// <summary>
/// MP-Alloc (2026-07-05): reusable per-entity MeshRefs buffer. Every
/// animated entity (including idle NPCs on a breathe cycle) used to
@ -422,22 +431,18 @@ public sealed class GameWindow : IDisposable
_remoteLastMove = new();
/// <summary>
/// Per-remote-entity dead-reckoning state for smoothing between server
/// UpdatePosition broadcasts. Without this, remote characters teleport
/// every ~100200 ms when the server pushes a new position (the retail
/// client hides the gap by integrating <c>CMotionInterp</c>-surfaced
/// velocity forward each tick — see chunk_00520000.c
/// <c>apply_current_movement</c> L7132-L7189 and holtburger's
/// <c>spatial/physics.rs::project_pose_by_velocity</c>).
/// Per-remote-entity movement state for smoothing between server
/// UpdatePosition broadcasts. Retail queues each received position and
/// advances the body toward the queue head through
/// <c>InterpolationManager::adjust_offset</c> every object tick.
///
/// <para>
/// Each entry records the last authoritative server position + time + a
/// measured velocity inferred from the delta between consecutive
/// UpdatePositions. The client's per-tick integrator uses the
/// sequencer's <c>CurrentVelocity</c> (rotated into world space by the
/// entity's orientation) as the primary source and falls back to the
/// inferred velocity when the motion table doesn't carry one (e.g. NPC
/// motion tables with HasVelocity=0).
/// Each entry owns the retail interpolation queue, MovementManager,
/// PhysicsBody, latest authoritative position, and the velocity estimate
/// used only to select animation cycles for NPCs that receive no motion
/// messages. Body translation consumes queue catch-up plus the literal
/// root-motion Frame emitted by CSequence; it never guesses movement from
/// a Walk/Run command or from packet cadence.
/// </para>
/// </summary>
private readonly LiveEntityRemoteMotionRuntimeView<RemoteMotion> _remoteDeadReckon;
@ -684,13 +689,10 @@ public sealed class GameWindow : IDisposable
/// <summary>
/// Diagnostic-only (gated on <c>ACDREAM_REMOTE_VEL_DIAG=1</c>): the
/// previous UpdatePosition's world position + timestamp. The per-tick
/// path computes <c>(serverPos - prevServerPos) / dt</c> and compares
/// it to the sequencer's <c>CurrentVelocity</c>. The ratio tells us
/// whether the local-prediction speed (animation root motion) is
/// outrunning the server's actual broadcast pace, which would cause
/// the InterpolationManager queue to walk back the body each UP and
/// produce visible 1-Hz blips. Read in TickAnimations and throttled
/// to one log line per remote per ~2 seconds.
/// path compares the server's broadcast pace with the literal root
/// displacement emitted by <c>CSequence::update</c>. The old inferred
/// walk/run velocity was the source of the periodic overshoot this
/// diagnostic was introduced to find.
/// </summary>
public System.Numerics.Vector3 PrevServerPos;
public double PrevServerPosTime;
@ -713,12 +715,11 @@ public sealed class GameWindow : IDisposable
/// </summary>
public double LastPartsDiagLogTime;
/// <summary>
/// Diagnostic-only: max |sequencer.CurrentVelocity| observed across
/// all per-tick samples since the last UpdatePosition arrival. The
/// next UP compares this against (LastServerPos - PrevServerPos) /
/// dtServer to compute the overshoot ratio. Reset on each UP.
/// Diagnostic-only: maximum scaled CSequence root-motion speed
/// observed since the last UpdatePosition arrival. The next UP
/// compares it with the server's observed pace. Reset on each UP.
/// </summary>
public float MaxSeqSpeedSinceLastUP;
public float MaxRootMotionSpeedSinceLastUP;
public RemoteMotion(AcDream.Core.Physics.PhysicsBody? sharedBody = null)
{
@ -6476,8 +6477,8 @@ public sealed class GameWindow : IDisposable
// Diagnostic (ACDREAM_REMOTE_VEL_DIAG=1): roll the previous
// server-pos snapshot forward AND print the per-UP comparison
// between the max sequencer speed observed since last UP and
// the actual server broadcast pace. Both sides are now sampled
// between the max literal CSequence root-motion speed observed
// since the last UP and the actual server broadcast pace. Both are sampled
// over the same window so the ratio reflects real overshoot.
{
double nowSecDiag = (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds;
@ -6489,17 +6490,17 @@ public sealed class GameWindow : IDisposable
{
var serverDelta = worldPos - rmState.LastServerPos;
float serverSpeed = (float)(serverDelta.Length() / dtServer);
float seqSpeed = rmState.MaxSeqSpeedSinceLastUP;
if (serverSpeed > 0.1f || seqSpeed > 0.1f)
float rootMotionSpeed = rmState.MaxRootMotionSpeedSinceLastUP;
if (serverSpeed > 0.1f || rootMotionSpeed > 0.1f)
{
System.Console.WriteLine(
$"[VEL_DIAG] guid={update.Guid:X8} maxSeqSpeed={seqSpeed:F3} m/s "
$"[VEL_DIAG] guid={update.Guid:X8} maxRootMotionSpeed={rootMotionSpeed:F3} m/s "
+ $"serverSpeed={serverSpeed:F3} m/s dtServer={dtServer:F3}s "
+ $"ratio={(serverSpeed > 1e-3f ? seqSpeed / serverSpeed : 0f):F3}");
+ $"ratio={(serverSpeed > 1e-3f ? rootMotionSpeed / serverSpeed : 0f):F3}");
}
}
}
rmState.MaxSeqSpeedSinceLastUP = 0f;
rmState.MaxRootMotionSpeedSinceLastUP = 0f;
rmState.PrevServerPos = rmState.LastServerPos;
rmState.PrevServerPosTime = rmState.LastServerPosTime;
rmState.LastServerPos = worldPos;
@ -9485,6 +9486,11 @@ public sealed class GameWindow : IDisposable
{
_frameProfiler.FrameBoundary(_gl!);
// gmSmartBoxUI::UseTime @ 0x004D6E30 swaps visibility between the
// normal SmartBox viewport and UIElement_Viewport's portal
// CreatureMode; it does not redraw the world behind portal space.
bool portalViewportVisible = _portalTunnel?.IsVisible == true;
// Phase G.1: set the clear color from the current sky's fog
// tint so the horizon band continues naturally past the
// rendered geometry. Fog blends to this color at max distance
@ -9500,11 +9506,22 @@ public sealed class GameWindow : IDisposable
// since keyframes may pre-multiply DirBright and produce over-1
// values that some drivers interpret as "bright clamp" (pink/green
// frames).
_gl!.ClearColor(
System.Math.Clamp(fogColor.X, 0f, 1f),
System.Math.Clamp(fogColor.Y, 0f, 1f),
System.Math.Clamp(fogColor.Z, 0f, 1f),
1f);
if (portalViewportVisible)
{
// SceneTool::BeginScene @ 0x0043DAD0 begins every retail frame
// with RenderDevice.Clear(7, black, 1). The portal viewport later
// clears depth only, preserving this frame's black target—not a
// previous swap-chain image or the hidden destination world.
_gl!.ClearColor(0f, 0f, 0f, 1f);
}
else
{
_gl!.ClearColor(
System.Math.Clamp(fogColor.X, 0f, 1f),
System.Math.Clamp(fogColor.Y, 0f, 1f),
System.Math.Clamp(fogColor.Z, 0f, 1f),
1f);
}
// §4 outdoor full-world flap (2026-06-10): the depth clear DEPENDS on the depth
// write mask — glClear(GL_DEPTH_BUFFER_BIT) is silently gated by glDepthMask.
@ -9514,7 +9531,7 @@ public sealed class GameWindow : IDisposable
// feedback_render_self_contained_gl_state: the clear site asserts the state it
// depends on rather than inheriting it. The [gl-state] tripwire still detects
// any future leak.
_gl.DepthMask(true);
_gl!.DepthMask(true);
_gl!.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit | ClearBufferMask.StencilBufferBit);
// WB GameScene.cs:830-843 establishes CW as the frame-global
@ -9582,7 +9599,7 @@ public sealed class GameWindow : IDisposable
int visibleLandblocks = 0;
int totalLandblocks = 0;
if (_cameraController is not null)
if (_cameraController is not null && !portalViewportVisible)
{
var activeCamera = _cameraController.Active;
var camera = _teleportViewPlane.ApplyTo(activeCamera);
@ -10492,7 +10509,8 @@ public sealed class GameWindow : IDisposable
}
// Retail gmSmartBoxUI swaps the world SmartBox viewport for a
// CreatureMode portal-space viewport. This replacement scene and
// CreatureMode portal-space viewport. The world block above is skipped
// while this replacement scene is visible. This scene and
// its projection transition draws BEFORE retained UI, so chat, windows,
// cursor, and toolbar remain visible and interactive in transit.
var portalProjection = _teleportViewPlane.Apply(
@ -10736,6 +10754,31 @@ public sealed class GameWindow : IDisposable
/// entities (no AnimatedEntity record) are untouched. The static
/// renderer reads the new MeshRefs on the next Draw call.
/// </summary>
private System.Numerics.Vector3 AdvanceLocalPlayerAnimationRoot(float dt)
{
if (!_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var entity)
|| !_animatedEntities.TryGetValue(entity.Id, out var ae)
|| ae.Sequencer is not { } sequencer
|| _liveEntities?.ShouldAdvanceRootRuntime(_playerServerGuid) == false
|| _liveEntities?.IsHidden(_playerServerGuid) == true)
{
return System.Numerics.Vector3.Zero;
}
EnsureMotionDoneBinding(_playerServerGuid, ae);
DatReaderWriter.Types.Frame rootFrame = ae.RootMotionScratch;
rootFrame.Origin = System.Numerics.Vector3.Zero;
rootFrame.Orientation = System.Numerics.Quaternion.Identity;
ae.PreparedSequenceFrames = sequencer.Advance(dt, rootFrame);
ae.SequenceAdvancedBeforeAnimationPass = true;
// TickAnimations will publish this advance's final part transforms
// before the deferred hook frame is drained.
_animationHookFrames?.Capture(ae.Entity.Id, sequencer);
return rootFrame.Origin;
}
private void TickAnimations(float dt)
{
// Retail has NO stop-detection heuristic — it relies on the
@ -10752,6 +10795,12 @@ public sealed class GameWindow : IDisposable
foreach (var kv in _animatedEntities)
{
var ae = kv.Value;
bool sequenceAdvancedBeforeAnimationPass =
ae.SequenceAdvancedBeforeAnimationPass;
IReadOnlyList<AcDream.Core.Physics.PartTransform>? preparedSequenceFrames =
ae.PreparedSequenceFrames;
ae.SequenceAdvancedBeforeAnimationPass = false;
ae.PreparedSequenceFrames = null;
// The server guid is carried on the entity itself
// (WorldEntity.ServerGuid, set == the _entitiesByServerGuid key at
@ -10790,21 +10839,19 @@ public sealed class GameWindow : IDisposable
bool hidden = serverGuid != 0
&& _liveEntities?.IsHidden(serverGuid) == true;
// ── Dead-reckoning: smooth position between UpdatePosition bursts.
// The server broadcasts UpdatePosition at ~5-10Hz for distant
// entities; without integration, remote chars jitter-hop between
// samples. Each tick we advance entity.Position by the
// sequencer's current velocity (rotated into world space by the
// entity's facing) — matching the retail client's
// apply_current_movement (chunk_00520000.c L7132-L7189) and
// holtburger's project_pose_by_velocity.
//
// The cap on predict-distance from the last server pos prevents
// runaway when the sequencer's velocity and the server's reality
// disagree (e.g. server is rubber-banding the entity). Retail
// uses a similar clamp at PhysicsObj::IsInterpolationComplete.
// CPhysicsObj::UpdatePositionInternal @ 0x00512C30 advances the
// CPartArray FIRST into a local root-motion Frame, then lets the
// PositionManager replace that Frame with interpolation catch-up.
// Advance remote sequences here so the physics owner consumes the
// literal CSequence delta; reconstructing a velocity from the
// interpreted Walk/Run command made bodies run past the queue head
// and snap backward on the next server position.
IReadOnlyList<AcDream.Core.Physics.PartTransform>? seqFrames =
sequenceAdvancedBeforeAnimationPass ? preparedSequenceFrames : null;
bool sequencerAdvancedForObject = sequenceAdvancedBeforeAnimationPass;
if (!hidden
&& ae.Sequencer is not null
&& !sequencerAdvancedForObject
&& serverGuid != 0
&& serverGuid != _playerServerGuid
&& _remoteDeadReckon.TryGetValue(serverGuid, out var rm)
@ -10822,11 +10869,32 @@ public sealed class GameWindow : IDisposable
// TickAnimations advance.
&& rm.LastServerPosTime > 0)
{
_remotePhysicsUpdater.Tick(rm, ae, dt, _liveCenterX, _liveCenterY);
var remoteRootMotionFrame = new DatReaderWriter.Types.Frame
{
Origin = System.Numerics.Vector3.Zero,
Orientation = System.Numerics.Quaternion.Identity,
};
seqFrames = ae.Sequencer.Advance(dt, remoteRootMotionFrame);
_animationHookFrames?.Capture(ae.Entity.Id, ae.Sequencer);
sequencerAdvancedForObject = true;
if (dt > 0f)
{
float rootMotionSpeed = remoteRootMotionFrame.Origin.Length()
* ae.Scale / dt;
rm.MaxRootMotionSpeedSinceLastUP = MathF.Max(
rm.MaxRootMotionSpeedSinceLastUP,
rootMotionSpeed);
}
_remotePhysicsUpdater.Tick(
rm,
ae,
dt,
remoteRootMotionFrame.Origin,
_liveCenterX,
_liveCenterY);
}
// ── Get per-part (origin, orientation) from either sequencer or legacy ──
IReadOnlyList<AcDream.Core.Physics.PartTransform>? seqFrames = null;
if (ae.Sequencer is not null)
{
// Per-tick sequencer-state diag: prove whether the sequencer
@ -10869,18 +10937,21 @@ public sealed class GameWindow : IDisposable
rmDiag.LastSeqStateLogTime = nowSec;
}
}
seqFrames = hidden
? ae.Sequencer.SampleCurrentPose()
: ae.Sequencer.Advance(dt);
if (!sequencerAdvancedForObject)
{
seqFrames = hidden
? ae.Sequencer.SampleCurrentPose()
: ae.Sequencer.Advance(dt);
// Capture hooks now, but deliver them only after every final
// part transform and equipped-child root has been published.
// This matches CPhysicsObj::UpdateObjectInternal followed by
// CPhysicsObj::UpdateChild (0x00512D50): a hand/weapon effect
// reads this frame's pose, never the previous frame's pose.
// AnimationDone/UseTime remain paired with the deferred drain.
if (!hidden)
_animationHookFrames?.Capture(ae.Entity.Id, ae.Sequencer);
// Capture hooks now, but deliver them only after every final
// part transform and equipped-child root has been published.
// This matches CPhysicsObj::UpdateObjectInternal followed by
// CPhysicsObj::UpdateChild (0x00512D50): a hand/weapon effect
// reads this frame's pose, never the previous frame's pose.
// AnimationDone/UseTime remain paired with the deferred drain.
if (!hidden)
_animationHookFrames?.Capture(ae.Entity.Id, ae.Sequencer);
}
}
else
{
@ -14231,6 +14302,8 @@ public sealed class GameWindow : IDisposable
&& playerAE.Sequencer is { } playerSeq)
{
_playerController.AttachCycleVelocityAccessor(() => playerSeq.CurrentVelocity);
_playerController.ObjectScale = playerAE.Scale;
_playerController.AttachAnimationRootMotionSource(AdvanceLocalPlayerAnimationRoot);
// R3-W4: bind the player interp's retail seams to the player
// sequencer — LeaveGround/HitGround strip transition links here
// (the K-fix18 replacement).

View file

@ -15,14 +15,18 @@ public sealed class PortalTunnelCamera : ICamera
public float DirectionDegrees { get; set; }
public float FovRadians { get; set; } = MathF.PI / 4f;
public float Near { get; set; } = 0.1f;
public float Far { get; set; } = 50f;
// Render::zfar defaults to 4000 in retail (0x0081EC88). CreatureMode::Render
// does not install a private far plane: PrimD3DRender::SetFOVInternal
// (0x0059AB40) rebuilds the portal projection with that shared value.
public float Far { get; set; } = 4000f;
public float Aspect { get; set; } = 1f;
/// <summary>
/// Retail <c>CreatureMode::UseSmartboxFOV</c> reads the active SmartBox
/// projection each draw. Recover its vertical field of view and near
/// plane from that perspective matrix while retaining this private
/// scene's bounded far plane.
/// and far planes from that perspective matrix. Retail's CreatureMode
/// changes the FOV through the SmartBox path while retaining the renderer's
/// shared near/far clip range.
/// </summary>
public void UseSmartBoxFov(Matrix4x4 smartBoxProjection)
{
@ -40,6 +44,11 @@ public sealed class PortalTunnelCamera : ICamera
float near = smartBoxProjection.M33 != 0f
? smartBoxProjection.M43 / smartBoxProjection.M33
: float.NaN;
float far = smartBoxProjection.M33 != -1f
? smartBoxProjection.M43 / (smartBoxProjection.M33 + 1f)
: float.NaN;
if (float.IsFinite(far) && far > 0f)
Far = far;
if (float.IsFinite(near) && near > 0f && near < Far)
Near = near;
}

View file

@ -27,6 +27,13 @@ public sealed class PortalTunnelPresentation : IDisposable
public const uint AnimationClientEnum = 0x10000002u;
public const uint ClientEnumCategory = 7u;
// UIViewportObject::DrawContent @ 0x006950A5 calls
// RenderDeviceD3D::Clear(4, RGBAColor_Black, 1). Clear @ 0x0059FD30
// maps retail flag 4 to D3DCLEAR_ZBUFFER only: portal space deliberately
// preserves the black color target established by SceneTool::BeginScene's
// whole-frame Clear(7) while replacing the hidden world viewport.
internal const ClearBufferMask RetailViewportClearMask = ClearBufferMask.DepthBufferBit;
private const uint SyntheticEntityId = 0xFFFF_FF01u;
private const uint SyntheticLandblockId = 0u;
private const float RotationDurationMin = 0.6f;
@ -222,8 +229,9 @@ public sealed class PortalTunnelPresentation : IDisposable
}
/// <summary>
/// Replace the already-rendered world viewport with retail portal space.
/// The caller then draws retained UI above this pass.
/// Draw retail portal space into the active viewport. The caller suppresses
/// the normal world viewport while this scene is visible, then draws the
/// retained UI above it.
/// </summary>
public void Draw(int width, int height, Matrix4x4 smartBoxProjection)
{
@ -236,10 +244,9 @@ public sealed class PortalTunnelPresentation : IDisposable
_gl.Viewport(0, 0, (uint)width, (uint)height);
_gl.Disable(EnableCap.ScissorTest);
_gl.ClearColor(0f, 0f, 0f, 1f);
_gl.ClearDepth(1.0);
_gl.DepthMask(true);
_gl.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit);
_gl.Clear(RetailViewportClearMask);
_gl.Enable(EnableCap.DepthTest);
_gl.DepthFunc(DepthFunction.Less);

View file

@ -0,0 +1,52 @@
using System;
using System.Numerics;
namespace AcDream.App.Rendering.Sky;
/// <summary>
/// Builds the temporary sky projection used by retail <c>GameSky::Draw</c>
/// (<c>0x00506FF0</c>): preserve the active viewport's field of view and
/// projection handedness, changing only its near/far depth mapping.
/// </summary>
internal static class SkyProjection
{
public static Matrix4x4 WithDepthRange(
in Matrix4x4 activeProjection,
float near,
float far)
{
if (!float.IsFinite(near) || !float.IsFinite(far)
|| near <= 0f || far <= near)
{
throw new ArgumentOutOfRangeException(
nameof(far),
"Sky depth range must be finite with 0 < near < far.");
}
var result = activeProjection;
// System.Numerics perspective matrices use M34=-1 (right-handed).
// Keep the source matrix's X/Y scale and offsets verbatim so a
// teleport projection remains pixel-aligned with terrain. The
// positive branch also preserves a left-handed source should one be
// supplied by a future backend.
if (activeProjection.M34 < 0f)
{
result.M33 = far / (near - far);
result.M43 = near * far / (near - far);
}
else if (activeProjection.M34 > 0f)
{
result.M33 = far / (far - near);
result.M43 = -near * far / (far - near);
}
else
{
throw new ArgumentException(
"Sky projection must be perspective (M34 cannot be zero).",
nameof(activeProjection));
}
return result;
}
}

View file

@ -162,13 +162,13 @@ public sealed unsafe class SkyRenderer : IDisposable
{
if (group is null || group.SkyObjects.Count == 0) return;
// Build a sky projection with a huge far plane so 1e6m-distant
// celestial meshes don't clip. The FOV is cargo-culted from the
// camera's projection — see WorldBuilder's implementation.
float fovY = MathF.PI / 3f; // 60° — matches FlyCamera/ChaseCamera
float aspect = camera.Aspect;
if (aspect <= 0f) aspect = 16f / 9f;
var skyProj = Matrix4x4.CreatePerspectiveFieldOfView(fovY, aspect, Near, Far);
// Keep sky meshes inside their authored depth range without
// replacing the active viewport's horizontal/vertical projection.
// Retail installs SmartBox's active projection before LScape::draw
// (SmartBox::RenderNormalMode @ 0x00453AA0), then GameSky::Draw
// (@ 0x00506FF0) changes ONLY zfar to 4x for the sky draw. Preserve
// that FOV here, including the near-180-degree teleport transition.
var skyProj = SkyProjection.WithDepthRange(camera.Projection, Near, Far);
// View with translation zeroed — keeps the sky at camera origin
// regardless of camera position in the world.

View file

@ -215,7 +215,12 @@ public sealed class RetailUiRuntime : IDisposable
probe,
bindings.Probe.ScriptPath,
bindings.Probe.DumpOnStart,
bindings.Probe.Log);
bindings.Probe.Log,
text => ChatCommandRouter.Submit(
text,
bindings.Chat.ViewModel,
bindings.Chat.CommandBus(),
ChatChannelKind.Say));
}
}

View file

@ -16,6 +16,7 @@ public sealed class RetailUiAutomationScriptRunner
{
private readonly RetailUiAutomationProbe _probe;
private readonly Action<string> _log;
private readonly Action<string>? _submitCommand;
private readonly List<ScriptCommand> _commands = new();
private readonly bool _dumpOnStart;
private readonly string? _loadError;
@ -30,10 +31,12 @@ public sealed class RetailUiAutomationScriptRunner
RetailUiAutomationProbe probe,
string? scriptPath,
bool dumpOnStart,
Action<string>? log = null)
Action<string>? log = null,
Action<string>? submitCommand = null)
{
_probe = probe ?? throw new ArgumentNullException(nameof(probe));
_log = log ?? (_ => { });
_submitCommand = submitCommand;
_dumpOnStart = dumpOnStart;
if (!string.IsNullOrWhiteSpace(scriptPath))
@ -125,6 +128,7 @@ public sealed class RetailUiAutomationScriptRunner
"wait" => DoWait(command),
"sleep" => DoSleep(command),
"assert" => DoAssert(command),
"command" => DoCommand(command),
_ => Stop(command, $"unknown command '{p[0]}'"),
};
}
@ -264,6 +268,18 @@ public sealed class RetailUiAutomationScriptRunner
return result.Success || Stop(command, result.Message);
}
private bool DoCommand(ScriptCommand command)
{
string text = command.Text[command.Parts[0].Length..].Trim();
if (text.Length == 0)
return Stop(command, "usage: command <chat-or-client-command>");
if (_submitCommand is null)
return Stop(command, "command submission is unavailable");
_submitCommand(text);
return true;
}
private bool WaitOrTimeout(ScriptCommand command, int timeoutMs, string label)
{
if (_elapsedMs - _commandStartMs <= timeoutMs) return false;