feat(combat): port retail held weapon parenting

Select the default combat mode from ordered equipped objects so bows request missile stance. Parse CreateObject parent metadata and ParentEvent, then render held objects as separate children composed from setup holding locations and placement frames each animation tick.
This commit is contained in:
Erik 2026-07-11 13:02:26 +02:00
parent 564d39dfea
commit ab6d96d113
18 changed files with 1152 additions and 17 deletions

View file

@ -0,0 +1,160 @@
# Retail default combat mode and held-item parenting
## Sources
- `ClientCombatSystem::GetDefaultCombatMode @ 0x0056B310`
- `ACCWeenieObject::GetObjectAtLocation @ 0x0058CE00`
- `CM_Physics::DispatchSB_ParentEvent @ 0x006ACAF0`
- `SmartBox::HandleParentEvent @ 0x004535D0`
- `SmartBox::DoParentEvent @ 0x00452290`
- `CPhysicsObj::set_parent @ 0x00515A90`
- `CPhysicsObj::add_child @ 0x0050F870`
- `CPhysicsObj::UpdateChild @ 0x00512D50`
- `CPartArray::SetPlacementFrame @ 0x005193D0`
- `Frame::combine @ 0x005122E0`
Cross-references:
- ACE `Creature_Equipment.GetPlacementLocation` and `TrySetChild`
- ACE `GameMessageParentEvent`
- ACE `WorldObject.SerializePhysicsData`
- DatReaderWriter `Setup.HoldingLocations`, `LocationType`, and
`Setup.PlacementFrames`
## Default combat mode
```text
GetDefaultCombatMode(showError):
player = get local player weenie
if player is missing:
return NonCombat
weaponId = player.GetObjectAtLocation(
MeleeWeapon | MissileWeapon | TwoHanded,
priority = 0)
if weaponId != 0:
weapon = get weenie weaponId
if weapon is missing:
return NonCombat
if weapon.PublicWeenieDesc.CombatUse == Missile:
return Missile
return Melee
heldId = player.GetObjectAtLocation(Held, priority = 0)
if heldId == 0:
return Melee
held = get weenie heldId
if held is missing:
return NonCombat
if held.InqType has the Caster bit (0x00008000):
return Magic
if showError == false:
print the retail cannot-use-held-item notice
return NonCombat
```
`GetObjectAtLocation` walks the player's ordered inventory-placement list and
returns the first entry whose location intersects the requested mask (and whose
priority intersects the requested priority when that priority is non-zero).
## ParentEvent wire and freshness
```text
DispatchParentEvent(blob):
require opcode 0xF749
parentGuid = read u32
childGuid = read u32
parentLocation = read u32
placementId = read u32
parentInstanceSequence = read u16
childPositionSequence = read u16
return HandleParentEvent(...)
HandleParentEvent(...):
parent = GetObjectA(parentGuid)
if parent is missing OR event parent-instance is newer than parent:
queue blob for parentGuid
return Queued
if event parent-instance is older than parent:
return Stale
child = GetObjectA(childGuid)
if child is missing:
queue blob for childGuid
return Queued
DoParentEvent(child, parent, parentLocation, placementId,
childPositionSequence)
```
## Applying the attachment
```text
DoParentEvent(child, parent, location, placement, positionSequence):
if positionSequence is not strictly newer than child.positionTimestamp:
return
child.positionTimestamp = positionSequence
parentWasMissing = child.parent is null
if parentWasMissing and parent is not the local player:
mark the parent's weenie as having parented state
child.set_parent(parent, location)
child.SetPlacementFrame(placement)
set_parent(child, parent, location):
holding = parent.setup.HoldingLocations[location]
if holding is missing:
fail
store child with holding.PartId and holding.Frame in parent's child list
detach child from its former parent and world cell
child.parent = parent
child.cell = parent.cell
UpdateChild(parent, child, holding.PartId, holding.Frame)
UpdateChild(parent, child, parentPartId, holdingFrame):
if parentPartId is a real part:
parentFrame = parent.parts[parentPartId].worldFrame
else:
parentFrame = parent.objectWorldFrame
child.objectWorldFrame = Combine(parentFrame, holdingFrame)
SetPlacementFrame(child, placement):
frame = child.setup.PlacementFrames[placement]
if frame is missing:
frame = child.setup.PlacementFrames[Default]
install frame (or null when Default is also absent)
update every child part from child.objectWorldFrame
```
With `System.Numerics` row-vector matrices, the render equivalent is:
```text
childRootRelativeToParent = holdingFrame * parentPartRelativeToParent
childPartRelativeToParent = childPlacementPart * childRootRelativeToParent
childWorldRoot = parent's WorldEntity position + rotation
```
The child root and each child part must be recomputed after the parent's
animation advances, because the referenced hand part moves every frame.
## CreateObject bootstrap
An equipped child's `CreateObject` PhysicsData carries the same relationship
without requiring a later ParentEvent:
```text
AnimationFrame flag -> child Placement id
Parent flag -> parent/wielder guid + ParentLocation
timestamp[0] -> child position sequence
timestamp[8] -> child instance sequence
```
ACE uses this form for already-equipped children sent while an observer begins
tracking a creature and at login. ParentEvent remains necessary for live
equip/re-parent changes and may arrive before the child's CreateObject, so the
client must retain it until both objects exist.