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>
377 lines
15 KiB
Markdown
377 lines
15 KiB
Markdown
# 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:
|
|
|
|
```text
|
|
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:
|
|
|
|
```text
|
|
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:
|
|
|
|
```text
|
|
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:
|
|
|
|
```text
|
|
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`:
|
|
|
|
```text
|
|
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:
|
|
|
|
```text
|
|
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:
|
|
|
|
```text
|
|
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:
|
|
|
|
```text
|
|
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:
|
|
|
|
```text
|
|
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:
|
|
|
|
```text
|
|
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:
|
|
|
|
```text
|
|
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:
|
|
|
|
```text
|
|
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:
|
|
|
|
```text
|
|
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
|
|
```
|