acdream/docs/research/2026-07-14-wield-ownership-and-target-facing-pseudocode.md
Erik 7b7ffcd278 fix(gameplay): reconcile wield ownership and target facing
Preserve PlayerDescription inventory/equipment ownership across authoritative manifest replacement, make weapon switching and combat/UI consumers read the same canonical object state, and carry the complete outbound player position frame across landblocks.

Route target-facing and mouse-look through the shared MovementManager and MotionInterpreter completion owner. Match retail input aggregation, toggle ordering, turn/sidestep remapping, per-axis hold keys, and synchronous movement publication without render-only heading state.

Initialize the live streaming origin from the first accepted canonical player Position, defer other projections until that origin exists, and retain logical entity identity through hydration.

Advance the project ledger from completed M2 to active M3, synchronize CLAUDE.md/AGENTS.md and durable memory, and record the next cast-lifecycle, spellbook/enchantment, and two-client portal gates.

Co-Authored-By: Codex <noreply@openai.com>
2026-07-15 08:19:23 +02:00

15 KiB

Wield ownership and targeted-action facing pseudocode

Date: 2026-07-14

Sources

  • Named retail ItemHolder::DetermineUseResult @ 0x00588460
  • Named retail ACCWeenieObject::UIAttemptWield @ 0x0058D590
  • Named retail ACCWeenieObject::ServerSaysMoveItem @ 0x0058DBB0
  • Named retail ACCWeenieObject::SetPlayerWieldLocation @ 0x0058D8C0
  • Named retail ACCObjectMaint::ViewObjectContents @ 0x00558A70
  • Named retail gmPaperDollUI::ServerSaysMoveItem @ 0x004A64A0
  • Named retail gmToolbarUI::RecvNotice_ServerSaysMoveItem @ 0x004BF2B0
  • Named retail WieldObject dispatch 0x0055B27C..0x0055B2B5
  • Named retail ClientCombatSystem::ExecuteAttack @ 0x0056BB70
  • Named retail ClientCombatSystem::StartAttackRequest @ 0x0056C040
  • Named retail CameraSet::ToggleMouseLook @ 0x00457490
  • Named retail CameraSet::MouseLookHandler @ 0x00458D80
  • Named retail CameraSet::Rotate @ 0x00458310
  • Named retail CommandInterpreter::HandleMouseMovementCommand @ 0x006B3870
  • Named retail CommandInterpreter::StopListHeadMovement @ 0x006B35A0
  • Named retail CommandInterpreter::MaybeStopCompletely @ 0x006B3B90
  • Named retail MovementManager::unpack_movement @ 0x00524440
  • Named retail MoveToManager::TurnToObject @ 0x005297D0
  • Named retail CommandInterpreter::SendMovementEvent @ 0x006B4680
  • Named retail CommandInterpreter::SendPositionEvent @ 0x006B4770
  • Named retail CommandInterpreter::ShouldSendPositionEvent @ 0x006B45E0
  • Named retail CommandInterpreter::UseTime @ 0x006B3BF0
  • Named retail Frame::is_equal @ 0x00424C30
  • Named retail Frame::is_quaternion_equal @ 0x00424C70
  • Named retail Position::Pack @ 0x005A9640
  • ACE Player_Missile.HandleActionTargetedMissileAttack and Creature.Rotate/TurnToObject (interpretation aid; retail remains the oracle)

Wield request and authoritative ownership

UIAttemptWield does not rewrite the item's public weenie fields. It sends the request and records the one outstanding inventory request:

UIAttemptWield(item, location):
    if player is not ready for an inventory request:
        return
    if item is a split stack and a partial split is selected:
        send StackableSplitToWield(item.id, location, splitSize)
        return
    send GetAndWieldItem(item.id, location)
    prevRequestObjectID = item.id
    prevRequest = Wield
    prevRequestTime = now

DetermineUseResult uses the authoritative wielderID, not container membership, to decide whether a player-owned weapon needs to be wielded:

if item is owned by the player
   and (item.combatUse != None or item is a caster or item has WieldOnUse)
   and item.wielderID != player.id:
    return WieldLeft when WieldLeft is set, otherwise WieldRight

The server move callback updates container, wielder, and location as one authoritative state transition:

ServerSaysMoveItem(item, newContainer, placement, newWielder, newLocation):
    oldContainer = item.containerID
    oldWielder = item.wielderID
    oldLocation = item.location

    remove item from oldContainer.contents, when present
    item.containerID = newContainer
    add item to newContainer.contents at placement, when present

    if newWielder == player.id:
        SetPlayerWieldLocation(item, newLocation)
        item.wielderID = player.id
    else if oldWielder == player.id:
        SetPlayerWieldLocation(item, None)
        item.wielderID = 0

    item.location = newLocation
    clear the matching outstanding inventory request
    notify inventory UI with old/new container, slot, wielder, and location

Paperdoll and toolbar consumers test the complete old and new placement. A post-mutation item plus old/new container alone is insufficient: confirmed equipment has ContainerId=0, so an unwield into a side bag would otherwise lose the old player wielder/location and fail to clear the visible slot.

Retail ViewContents is a different operation:

ViewObjectContents(container, contentProfile):
    clear container.itemsList and container.containersList
    rebuild those ordered lists from ContentProfile
    do not mutate any child containerID, wielderID, or location

PlayerDescription therefore initializes login inventory ownership explicitly; later ViewContents packets replace only the viewed list projection. A later authoritative move or delete evicts the GUID from every stale viewed-list projection before applying its new canonical membership. Only move, wield, and put-in-3D events publish placement transitions.

PlayerDescription assigns its complete packed InventoryPlacement list directly, preserving wire order. Later live SetPlayerWieldLocation calls insert the new placement at the head, and GetObjectAtLocation scans head-to-tail. Combat mode and ammo selection must preserve this mixed lifecycle rather than sorting confirmed equipment by GUID.

The retail 0x22 and 0x23 dispatchers publish ServerSaysMoveItem_s even when the item GUID has not resolved to a weenie yet. The notice carries the GUID and zero old placement plus the raw new placement; it must not be suppressed or manufacture a retained stub. This lets UI transaction owners observe the exact server boundary despite CreateObject/message reordering.

The WieldObject event itself contains exactly two words. Its dispatch supplies the local player identity and an empty container to ServerSaysMoveItem:

WieldObject(itemGuid, equipLocation):
    item = FindObject(itemGuid)
    ServerSaysMoveItem(
        item,
        newContainer = 0,
        placement = 0,
        newWielder = GetPlayerID(),
        newLocation = equipLocation,
        isServerMove = true)

Conformance consequence: a bow moved from the paper doll into a backpack must have WielderId == 0 after the authoritative PutObjectInContainer event. If it retains the player's ID, the next double click is classified as already wielded and no GetAndWieldItem request is sent.

Targeted missile facing ownership

Retail's client combat UI does not invent a local pre-attack rotation:

ClientCombatSystem::ExecuteAttack(target, height, power):
    validate target and player readiness
    send Event_TargetedMissileAttack(target.id, height, power)

The authoritative movement response is consumed through the ordinary movement funnel:

MovementManager::unpack_movement(server movement):
    interrupt current movement
    apply the wire stance when it changed
    case TurnToObject:
        unpack object id, heading, and turn parameters
        physicsObject.TurnToObject(object id, parameters)

ACE independently confirms the expected server sequence for the local test environment:

HandleActionTargetedMissileAttack(target):
    rotateTime = Rotate(target)
    wait rotateTime
    LaunchMissile(target)

Rotate(target):
    broadcast server-controlled TurnToObject(target)
    calculate the retail motion-table turn duration

Therefore missile facing belongs in the shared inbound TurnToObject execution path. Delaying the outbound missile attack request or synthesizing a bow-specific turn would be a retail divergence. Targeted spell facing needs its own retail casting-path proof before making the same claim.

The inbound turn also depends on the server knowing the player's current heading. ACE calculates its turn duration from the server-side Location orientation. If a stationary client turn never publishes its quaternion, ACE can believe the player already faces the target, schedule a zero-duration turn, and launch before the ordinary inbound TurnToObject visibly completes.

Retail keeps that authority synchronized through the full position-event cadence:

CommandInterpreter.UseTime():
    if ShouldSendPositionEvent():
        SendPositionEvent()

ShouldSendPositionEvent():
    if lastSentTime + 1 second >= now:
        return cell changed OR contact plane changed

    return cell changed OR NOT Frame.is_equal(lastFrame, player.frame)

Frame.is_equal(a, b):
    return origin components differ by <= 0.0002 world units
       AND quaternion W/X/Y/Z components differ by < 0.0002

SendMovementEvent():
    send MoveToState(player.m_position)
    lastSentTime = now
    // do not replace last frame or contact plane

SendPositionEvent():
    send AutonomousPosition(player.m_position)
    lastSentTime = now
    lastSentPosition = player.m_position   // cell + origin + orientation
    lastSentContactPlane = player.contact_plane

Quaternion comparison is component-wise; retail does not canonicalize the equivalent stored q and -q representations during this comparison. Plane comparison uses <= 0.0002 for the three normal components but < 0.0002 for the distance component. UseTime proves the internal Should→AP order, while MTS originates in separate input handlers; their relative same-tick callback and wire order is not established by these functions and remains the narrow TS-33 trace item. This is the shared correction for melee, missile, use, and spell targeting; there is no weapon-specific pre-turn or artificial attack delay.

Instant mouse-look is player movement, not a render-only yaw

Retail's middle-mouse instant look does not mutate the visible character frame behind the movement system. The camera owner translates the mouse into ordinary turn motions and the command interpreter publishes those motions to the server:

CameraSet.ToggleMouseLook(active):
    mouseLookActive = active
    commandInterpreter.SetMouseLookActive(active)
    commandInterpreter.MovePlayer(CameraInstantMouseLook, active, 1, mouse=true, holdRun=true)
    if !active:
        commandInterpreter.ApplyCurrentMovement()
    commandInterpreter.SendMovementEvent()
    // Toggle does not update CameraSet.m_LastServerMessage.

CInputManager_WIN32.GetInput():
    accumulate all raw device records into the frame's cursor displacement
    if the cursor position changed:
        lastInputEvent = now
        CallMouseLookHandler(totalDx, totalDy) once
    else if mouseLookActive && now > lastInputEvent + 0.2 seconds:
        CallMouseLookHandler(0, 0)

CameraSet.MouseLookHandler(mouseX, mouseY):
    if raw mouseX == 0 && raw mouseY == 0:
        StopDrift()
        if now > m_LastServerMessage + 0.5 seconds:
            m_LastServerMessage = now
            SendMovementEvent()
    low-pass filter the one accumulated raw sample
    multiply by configured input sensitivity and 0.0666666701
    on horizontal zero: reset mouselook_x_extent
    on horizontal nonzero: increment mouselook_x_extent
    only after five consecutive nonzero samples:
        CameraSet.Rotate(filtered horizontal input)
    Rotate publishes only when its own invocation crosses the same strict
    m_LastServerMessage + 0.5-second boundary

CameraSet.Rotate(horizontal input):
    direction = horizontal input selects TurnLeft or TurnRight
    speed = 2 * filtered camera adjustment for this frame
    speed = min(speed, 1.5)
    commandInterpreter.MovePlayer(direction, start=true, speed, mouse=true, holdRun=true)
    if last mouse movement event + 0.5 seconds < now:
        commandInterpreter.SendMovementEvent()

CommandInterpreter.HandleMouseMovementCommand(command):
    TakeControlFromServer()
    remember command as mouse-origin movement
    MovePlayer(command.motion, start=true, command.speed, mouse=true, holdRun=true)
    SendMovementEvent()

CommandInterpreter.StopListHeadMovement():
    if the active command came from the mouse:
        MovePlayer(command.motion, start=false, command.speed, mouse=true, holdRun=true)

CommandInterpreter.TakeControlFromServer():
    if controlled_by_server and autonomy_level != 0 and player is alive:
        controlled_by_server = false
        player.last_move_was_autonomous = true
        player.StopCompletely()
        StopInterpolating()
        SetHoldRun()
        ApplyCurrentMovement()

ApplyCurrentMovement means the complete device snapshot: global run state, forward/back, sidestep, and turn. CameraInstantMouseLook remaps a held keyboard turn into the sidestep channel with that axis's HoldKey.Run, even when the global movement mode is walk. Both the synchronous toggle packet and later movement-edge packets must serialize the canonical remapped raw channel; reconstructing sidestep from only the physical strafe keys loses A/D after MMB is already active. RawMotionState.CurrentHoldKey is serialized independently of active axes, so an idle run/walk change cannot be inferred from “is moving.”

Attack entry also clears client movement before the request:

ClientCombatSystem.StartAttackRequest(...):
    attack_request_in_progress = true
    requested_attack_power = 1
    FinishJump()
    commandInterpreter.MaybeStopCompletely()
    current_build_is_automatic = false
    build and send the attack request

CommandInterpreter.MaybeStopCompletely():
    if player movement is not currently controlled by the server:
        StopCompletely()

The architectural invariant is therefore one heading with one owner: the character seen on screen, the local physics orientation, the raw outbound turn state, and ACE's authoritative player orientation all advance through the same movement command. A mouse callback that writes PlayerMovementController.Yaw directly violates that invariant. It creates the exact failure mode observed in the bow test: the client can appear 180 degrees away while ACE retains the old heading, computes a zero-duration Rotate(target), and launches immediately.

ACE 1.76.4751 commit 7233b0e57db9a8566954d8dfbc2128c84e64c4ff cross-checks the consequence: HandleActionTargetedMissileAttack calls Rotate(target) and delays by the returned turn time; Creature.Rotate computes that delay from the server-side heading and broadcasts TurnToObject. ACE is an interpretation aid here; the client mechanism and ordering above come from named retail.

Outbound position frame

Retail never reconstructs protocol coordinates from a renderer origin:

SendMovementEvent():
    MoveToStatePack(rawMotionState, &player.m_position, ...)
    SendMoveToStateEvent(pack)

SendPositionEvent():
    if Position.IsValid(player.m_position):
        AutonomousPositionPack(&player.m_position, ...)
        SendAutonomousPositionEvent(pack)

Position.Pack():
    write objcell_id
    Frame.Pack(frame)  // landblock-local origin + orientation

CPhysicsObj::m_position is the canonical carried pair (cell id, local Frame.Origin). In acdream the equivalent is PhysicsBody.CellPosition. _liveCenterX/Y is a render/streaming translation and must not participate in outbound messages.

The captured regression was exact: the player crossed from A9B1 to A9B2 and the logical entity's projection was recovered. That recovery re-ran the login origin initialization, moving _liveCenterY by one block while the existing world position remained in the A9B1 render frame. Reconstructing local Y then sent 214.56 instead of the carried 22.56, a 192 m displacement. The fix has two invariants:

first accepted canonical player Position in CreateObject or Position update:
    initialize render/streaming origin once, before world translation

non-player projection before that Position:
    retain canonical live record, defer render projection

spatial projection recovery, rebucketing, or appearance mutation afterward:
    never initialize or move the session origin

every outbound movement/position/jump packet:
    serialize PlayerMovementController.CellPosition directly