acdream/docs/research/2026-07-15-retail-portal-space-pseudocode.md
Erik 124e046976 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>
2026-07-17 08:48:27 +02:00

21 KiB

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)

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

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:

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:

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:

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:

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
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.

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:

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.

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:

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:

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:

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:

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.

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, 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

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 numeric 0x3c is the internal ITeleported completion status, not a player-visible error. ClientCommunicationSystem::HandleFailureEvent 0x00571990 has a visible case for 0x3d (YouChargedTooFar) but no case for 0x3b (ILeftTheWorld) or 0x3c; both fall through without adding scroll text. ACE also sends ITeleported after a successful portal use. acdream must consume those two control statuses silently rather than formatting an unknown WeenieError line.

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:

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.
  • Hidden/UnHide typed scripts use retail's internal path, not ordinary CPhysicsObj::play_script (0x00513260). set_hidden resolves the current PhysicsScriptTable and calls play_script_internal even when the object has no current cell. The serial script remains paused until destination re-entry; this preserves table 0x34000004's purple Hidden emitters across asynchronous portal placement. Ordinary F755/default-hook playback retains the cell gate.
  • 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.