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>
This commit is contained in:
Erik 2026-07-15 08:19:23 +02:00
parent b26f84cc69
commit 7b7ffcd278
36 changed files with 3884 additions and 761 deletions

View file

@ -0,0 +1,377 @@
# 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
```