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

@ -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)

View file

@ -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)

View file

@ -495,6 +495,7 @@ behavior. Estimated 1726 days focused work, 35 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.

View file

@ -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:**

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.

View file

@ -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;
/// <summary>
/// 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
/// <see cref="WorldEntity"/> and recomposes it after the parent's animation
/// advances each frame.
/// </summary>
public sealed class EquippedChildRenderController : IDisposable
{
private readonly DatCollection _dats;
private readonly object _datLock;
private readonly ClientObjectTable _objects;
private readonly GpuWorldState _worldState;
private readonly Func<uint, WorldEntity?> _resolveEntity;
private readonly Func<uint, WorldSession.EntitySpawn?> _resolveSpawn;
private readonly Func<uint> _nextEntityId;
private readonly Dictionary<uint, PendingAttachment> _pendingByChild = new();
private readonly Dictionary<uint, PendingAttachment> _lastRelationByChild = new();
private readonly Dictionary<uint, AttachedChild> _attachedByChild = new();
private readonly Dictionary<uint, ushort> _positionSequenceByChild = new();
public IEnumerable<uint> AttachedEntityIds
{
get
{
foreach (AttachedChild child in _attachedByChild.Values)
yield return child.Entity.Id;
}
}
public EquippedChildRenderController(
DatCollection dats,
object datLock,
ClientObjectTable objects,
GpuWorldState worldState,
Func<uint, WorldEntity?> resolveEntity,
Func<uint, WorldSession.EntitySpawn?> resolveSpawn,
Func<uint> 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;
}
/// <summary>
/// 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.
/// </summary>
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]);
}
}
/// <summary>
/// Apply/queue a live ParentEvent using retail's two sequence gates:
/// parent instance must match, child position must advance strictly.
/// </summary>
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);
}
/// <summary>Recompose every child after the parent's animation tick.</summary>
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<MeshRef> 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<Setup>(parentSetupId);
Setup? childSetup = _dats.Get<Setup>(childSetupId);
if (parentSetup is null || childSetup is null)
return;
var parentLocation = (ParentLocation)pending.ParentLocation;
var placement = (Placement)pending.PlacementId;
IReadOnlyList<MeshRef> 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<MeshRef> 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<MeshRef> 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<CreateObject.AnimPartChange> partChanges =
spawn.AnimPartChanges ?? Array.Empty<CreateObject.AnimPartChange>();
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<CreateObject.TextureChange> textureChanges =
spawn.TextureChanges ?? Array.Empty<CreateObject.TextureChange>();
for (int partIndex = 0; partIndex < result.Length; partIndex++)
{
Dictionary<uint, uint>? oldToNew = null;
for (int t = 0; t < textureChanges.Count; t++)
{
CreateObject.TextureChange change = textureChanges[t];
if (change.PartIndex != partIndex) continue;
oldToNew ??= new Dictionary<uint, uint>();
oldToNew[change.OldTexture] = change.NewTexture;
}
if (oldToNew is null) continue;
GfxObj? gfx = _dats.Get<GfxObj>(result[partIndex].GfxObjId);
if (gfx is null) continue;
Dictionary<uint, uint>? surfaceOverrides = null;
foreach (var surfaceQid in gfx.Surfaces)
{
uint surfaceId = (uint)surfaceQid;
Surface? surface = _dats.Get<Surface>(surfaceId);
if (surface is null) continue;
uint oldTexture = (uint)surface.OrigTextureId;
if (!oldToNew.TryGetValue(oldTexture, out uint replacement)) continue;
surfaceOverrides ??= new Dictionary<uint, uint>();
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<MeshRef> PartTemplate,
float Scale,
WorldEntity Entity);
}

View file

@ -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<uint, AcDream.Core.World.WorldEntity> _entitiesByServerGuid = new();
/// <summary>
/// Latest <see cref="AcDream.Core.Net.WorldSession.EntitySpawn"/> for each
/// guid. Captured at the end of <see cref="OnLiveEntitySpawnedLocked"/> so
/// <see cref="OnLiveAppearanceUpdated"/> 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 <see cref="OnLiveEntitySpawnedLocked"/>.
/// <see cref="OnLiveAppearanceUpdated"/> reuses the cached position/setup/motion
/// fields when a 0xF625 ObjDescEvent arrives carrying only updated visuals.
/// </summary>
private readonly Dictionary<uint, AcDream.Core.Net.WorldSession.EntitySpawn> _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<uint>? 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<AcDream.Core.Items.ClientObject>(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

View file

@ -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,

View file

@ -0,0 +1,42 @@
using System.Buffers.Binary;
namespace AcDream.Core.Net.Messages;
/// <summary>
/// Inbound retail <c>ParentEvent</c> (<c>0xF749</c>): 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: <c>CM_Physics::DispatchSB_ParentEvent @ 0x006ACAF0</c>.
/// Runtime oracle: <c>SmartBox::HandleParentEvent @ 0x004535D0</c> and
/// <c>SmartBox::DoParentEvent @ 0x00452290</c>.
/// </summary>
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<byte> 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)));
}
}

View file

@ -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
/// </summary>
public event Action<VectorUpdate.Parsed>? VectorUpdated;
/// <summary>
/// Fires for retail <c>ParentEvent (0xF749)</c>, which attaches a separate
/// child object to a creature holding location (weapons, shields, ammo).
/// </summary>
public event Action<ParentEvent.Parsed>? ParentUpdated;
/// <summary>
/// Fires when the server broadcasts a <c>SetState (0xF74B)</c> game
/// message — a previously-spawned entity's <c>PhysicsState</c>
@ -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

View file

@ -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
/// </summary>
public static class CombatInputPlanner
{
private const EquipMask PrimaryWeaponLocations =
EquipMask.MeleeWeapon | EquipMask.MissileWeapon | EquipMask.TwoHanded;
/// <summary>
/// Port of retail <c>ClientCombatSystem::GetDefaultCombatMode</c>
/// (0x0056B310). <paramref name="orderedPlayerContents"/> must be in the
/// player's inventory-placement order; retail returns the first equipped
/// object intersecting the requested location mask.
/// </summary>
public static CombatMode GetDefaultCombatMode(
IReadOnlyList<ClientObject> 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<ClientObject> 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)

View file

@ -65,6 +65,15 @@ public sealed class ClientObjectTable
/// </summary>
public event Action<ClientObject, uint, uint>? ObjectMoved;
/// <summary>
/// Fires after an optimistic inventory/equipment move is rejected and
/// <see cref="RollbackMove"/> 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.
/// </summary>
public event Action<ClientObject>? MoveRolledBack;
/// <summary>Fires when an object is removed from the session.</summary>
public event Action<ClientObject>? 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;
}
/// <summary>

View file

@ -0,0 +1,85 @@
using System.Numerics;
using AcDream.Core.World;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
using DatReaderWriter.Types;
namespace AcDream.Core.Meshing;
/// <summary>
/// Retail held-object transform composition. A weapon is a separate child
/// physics object: the parent's <see cref="Setup.HoldingLocations"/> selects
/// a hand part and a frame relative to it, while the child's placement frame
/// poses the weapon's own setup parts.
///
/// Sources: <c>CPhysicsObj::add_child @ 0x0050F870</c>,
/// <c>CPhysicsObj::UpdateChild @ 0x00512D50</c>,
/// <c>CPartArray::SetPlacementFrame @ 0x005193D0</c>, and
/// <c>Frame::combine @ 0x005122E0</c>.
/// </summary>
public static class EquippedChildAttachment
{
public static bool TryCompose(
Setup parentSetup,
IReadOnlyList<MeshRef> currentParentPose,
Setup childSetup,
ParentLocation parentLocation,
Placement placement,
IReadOnlyList<MeshRef> childPartTemplate,
float childScale,
out IReadOnlyList<MeshRef> attachedParts)
{
ArgumentNullException.ThrowIfNull(parentSetup);
ArgumentNullException.ThrowIfNull(currentParentPose);
ArgumentNullException.ThrowIfNull(childSetup);
ArgumentNullException.ThrowIfNull(childPartTemplate);
if (!parentSetup.HoldingLocations.TryGetValue(parentLocation, out LocationType? holding))
{
attachedParts = Array.Empty<MeshRef>();
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);
}

View file

@ -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.

View file

@ -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));
}
}

View file

@ -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,
};
}

View file

@ -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]

View file

@ -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,
};
}