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:
parent
e95f55f25b
commit
124e046976
30 changed files with 1362 additions and 348 deletions
|
|
@ -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 ~100–200 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).
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue