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