Port CPhysicsObj::set_hidden's PartArray HandleEnterWorld boundary on both Hidden and UnHide. This strips linked recall/casting animations and aborts their pending completions before cell visibility changes, preventing spell-recall tails after portal travel without arrival resets or action classifiers.\n\nCo-authored-by: OpenAI Codex <codex@openai.com>
400 lines
16 KiB
Markdown
400 lines
16 KiB
Markdown
# Retail portal-space presentation pseudocode
|
|
|
|
Date: 2026-07-15
|
|
|
|
Scope: the client-side 3-D scene and timing shown while a player is between
|
|
world positions. This is presentation only. The server remains authoritative
|
|
for teleport start, destination position, world readiness, and completion.
|
|
|
|
## Retail oracle
|
|
|
|
Named Sept-2013 retail symbols:
|
|
|
|
- `gmSmartBoxUI::BeginTeleportAnimation` `0x004D6300`
|
|
- `gmSmartBoxUI::EndTeleportAnimation` `0x004D65A0`
|
|
- `gmSmartBoxUI::PostInit` `0x004D6B60`
|
|
- `gmSmartBoxUI::UseTime` `0x004D6E30`
|
|
- `CreatureMode::CreatureMode` `0x00454390`
|
|
- `CreatureMode::SetCameraDirection_Degrees` `0x00453760`
|
|
- `Frame::rotate` `0x004525B0`
|
|
- `CreatureMode::AddObject` `0x004556D0`
|
|
- `UIGlobals::Init` `0x004EE470`
|
|
- `UIGlobals::GetAnimLevel` `0x004EE540`
|
|
- `CPhysicsObj::set_sequence_animation` `0x0050F6F0`
|
|
- `SmartBox::TeleportPlayer` `0x00453910`
|
|
- `SmartBox::PlayerPositionUpdated` `0x00453870`
|
|
- `CPhysicsObj::teleport_hook` `0x00514ED0`
|
|
- `CommandInterpreter::PlayerTeleported` `0x006B32B0`
|
|
- `SmartBox::SetOverrideFovDistance` `0x00451BC0`
|
|
- `SmartBox::GetOverrideFovDistance` `0x00451BE0`
|
|
- `Render::set_vdst` `0x0054B240`
|
|
|
|
Constants were checked against static x86 disassembly. The Binary Ninja
|
|
pseudocode loses several x87 operands in `UseTime`; disassembly is authoritative
|
|
for those comparisons.
|
|
|
|
## Asset construction (`gmSmartBoxUI::PostInit`)
|
|
|
|
```text
|
|
portalSetupDid = ResolveClientEnum(enumValue=0x10000001, category=7)
|
|
teleportObj = CPhysicsObj.makeObject(portalSetupDid, noWeenie, makeParts)
|
|
|
|
portalViewport = child 0x10000436 as UIElement_Viewport
|
|
portalViewport.CreatureMode.AddObject(teleportObj)
|
|
portalViewport.CreatureMode.AddLight(DISTANT_LIGHT, intensity=2.0)
|
|
portalViewport.CreatureMode.SetLightDirection(0, (0.3, -1.9, 0.65))
|
|
portalViewport.CreatureMode.SetCameraPosition((0.24, -2.7, 0.88))
|
|
portalViewport.CreatureMode.UseSmartboxFOV()
|
|
```
|
|
|
|
`CreatureMode` starts with ambient light `(0.3, 0.3, 0.3)` and a 45-degree
|
|
default FOV, but `UseSmartboxFOV` makes this viewport inherit the active world
|
|
view's current FOV on every draw. Its identity camera frame looks along AC
|
|
`+Y`, with `+Z` up.
|
|
The portal viewport is a replacement for the world viewport. It is not a
|
|
full-screen UI overlay: normal retained UI continues to update and draw above
|
|
it.
|
|
|
|
## Begin/end and scene visibility
|
|
|
|
```text
|
|
BeginTeleportAnimation(requestedState):
|
|
if currentState == Off:
|
|
gameViewDistance = SmartBox.GetOverrideFovDistance()
|
|
currentViewDistance = gameViewDistance
|
|
clear rotation-segment state
|
|
currentState = requestedState
|
|
transitionStart = now
|
|
play centered UI sound Sound_UI_EnterPortal
|
|
|
|
EndTeleportAnimation():
|
|
if currentState != Off:
|
|
currentState = TunnelContinue
|
|
transitionStart = now
|
|
SmartBox.SetOverrideFovDistance(disabled)
|
|
```
|
|
|
|
Portal/login/death transit begins directly in `Tunnel`. Logout begins in
|
|
`WorldFadeOut`. When a tunnel-family state first becomes visible:
|
|
|
|
```text
|
|
disable vivid target indicator
|
|
portalAnimDid = ResolveClientEnum(enumValue=0x10000002, category=7)
|
|
teleportObj.set_sequence_animation(
|
|
animation=portalAnimDid,
|
|
clearExisting=true,
|
|
lowFrame=1,
|
|
highFrame=end,
|
|
fps=40)
|
|
reset portal camera position to (0.24, -2.7, 0.88)
|
|
show portal viewport
|
|
hide world SmartBox viewport
|
|
```
|
|
|
|
At the end of `TunnelFadeOut`, retail commits the current transition view
|
|
distance, hides the portal viewport, shows the world viewport, clears the
|
|
teleport object's sequence animations, plays `Sound_UI_ExitPortal`, and enters
|
|
`WorldFadeIn`. That following state restores the ordinary game view distance.
|
|
|
|
## Camera motion
|
|
|
|
The camera rolls around its own AC `+Y` forward axis. It does not yaw around
|
|
world `+Z`. `SetCameraDirection_Degrees` resets the frame to identity, converts
|
|
the vector to radians, then passes `(0, angle, 0)` to `Frame::rotate`, whose
|
|
input is an axis-angle rotation vector. The forward direction therefore stays
|
|
down the portal passage while local `+Z` up rolls around it. Each segment is
|
|
independent:
|
|
|
|
```text
|
|
if now >= rotationStart + rotationDuration:
|
|
currentAngle = previousEndAngle
|
|
rotationStart = now
|
|
rotationDuration = RandomUniform(0.6, 1.8) seconds
|
|
startAngle = currentAngle
|
|
endAngle = RandomUniform(0, 360) degrees
|
|
display "In Portal Space - Please Wait..."
|
|
else:
|
|
t = (now - rotationStart) / rotationDuration
|
|
level = UIGlobals.GetAnimLevel(t) / 1024
|
|
currentAngle = startAngle + (endAngle - startAngle) * level
|
|
|
|
CreatureMode.SetCameraDirection_Degrees((0, currentAngle, 0))
|
|
```
|
|
|
|
`UIGlobals.GetAnimLevel` is a 100-entry cumulative sine table, not a
|
|
polynomial smoothstep:
|
|
|
|
```text
|
|
for i in 0..99:
|
|
sample[i] = truncate(sin(i * pi / 99) * 1024)
|
|
total = sum(sample)
|
|
running = 0
|
|
for i in 0..99:
|
|
running += sample[i]
|
|
level[i] = (running << 10) / total // integer division, 0..1024
|
|
|
|
GetAnimLevel(t):
|
|
t = clamp(t, 0, 1)
|
|
index = floor(t * 99)
|
|
return level[index]
|
|
```
|
|
|
|
## State timing (`gmSmartBoxUI::UseTime`)
|
|
|
|
Golden data constants:
|
|
|
|
```text
|
|
FadeTime = 1.0 seconds (0x007BD278)
|
|
MinContinue = 2.0 seconds (0x007BD268)
|
|
MaxContinue = 5.0 seconds (0x007BD270)
|
|
PortalFPS = 40 frames/s (0x007BD280)
|
|
EndFrame = 120
|
|
ExitWindowLow = FadeTime + 0.1 = 1.1 seconds
|
|
ExitWindowHigh = FadeTime + 0.3 = 1.3 seconds
|
|
```
|
|
|
|
```text
|
|
WorldFadeOut:
|
|
after 1 second -> TunnelFadeIn
|
|
|
|
TunnelFadeIn:
|
|
after 1 second -> Tunnel
|
|
|
|
Tunnel:
|
|
hold until SmartBox teleport-in-progress clears
|
|
EndTeleportAnimation -> TunnelContinue
|
|
|
|
TunnelContinue:
|
|
elapsed = now - transitionStart
|
|
if elapsed >= 2 seconds:
|
|
remaining = unsigned(120 - teleportObj.currentFrame) / 40
|
|
if elapsed >= 5 seconds
|
|
or 1.1 < remaining < 1.3 seconds:
|
|
-> TunnelFadeOut
|
|
|
|
TunnelFadeOut:
|
|
after 1 second:
|
|
hide/clear portal scene
|
|
show world
|
|
play exit sound
|
|
-> WorldFadeIn
|
|
|
|
WorldFadeIn:
|
|
after 1 second:
|
|
send LoginComplete
|
|
clear teleport-in-progress
|
|
-> Off
|
|
```
|
|
|
|
The narrow remaining-time window makes the one-second tunnel projection transition finish at
|
|
the end of the 120-frame animation. The five-second ceiling is retail's escape
|
|
for a missed window.
|
|
|
|
The four states whose retail enum names contain `FADE` use
|
|
`GetAnimLevel(elapsed / FadeTime)`. Instruction-level x86 inspection of
|
|
`gmSmartBoxUI::UseTime` (`0x004D7133..0x004D725F`) shows that this value feeds
|
|
only `SmartBox::SetOverrideFovDistance`. The function performs no alpha or
|
|
black-quad draw; `FADE` names the view-plane transition, not a transparency
|
|
compositor. The retained UI remains independent because only the two 3-D
|
|
viewports are switched.
|
|
|
|
## View-plane warp
|
|
|
|
Retail couples every `FADE` state to a projection transition. On begin it
|
|
captures the current SmartBox view-plane distance; for a perspective
|
|
projection this is `cot(verticalFov / 2)`. The transition endpoint is the
|
|
named constant `TRANSITION_VIEW_PLANE_DISTANCE = 0.001` at `0x007BD260`.
|
|
|
|
```text
|
|
viewPlaneLevel = GetAnimLevel(elapsed / FadeTime) / 1024
|
|
|
|
WorldFadeOut or TunnelFadeOut:
|
|
currentDistance = gameDistance
|
|
+ (0.001 - gameDistance) * viewPlaneLevel
|
|
|
|
TunnelFadeIn or WorldFadeIn:
|
|
currentDistance = 0.001
|
|
+ (gameDistance - 0.001) * viewPlaneLevel
|
|
|
|
SmartBox.SetOverrideFovDistance(true, currentDistance)
|
|
```
|
|
|
|
`Render::set_vdst` turns the distance back into projection values:
|
|
|
|
```text
|
|
verticalFov = 2 * atan(1 / currentDistance)
|
|
znear = max(0.1, currentDistance * 0.25)
|
|
```
|
|
|
|
At `0.001`, the view is nearly 180 degrees wide. Retail does not cover that
|
|
endpoint with black: it switches directly from the tunnel viewport to the
|
|
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.
|
|
|
|
Instruction-level checks that disambiguate the Binary Ninja x87 output:
|
|
|
|
- `SmartBox::GetOverrideFovDistance` `0x00451BE0`: when no override is active,
|
|
returns `cot(activeVerticalFov / 2)` (`0x00798088 = 0.5`,
|
|
`0x007928C0 = 1.0`).
|
|
- `gmSmartBoxUI::UseTime` `0x004D7199` and `0x004D722E`: converts the signed
|
|
table result with `0x007BD6A0 = 1/1024` and performs the two lerps above.
|
|
- `Render::set_vdst` `0x0054B240`: x87 `fpatan` computes
|
|
`atan(1 / distance)`, doubles it, and sets
|
|
`znear = max(0.1, distance * 0.25)`.
|
|
|
|
## Player placement boundary
|
|
|
|
```text
|
|
SmartBox.TeleportPlayer(position):
|
|
player.SetPositionSimple(position, slide=true)
|
|
PlayerPositionUpdated(isTeleport=true)
|
|
|
|
PlayerPositionUpdated(isTeleport=true):
|
|
clear position-update/teleport bookkeeping
|
|
player.teleport_hook()
|
|
commandInterpreter.PlayerTeleported()
|
|
SmartBox.set_viewer(player.position, reset_sought=true)
|
|
update CellManager position
|
|
|
|
player.teleport_hook():
|
|
MovementManager.CancelMoveTo(error=0x3c)
|
|
PositionManager.UnStick()
|
|
PositionManager.StopInterpolating()
|
|
PositionManager.UnConstrain()
|
|
TargetManager.ClearTarget()
|
|
TargetManager.NotifyVoyeurOfEvent(Teleported)
|
|
report_collision_end(force=true)
|
|
|
|
PlayerTeleported():
|
|
SetAutoRun(false, send=true)
|
|
SendMovementEvent()
|
|
```
|
|
|
|
The viewer reset copies the player's complete position into both `viewer` and
|
|
`viewer_sought_position`. Subsequent ordinary camera updates extend the chase
|
|
boom outward through the same damped and swept path used after camera
|
|
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.
|
|
Recall completion is instead guaranteed by the enclosing frame order:
|
|
|
|
```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 the accepted
|
|
state through `CPhysicsObj::set_state` `0x00514DD0`. Separately,
|
|
`CPhysicsObj::UpdatePositionInternal` `0x00512C30` skips `CPartArray::Update`
|
|
while Hidden, so ordinary animation never advances behind the portal viewport.
|
|
|
|
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 if Hidden only suppresses drawing. Retail has a second, decisive boundary:
|
|
`CPhysicsObj::set_hidden` `0x00514C60` invokes
|
|
`CPartArray::HandleEnterWorld` `0x00517D70` on **both** Hidden and UnHide.
|
|
That delegates to `MotionTableManager::HandleEnterWorld` `0x0051BDD0`, which
|
|
removes every link animation from the sequence and drains every pending
|
|
completion as aborted. On the Hidden edge it runs after the Hidden PES, child
|
|
NoDraw propagation, collision stop, and cell hide. On UnHide it runs after
|
|
collision restoration and before the cell becomes visible. A casting recoil,
|
|
recall remainder, or authored stance transition therefore cannot resume after
|
|
portal space.
|
|
|
|
ACE also has timing differences when recall starts in combat. Its
|
|
`Player_Location` handlers call `SetCombatMode(NonCombat)` and immediately send
|
|
the NonCombat/Ready/recall command state. House, Lifestone, Allegiance Hometown,
|
|
and PK Arena schedule `Teleport` using only
|
|
`MotionTable.GetAnimationLength(recall)`. Retail
|
|
`MovementManager::unpack_movement` (`0x00524440`) and
|
|
`CMotionInterp::move_to_interpreted_state` (`0x005289C0`) preserve the authored
|
|
combat-to-NonCombat link before the recall action. For a human leaving Magic
|
|
stance that link is about 0.31 seconds, but ACE does not add it to the teleport
|
|
delay. The retail Hidden boundary intentionally tolerates that: whichever
|
|
authored links remain when teleport begins are retired by `set_hidden`, not
|
|
carried through the tunnel.
|
|
|
|
Marketplace is a separate ACE residual: its handler deliberately uses a fixed
|
|
14-second delay and comments out `GetAnimationLength` because the installed
|
|
animation is approximately 18.4 seconds. That can shorten the visible source-
|
|
side action, but the client must still apply the same retail Hidden teardown;
|
|
no Marketplace-specific classifier or portal-exit behavior is required.
|
|
|
|
## acdream integration translation
|
|
|
|
- A dedicated App-layer portal presentation owns the synthetic portal object,
|
|
animation sequence, camera, scene light, resource references, and draw pass.
|
|
- The existing pure teleport state machine remains the lifecycle owner, but it
|
|
consumes the portal object's current frame for retail exit timing and uses
|
|
the exact table-driven easing curve.
|
|
- During tunnel states the portal scene replaces world pixels before retained
|
|
UI draws. There is no black compositor. Input dispatch and
|
|
`RetailUiRuntime.Tick` continue unchanged.
|
|
- The projection owner captures the active camera's M22 view-plane distance at
|
|
teleport begin and applies `Render::set_vdst` semantics to both the tunnel
|
|
and world viewport during the four `FADE` states.
|
|
- F751 remains a notification gate and does not itself advance `TELEPORT_TS`.
|
|
Presentation correlates its sequence with exactly one accepted destination
|
|
Position. A Position that arrives before F751 is buffered only when it
|
|
advances that timestamp; after F751, the first accepted matching Position is
|
|
consumed even if the channel already advanced. Thus reordered delivery and
|
|
retransmitted notifications cannot materialize the wrong destination or
|
|
strand the transit, while canonical physics retains its own retail gates.
|
|
- Destination placement resets the retail chase camera's published and sought
|
|
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.
|
|
- `LiveEntityPresentationController` owns the complete accepted Hidden/UnHide
|
|
side-effect order. Both edges route the current local entity to its existing
|
|
`AnimationSequencer.Manager.HandleEnterWorld`, matching retail's PartArray
|
|
boundary for player, creature, and object animations without classifying the
|
|
preceding action.
|
|
- Destination residency remains acdream's asynchronous adaptation. It supplies
|
|
the same `EndTeleportAnimation` edge retail receives when its blocking cell
|
|
load completes; it does not alter presentation ordering.
|
|
- The portal object is synthetic and never enters `LiveEntityRuntime`, world
|
|
picking, collision, radar, or server GUID ownership.
|
|
|
|
## Cross-reference notes
|
|
|
|
- `references/WorldBuilder` supplies the extracted setup/GfxObj mesh and modern
|
|
draw pipeline used by the synthetic scene; it has no teleport lifecycle.
|
|
- Holtburger's network study (`docs/research/2026-05-10-holtburger-network-stack-study.md`)
|
|
corroborates the LoginComplete wire acknowledgement, but intentionally has no
|
|
retail UI/CreatureMode implementation. The named retail client is therefore
|
|
the presentation oracle.
|