acdream/docs/research/2026-07-11-combat-default-and-parent-event-pseudocode.md
Erik ab98cda26b fix #211: index login-equipped items under player
Project PlayerDescription equipment through the same contained-by-wielder ownership and ordered contents index as live WieldObject updates. Preserve equip masks and priorities so retail GetObjectAtLocation selects Missile for an already-equipped crossbow instead of sending a server-rejected Melee request.

Co-Authored-By: Codex <codex@openai.com>
2026-07-13 10:40:29 +02:00

6.1 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).

Login projection invariant

PlayerDescription carries loose inventory and equipped objects as two ordered lists. They must feed one player-owned location lookup after parsing:

OnPlayerDescription(player, inventory, equipped):
    replace player-owned loose contents with inventory, preserving wire order
    for each equipped entry in wire order:
        record item as owned by player
        record equipped location and layering priority
        append it to the same ordered player-owned lookup

This is the login equivalent of a live WieldObject, which also models the item as contained by its wielder with a non-zero equipped location. Keeping login equipment outside that lookup makes GetDefaultCombatMode miss an already-equipped bow/crossbow and request Melee; ACE then correctly rejects the mode because a missile weapon is equipped. ACE's Player.HandleActionChangeCombatMode_Inner performs that rejection, while holtburger independently retains PlayerDescription's equipped_objects list for its suggested-combat-mode decision.

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.