acdream/docs/research/2026-07-11-combat-default-and-parent-event-pseudocode.md
Erik ab6d96d113 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.
2026-07-11 13:02:26 +02:00

5 KiB

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

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

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

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:

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:

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.