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