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
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue