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

@ -232,6 +232,100 @@ destination world at this projection, then `WorldFadeIn` expands the world
from the center as the captured game projection is restored. This is retail's
characteristic destination-world warp, independent of chase-camera movement.
`CreatureMode::Render @ 0x004529D0` changes the portal viewport's FOV through
the SmartBox path but does not install a private far clip. The resulting
`PrimD3DRender::SetFOVInternal @ 0x0059AB40` projection uses the shared
`Render::zfar` (default `4000` at `0x0081EC88`). The portal CreatureMode must
therefore inherit the active SmartBox projection's near and far planes rather
than installing a private short clip range.
### Portal viewport buffer lifecycle
The portal tunnel is rendered by `UIViewportObject::DrawContent @ 0x00695030`.
Before calling `CreatureMode::Render`, it executes:
```text
SetViewport(portal bounds)
RenderDevice.Clear(flags = 4, color = black, depth = 1.0)
CreatureMode.Render()
restore previous viewport and matrices
```
`RenderDeviceD3D::Clear @ 0x0059FD30` maps retail clear flags explicitly:
```text
flag 1 -> D3DCLEAR_TARGET
flag 2 -> D3DCLEAR_STENCIL, when a stencil buffer exists
flag 4 -> D3DCLEAR_ZBUFFER
```
Therefore the portal viewport clears **depth only**. The black color argument
is not used for flag 4. This does not preserve an earlier swap-chain image:
`Client::UseTime @ 0x00411C40` begins the complete frame through
`SceneTool::BeginScene @ 0x0043DAD0`, whose `RenderDevice.Clear(flags=7,
black, depth=1)` clears color, depth, and stencil. The later viewport clear
retains that frame's fresh black color target while resetting depth for the
portal CreatureMode.
`gmSmartBoxUI::UseTime @ 0x004D6E30` swaps visibility between the normal
SmartBox and the portal `UIElement_Viewport`. The world viewport is therefore
not redrawn behind portal space; the portal mesh is drawn over the whole-frame
black target, never over the destination world or stale tunnel history.
The faithful presentation rule is:
```text
at the beginning of every frame:
clear color, depth, and stencil to black
while the portal viewport is visible:
do not draw the normal world viewport
clear depth to 1.0
draw portal CreatureMode over this frame's black color target
```
One high-refresh presentation edge remains. `UIGlobals::GetAnimLevel` yields
1022 at table index 96, 1023 at index 97, and 1024 at indices 98 and 99. The
2013 client capture presents 1022 as the last outgoing viewport sample and
does not present the finite tunnel at 1023/1024 before switching viewports.
With the world pass suppressed, acdream runs portal space at roughly 2000 FPS
and can publish those sub-20.2 ms samples; the desktop compositor may then
hold one for a complete monitor refresh and expose the finite tunnel boundary.
The frame-rate-independent presentation adaptation is therefore:
```text
if state is WorldFadeOut or TunnelFadeOut
and GetAnimLevel(elapsed / FadeTime) > 1022:
perform the normal state transition before publishing the draw snapshot
```
This advances only the outgoing presentation edge by at most two table quanta
(about 20.2 ms) and is recorded as AD-38. Incoming fades retain the literal
timer.
### Shared sky and landscape projection during destination reveal
`SmartBox::RenderNormalMode @ 0x00453AA0` installs the active SmartBox
view-plane/FOV through `Render::set_vdst` or `Render::SetFOVRad` before calling
`LScape::draw @ 0x00506330`. `GameSky::Draw @ 0x00506FF0` then saves
`Render::zfar`, sets it to four times the active value for the sky draw, and
restores it. It does **not** install an independent sky FOV.
The destination world is first revealed at a nearly 180-degree projection.
Sky and landscape must therefore preserve the same horizontal/vertical
projection scales and off-center terms. Only the sky's near/far depth mapping
may differ. acdream's former fixed 60-degree sky projection left part of the
frame covered only by the clear/fog background while terrain used the teleport
projection; this was the brief background flash at tunnel exit.
```text
activeProjection = SmartBox view-plane projection
skyProjection = activeProjection
skyProjection.depthRange = authored sky near/far range
draw sky
draw landscape with activeProjection
```
Instruction-level checks that disambiguate the Binary Ninja x87 output:
- `SmartBox::GetOverrideFovDistance` `0x00451BE0`: when no override is active,

View file

@ -0,0 +1,84 @@
# Retail combat-style ownership in outbound movement
## Symptom
The local acdream player remains visually in combat while moving, but a retail
observer sees the player leave the combat stance and locomote in NonCombat.
## Named-retail oracle
`CommandInterpreter::SendMovementEvent @ 0x006B4680` does not construct a new
axis-only motion object. It obtains the player's canonical raw state and passes
that exact object to the packet constructor:
```text
player = CommandInterpreter.player
raw = player.InqRawMotionState()
if player != null and smartbox != null and raw != null and autonomy_level != 0:
packet = MoveToStatePack(
raw,
player.position,
player is Contact+OnWalkable,
player.minterp.standing_longjump,
player movement timestamps)
SendMoveToStateEvent(packet)
```
`CPhysicsObj::InqRawMotionState @ 0x0050FDE0` returns
`MovementManager::InqRawMotionState`; stance and movement axes therefore have
one canonical owner.
`RawMotionState::Pack @ 0x0051ED10` compares `current_style` with the retail
default `MotionStance_NonCombat` (`0x8000003D`):
```text
flags.bit1 = current_style != NonCombat
write flags
if flags.bit0: write current_holdkey
if flags.bit1: write current_style as uint32
if flags.bit2: write forward_command
... write the remaining axes and action queue ...
```
`RawMotionState::UnPack @ 0x0051EFC0` restores NonCombat when bit 1 is absent.
Omitting the bit is therefore an explicit NonCombat value, not “preserve the
receiver's previous stance.”
## Reference cross-checks
- ACE `Network/Motion/RawMotionState.cs` reads `RawMotionFlags.CurrentStyle`
(`0x2`) as a 32-bit stance.
- ACE `Network/Motion/MovementData.cs` copies that field into the interpreted
state relayed to observers only when the flag is present.
- acclientlib `UtilityBelt.Common/DataTypes.cs` independently documents that an
absent raw `CurrentStyle` defaults to `0x3D` NonCombat.
Thus an axis-only acdream packet necessarily makes ACE/retail reconstruct
NonCombat movement.
## acdream root cause and port
`PlayerMovementController` already owns retail's canonical
`MotionInterpreter.RawState`, and inbound server stance changes correctly
update `RawState.CurrentStyle`. However, `MovementResult` omitted that field and
`LocalPlayerOutboundController.BuildRawMotionState` rebuilt only the hold key
and three axes. Its new `RawMotionState` retained the constructor default
NonCombat, so `RawMotionStatePacker` correctly omitted the CurrentStyle bit.
Port the missing canonical field through the existing immutable frame result:
```text
MovementResult.CurrentStyle = MotionInterpreter.RawState.CurrentStyle
BuildRawMotionState(result):
raw.CurrentStyle = result.CurrentStyle
raw.CurrentHoldKey = result current hold level
raw.forward/side/turn = result axes
return raw
```
This preserves the existing update-thread ownership and packet scheduler. It
does not infer a stance from `CombatMode` or equipped items; the raw motion
state remains the oracle, exactly as retail. The still-unwired raw action queue
is separate and remains tracked by divergence TS-24.

View file

@ -0,0 +1,95 @@
# Local-player root-motion start — retail pseudocode (2026-07-17)
## Symptom
A short forward-key tap moved the local acdream body immediately while the
Ready-to-locomotion animation was still starting. The character therefore
glided before its legs began the corresponding movement.
## Retail oracle
Named Sept-2013 client:
- `CMotionInterp::DoInterpretedMotion @ 0x00528360`
- `CMotionInterp::apply_interpreted_movement @ 0x00528600`
- `CMotionInterp::apply_current_movement @ 0x00528870`
- `CMotionInterp::get_state_velocity @ 0x00527D50`
- `CPhysicsObj::UpdatePositionInternal @ 0x00512C30`
- `CPhysicsObj::UpdateObjectInternal @ 0x005156B0`
- `CPhysicsObj::get_velocity @ 0x005113C0`
Cross-checks:
- `docs/research/2026-07-02-inbound-motion-maps/map-ace-port.md`, which records
ACE's `PartArray.Update` into the shared offset frame before
`PositionManager.AdjustOffset`.
- `docs/research/2026-07-02-r3-motioninterp/r3-ace-motioninterp.md`, which
independently shows `apply_interpreted_movement` dispatching motions rather
than installing ordinary grounded velocity.
## Pseudocode
```text
on input motion edge(command, params):
CMotionInterp updates raw/interpreted state
apply_interpreted_movement dispatches style, forward, sidestep, and turn
CPhysicsObj::DoInterpretedMotion forwards those operations to MotionTableManager
// No ordinary grounded set_local_velocity occurs here.
CPhysicsObj::UpdatePositionInternal(dt):
offset = identity Frame
if visible and part_array exists:
part_array.Update(dt, offset)
if OnWalkable:
offset.origin *= object_scale
else:
offset.origin = zero
position_manager.adjust_offset(offset, dt)
candidate = current_position.frame combined with offset
candidate.origin += m_velocityVector * dt + acceleration * dt^2 / 2
m_velocityVector += acceleration * dt
return candidate
CPhysicsObj::UpdateObjectInternal(dt):
old = current_position
candidate = UpdatePositionInternal(dt)
transition = sweep(old, candidate)
if transition succeeds:
cached_velocity = offset(old, resolved_position) / dt
SetPositionInternal(transition)
else:
cached_velocity = zero
```
`get_state_velocity` is not the ordinary grounded locomotion driver. Retail
uses it for leave-ground/jump velocity and related state queries. Grounded
translation comes from the complete `CSequence::update` Frame, including the
authored Ready-to-Walk/Run link timing. `CPhysicsObj::get_velocity` returns the
resolved `cached_velocity`, distinct from the integrator's
`m_velocityVector`.
## Port shape
1. The local player's input edge must reach `MotionTableManager` before the
current object's animation advance.
2. `AnimationSequencer.Advance(dt, rootFrame)` runs exactly once for the local
owner and publishes both the pose and `rootFrame.Origin`.
3. `PlayerMovementController` accumulates that local displacement until the
existing 30 Hz physics quantum runs.
4. While grounded, scale it by the server object scale and seed the same
`MotionDeltaFrame` passed through `PositionManager.AdjustOffset`.
5. The transition sweep resolves the combined delta and computes cached
velocity from the realized displacement.
6. Do not install `get_state_velocity()` as ordinary grounded body velocity
when a retail root-motion source is attached. Keep that function for
leave-ground/jump behavior.
7. The animation pass consumes the already-advanced pose instead of advancing
the local sequence a second time.
The existing manual yaw path remains separate; this slice consumes the root
Frame's translation only. Full root-orientation ownership remains part of the
registered turn/omega divergence and is not guessed here.

View file

@ -0,0 +1,115 @@
# Remote root-motion reconciliation — retail pseudocode
**Date:** 2026-07-17
**Symptom:** an observed character walks forward, blips backward, then repeats.
**Oracle:** Sept 2013 named retail decomp, installed retail DAT, ACE physics port.
## Named retail path
### `CPhysicsObj::UpdatePositionInternal``0x00512C30`
```text
offset = identity Frame
if object is visible:
if partArray exists:
partArray.Update(dt, offset)
// CPartArray::Update 0x00517DB0 calls
// CSequence::update(sequence, dt, offset)
if OnWalkable:
offset.origin *= objectScale
else:
offset.origin = zero
if positionManager exists:
positionManager.adjust_offset(offset, dt)
// InterpolationManager may REPLACE offset.origin with the
// queue-head catch-up delta.
newFrame = currentWorldFrame combined with offset
if object is visible:
UpdatePhysicsInternal(dt, newFrame)
process_hooks()
```
### `add_motion``0x005224B0`
```text
if motionData exists:
sequence.velocity = motionData.velocity * speed
sequence.omega = motionData.omega * speed
append every authored AnimData * speed
```
There is no substitution of `CMotionInterp::get_state_velocity` constants into
`CSequence::velocity`. Those constants drive body physics in the interpreted
movement path; they are not animation-root data.
### `InterpolationManager::adjust_offset``0x00555D30`
```text
if queue empty, no physics object, or object lacks Contact:
leave offset unchanged
if queue head is within 0.05 m:
complete the node
leave offset unchanged
maxSpeed = minterp.adjustedMaxSpeed * 2
if maxSpeed is effectively zero:
maxSpeed = 7.5 m/s
offset.origin = directionToHead * min(maxSpeed * dt, distanceToHead)
```
The queue catch-up replaces the CSequence displacement while active. Once the
head is reached, the literal CSequence displacement remains.
## Installed-DAT result
`client_portal.dat` MotionTable `0x09000001` (Humanoid), NonCombat style
`0x8000003D`:
- WalkForward `0x40000005`: `MotionData.Velocity == (0,0,0)`
- RunForward `0x40000007`: `MotionData.Velocity == (0,0,0)`
Pinned by `HumanoidMotionTableRootMotionTests`.
## acdream correction
Previous behavior synthesized `WalkAnimSpeed × speed` or
`RunAnimSpeed × speed` into `CSequence.Velocity`, then used that guessed value
after the interpolation queue reached its latest waypoint. The body could run
past server truth; the next UpdatePosition queued a backward correction,
creating the repeating blip.
Correct frame order:
```text
rootDelta = identity Frame
partTransforms = sequencer.Advance(dt, rootDelta)
worldDelta = RemoteMotionCombiner(
rootDelta.origin,
interpolationQueue,
positionManager)
sweep and commit worldDelta
publish root and part transforms
```
The client now consumes the complete root delta produced by the already-ported
`CSequence` (authored PosFrames plus literal MotionData velocity). It does not
derive remote translation from a command name or packet cadence.
## Cross-reference
- ACE `PhysicsObj.UpdatePositionInternal`: PartArray update → PositionManager
adjustment → world-frame combine, matching retail ordering.
- ACE `InterpolationManager.adjust_offset`: queue correction replaces the
supplied frame origin and uses the same 0.05 m completion radius and 2× speed.
- Earlier live retail cdb trace:
`docs/research/2026-05-02-remote-entity-motion/resolved-via-cdb.md` — walking
remotes use queued position catch-up; their body velocity is not synthesized
from UpdatePosition.