diff --git a/docs/ISSUES.md b/docs/ISSUES.md
index 4ed37884..b74926f9 100644
--- a/docs/ISSUES.md
+++ b/docs/ISSUES.md
@@ -2754,18 +2754,19 @@ Retail oracle for cell-id hysteresis: `acclient_2013_pseudo_c.txt:308742-308783`
## #94 — Held items project spotlight on walls
-**Status:** OPEN — **BLOCKED, not an active A7 investigation target.** acdream does not
-yet support equipping hand-held items (weapons, orbs, light sources) at all
-(user-confirmed 2026-07-09), so this can't be reproduced or verified either way right
-now. Re-open for real investigation once item-equipping ships; until then this is a
-placeholder, not a live bug.
+**Status:** OPEN — **UNBLOCKED 2026-07-11.** Retail ParentEvent/CreateObject child
+parenting now renders hand-held objects and follows the animated attachment part. The
+original lighting symptom can now be reproduced and investigated after the held-item
+visual gate; no lighting conclusion has been drawn yet.
**Severity:** MEDIUM (visual fidelity; doesn't block gameplay)
**Filed:** 2026-05-20
**Component:** lighting, rendering
**Description:** Items the player is holding (torches, light-source items) project a spotlight effect onto nearby walls. The spotlight direction is wrong — should be omnidirectional from the item, but appears to project specifically toward wall surfaces.
-**Root cause / status:** Per-entity light direction transform. `LightingHookSink` owner-tracking applies an entity-rotation transform that's probably wrong for held-light items — likely passing the entity's facing-direction as the spotlight cone direction when retail's behavior is omnidirectional point-light. UNTESTABLE until item-equipping exists — this is a hypothesis from the original 2026-05-20 filing, not re-confirmed.
+**Root cause / status:** Unknown. The original per-entity light-direction theory is only
+a hypothesis and must be re-tested now that held-item parenting exists. Do not treat it
+as established root cause.
**Files:**
- `src/AcDream.App/Rendering/Vfx/LightingHookSink.cs` (suspected — verify during A7.L1)
diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md
index 0b6de775..33bf3a01 100644
--- a/docs/architecture/retail-divergence-register.md
+++ b/docs/architecture/retail-divergence-register.md
@@ -211,6 +211,7 @@ AP-94..AP-110 for the confirmed retail-UI completion gaps.
| AP-108 | Paperdoll omits Aetheria, full `AutoWieldIsLegal`/dual-wield rules, double-click examine/drag from the doll, and body-part selection lighting | `src/AcDream.App/UI/Layout/PaperdollController.cs` | Basic equip slots, live doll, toggle, optimistic wield, exact DAT body hit map, authoritative upper-item priority, normal selection, and target-self dispatch work | Special slots, invalid drops, doll examine/drag, and selection lighting still differ functionally | `gmPaperDollUI @ 0x004A3590..0x004A5F90`; `InventoryPlacement::DetermineHigherPriority` |
| AP-109 | Character Titles page is inert and live displayed-title/luminance state is absent | `src/AcDream.App/UI/Layout/CharacterStatController.cs`; `CharacterSheetProvider.cs` | Attributes/skills core output is user-accepted | Titles cannot be selected/displayed and level-200 luminance fields are missing | `gmCharacterTitleUI @ 0x0049A610`; `gmStatManagementUI::UpdateExperience @ 0x004F0A70` |
| AP-110 | Remaining retained gameplay panels and world HUD are absent: combat/powerbar, spellbook/effects/favorite spell bars, residual social/examine/floating chat, quests/map/vitae/options/smartbox, vendor/trade/salvage/tinkering, and D.6 nameplates/floaters | D.5/D.6 roadmap; retained layout registration set | Existing panels cover the current M2 loop; Wave 10 lands each missing surface against authoritative state | Large portions of retail gameplay have no production UI | Named `gm*UI::PostInit` methods and LayoutDesc catalog |
+| ~~AP-111~~ | **RETIRED 2026-07-11 (M2 held-object parenting)** — equipped hand items are no longer omitted from the render world. CreateObject now preserves Placement/Parent/position timestamp bootstrap; live `0xF749` ParentEvent is parsed with retail sequence freshness; a focused render controller resolves `Setup.HoldingLocations`, applies the child's placement frame, and recomposes the separate child entity after every parent animation tick. Pickup retains the weenie's visual metadata for a later wield. | `src/AcDream.Core.Net/Messages/{CreateObject,ParentEvent}.cs`; `src/AcDream.Core/Meshing/EquippedChildAttachment.cs`; `src/AcDream.App/Rendering/EquippedChildRenderController.cs` | — | — | `ClientCombatSystem::GetDefaultCombatMode @ 0x0056B310`; `SmartBox::HandleParentEvent @ 0x004535D0`; `CPhysicsObj::set_parent @ 0x00515A90`; `CPhysicsObj::UpdateChild @ 0x00512D50` |
## 4. Temporary stopgap (TS) — 38 rows (TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-44 narrowed same day — NPC UP unified onto the interp queue, gate retained for orientation)
diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md
index 123e2a64..2e41c614 100644
--- a/docs/plans/2026-04-11-roadmap.md
+++ b/docs/plans/2026-04-11-roadmap.md
@@ -495,6 +495,7 @@ behavior. Estimated 17–26 days focused work, 3–5 weeks calendar.
- **✓ SHIPPED — Wave 4.4c exact selected-health policy.** Core ports `ObjectIsAttackable @ 0x0056A600`; CreateObject preserves second-header PetOwner; toolbar player/pet composition is exact. Self, pets, and Free-PK creatures now query health while friendly NPCs and attackable non-creatures remain name-only. AP-46 retired. The world/radar policy matrix and exact DAT paperdoll body-map + upper-item-priority route passed live 2026-07-11.
- **✓ SHIPPED — Wave 4.4d toolbar launchers + Use/Examine.** All seven authored launchers are discovered through their DAT panel-id attribute; mounted Inventory/Character panels toggle through the registry and the other five buttons are ghosted. Typed window-visibility events own Highlight/Normal state. Use and Examine act on selection or enter their one-shot retail target modes, with exact Appraise wire dispatch and retail cursors. Warning-free App build and 4,747-pass / 5-skip Release suite are green; the connected live gate passed 2026-07-11. AP-101 is narrowed to the remaining ammo-number display.
- **Wave 4.4e implemented — exact missile ammo number (live gate pending).** Pure Core reproduces retail's thrown-weapon-vs-separate-ammo resolution over the ordered player equipment list and its zero-to-one count normalization. Relevant equipment/stack events update authored missile indicator `0x10000194` with the DAT font. Warning-free Release build and 4,754-pass / 5-skip suite are green; AP-101 is retired.
+- **M2 held-object parenting implemented 2026-07-11; live gate pending.** The combat toggle now ports `GetDefaultCombatMode` over ordered equipped contents, so a bow requests Missile instead of the old hardcoded Melee. CreateObject preserves parent/placement/timestamp fields, `0xF749` ParentEvent is handled, and `EquippedChildRenderController` renders the weapon as a separate child composed from the animated hand part + holding frame + child placement frame. App Release builds with zero warnings; the full 4,765-pass / 5-skip suite is green. AP-111 is retired; research: `docs/research/2026-07-11-combat-default-and-parent-event-pseudocode.md`.
- **✓ SHIPPED — Character window** (`LayoutDesc 0x2100002E`, `CharacterStatController`, 2026-06-26, same branch). **Visually user-confirmed 2026-06-26 — Attributes tab reads as retail.** Three tabs, header (name/heritage/PK), large-gold level number (dat font, `largeDatFont` 18px), "Total Experience (XP):" + "XP for next level:" captions, 9-row attribute list (icons + right-aligned values + Health/Stamina/Mana vitals), click-to-select (top/bottom selection bars + footer State-B "{Attr}: {value}" / "Experience To Raise: Infinity!" + affordability-gated raise triangles), centered footer. User noted "still needs some polish for later" — deferred to Issue #158.
- **✓ SHIPPED — D.5.4 — Client object/item data model (foundation).** Shipped 2026-06-18 (`b506f53`..`a33e897`, 11 commits). Renamed `ItemRepository`→`ClientObjectTable` / `ItemInstance`→`ClientObject`; broadened the table to hold EVERY server object (retail `weenie_object_table` shape). `CreateObject` is now the canonical merge-upsert (`ClientObjectTable.Ingest`, retail `SetWeenieDesc` semantics) via a new Core.Net `ObjectTableWiring` (off GameWindow); `DeleteObject` evicts; `PlayerDescription` is a membership manifest (`RecordMembership`); live container-membership index (`GetContents`, retail `object_inventory_table`). `_liveEntityInfoByGuid` retired (selection/describe resolve from the one table). Root fix: the old enrich-existing-only `EnrichItem` dropped `CreateObject`s for items with no `PlayerDescription` stub — live-Coldeve 4/6 hotbar slots blank; items are now created, not dropped. **Crux resolved:** retail is TWO tables (`object_table` + `weenie_object_table`), NOT one — acdream's `WorldEntity` (3D system) + `ClientObjectTable` (data/UI) split was already architecturally faithful; the fix was the ingestion path, not a table unification. 2671 tests green.
- **Roadmap correction (2026-07-10):** the completion order is now the architecture-first campaign in `docs/superpowers/plans/2026-07-10-retail-ui-fidelity-completion.md`. Retail `gmToolbarUI` is object-only: preserve `ShortCutData.index_`, `objectID_`, and `spellID_`, but do not invent spell glyphs on this bar. `PlayerModule::favorite_spells_[8]` feeds separate spell bars.
diff --git a/docs/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md
index a5ee2769..0304a30e 100644
--- a/docs/plans/2026-05-12-milestones.md
+++ b/docs/plans/2026-05-12-milestones.md
@@ -451,6 +451,9 @@ include dungeons.
Inventory list) — minimal ImGui surfaces, retail-skin deferred to M5.
- **L.1c** — Combat animation wiring (draw/sheath, attack swings by
stance/power/height, hit reactions, evades).
+- **L.1c held-object foundation (implemented 2026-07-11; live gate pending)** — exact
+ equipment-aware default combat mode (bow→Missile, caster→Magic) plus retail
+ CreateObject/ParentEvent child attachment and per-frame hand-follow rendering.
- **L.1b** — Command router + motion-state cleanup (prereq for L.1c).
**Freeze on landing:**
diff --git a/docs/research/2026-07-11-combat-default-and-parent-event-pseudocode.md b/docs/research/2026-07-11-combat-default-and-parent-event-pseudocode.md
new file mode 100644
index 00000000..db6b52c8
--- /dev/null
+++ b/docs/research/2026-07-11-combat-default-and-parent-event-pseudocode.md
@@ -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.
diff --git a/src/AcDream.App/Rendering/EquippedChildRenderController.cs b/src/AcDream.App/Rendering/EquippedChildRenderController.cs
new file mode 100644
index 00000000..a4ca5cbb
--- /dev/null
+++ b/src/AcDream.App/Rendering/EquippedChildRenderController.cs
@@ -0,0 +1,423 @@
+using System.Numerics;
+using AcDream.App.Streaming;
+using AcDream.Core.Items;
+using AcDream.Core.Meshing;
+using AcDream.Core.Net;
+using AcDream.Core.Net.Messages;
+using AcDream.Core.Physics;
+using AcDream.Core.World;
+using DatReaderWriter;
+using DatReaderWriter.DBObjs;
+using DatReaderWriter.Enums;
+
+namespace AcDream.App.Rendering;
+
+///
+/// Render-side owner of retail parented physics objects (held weapons,
+/// shields, and visible ammunition). Network/state parsing remains in Core;
+/// this controller projects an accepted parent relation into a dynamic child
+/// and recomposes it after the parent's animation
+/// advances each frame.
+///
+public sealed class EquippedChildRenderController : IDisposable
+{
+ private readonly DatCollection _dats;
+ private readonly object _datLock;
+ private readonly ClientObjectTable _objects;
+ private readonly GpuWorldState _worldState;
+ private readonly Func _resolveEntity;
+ private readonly Func _resolveSpawn;
+ private readonly Func _nextEntityId;
+
+ private readonly Dictionary _pendingByChild = new();
+ private readonly Dictionary _lastRelationByChild = new();
+ private readonly Dictionary _attachedByChild = new();
+ private readonly Dictionary _positionSequenceByChild = new();
+
+ public IEnumerable AttachedEntityIds
+ {
+ get
+ {
+ foreach (AttachedChild child in _attachedByChild.Values)
+ yield return child.Entity.Id;
+ }
+ }
+
+ public EquippedChildRenderController(
+ DatCollection dats,
+ object datLock,
+ ClientObjectTable objects,
+ GpuWorldState worldState,
+ Func resolveEntity,
+ Func resolveSpawn,
+ Func nextEntityId)
+ {
+ _dats = dats ?? throw new ArgumentNullException(nameof(dats));
+ _datLock = datLock ?? throw new ArgumentNullException(nameof(datLock));
+ _objects = objects ?? throw new ArgumentNullException(nameof(objects));
+ _worldState = worldState ?? throw new ArgumentNullException(nameof(worldState));
+ _resolveEntity = resolveEntity ?? throw new ArgumentNullException(nameof(resolveEntity));
+ _resolveSpawn = resolveSpawn ?? throw new ArgumentNullException(nameof(resolveSpawn));
+ _nextEntityId = nextEntityId ?? throw new ArgumentNullException(nameof(nextEntityId));
+
+ _objects.ObjectMoved += OnObjectMoved;
+ _objects.MoveRolledBack += OnMoveRolledBack;
+ _objects.ObjectRemoved += OnObjectRemoved;
+ }
+
+ ///
+ /// Seed/refresh an object from CreateObject. Equipped child CreateObjects
+ /// carry Placement + Parent directly; this is retail's login/first-observe
+ /// bootstrap and does not wait for a separate ParentEvent.
+ ///
+ public void OnSpawn(WorldSession.EntitySpawn spawn)
+ {
+ _positionSequenceByChild[spawn.Guid] = spawn.PositionSequence;
+
+ if (spawn.ParentGuid is { } parentGuid and not 0
+ && spawn.ParentLocation is { } parentLocation
+ && spawn.PlacementId is { } placementId)
+ {
+ var relation = new PendingAttachment(
+ parentGuid,
+ spawn.Guid,
+ parentLocation,
+ placementId,
+ spawn.InstanceSequence,
+ spawn.PositionSequence,
+ FromCreateObject: true);
+ _pendingByChild[spawn.Guid] = relation;
+ _lastRelationByChild[spawn.Guid] = relation;
+ }
+
+ lock (_datLock)
+ {
+ TryRealize(spawn.Guid);
+
+ // ParentEvent can precede the parent's CreateObject. Revisit every
+ // child waiting specifically on the object that just arrived.
+ uint[] waiting = _pendingByChild.Values
+ .Where(p => p.ParentGuid == spawn.Guid)
+ .Select(p => p.ChildGuid)
+ .ToArray();
+ for (int i = 0; i < waiting.Length; i++)
+ TryRealize(waiting[i]);
+ }
+ }
+
+ ///
+ /// Apply/queue a live ParentEvent using retail's two sequence gates:
+ /// parent instance must match, child position must advance strictly.
+ ///
+ public void OnParentEvent(ParentEvent.Parsed update)
+ {
+ WorldSession.EntitySpawn? parentSpawn = _resolveSpawn(update.ParentGuid);
+ if (parentSpawn is { } knownParent
+ && knownParent.InstanceSequence != update.ParentInstanceSequence)
+ {
+ // Known parent newer than the event: stale. Event newer than the
+ // known parent: retain until its CreateObject arrives.
+ if (MotionSequenceGate.IsNewer(
+ update.ParentInstanceSequence,
+ knownParent.InstanceSequence))
+ return;
+ }
+
+ if (_positionSequenceByChild.TryGetValue(update.ChildGuid, out ushort current)
+ && !MotionSequenceGate.IsNewer(current, update.ChildPositionSequence))
+ return;
+
+ var relation = new PendingAttachment(
+ update.ParentGuid,
+ update.ChildGuid,
+ update.ParentLocation,
+ update.PlacementId,
+ update.ParentInstanceSequence,
+ update.ChildPositionSequence,
+ FromCreateObject: false);
+ _pendingByChild[update.ChildGuid] = relation;
+ _lastRelationByChild[update.ChildGuid] = relation;
+
+ lock (_datLock)
+ TryRealize(update.ChildGuid);
+ }
+
+ public void OnObjectDeleted(uint guid)
+ {
+ Remove(guid);
+
+ uint[] children = _attachedByChild.Values
+ .Where(c => c.ParentGuid == guid)
+ .Select(c => c.ChildGuid)
+ .ToArray();
+ for (int i = 0; i < children.Length; i++)
+ Remove(children[i]);
+
+ uint[] pendingChildren = _lastRelationByChild.Values
+ .Where(c => c.ParentGuid == guid)
+ .Select(c => c.ChildGuid)
+ .ToArray();
+ for (int i = 0; i < pendingChildren.Length; i++)
+ {
+ _pendingByChild.Remove(pendingChildren[i]);
+ _lastRelationByChild.Remove(pendingChildren[i]);
+ }
+
+ _pendingByChild.Remove(guid);
+ _lastRelationByChild.Remove(guid);
+ _positionSequenceByChild.Remove(guid);
+ }
+
+ /// Recompose every child after the parent's animation tick.
+ public void Tick()
+ {
+ foreach (AttachedChild child in _attachedByChild.Values)
+ {
+ WorldEntity? parent = _resolveEntity(child.ParentGuid);
+ if (parent is null)
+ continue;
+
+ if (!EquippedChildAttachment.TryCompose(
+ child.ParentSetup,
+ parent.MeshRefs,
+ child.ChildSetup,
+ child.ParentLocation,
+ child.Placement,
+ child.PartTemplate,
+ child.Scale,
+ out IReadOnlyList parts))
+ continue;
+
+ child.Entity.MeshRefs = parts;
+ child.Entity.SetPosition(parent.Position);
+ child.Entity.Rotation = parent.Rotation;
+ child.Entity.ParentCellId = parent.ParentCellId;
+ }
+ }
+
+ private void TryRealize(uint childGuid)
+ {
+ if (!_pendingByChild.TryGetValue(childGuid, out PendingAttachment pending))
+ return;
+
+ // A ParentEvent queued before the child existed is replayed only if
+ // its position stamp still advances the child's CreateObject seed.
+ // Retail reaches the same check inside DoParentEvent after its blob
+ // queue resolves both objects.
+ if (!pending.FromCreateObject
+ && _positionSequenceByChild.TryGetValue(childGuid, out ushort childPosition)
+ && !MotionSequenceGate.IsNewer(childPosition, pending.ChildPositionSequence))
+ {
+ _pendingByChild.Remove(childGuid);
+ return;
+ }
+
+ WorldEntity? parentEntity = _resolveEntity(pending.ParentGuid);
+ WorldSession.EntitySpawn? parentSpawn = _resolveSpawn(pending.ParentGuid);
+ WorldSession.EntitySpawn? childSpawn = _resolveSpawn(childGuid);
+ if (parentEntity is null || parentSpawn is null || childSpawn is null)
+ return;
+
+ if (!pending.FromCreateObject
+ && parentSpawn.Value.InstanceSequence != pending.ParentInstanceSequence)
+ {
+ if (MotionSequenceGate.IsNewer(
+ pending.ParentInstanceSequence,
+ parentSpawn.Value.InstanceSequence))
+ _pendingByChild.Remove(childGuid);
+ return;
+ }
+
+ if (parentSpawn.Value.SetupTableId is not { } parentSetupId
+ || childSpawn.Value.SetupTableId is not { } childSetupId
+ || parentEntity.ParentCellId is not { } parentCellId)
+ return;
+
+ Setup? parentSetup = _dats.Get(parentSetupId);
+ Setup? childSetup = _dats.Get(childSetupId);
+ if (parentSetup is null || childSetup is null)
+ return;
+
+ var parentLocation = (ParentLocation)pending.ParentLocation;
+ var placement = (Placement)pending.PlacementId;
+ IReadOnlyList template = BuildPartTemplate(childSetup, childSpawn.Value);
+ float scale = childSpawn.Value.ObjScale is { } objScale && objScale > 0f
+ ? objScale
+ : 1.0f;
+
+ if (!EquippedChildAttachment.TryCompose(
+ parentSetup,
+ parentEntity.MeshRefs,
+ childSetup,
+ parentLocation,
+ placement,
+ template,
+ scale,
+ out IReadOnlyList parts))
+ return;
+
+ Remove(childGuid);
+ var entity = new WorldEntity
+ {
+ Id = _nextEntityId(),
+ ServerGuid = childGuid,
+ SourceGfxObjOrSetupId = childSetupId,
+ Position = parentEntity.Position,
+ Rotation = parentEntity.Rotation,
+ MeshRefs = parts,
+ PaletteOverride = BuildPaletteOverride(childSpawn.Value),
+ ParentCellId = parentCellId,
+ };
+
+ _worldState.AppendLiveEntity(parentCellId, entity);
+ _attachedByChild[childGuid] = new AttachedChild(
+ pending.ParentGuid,
+ childGuid,
+ parentLocation,
+ placement,
+ parentSetup,
+ childSetup,
+ template,
+ scale,
+ entity);
+ Console.WriteLine(
+ $"equipment: attached child=0x{childGuid:X8} parent=0x{pending.ParentGuid:X8} " +
+ $"location={parentLocation} placement={placement}");
+ _positionSequenceByChild[childGuid] = pending.ChildPositionSequence;
+ _pendingByChild.Remove(childGuid);
+ }
+
+ private IReadOnlyList BuildPartTemplate(
+ Setup setup,
+ WorldSession.EntitySpawn spawn)
+ {
+ var result = new MeshRef[setup.Parts.Count];
+ for (int i = 0; i < result.Length; i++)
+ result[i] = new MeshRef((uint)setup.Parts[i], Matrix4x4.Identity);
+
+ IReadOnlyList partChanges =
+ spawn.AnimPartChanges ?? Array.Empty();
+ for (int i = 0; i < partChanges.Count; i++)
+ {
+ CreateObject.AnimPartChange change = partChanges[i];
+ if (change.PartIndex < result.Length)
+ result[change.PartIndex] = new MeshRef(change.NewModelId, Matrix4x4.Identity);
+ }
+
+ IReadOnlyList textureChanges =
+ spawn.TextureChanges ?? Array.Empty();
+ for (int partIndex = 0; partIndex < result.Length; partIndex++)
+ {
+ Dictionary? oldToNew = null;
+ for (int t = 0; t < textureChanges.Count; t++)
+ {
+ CreateObject.TextureChange change = textureChanges[t];
+ if (change.PartIndex != partIndex) continue;
+ oldToNew ??= new Dictionary();
+ oldToNew[change.OldTexture] = change.NewTexture;
+ }
+ if (oldToNew is null) continue;
+
+ GfxObj? gfx = _dats.Get(result[partIndex].GfxObjId);
+ if (gfx is null) continue;
+ Dictionary? surfaceOverrides = null;
+ foreach (var surfaceQid in gfx.Surfaces)
+ {
+ uint surfaceId = (uint)surfaceQid;
+ Surface? surface = _dats.Get(surfaceId);
+ if (surface is null) continue;
+ uint oldTexture = (uint)surface.OrigTextureId;
+ if (!oldToNew.TryGetValue(oldTexture, out uint replacement)) continue;
+ surfaceOverrides ??= new Dictionary();
+ surfaceOverrides[surfaceId] = replacement;
+ }
+
+ if (surfaceOverrides is not null)
+ {
+ result[partIndex] = new MeshRef(
+ result[partIndex].GfxObjId,
+ Matrix4x4.Identity)
+ {
+ SurfaceOverrides = surfaceOverrides,
+ };
+ }
+ }
+
+ return result;
+ }
+
+ private static PaletteOverride? BuildPaletteOverride(WorldSession.EntitySpawn spawn)
+ {
+ if (spawn.SubPalettes is not { Count: > 0 } subPalettes)
+ return null;
+
+ var ranges = new PaletteOverride.SubPaletteRange[subPalettes.Count];
+ for (int i = 0; i < subPalettes.Count; i++)
+ {
+ CreateObject.SubPaletteSwap swap = subPalettes[i];
+ ranges[i] = new PaletteOverride.SubPaletteRange(
+ swap.SubPaletteId,
+ swap.Offset,
+ swap.Length);
+ }
+ return new PaletteOverride(spawn.BasePaletteId ?? 0, ranges);
+ }
+
+ private void OnObjectMoved(ClientObject item, uint _, uint __)
+ {
+ if (item.CurrentlyEquippedLocation == EquipMask.None)
+ Remove(item.ObjectId);
+ }
+
+ private void OnMoveRolledBack(ClientObject item)
+ {
+ if (item.CurrentlyEquippedLocation == EquipMask.None)
+ return;
+
+ // A rejected unwield restores the equipped location without a fresh
+ // wire ParentEvent; reinstall the last accepted relationship only for
+ // this explicit rollback signal.
+ if (_lastRelationByChild.TryGetValue(item.ObjectId, out PendingAttachment relation))
+ {
+ _pendingByChild[item.ObjectId] = relation;
+ lock (_datLock)
+ TryRealize(item.ObjectId);
+ }
+ }
+
+ private void OnObjectRemoved(ClientObject item) => OnObjectDeleted(item.ObjectId);
+
+ private void Remove(uint childGuid)
+ {
+ if (!_attachedByChild.Remove(childGuid))
+ return;
+ _worldState.RemoveEntityByServerGuid(childGuid);
+ }
+
+ public void Dispose()
+ {
+ _objects.ObjectMoved -= OnObjectMoved;
+ _objects.MoveRolledBack -= OnMoveRolledBack;
+ _objects.ObjectRemoved -= OnObjectRemoved;
+ }
+
+ private readonly record struct PendingAttachment(
+ uint ParentGuid,
+ uint ChildGuid,
+ uint ParentLocation,
+ uint PlacementId,
+ ushort ParentInstanceSequence,
+ ushort ChildPositionSequence,
+ bool FromCreateObject);
+
+ private sealed record AttachedChild(
+ uint ParentGuid,
+ uint ChildGuid,
+ ParentLocation ParentLocation,
+ Placement Placement,
+ Setup ParentSetup,
+ Setup ChildSetup,
+ IReadOnlyList PartTemplate,
+ float Scale,
+ WorldEntity Entity);
+}
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index a6984a9e..185efe51 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -131,6 +131,7 @@ public sealed class GameWindow : IDisposable
// Phase A.1: streaming fields replacing the one-shot _entities list.
private AcDream.App.Streaming.LandblockStreamer? _streamer;
private AcDream.App.Streaming.GpuWorldState _worldState = new();
+ private AcDream.App.Rendering.EquippedChildRenderController? _equippedChildRenderer;
private AcDream.App.Streaming.StreamingController? _streamingController;
private int _streamingRadius = 2; // default 5×5 (kept for debug overlay getStreamingRadius callback)
private int _nearRadius = 4; // Phase A.5 T16: two-tier near ring (default 4 → 9×9)
@@ -1030,8 +1031,10 @@ public sealed class GameWindow : IDisposable
private readonly Dictionary _entitiesByServerGuid = new();
///
/// Latest for each
- /// guid. Captured at the end of so
- /// can reuse the position/setup/motion
+ /// guid. Captured before the renderability gate so no-position inventory /
+ /// parented children retain Setup and parent metadata; hydrated world objects
+ /// refresh it again at the end of .
+ /// reuses the cached position/setup/motion
/// fields when a 0xF625 ObjDescEvent arrives carrying only updated visuals.
///
private readonly Dictionary _lastSpawnByGuid = new();
@@ -2269,6 +2272,17 @@ public sealed class GameWindow : IDisposable
onLandblockUnloaded: _classificationCache.InvalidateLandblock,
entityScriptActivator: entityScriptActivator);
+ _equippedChildRenderer = new AcDream.App.Rendering.EquippedChildRenderController(
+ _dats!,
+ _datLock,
+ Objects,
+ _worldState,
+ guid => _entitiesByServerGuid.TryGetValue(guid, out var entity) ? entity : null,
+ guid => _lastSpawnByGuid.TryGetValue(guid, out var spawn)
+ ? spawn
+ : (AcDream.Core.Net.WorldSession.EntitySpawn?)null,
+ () => _liveEntityIdCounter++);
+
_wbDrawDispatcher = new AcDream.App.Rendering.Wb.WbDrawDispatcher(
_gl, _meshShader!, _textureCache!, _wbMeshAdapter!, _wbEntitySpawnAdapter, _bindlessSupport!,
_classificationCache, _translucencyFades);
@@ -2494,6 +2508,7 @@ public sealed class GameWindow : IDisposable
_liveSession.PositionUpdated += OnLivePositionUpdated;
_liveSession.VectorUpdated += OnLiveVectorUpdated;
_liveSession.StateUpdated += OnLiveStateUpdated;
+ _liveSession.ParentUpdated += update => _equippedChildRenderer?.OnParentEvent(update);
_liveSession.TeleportStarted += OnTeleportStarted;
_liveSession.AppearanceUpdated += OnLiveAppearanceUpdated;
@@ -2908,6 +2923,14 @@ public sealed class GameWindow : IDisposable
if (appearanceUpdate is null)
RemoveLiveEntityByServerGuid(spawn.Guid);
+ // Retail's weenie-object table retains CreateObject data for inventory
+ // and parented children even when they have no world Position. Held
+ // weapon CreateObjects are intentionally no-position and carry their
+ // Parent + Placement in PhysicsData, so cache before the renderability
+ // gate and offer the relationship to the focused child controller.
+ _lastSpawnByGuid[spawn.Guid] = spawn;
+ _equippedChildRenderer?.OnSpawn(spawn);
+
// When requested, log every spawn that arrives so we can inventory what the server
// sends (including the ones we can't render yet). The Name field
// is the critical one — we can grep the log for "Nullified Statue
@@ -3549,6 +3572,7 @@ public sealed class GameWindow : IDisposable
// Cache the spawn so OnLiveAppearanceUpdated can replay it with new
// appearance fields when a later 0xF625 ObjDescEvent arrives.
_lastSpawnByGuid[spawn.Guid] = spawn;
+ _equippedChildRenderer?.OnSpawn(spawn);
// Commit B 2026-04-29 — live-entity collision registration. The
// local player is the simulator (its PhysicsBody is the source of
@@ -3796,8 +3820,23 @@ public sealed class GameWindow : IDisposable
// re-create adopts fresh stamps from its CreateObject (retail's
// update_times die with the CPhysicsObj).
_motionSequenceGates.Remove(delete.Guid);
+ _equippedChildRenderer?.OnObjectDeleted(delete.Guid);
- if (RemoveLiveEntityByServerGuid(delete.Guid)
+ // Snapshot before RemoveLiveEntityByServerGuid clears the render-side
+ // cache. Pickup removes only the 3-D projection; the weenie persists
+ // in inventory and may later become a parented weapon.
+ AcDream.Core.Net.WorldSession.EntitySpawn? pickedUp =
+ delete.FromPickup && _lastSpawnByGuid.TryGetValue(delete.Guid, out var cached)
+ ? cached with { Position = null }
+ : null;
+
+ bool removed = RemoveLiveEntityByServerGuid(delete.Guid);
+ if (pickedUp is { } retained)
+ _lastSpawnByGuid[delete.Guid] = retained;
+ else if (!delete.FromPickup)
+ _lastSpawnByGuid.Remove(delete.Guid);
+
+ if (removed
&& Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1")
{
Console.WriteLine(
@@ -8889,6 +8928,7 @@ public sealed class GameWindow : IDisposable
// so the renderer always sees the up-to-date per-part transforms.
if (_animatedEntities.Count > 0)
TickAnimations((float)deltaSeconds);
+ _equippedChildRenderer?.Tick();
// #188 — advance translucency fades UNCONDITIONALLY (not gated on
// _animatedEntities.Count): a one-shot open-cycle animation can
@@ -9186,6 +9226,11 @@ public sealed class GameWindow : IDisposable
_animatedIdsScratch.Clear();
foreach (var k in _animatedEntities.Keys)
_animatedIdsScratch.Add(k);
+ if (_equippedChildRenderer is not null)
+ {
+ foreach (uint id in _equippedChildRenderer.AttachedEntityIds)
+ _animatedIdsScratch.Add(id);
+ }
HashSet? animatedIds = _animatedIdsScratch;
// Phase G.1: sky renderer — draws the far-plane-infinity
@@ -11971,7 +12016,18 @@ public sealed class GameWindow : IDisposable
|| _liveSession.CurrentState != AcDream.Core.Net.WorldSession.State.InWorld)
return;
- var nextMode = AcDream.Core.Combat.CombatInputPlanner.ToggleMode(Combat.CurrentMode);
+ var orderedEquipmentIds = Objects.GetContents(_playerServerGuid);
+ var orderedEquipment = new List(orderedEquipmentIds.Count);
+ for (int i = 0; i < orderedEquipmentIds.Count; i++)
+ {
+ if (Objects.Get(orderedEquipmentIds[i]) is { } item)
+ orderedEquipment.Add(item);
+ }
+ var defaultMode = AcDream.Core.Combat.CombatInputPlanner
+ .GetDefaultCombatMode(orderedEquipment);
+ var nextMode = AcDream.Core.Combat.CombatInputPlanner.ToggleMode(
+ Combat.CurrentMode,
+ defaultMode);
_liveSession.SendChangeCombatMode(nextMode);
Combat.SetCombatMode(nextMode);
string text = $"Combat mode {nextMode}";
@@ -13618,6 +13674,7 @@ public sealed class GameWindow : IDisposable
// Phase I.7: unsubscribe combat → chat translator before the
// session it depends on goes away.
_combatChatTranslator?.Dispose();
+ _equippedChildRenderer?.Dispose();
_liveSessionController?.Dispose();
_liveSession = null;
_audioEngine?.Dispose(); // Phase E.2: stop all voices, close AL context
diff --git a/src/AcDream.Core.Net/Messages/CreateObject.cs b/src/AcDream.Core.Net/Messages/CreateObject.cs
index e6d6abe9..d0be364d 100644
--- a/src/AcDream.Core.Net/Messages/CreateObject.cs
+++ b/src/AcDream.Core.Net/Messages/CreateObject.cs
@@ -122,6 +122,12 @@ public static class CreateObject
// L.2g S1 (DEV-6): ObjectMovement stamp (timestamp block index 1)
// seeds MotionSequenceGate's MOVEMENT_TS at spawn.
ushort MovementSequence = 0,
+ // Parent/placement bootstrap for equipped child objects. These are
+ // the CreateObject equivalents of ParentEvent 0xF749.
+ ushort PositionSequence = 0,
+ uint? ParentGuid = null,
+ uint? ParentLocation = null,
+ uint? PlacementId = null,
uint? PhysicsState = null,
uint? ObjectDescriptionFlags = null,
// L.3b (2026-04-30): per-object friction + elasticity from the
@@ -522,6 +528,10 @@ public static class CreateObject
physicsState = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
pos += 4;
+ uint? placementId = null;
+ uint? parentGuid = null;
+ uint? parentLocation = null;
+
if ((physicsFlags & PhysicsDescriptionFlag.Movement) != 0)
{
// u32 length, length bytes of serialized MovementData (no header
@@ -543,6 +553,7 @@ public static class CreateObject
else if ((physicsFlags & PhysicsDescriptionFlag.AnimationFrame) != 0)
{
if (body.Length - pos < 4) return null;
+ placementId = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
pos += 4;
}
@@ -593,7 +604,9 @@ public static class CreateObject
if ((physicsFlags & PhysicsDescriptionFlag.Parent) != 0)
{
if (body.Length - pos < 8) return null;
- pos += 8; // wielderId u32 + parentLocation u32
+ parentGuid = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
+ parentLocation = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos + 4));
+ pos += 8;
}
if ((physicsFlags & PhysicsDescriptionFlag.Children) != 0)
{
@@ -643,6 +656,7 @@ public static class CreateObject
if (body.Length - pos < 9 * 2) return PartialResult();
var seqSpan = body.Slice(pos, 9 * 2);
ushort instanceSeq = BinaryPrimitives.ReadUInt16LittleEndian(seqSpan.Slice(8 * 2));
+ ushort positionSeq = BinaryPrimitives.ReadUInt16LittleEndian(seqSpan.Slice(0 * 2));
ushort movementSeq = BinaryPrimitives.ReadUInt16LittleEndian(seqSpan.Slice(1 * 2));
ushort teleportSeq = BinaryPrimitives.ReadUInt16LittleEndian(seqSpan.Slice(4 * 2));
ushort serverControlSeq = BinaryPrimitives.ReadUInt16LittleEndian(seqSpan.Slice(5 * 2));
@@ -984,8 +998,14 @@ public static class CreateObject
textureChanges, subPalettes, basePaletteId, objScale, name, itemType, motionState, motionTableId,
instanceSeq, teleportSeq, serverControlSeq, forcePositionSeq,
movementSeq,
- physicsState, objectDescriptionFlags,
- friction, elasticity,
+ PositionSequence: positionSeq,
+ ParentGuid: parentGuid,
+ ParentLocation: parentLocation,
+ PlacementId: placementId,
+ PhysicsState: physicsState,
+ ObjectDescriptionFlags: objectDescriptionFlags,
+ Friction: friction,
+ Elasticity: elasticity,
IconId: iconId,
Useability: useability, UseRadius: useRadius, TargetType: targetType,
IconOverlayId: iconOverlayId, IconUnderlayId: iconUnderlayId,
diff --git a/src/AcDream.Core.Net/Messages/ParentEvent.cs b/src/AcDream.Core.Net/Messages/ParentEvent.cs
new file mode 100644
index 00000000..f5cf90d5
--- /dev/null
+++ b/src/AcDream.Core.Net/Messages/ParentEvent.cs
@@ -0,0 +1,42 @@
+using System.Buffers.Binary;
+
+namespace AcDream.Core.Net.Messages;
+
+///
+/// Inbound retail ParentEvent (0xF749): attach a child physics
+/// object (weapon, shield, ammunition) to a named holding location on a
+/// parent creature and apply the child's placement frame.
+///
+/// Wire oracle: CM_Physics::DispatchSB_ParentEvent @ 0x006ACAF0.
+/// Runtime oracle: SmartBox::HandleParentEvent @ 0x004535D0 and
+/// SmartBox::DoParentEvent @ 0x00452290.
+///
+public static class ParentEvent
+{
+ public const uint Opcode = 0xF749u;
+
+ public readonly record struct Parsed(
+ uint ParentGuid,
+ uint ChildGuid,
+ uint ParentLocation,
+ uint PlacementId,
+ ushort ParentInstanceSequence,
+ ushort ChildPositionSequence);
+
+ public static Parsed? TryParse(ReadOnlySpan body)
+ {
+ if (body.Length < 24)
+ return null;
+
+ if (BinaryPrimitives.ReadUInt32LittleEndian(body) != Opcode)
+ return null;
+
+ return new Parsed(
+ BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(4, 4)),
+ BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(8, 4)),
+ BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(12, 4)),
+ BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(16, 4)),
+ BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(20, 2)),
+ BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(22, 2)));
+ }
+}
diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs
index 6524bb89..ffc805b9 100644
--- a/src/AcDream.Core.Net/WorldSession.cs
+++ b/src/AcDream.Core.Net/WorldSession.cs
@@ -118,6 +118,10 @@ public sealed class WorldSession : IDisposable
ushort InstanceSequence = 0,
ushort MovementSequence = 0,
ushort ServerControlSequence = 0,
+ ushort PositionSequence = 0,
+ uint? ParentGuid = null,
+ uint? ParentLocation = null,
+ uint? PlacementId = null,
// PublicWeenieDesc optional-tail bytes. null means the corresponding
// flag was absent; zero means the server explicitly sent the enum's
// undefined/default value.
@@ -175,6 +179,10 @@ public sealed class WorldSession : IDisposable
InstanceSequence: parsed.InstanceSequence,
MovementSequence: parsed.MovementSequence,
ServerControlSequence: parsed.ServerControlSequence,
+ PositionSequence: parsed.PositionSequence,
+ ParentGuid: parsed.ParentGuid,
+ ParentLocation: parsed.ParentLocation,
+ PlacementId: parsed.PlacementId,
RadarBlipColor: parsed.RadarBlipColor,
RadarBehavior: parsed.RadarBehavior,
CombatUse: parsed.CombatUse,
@@ -243,6 +251,12 @@ public sealed class WorldSession : IDisposable
///
public event Action? VectorUpdated;
+ ///
+ /// Fires for retail ParentEvent (0xF749), which attaches a separate
+ /// child object to a creature holding location (weapons, shields, ammo).
+ ///
+ public event Action? ParentUpdated;
+
///
/// Fires when the server broadcasts a SetState (0xF74B) game
/// message — a previously-spawned entity's PhysicsState
@@ -886,6 +900,12 @@ public sealed class WorldSession : IDisposable
new DeleteObject.Parsed(
parsed.Value.Guid, parsed.Value.InstanceSequence, FromPickup: true));
}
+ else if (op == ParentEvent.Opcode)
+ {
+ var parsed = ParentEvent.TryParse(body);
+ if (parsed is not null)
+ ParentUpdated?.Invoke(parsed.Value);
+ }
else if (op == UpdateMotion.Opcode)
{
// Phase 6.6: the server sends UpdateMotion (0xF74C) whenever an
diff --git a/src/AcDream.Core/Combat/CombatModel.cs b/src/AcDream.Core/Combat/CombatModel.cs
index a57d37d3..3aa2fefa 100644
--- a/src/AcDream.Core/Combat/CombatModel.cs
+++ b/src/AcDream.Core/Combat/CombatModel.cs
@@ -1,4 +1,6 @@
using System;
+using System.Collections.Generic;
+using AcDream.Core.Items;
namespace AcDream.Core.Combat;
@@ -48,6 +50,56 @@ public enum CombatAttackAction
///
public static class CombatInputPlanner
{
+ private const EquipMask PrimaryWeaponLocations =
+ EquipMask.MeleeWeapon | EquipMask.MissileWeapon | EquipMask.TwoHanded;
+
+ ///
+ /// Port of retail ClientCombatSystem::GetDefaultCombatMode
+ /// (0x0056B310). must be in the
+ /// player's inventory-placement order; retail returns the first equipped
+ /// object intersecting the requested location mask.
+ ///
+ public static CombatMode GetDefaultCombatMode(
+ IReadOnlyList orderedPlayerContents)
+ {
+ ArgumentNullException.ThrowIfNull(orderedPlayerContents);
+
+ ClientObject? weapon = GetObjectAtLocation(
+ orderedPlayerContents, PrimaryWeaponLocations);
+ if (weapon is not null)
+ {
+ // Retail COMBAT_USE_MISSILE = 2. Every other combat-use value in
+ // this primary weapon slot selects melee.
+ return weapon.CombatUse == 2
+ ? CombatMode.Missile
+ : CombatMode.Melee;
+ }
+
+ ClientObject? held = GetObjectAtLocation(
+ orderedPlayerContents, EquipMask.Held);
+ if (held is null)
+ return CombatMode.Melee;
+
+ // The decomp's byte-1 sign test is ITEM_TYPE bit 15 (Caster).
+ return (held.Type & ItemType.Caster) != 0
+ ? CombatMode.Magic
+ : CombatMode.NonCombat;
+ }
+
+ private static ClientObject? GetObjectAtLocation(
+ IReadOnlyList orderedPlayerContents,
+ EquipMask locationMask)
+ {
+ for (int i = 0; i < orderedPlayerContents.Count; i++)
+ {
+ ClientObject candidate = orderedPlayerContents[i];
+ if ((candidate.CurrentlyEquippedLocation & locationMask) != 0)
+ return candidate;
+ }
+
+ return null;
+ }
+
public static CombatMode ToggleMode(
CombatMode currentMode,
CombatMode defaultCombatMode = CombatMode.Melee)
diff --git a/src/AcDream.Core/Items/ClientObjectTable.cs b/src/AcDream.Core/Items/ClientObjectTable.cs
index 49e52399..5ea4588d 100644
--- a/src/AcDream.Core/Items/ClientObjectTable.cs
+++ b/src/AcDream.Core/Items/ClientObjectTable.cs
@@ -65,6 +65,15 @@ public sealed class ClientObjectTable
///
public event Action? ObjectMoved;
+ ///
+ /// Fires after an optimistic inventory/equipment move is rejected and
+ /// has restored the exact pre-move state.
+ /// Consumers with projections outside the item grid (for example a
+ /// parented 3-D weapon) can restore their prior projection without
+ /// treating every ordinary equip as a rollback.
+ ///
+ public event Action? MoveRolledBack;
+
/// Fires when an object is removed from the session.
public event Action? ObjectRemoved;
@@ -239,7 +248,10 @@ public sealed class ClientObjectTable
{
if (!_pendingMoves.TryGetValue(itemId, out var pre)) return false;
_pendingMoves.Remove(itemId);
- return MoveItem(itemId, pre.container, pre.slot, pre.equip);
+ if (!MoveItem(itemId, pre.container, pre.slot, pre.equip))
+ return false;
+ MoveRolledBack?.Invoke(_objects[itemId]);
+ return true;
}
///
diff --git a/src/AcDream.Core/Meshing/EquippedChildAttachment.cs b/src/AcDream.Core/Meshing/EquippedChildAttachment.cs
new file mode 100644
index 00000000..344dbcde
--- /dev/null
+++ b/src/AcDream.Core/Meshing/EquippedChildAttachment.cs
@@ -0,0 +1,85 @@
+using System.Numerics;
+using AcDream.Core.World;
+using DatReaderWriter.DBObjs;
+using DatReaderWriter.Enums;
+using DatReaderWriter.Types;
+
+namespace AcDream.Core.Meshing;
+
+///
+/// Retail held-object transform composition. A weapon is a separate child
+/// physics object: the parent's selects
+/// a hand part and a frame relative to it, while the child's placement frame
+/// poses the weapon's own setup parts.
+///
+/// Sources: CPhysicsObj::add_child @ 0x0050F870,
+/// CPhysicsObj::UpdateChild @ 0x00512D50,
+/// CPartArray::SetPlacementFrame @ 0x005193D0, and
+/// Frame::combine @ 0x005122E0.
+///
+public static class EquippedChildAttachment
+{
+ public static bool TryCompose(
+ Setup parentSetup,
+ IReadOnlyList currentParentPose,
+ Setup childSetup,
+ ParentLocation parentLocation,
+ Placement placement,
+ IReadOnlyList childPartTemplate,
+ float childScale,
+ out IReadOnlyList attachedParts)
+ {
+ ArgumentNullException.ThrowIfNull(parentSetup);
+ ArgumentNullException.ThrowIfNull(currentParentPose);
+ ArgumentNullException.ThrowIfNull(childSetup);
+ ArgumentNullException.ThrowIfNull(childPartTemplate);
+
+ if (!parentSetup.HoldingLocations.TryGetValue(parentLocation, out LocationType? holding))
+ {
+ attachedParts = Array.Empty();
+ return false;
+ }
+
+ Matrix4x4 parentPart = holding.PartId >= 0
+ && holding.PartId < currentParentPose.Count
+ ? currentParentPose[holding.PartId].PartTransform
+ : Matrix4x4.Identity;
+ Matrix4x4 holdingFrame = ToMatrix(holding.Frame);
+ Matrix4x4 childRoot = holdingFrame * parentPart;
+
+ // Retail CPartArray::SetPlacementFrame falls back specifically to
+ // placement 0 (Default), then installs null if Default is absent.
+ AnimationFrame? placementFrame = null;
+ if (!childSetup.PlacementFrames.TryGetValue(placement, out placementFrame))
+ childSetup.PlacementFrames.TryGetValue(Placement.Default, out placementFrame);
+
+ int partCount = Math.Min(childSetup.Parts.Count, childPartTemplate.Count);
+ var result = new MeshRef[partCount];
+ for (int i = 0; i < partCount; i++)
+ {
+ Frame partFrame = placementFrame is not null && i < placementFrame.Frames.Count
+ ? placementFrame.Frames[i]
+ : new Frame { Orientation = Quaternion.Identity };
+ Vector3 scale = i < childSetup.DefaultScale.Count
+ ? childSetup.DefaultScale[i]
+ : Vector3.One;
+ Matrix4x4 childPart = Matrix4x4.CreateScale(scale)
+ * ToMatrix(partFrame);
+ if (childScale != 1.0f)
+ childPart *= Matrix4x4.CreateScale(childScale);
+
+ MeshRef template = childPartTemplate[i];
+ result[i] = new MeshRef(template.GfxObjId, childPart * childRoot)
+ {
+ SurfaceOverrides = template.SurfaceOverrides,
+ };
+ }
+
+ attachedParts = result;
+ return true;
+ }
+
+ private static Matrix4x4 ToMatrix(Frame frame) =>
+ Matrix4x4.CreateFromQuaternion(frame.Orientation)
+ * Matrix4x4.CreateTranslation(frame.Origin);
+}
diff --git a/tests/AcDream.Core.Net.Tests/Messages/CreateObjectTests.cs b/tests/AcDream.Core.Net.Tests/Messages/CreateObjectTests.cs
index 706abbec..49190ffb 100644
--- a/tests/AcDream.Core.Net.Tests/Messages/CreateObjectTests.cs
+++ b/tests/AcDream.Core.Net.Tests/Messages/CreateObjectTests.cs
@@ -188,6 +188,29 @@ public sealed class CreateObjectTests
Assert.Equal((byte)2, parsed.Value.CombatUse);
}
+ [Fact]
+ public void TryParse_ParentedChild_CapturesPlacementParentAndPositionSequence()
+ {
+ byte[] body = BuildMinimalCreateObjectWithWeenieHeader(
+ guid: 0x60000002u,
+ name: "Bow",
+ itemType: (uint)ItemType.MissileWeapon,
+ placementId: (uint)DatReaderWriter.Enums.Placement.LeftHand,
+ parentGuid: 0x50000001u,
+ parentLocation: (uint)DatReaderWriter.Enums.ParentLocation.LeftHand,
+ positionSeq: 0x3456,
+ instanceSeq: 0x789A);
+
+ var parsed = CreateObject.TryParse(body);
+
+ Assert.NotNull(parsed);
+ Assert.Equal((uint)DatReaderWriter.Enums.Placement.LeftHand, parsed.Value.PlacementId);
+ Assert.Equal(0x50000001u, parsed.Value.ParentGuid);
+ Assert.Equal((uint)DatReaderWriter.Enums.ParentLocation.LeftHand, parsed.Value.ParentLocation);
+ Assert.Equal((ushort)0x3456, parsed.Value.PositionSequence);
+ Assert.Equal((ushort)0x789A, parsed.Value.InstanceSequence);
+ }
+
// -----------------------------------------------------------------------
// Retail radar: PublicWeenieDesc carries two independently gated bytes.
// Absence is distinct from an explicitly transmitted zero because zero is
@@ -625,6 +648,11 @@ public sealed class CreateObjectTests
byte? radarBehavior = null,
byte? combatUse = null,
ushort movementSeq = 0,
+ uint? placementId = null,
+ uint? parentGuid = null,
+ uint? parentLocation = null,
+ ushort positionSeq = 0,
+ ushort instanceSeq = 0,
uint materialType = 0,
uint cooldownId = 0,
double cooldownDuration = 0,
@@ -640,12 +668,27 @@ public sealed class CreateObjectTests
bytes.Add(0);
bytes.Add(0);
- // PhysicsData: physics flags = 0, then PhysicsState u32, then 9 seq stamps
+ // PhysicsData: optional placement + parent bootstrap, then 9 seq stamps
// (PhysicsTimeStamp enum order; index 1 = ObjectMovement).
- WriteU32(bytes, 0);
+ uint physicsFlags = 0;
+ if (placementId.HasValue) physicsFlags |= (uint)CreateObject.PhysicsDescriptionFlag.AnimationFrame;
+ if (parentGuid.HasValue) physicsFlags |= (uint)CreateObject.PhysicsDescriptionFlag.Parent;
+ WriteU32(bytes, physicsFlags);
WriteU32(bytes, physicsState);
+ if (placementId.HasValue) WriteU32(bytes, placementId.Value);
+ if (parentGuid.HasValue)
+ {
+ WriteU32(bytes, parentGuid.Value);
+ WriteU32(bytes, parentLocation ?? 0u);
+ }
for (int i = 0; i < 9; i++)
- WriteU16(bytes, i == 1 ? movementSeq : (ushort)0);
+ WriteU16(bytes, i switch
+ {
+ 0 => positionSeq,
+ 1 => movementSeq,
+ 8 => instanceSeq,
+ _ => 0,
+ });
Align4(bytes);
// Fixed WeenieHeader prefix per ACE SerializeCreateObject.
diff --git a/tests/AcDream.Core.Net.Tests/Messages/ParentEventTests.cs b/tests/AcDream.Core.Net.Tests/Messages/ParentEventTests.cs
new file mode 100644
index 00000000..fe37cf47
--- /dev/null
+++ b/tests/AcDream.Core.Net.Tests/Messages/ParentEventTests.cs
@@ -0,0 +1,40 @@
+using System.Buffers.Binary;
+using AcDream.Core.Net.Messages;
+
+namespace AcDream.Core.Net.Tests.Messages;
+
+public sealed class ParentEventTests
+{
+ [Fact]
+ public void TryParse_RetailWireOrder()
+ {
+ var body = new byte[24];
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(0, 4), ParentEvent.Opcode);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4, 4), 0x50000001u);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8, 4), 0x60000002u);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12, 4), 2u);
+ BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(16, 4), 3u);
+ BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(20, 2), 0x1234);
+ BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(22, 2), 0x5678);
+
+ var parsed = ParentEvent.TryParse(body);
+
+ Assert.NotNull(parsed);
+ Assert.Equal(0x50000001u, parsed.Value.ParentGuid);
+ Assert.Equal(0x60000002u, parsed.Value.ChildGuid);
+ Assert.Equal(2u, parsed.Value.ParentLocation);
+ Assert.Equal(3u, parsed.Value.PlacementId);
+ Assert.Equal((ushort)0x1234, parsed.Value.ParentInstanceSequence);
+ Assert.Equal((ushort)0x5678, parsed.Value.ChildPositionSequence);
+ }
+
+ [Fact]
+ public void TryParse_RejectsTruncationAndWrongOpcode()
+ {
+ Assert.Null(ParentEvent.TryParse(new byte[23]));
+
+ var body = new byte[24];
+ BinaryPrimitives.WriteUInt32LittleEndian(body, 0xF748u);
+ Assert.Null(ParentEvent.TryParse(body));
+ }
+}
diff --git a/tests/AcDream.Core.Tests/Combat/CombatInputPlannerTests.cs b/tests/AcDream.Core.Tests/Combat/CombatInputPlannerTests.cs
index c970d45d..89257756 100644
--- a/tests/AcDream.Core.Tests/Combat/CombatInputPlannerTests.cs
+++ b/tests/AcDream.Core.Tests/Combat/CombatInputPlannerTests.cs
@@ -1,9 +1,62 @@
using AcDream.Core.Combat;
+using AcDream.Core.Items;
namespace AcDream.Core.Tests.Combat;
public sealed class CombatInputPlannerTests
{
+ [Fact]
+ public void GetDefaultCombatMode_MissileCombatUseSelectsMissile()
+ {
+ var bow = Equipped(EquipMask.MissileWeapon, ItemType.MissileWeapon, combatUse: 2);
+
+ Assert.Equal(
+ CombatMode.Missile,
+ CombatInputPlanner.GetDefaultCombatMode([bow]));
+ }
+
+ [Fact]
+ public void GetDefaultCombatMode_PrimaryWeaponOrderMatchesRetailInventoryPlacement()
+ {
+ var bow = Equipped(EquipMask.MissileWeapon, ItemType.MissileWeapon, combatUse: 2);
+ var sword = Equipped(EquipMask.MeleeWeapon, ItemType.MeleeWeapon, combatUse: 1);
+
+ Assert.Equal(
+ CombatMode.Missile,
+ CombatInputPlanner.GetDefaultCombatMode([bow, sword]));
+ Assert.Equal(
+ CombatMode.Melee,
+ CombatInputPlanner.GetDefaultCombatMode([sword, bow]));
+ }
+
+ [Fact]
+ public void GetDefaultCombatMode_HeldCasterSelectsMagic()
+ {
+ var wand = Equipped(EquipMask.Held, ItemType.Caster, combatUse: 0);
+
+ Assert.Equal(
+ CombatMode.Magic,
+ CombatInputPlanner.GetDefaultCombatMode([wand]));
+ }
+
+ [Fact]
+ public void GetDefaultCombatMode_HeldNonCasterCannotEnterCombat()
+ {
+ var held = Equipped(EquipMask.Held, ItemType.Misc, combatUse: 0);
+
+ Assert.Equal(
+ CombatMode.NonCombat,
+ CombatInputPlanner.GetDefaultCombatMode([held]));
+ }
+
+ [Fact]
+ public void GetDefaultCombatMode_NoWeaponDefaultsToUnarmedMelee()
+ {
+ Assert.Equal(
+ CombatMode.Melee,
+ CombatInputPlanner.GetDefaultCombatMode([]));
+ }
+
[Fact]
public void ToggleMode_FromNonCombat_UsesDefaultCombatMode()
{
@@ -40,4 +93,15 @@ public sealed class CombatInputPlannerTests
{
Assert.Equal(expected, CombatInputPlanner.SupportsTargetedAttack(mode));
}
+
+ private static ClientObject Equipped(
+ EquipMask location,
+ ItemType type,
+ byte combatUse) => new()
+ {
+ ObjectId = 1,
+ CurrentlyEquippedLocation = location,
+ Type = type,
+ CombatUse = combatUse,
+ };
}
diff --git a/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs b/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs
index c6614878..b95b361b 100644
--- a/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs
+++ b/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs
@@ -678,12 +678,15 @@ public sealed class ClientObjectTableTests
{
var table = new ClientObjectTable();
const uint player = 0x50000001u;
+ ClientObject? rolledBack = null;
+ table.MoveRolledBack += item => rolledBack = item;
table.AddOrUpdate(new ClientObject { ObjectId = 0x942u });
table.MoveItem(0x942u, player, newSlot: -1, newEquipLocation: EquipMask.Shield); // equipped
table.MoveItemOptimistic(0x942u, player, 0); // optimistic UNWIELD into the pack (clears equip)
Assert.Equal(EquipMask.None, table.Get(0x942u)!.CurrentlyEquippedLocation);
Assert.True(table.RollbackMove(0x942u)); // server rejected the unwield
Assert.Equal(EquipMask.Shield, table.Get(0x942u)!.CurrentlyEquippedLocation); // restored to equipped
+ Assert.Same(table.Get(0x942u), rolledBack);
}
[Fact]
diff --git a/tests/AcDream.Core.Tests/Meshing/EquippedChildAttachmentTests.cs b/tests/AcDream.Core.Tests/Meshing/EquippedChildAttachmentTests.cs
new file mode 100644
index 00000000..3c8dd1de
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Meshing/EquippedChildAttachmentTests.cs
@@ -0,0 +1,108 @@
+using System.Numerics;
+using AcDream.Core.Meshing;
+using AcDream.Core.World;
+using DatReaderWriter.DBObjs;
+using DatReaderWriter.Enums;
+using DatReaderWriter.Types;
+
+namespace AcDream.Core.Tests.Meshing;
+
+public sealed class EquippedChildAttachmentTests
+{
+ [Fact]
+ public void TryCompose_CombinesChildPlacementHoldingFrameAndAnimatedHandPart()
+ {
+ var parent = new Setup
+ {
+ HoldingLocations =
+ {
+ [ParentLocation.LeftHand] = new LocationType
+ {
+ PartId = 1,
+ Frame = FrameAt(2, 0, 0),
+ },
+ },
+ };
+ var parentPose = new[]
+ {
+ new MeshRef(1, Matrix4x4.Identity),
+ new MeshRef(2, Matrix4x4.CreateTranslation(10, 0, 0)),
+ };
+ var child = new Setup
+ {
+ Parts = { 0x01000001u },
+ DefaultScale = { Vector3.One },
+ PlacementFrames =
+ {
+ [Placement.LeftHand] = new AnimationFrame(1)
+ {
+ Frames = { FrameAt(3, 0, 0) },
+ },
+ },
+ };
+ var template = new[] { new MeshRef(0x01000001u, Matrix4x4.Identity) };
+
+ bool ok = EquippedChildAttachment.TryCompose(
+ parent, parentPose, child, ParentLocation.LeftHand,
+ Placement.LeftHand, template, 1.0f, out var result);
+
+ Assert.True(ok);
+ Assert.Equal(15f, Assert.Single(result).PartTransform.Translation.X);
+ }
+
+ [Fact]
+ public void TryCompose_MissingRequestedPlacementFallsBackToDefault()
+ {
+ var parent = ParentWithRightHand(partId: -1, FrameAt(4, 0, 0));
+ var child = new Setup
+ {
+ Parts = { 0x01000001u },
+ PlacementFrames =
+ {
+ [Placement.Default] = new AnimationFrame(1)
+ {
+ Frames = { FrameAt(6, 0, 0) },
+ },
+ },
+ };
+
+ bool ok = EquippedChildAttachment.TryCompose(
+ parent, [], child, ParentLocation.RightHand,
+ Placement.RightHandCombat,
+ [new MeshRef(0x01000001u, Matrix4x4.Identity)],
+ 1.0f,
+ out var result);
+
+ Assert.True(ok);
+ Assert.Equal(10f, Assert.Single(result).PartTransform.Translation.X);
+ }
+
+ [Fact]
+ public void TryCompose_MissingHoldingLocationRejectsAttachment()
+ {
+ bool ok = EquippedChildAttachment.TryCompose(
+ new Setup(), [], new Setup(), ParentLocation.RightHand,
+ Placement.RightHandCombat, [], 1.0f, out var result);
+
+ Assert.False(ok);
+ Assert.Empty(result);
+ }
+
+ private static Setup ParentWithRightHand(int partId, Frame frame) => new()
+ {
+ HoldingLocations =
+ {
+ [ParentLocation.RightHand] = new LocationType
+ {
+ PartId = partId,
+ Frame = frame,
+ },
+ },
+ };
+
+ private static Frame FrameAt(float x, float y, float z) => new()
+ {
+ Origin = new Vector3(x, y, z),
+ Orientation = Quaternion.Identity,
+ };
+}