diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 5a383538..6483848e 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -103,6 +103,63 @@ Copy this block when adding a new issue: --- +## #247 — Loot ordering and local dropped-item projection regressed + +**Status:** DONE — 2026-07-26; connected loot/drop/selection gate passed +**Severity:** HIGH +**Filed:** 2026-07-26 +**Component:** inventory / live-entity projection + +**Description:** Double-click loot transferred successfully but settled at an +unrelated backpack position. Locally dropped items were authoritative and +visible to retail observers, yet remained invisible in acdream. + +**Root cause / status:** The compact container projection sorted a completed +server move by stale slot state instead of performing retail's separate +remove-then-`IDList::AddAtNum` insertion. Whole-object drops then exposed two +projection errors. Inventory-only objects retained canonical CreateObject and +timestamp state without an App projection sidecar; the Position authority gate +committed their server position and then rejected the presentation tail +because it required a pre-existing `LiveEntityRecord`. Accepted Position now +returns the exact canonical incarnation, and the network presenter crosses +the normal spatial-hydration boundary before continuing. Objects which already +retain a sidecar recover whenever `IsSpatiallyProjected` is false. +`EquippedChildRenderController` also no longer interprets every final `None` +equip mask as a detach; it requires the actual equipped-to-unequipped edge. + +Partial-stack drops have an additional ACE-specific wire omission. +`HandleActionStackableSplitTo3D` creates a new GUID and sends Position updates, +but does not send that new object's CreateObject to the initiating session. +Retail records the source WCID, selected count, and request time for ten +seconds so the created object can be recognized. acdream now retains that same +single pending identity; the one matching unknown ACE Position hydrates a +canonical clone of the source appearance through the normal live-entity path. +Reconnect evidence proved all prior drops were persisted and received as +ordinary CreateObjects. Focused ordering, projection, and pending-identity +tests pass, including stale-authority rejection, first projection construction, +and no duplicate resource/ready publication. + +The follow-up stale marker was a separate projection-lifetime ordering edge. +Retail keeps canonical selection but suppresses its vivid world marker while +the object is player-owned or `IN_CONTAINER`. acdream additionally must not +mistake a retained leave-world `WorldEntity` pose for a current ground pose: +ACE may publish the drop placement before the new Position. Marker resolution +now requires both world placement and a current top-level spatial projection. +The inverse packet order is also safe: a fresh Position remains suppressed by +ownership until the placement arrives. This uses no timer and does not impose +camera/PVS visibility, preserving off-screen and through-wall indicators. + +**Research:** +[`research/2026-07-26-retail-inventory-placement-and-world-drop-pseudocode.md`](research/2026-07-26-retail-inventory-placement-and-world-drop-pseudocode.md). + +**Acceptance:** Looted items occupy the retail-selected backpack slot in +server order; locally and remotely dropped items appear once in the world; +pickup removes the ground marker without clearing inventory selection; re-drop +shows the marker only at the new authoritative pose. User accepted all four +connected checks on 2026-07-26. + +--- + ## #246 — Prepared indoor cells stopped traversing their portals **Status:** DONE — 2026-07-26; connected house/dungeon gate passed diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 7a0a1600..5a41e852 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -108,7 +108,7 @@ accepted-divergence entries (#96, #49, #50). --- -## 3. Documented approximation (AP) — 90 active rows +## 3. Documented approximation (AP) — 91 active rows Wave-0 UI ledger repair (2026-07-10) retired stale AP-38, resolved the AP-84 collision, restored overwritten paperdoll rows as AP-92/AP-93, and registered @@ -221,6 +221,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-121 | The `/framerate` command persists acdream's live ShowFps value in the modern `settings.json` Display bag after performing retail's live toggle/notice behavior. The named retail `DoFrameRate` body itself only flips `fShowFramerate` and sends `SetFramerateDisplay`; no equivalent persistence write occurs in that command body. | `src/AcDream.App/Settings/RuntimeSettingsController.cs` (`ToggleFrameRate`) | This preserves acdream's shipped L.0 cross-launch display preference while keeping the live retail mechanism and SmartBox visibility result exact; it changes no gameplay or wire state | A relaunch can retain FPS visibility where the named retail command body alone would not; a future port of retail's wider preference owner must avoid double-persisting or applying conflicting startup state | `ClientCommunicationSystem::DoFrameRate @ 0x005707D0`; `CM_UI::SendNotice_SetFramerateDisplay @ 0x0047A050`; `gmSmartBoxUI::RecvNotice_SetFramerateDisplay @ 0x004D65E0` | | AP-122 | The toolbar Use hand is Ghosted when selection is empty. Retail sets state 1 (Normal) and an empty-selection click enters generic `TARGET_MODE_USE`; selected-object enablement and activation remain retail-faithful. | `src/AcDream.App/UI/Layout/ToolbarController.cs` (`RefreshUseButton`) | User explicitly requires no selection to present as unavailable. The disabled retained button consumes the click without opening a cursor; every nonempty selection still uses the exact retail CombatUse/type/ItemUses predicate and the shared activation owner. | Generic pick-then-use mode cannot be entered from the empty toolbar hand; an object must first be selected, after which the same Use command is available. | `gmToolbarUI::HandleSelectionChanged @ 0x004BF380`; `gmToolbarUI::ListenToElementMessage @ 0x004BEE90` | | AP-123 | Item cooldowns use the retained toolkit's existing procedural `UiItemSlot` leaf rather than materializing retail's ten `m_elem_Icon_Cooldown_*` child elements. The group lookup, remaining-time formula, exact DAT sprites, step choice, and topmost ReadOrder-8 outcome are faithful. | `src/AcDream.App/UI/Layout/ItemCooldownUiController.cs`; `src/AcDream.App/UI/UiItemSlot.cs` | `UiItemSlot` already consumes/reproduces UIItem children procedurally under the IA-15 retained-toolkit architecture. Selecting one imported sprite at draw time gives every inventory/equipment/shortcut alias the same output without a parallel widget tree or per-cell timers. | A future feature that observes the individual cooldown child visibility/state rather than the rendered UIItem could see no child elements even though the cell looks and advances correctly. | `CEnchantmentRegistry::OnCooldown @ 0x005943C0`; `UIElement_UIItem::UpdateCooldownDisplay @ 0x004E1E20`; common UIItem prototype `0x1000033E` | +| AP-124 | Local ACE omits the new object's CreateObject to the initiating session after `StackableSplitTo3D`, sending only F748 Position for the previously unknown GUID. acdream retains retail's one pending split source/count/time identity for ten seconds and, only for that otherwise-impossible unknown Position, hydrates a canonical clone of the source description with the new GUID and authoritative world placement. | `src/AcDream.App/World/InventoryWorldDropProjectionController.cs`; `src/AcDream.App/UI/ItemInteractionController.cs`; `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` | Nearby/reconnecting clients receive the ordinary CreateObject, while the initiator otherwise cannot render the authoritative object until relog. A server that sends CreateObject never enters this path; the pending identity is consumed before normal hydration and all other unknown Positions remain rejected. | ACE's F748 does not carry WCID or stack size, so an unrelated unknown Position arriving during the exact pending ten-second window could be associated with the split. Retail confirms WCID/count from CreateObject; removing the approximation requires ACE to send that packet to the initiator. | `ACCWeenieObject::UIAttemptSplitTo3D @ 0x0058D850`; `ACCWeenieObject::DeclareValid @ 0x0058E340`; ACE `Player.HandleActionStackableSplitTo3D` / `TryDropItem`; `docs/research/2026-07-26-retail-inventory-placement-and-world-drop-pseudocode.md` | ## 4. Temporary stopgap (TS) — 38 active rows + TS-37 historical note (TS-20 retired 2026-07-16 — the later named-retail audit disproved the proposed DrawingBSP polygon filter; 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-42 retired 2026-07-19 — semantic animation completion now precedes the ordered Target/Movement/PartArray/Position tail; TS-44 narrowed again 2026-07-19 — complete orientation joined interpolation, only during-stick enqueue suppression remains) diff --git a/docs/research/2026-07-26-retail-inventory-placement-and-world-drop-pseudocode.md b/docs/research/2026-07-26-retail-inventory-placement-and-world-drop-pseudocode.md new file mode 100644 index 00000000..a1e4c7cd --- /dev/null +++ b/docs/research/2026-07-26-retail-inventory-placement-and-world-drop-pseudocode.md @@ -0,0 +1,276 @@ +# Retail inventory placement and world-drop confirmation + +## Scope + +This note pins the two retail mechanisms needed by the 2026-07-26 loot/drop +regression repair: + +- where an authoritative pickup is inserted in a container's ordered contents; +- why an `InventoryPutObjectIn3D` confirmation must not be treated as an + equipped-object detach. + +The client remains server-authoritative. Pending drag/drop presentation may +reserve a slot, but the accepted `ServerSaysMoveItem` placement is the +canonical container order. + +## Named-retail anchors + +- `CPlayerSystem::PlaceInBackpack @ 0x0055D8C0` +- `ACCWeenieObject::AddContent @ 0x0058CCE0` +- `ACCWeenieObject::RemoveContent @ 0x0058CD70` +- `ACCWeenieObject::UIAttemptPutIn3D @ 0x0058D700` +- `ACCWeenieObject::UIAttemptSplitTo3D @ 0x0058D850` +- `ACCWeenieObject::ServerSaysMoveItem @ 0x0058DBB0` +- `ACCWeenieObject::IsOwnedByObject @ 0x0058CEB0` +- `ACCWeenieObject::IsOwnedByPlayer @ 0x0058D160` +- `ACCWeenieObject::DeclareValid @ 0x0058E340` +- `VividTargetIndicator::SetSelected @ 0x004F5CE0` +- `VividTargetIndicator::RecvNotice_ServerSaysMoveItem @ 0x004F5F40` +- `gmInventoryUI::RecvNotice_ShowPendingInPlayer @ 0x004A6B50` +- `UIElement_ItemList::ItemList_InsertItem @ 0x004E3F40` + +Source: +`docs/research/named-retail/acclient_2013_pseudo_c.txt`. + +## Container-order pseudocode + +```text +PlaceInBackpack(itemId, forceMainPack): + notify UI that itemId is pending in the player + destination = openContainer + if forceMainPack or MainPackPreferred: + destination = player + AttemptToPlaceInContainer( + itemId, + playerId, + destination, + wholeStack = true, + placement = 0) + +ServerSaysMoveItem(item, newContainerId, placement, newWielderId, location): + if item.oldContainerId != 0: + oldContainer.RemoveContent(item) + + item.containerId = newContainerId + if newContainerId != 0: + newContainer.AddContent(item, placement) + + update wield and location state + clear the matching pending item request + publish the move notice + +AddContent(container, item, placement): + list = item is a container ? container.containersList + : container.itemsList + if item is not already in list: + insertionIndex = min(placement, list.count) + list.AddAtNum(item.id, insertionIndex) + publish ItemAttributesChanged +``` + +Consequences: + +1. A normal pickup carries placement zero and inserts at the head of the + relevant retail list. +2. Items and subcontainers have separate ordered lists. A compact client model + may store both in one array, but it must preserve the insertion order within + each category. +3. Removing the item from every stale container projection must precede the + authoritative insert; otherwise a duplicate or an old equal-slot entry can + win a stable sort. +4. The raw accepted placement remains part of the move notice. Renumbering + neighboring UI slots is a derived projection, not a rewrite of the server + packet. + +## World-drop state pseudocode + +```text +ServerSaysMoveItem(item, containerId = 0, wielderId = 0, location = 0): + remove item from its old container + item.containerId = 0 + if item was wielded by the player: + clear player wield location + item.wielderId = 0 + item.location = 0 + item.currentState = not-in-container / not-wielded +``` + +The move confirmation describes ownership, not spatial creation. The live +object's Position/CreateObject channel owns its world pose and projection. +Therefore: + +```text +if previous equip location != None + and current equip location == None: + withdraw the old equipped-child projection + begin the detached-object handoff +else: + do not withdraw a recovered world projection +``` + +A ground drop can arrive as: + +```text +Position (recover/rebucket existing live object into the world) +then InventoryPutObjectIn3D / ServerSaysMoveItem (None -> None) +``` + +Inventory-only objects retain their canonical CreateObject snapshot and +retail timestamp authority but may never have crossed the App projection +boundary. Position admission must therefore resolve the canonical incarnation +first, consume the shared Position timestamp there, and only then construct +the first `LiveEntityRecord`/world projection from the accepted canonical +snapshot. Requiring a pre-existing projection record at the authority gate +drops the presentation tail after authority has already accepted the packet; +reconnect appears to fix it only because the new session receives a complete +CreateObject and takes the ordinary creation path. + +Treating every `current EquipLocation == None` notice as an unequip tears down +the world projection that the preceding Position just recovered. This is why +the local client could fail to draw its own dropped item while another client, +which received a normal world CreateObject, drew it correctly. + +## Selection versus vivid world-marker lifetime + +Retail keeps the canonical selected object and the vivid world marker as +separate projections. Every authoritative move notice makes the marker +controller re-evaluate the current selection: + +```text +RecvNotice_ServerSaysMoveItem: + SetSelected(ACCWeenieObject.selectedID) + +SetSelected(selectedID): + object = GetWeenieObject(selectedID) + + if selectedID == playerID + or object is missing + or object.IsOwnedByPlayer() + or object.currentState == IN_CONTAINER: + markerTargetID = 0 + hide on-screen and off-screen marker elements + return + + markerTargetID = selectedID + update radar color and marker geometry +``` + +`ACCWeenieObject::IsOwnedByPlayer` delegates to +`IsOwnedByObject(playerID)`. The latter accepts direct containment or +wielding, an item inside one of the owner's pack containers, or an item on one +of the owner's locations. + +Consequently, picking a selected ground item up does **not** clear the shared +selection used by the inventory. It only makes the vivid marker ineligible. +Dropping that same GUID again makes the marker eligible at its new authoritative +world pose. A retained `WorldEntity` and its old position are never sufficient +reason to draw a marker while the object is owned, in any container, or between +`leave_world` and the next accepted spatial projection. + +ACE may deliver the drop placement and Position in either order. The marker +therefore requires both independent truths: + +```text +placement says IN_3D_VIEW +and +the current incarnation has entered the spatial world after pickup +``` + +If Position arrives first, ownership suppresses the freshly projected pose +until the placement follows. If placement arrives first, the withdrawn spatial +state suppresses the retained old pose until Position follows. This is an +ordering gate with no timer and does not use camera/PVS visibility, so +off-screen and through-wall target indicators retain retail behavior. + +## Retail split-to-world identity + +Dropping a selected portion of a stack creates a different object. Retail +retains enough request identity to recognize that new object when its +CreateObject becomes valid: + +```text +UIAttemptSplitTo3D(source, amount): + if player cannot start an inventory request: + return + + send StackableSplitTo3D(source.id, amount) + prevRequestObjectID = source.id + prevRequest = IR_SPLIT + prevRequestTime = now + splitClassID = source.wcid + splitStackSize = amount + splitTime = now + +DeclareValid(createdObject): + publish normal object/item validity notices + + if splitClassID is valid: + createdAmount = createdObject.stackSize + if createdAmount == 0: + createdAmount = 1 + + if createdObject.wcid == splitClassID + and createdAmount == splitStackSize: + select createdObject + clear splitClassID + return + + if now - splitTime >= 10 seconds: + clear splitClassID +``` + +The matching identity is one global pending value, not a queue. A subsequent +split replaces it. The exact boundary is ten seconds; equality expires. + +## ACE initiator packet difference + +ACE `Player.HandleActionStackableSplitTo3D` creates a new world object, sets +its stack size, and calls `TryDropItem`. `TryDropItem` publishes the new object +to the landblock and sends Position updates. Unlike ACE's split-to-container +path, split-to-3D does not enqueue `GameMessageCreateObject(newStack)` to the +initiating session. Nearby clients learn the object through the ordinary +landblock broadcast, and a reconnect receives its full CreateObject, but the +initiator can receive only F748 Position packets for a previously unknown +GUID. + +That is why the live trace showed the newly split GUID rejected as unknown. +Whole drops showed the distinct canonical-only projection failure above; both +classes appeared normally after reconnect because reconnect supplied complete +CreateObjects. + +acdream keeps the retail pending source/amount/time identity after the +successful wire send. For the one otherwise-impossible ACE case—an unknown +Position while that exact split identity is younger than ten seconds—it +clones the source object's canonical CreateObject description, replaces +ownership/parenting with the authoritative ground Position and new GUID, and +enters the normal `LiveEntityHydrationController` registration path. The +pending identity is consumed before hydration so re-entrant packets cannot +bind a second GUID. All unrelated unknown Position packets continue through +the normal reject path. + +This is an ACE protocol adaptation, recorded as AP-124. A server that sends +the expected CreateObject never enters the adaptation. + +## Reference cross-checks + +- ACE: + `ACE.Server.WorldObjects.Player.HandleActionStackableSplitTo3D` and + `TryDropItem` confirm the initiating-session omission described above. +- ACViewer has no inventory transaction client; it does not contradict the + retail request identity or ACE packet sequence. +- WorldBuilder is a DAT/world editor and contains no network inventory + transaction path. + +## Conformance coverage + +- `ClientObjectTableTests.AuthoritativePickup_PlacementZeroInsertsAtRetailListHead` +- `ClientObjectTableTests.AuthoritativeContainerMove_IndexesOnlyTheRetailContainerList` +- `ClientObjectTableTests.IsOwnedByObjectMatchesRetailDirectPackAndLocationRules` +- `EquippedChildProjectionWithdrawalTests.InventoryPutObjectIn3D_DoesNotWithdrawRecoveredWorldProjection` +- `WorldSelectionQueryTests.VividTargetMarkerWaitsForFreshProjectionWhenDropMovePrecedesPosition` +- `WorldSelectionQueryTests.VividTargetMarkerUsesFreshPoseWhenPositionPrecedesDropMove` +- `WorldSelectionQueryTests.VividTargetMarkerRejectsRetainedProjectionInsideExternalContainer` +- `PendingSplitToWorldProjectionTests.Resolve_ClonesSourceAppearanceIntoAuthoritativeGroundPlacement` +- `PendingSplitToWorldProjectionTests.Resolve_ConsumesOneRetailPendingIdentity` +- `PendingSplitToWorldProjectionTests.Resolve_RejectsAtRetailTenSecondBoundary` +- `PendingSplitToWorldProjectionTests.Resolve_SourceGuidDoesNotConsumePendingIdentity` diff --git a/src/AcDream.App/Composition/SessionPlayerComposition.cs b/src/AcDream.App/Composition/SessionPlayerComposition.cs index 1d44f1d9..e9fbc226 100644 --- a/src/AcDream.App/Composition/SessionPlayerComposition.cs +++ b/src/AcDream.App/Composition/SessionPlayerComposition.cs @@ -503,6 +503,17 @@ internal sealed class SessionPlayerCompositionPhase live.LandblockLoaded.Bind(hydration)); Fault(SessionPlayerCompositionPoint.HydrationCreated); + var worldDropProjection = + new InventoryWorldDropProjectionController( + interaction.ItemInteraction, + d.EntityObjects.Objects, + live.LiveEntities, + hydration, + () => d.UpdateClock.SimulationTimeSeconds); + bindings.Adopt( + "inventory world-drop projection", + worldDropProjection); + var networkUpdates = new LiveEntityNetworkUpdateController( live.LiveEntities, d.EntityObjects.Objects, @@ -531,7 +542,8 @@ internal sealed class SessionPlayerCompositionPhase d.UpdateClock, liveSessionSource, localPhysicsTimestamps.Publish, - d.MovementDiagnostics); + d.MovementDiagnostics, + worldDropProjection); var liveness = new LiveEntityLivenessController( live.LiveEntities, d.PlayerIdentity, diff --git a/src/AcDream.App/Interaction/SelectionInteractionController.cs b/src/AcDream.App/Interaction/SelectionInteractionController.cs index 49f9191f..ca95ec1e 100644 --- a/src/AcDream.App/Interaction/SelectionInteractionController.cs +++ b/src/AcDream.App/Interaction/SelectionInteractionController.cs @@ -230,12 +230,6 @@ internal sealed class SelectionInteractionController CancelPickupPresentation(itemGuid); return; } - if (!ValidatePickupTarget(itemGuid, showToast: true) - || !_query.TryGetApproach(itemGuid, out InteractionApproach approach)) - { - CancelPickupPresentation(itemGuid); - return; - } ulong pendingPlacementToken = _items.TryGetPendingBackpackPlacement( itemGuid, @@ -251,6 +245,42 @@ internal sealed class SelectionInteractionController return; } + // Retail CPlayerSystem::PlaceInBackpack @ 0x0055D8C0 delegates + // current-ground-object contents straight to + // ItemHolder::AttemptToPlaceInContainer @ 0x00588140. A corpse or + // chest child has no independent 3-D projection to approach. + if (_items.IsInCurrentGroundObject(itemGuid)) + { + var contained = new RuntimePendingPickup( + Token: 0u, + itemGuid, + LocalEntityId: 0u, + destinationContainerId, + placement, + pendingPlacementToken, + ApproachToken: default); + if (_transactions.TryDispatchPickup( + contained, + _transport, + out uint containedSequence)) + { + Console.WriteLine( + $"[B.5] contained pickup item=0x{itemGuid:X8} container=0x{destinationContainerId:X8} placement={placement} seq={containedSequence}"); + } + else + { + CancelPickupPresentation(itemGuid, pendingPlacementToken); + } + return; + } + + if (!ValidatePickupTarget(itemGuid, showToast: true) + || !_query.TryGetApproach(itemGuid, out InteractionApproach approach)) + { + CancelPickupPresentation(itemGuid, pendingPlacementToken); + return; + } + if (approach.IsCloseRange) { bool armed = false; diff --git a/src/AcDream.App/Interaction/WorldSelectionQuery.cs b/src/AcDream.App/Interaction/WorldSelectionQuery.cs index 1f6bbfbc..6bbe9872 100644 --- a/src/AcDream.App/Interaction/WorldSelectionQuery.cs +++ b/src/AcDream.App/Interaction/WorldSelectionQuery.cs @@ -261,7 +261,19 @@ internal sealed class WorldSelectionQuery public VividTargetInfo? ResolveVividTargetInfo(uint serverGuid) { - if (!_liveEntities.TryGetWorldEntity(serverGuid, out _) + uint playerGuid = _playerGuid(); + ClientObject? target = _objects.Get(serverGuid); + + // VividTargetIndicator::SetSelected @ 0x004F5CE0 deliberately keeps + // ACCWeenieObject::selectedID intact while suppressing its own marker + // for self, player-owned objects, and objects whose current state is + // IN_CONTAINER. ContainerId is our authoritative placement projection + // for that retail state. + if (serverGuid == playerGuid + || target is null + || _objects.IsOwnedByObject(serverGuid, playerGuid) + || target.ContainerId != 0u + || !_liveEntities.TryGetSpatiallyProjectedRecord(serverGuid, out _) || !TryGetSelectionSphere(serverGuid, out Vector3 center, out float radius)) { return null; diff --git a/src/AcDream.App/Physics/LiveEntityInboundAuthorityGate.cs b/src/AcDream.App/Physics/LiveEntityInboundAuthorityGate.cs index cae94521..d8f59b4f 100644 --- a/src/AcDream.App/Physics/LiveEntityInboundAuthorityGate.cs +++ b/src/AcDream.App/Physics/LiveEntityInboundAuthorityGate.cs @@ -27,7 +27,7 @@ internal readonly record struct AcceptedStateNetworkUpdate( internal readonly record struct AcceptedPositionNetworkUpdate( WorldSession.EntityPositionUpdate Update, - LiveEntityRecord Record, + RuntimeEntityRecord Canonical, WorldSession.EntitySpawn Spawn, AcceptedPhysicsTimestamps Timestamps, PositionTimestampDisposition TimestampDisposition, @@ -173,22 +173,22 @@ internal sealed class LiveEntityInboundAuthorityGate return false; } - LiveEntityRecord? record = null; + RuntimeEntityRecord? canonical = null; ulong positionAuthorityVersion = 0; ulong velocityAuthorityVersion = 0; if (disposition is not PositionTimestampDisposition.Rejected) { - if (!_liveEntities.TryGetRecord(update.Guid, out record)) + if (!_liveEntities.TryGetCanonical(update.Guid, out canonical)) return false; - positionAuthorityVersion = record.PositionAuthorityVersion; - velocityAuthorityVersion = record.VelocityAuthorityVersion; + positionAuthorityVersion = canonical.PositionAuthorityVersion; + velocityAuthorityVersion = canonical.VelocityAuthorityVersion; } _publishTimestamps(update.Guid, timestamps); if (disposition is PositionTimestampDisposition.Rejected) return false; if (!_liveEntities.IsCurrentPositionAuthority( - record!, + canonical!, positionAuthorityVersion)) { return false; @@ -196,7 +196,7 @@ internal sealed class LiveEntityInboundAuthorityGate accepted = new AcceptedPositionNetworkUpdate( update, - record!, + canonical!, spawn, timestamps, disposition, diff --git a/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs b/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs index 34ec832f..62bddc45 100644 --- a/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs +++ b/src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs @@ -61,6 +61,8 @@ internal sealed class LiveEntityNetworkUpdateController private readonly ILiveWorldSessionSource _session; private readonly LiveEntityInboundAuthorityGate _authorityGate; private readonly IMovementTruthDiagnosticSink _movementTruthDiagnostics; + private readonly InventoryWorldDropProjectionController? + _worldDropProjection; private PlayerMovementController? _playerController => _playerControllerSource.Controller; private EntityPhysicsHost? _playerHost => _playerHostSource.Host; @@ -100,7 +102,8 @@ internal sealed class LiveEntityNetworkUpdateController IPhysicsScriptTimeSource gameTime, ILiveWorldSessionSource session, Action publishTimestamps, - IMovementTruthDiagnosticSink movementTruthDiagnostics) + IMovementTruthDiagnosticSink movementTruthDiagnostics, + InventoryWorldDropProjectionController? worldDropProjection = null) { _liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities)); _objects = objects ?? throw new ArgumentNullException(nameof(objects)); @@ -135,6 +138,7 @@ internal sealed class LiveEntityNetworkUpdateController publishTimestamps); _movementTruthDiagnostics = movementTruthDiagnostics ?? throw new ArgumentNullException(nameof(movementTruthDiagnostics)); + _worldDropProjection = worldDropProjection; } internal void ResetSessionState() => _authorityGate.ResetSessionState(); @@ -936,6 +940,11 @@ internal sealed class LiveEntityNetworkUpdateController public void OnPosition(AcDream.Core.Net.WorldSession.EntityPositionUpdate update) { + if (_worldDropProjection?.TryRecoverUnknownPosition(update) == true) + { + return; + } + bool payloadIsValid = _projectileController?.CanAcceptPositionPayload( update.Guid, update.Position, @@ -957,11 +966,22 @@ internal sealed class LiveEntityNetworkUpdateController var timestampDisposition = accepted.TimestampDisposition; var acceptedSpawn = accepted.Spawn; var timestamps = accepted.Timestamps; - LiveEntityRecord acceptedPositionRecord = accepted.Record; + RuntimeEntityRecord acceptedPositionCanonical = accepted.Canonical; ulong acceptedPositionAuthorityVersion = accepted.PositionAuthorityVersion; ulong acceptedPositionVelocityAuthorityVersion = accepted.VelocityAuthorityVersion; + if (!_liveEntities.TryGetProjection( + acceptedPositionCanonical, + out LiveEntityRecord acceptedPositionRecord) + && !_liveEntityHydration.RecoverCanonicalProjection( + acceptedPositionCanonical, + acceptedPositionAuthorityVersion, + out acceptedPositionRecord)) + { + return; + } + bool IsCurrentPositionOwner( AcDream.Core.World.WorldEntity? expectedEntity = null) => _liveEntities.IsCurrentPositionAuthority( @@ -1013,7 +1033,12 @@ internal sealed class LiveEntityNetworkUpdateController _playerController))) return; - if (!_liveEntities.ContainsWorldEntity(update.Guid)) + // A leave-world transition deliberately retains WorldEntity as the + // logical/render-resource owner while IsSpatiallyProjected is false. + // A fresh retail Position is the re-entry edge; testing only for the + // retained object reference leaves dropped inventory permanently + // invisible after InventoryPutObjectIn3D. + if (RequiresSpatialProjectionRecovery(acceptedPositionRecord)) { if (!IsCurrentPositionOwner()) return; @@ -1771,6 +1796,13 @@ internal sealed class LiveEntityNetworkUpdateController } } + internal static bool RequiresSpatialProjectionRecovery( + LiveEntityRecord record) + { + ArgumentNullException.ThrowIfNull(record); + return record.WorldEntity is null || !record.IsSpatiallyProjected; + } + // Retail teleport transit: the 7-state TAS drives portal/view-plane presentation, holds the // player in PortalSpace until the destination is resident (TeleportWorldReady), then // fires Place (materialize) and FireLoginComplete (regain control + ack the server). diff --git a/src/AcDream.App/Rendering/EquippedChildRenderController.cs b/src/AcDream.App/Rendering/EquippedChildRenderController.cs index 1cfa80f4..e408afa8 100644 --- a/src/AcDream.App/Rendering/EquippedChildRenderController.cs +++ b/src/AcDream.App/Rendering/EquippedChildRenderController.cs @@ -993,7 +993,13 @@ public sealed class EquippedChildRenderController : IDisposable private void OnObjectMoved(ClientObjectMove move) { - if (move.Current.EquipLocation == EquipMask.None) + // ServerSaysMoveItem publishes both complete placements. Only an + // equipped -> unequipped transition retires an attached paperdoll + // projection. InventoryPutObjectIn3D also has EquipLocation=None, but + // it is a world-entry confirmation and must never withdraw a Position + // projection that arrived just before the F019A event. + if (move.Previous.EquipLocation != EquipMask.None + && move.Current.EquipLocation == EquipMask.None) BeginDetachedRemoval(move.ItemId); } diff --git a/src/AcDream.App/UI/ItemInteractionController.cs b/src/AcDream.App/UI/ItemInteractionController.cs index 711118e2..8ac8f607 100644 --- a/src/AcDream.App/UI/ItemInteractionController.cs +++ b/src/AcDream.App/UI/ItemInteractionController.cs @@ -179,6 +179,14 @@ public sealed class ItemInteractionController : IDisposable /// public event Action? PendingBackpackPlacementResolved; + /// + /// Publishes the exact split-to-world request after its wire send has + /// succeeded. Retail ACCWeenieObject::UIAttemptSplitTo3D @ + /// 0x0058D850 retains the source class, selected amount, and request + /// time so the subsequently created ground stack can be recognized. + /// + public event Action? WorldDropDispatched; + public uint PlayerGuid => _playerGuid(); public InteractionState InteractionState => _interactionState; @@ -302,14 +310,10 @@ public sealed class ItemInteractionController : IDisposable /// /// Retail ACCWeenieObject::IsOwnedByPlayer projection shared with - /// toolbar shortcut creation. Ownership includes self, equipped items, and - /// arbitrarily nested carried containers. + /// toolbar shortcut creation. /// public bool IsOwnedByPlayer(uint itemGuid) - => _objects.Get(itemGuid) is { } item - && (item.ObjectId == _playerGuid() - || IsCarriedByPlayer(item) - || IsEquippedByPlayer(item)); + => _objects.IsOwnedByObject(itemGuid, _playerGuid()); public bool IsCurrentTargetCompatible(uint targetGuid) { @@ -721,6 +725,21 @@ public sealed class ItemInteractionController : IDisposable return item is not null; } + /// + /// Returns whether the object is a member of retail's current + /// ClientUISystem::groundObject. Such objects are container + /// contents, not independently projected world objects, so + /// CPlayerSystem::PlaceInBackpack @ 0x0055D8C0 sends their + /// container transfer directly without a 3-D approach lookup. + /// + internal bool IsInCurrentGroundObject(uint objectId) + { + uint groundObjectId = _groundObjectId(); + return groundObjectId != 0u + && _objects.Get(objectId) is { } item + && item.ContainerId == groundObjectId; + } + internal bool IsCurrentObjectIdentity(uint objectId, ClientObject item) => ReferenceEquals(_objects.Get(objectId), item); @@ -983,6 +1002,14 @@ public sealed class ItemInteractionController : IDisposable if (_sendSplitToWorld is null) return false; _sendSplitToWorld(action.ObjectId, (uint)action.Amount); + if (_transactions.TryGetPending(out PendingInventoryRequest pending) + && pending.Kind is InventoryRequestKind.SplitToWorld + && pending.ItemId == action.ObjectId) + { + WorldDropDispatched?.Invoke(new WorldDropDispatch( + pending, + (uint)action.Amount)); + } return true; }); break; @@ -1137,6 +1164,7 @@ public sealed class ItemInteractionController : IDisposable _transactions.ObjectTableCleared -= OnInventoryObjectsCleared; _transactions.RequestCompleted -= OnInventoryRequestCompleted; _transactions.StateChanged -= OnTransactionStateChanged; + WorldDropDispatched = null; _autoWield.Dispose(); } diff --git a/src/AcDream.App/UI/WorldDropDispatch.cs b/src/AcDream.App/UI/WorldDropDispatch.cs new file mode 100644 index 00000000..598c18f6 --- /dev/null +++ b/src/AcDream.App/UI/WorldDropDispatch.cs @@ -0,0 +1,11 @@ +using AcDream.Core.Items; + +namespace AcDream.App.UI; + +/// +/// One successfully dispatched inventory-to-world request together with the +/// quantity selected by the retained UI. +/// +public readonly record struct WorldDropDispatch( + PendingInventoryRequest Request, + uint Amount); diff --git a/src/AcDream.App/World/InventoryWorldDropProjectionController.cs b/src/AcDream.App/World/InventoryWorldDropProjectionController.cs new file mode 100644 index 00000000..8397fa02 --- /dev/null +++ b/src/AcDream.App/World/InventoryWorldDropProjectionController.cs @@ -0,0 +1,216 @@ +using AcDream.App.UI; +using AcDream.Core.Items; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; + +namespace AcDream.App.World; + +/// +/// Bridges retail's pending split-to-3D identity into canonical live-entity +/// registration when an ACE owner receives the result's Position before a +/// CreateObject. +/// +/// +/// Retail records splitClassID, splitStackSize, and +/// splitTime in ACCWeenieObject::UIAttemptSplitTo3D @ +/// 0x0058D850, then recognizes the created stack from +/// ACCWeenieObject::DeclareValid @ 0x0058E340. ACE currently omits +/// CreateObject for the initiating player but sends the new GUID's F748 +/// Position. This owner accepts only that otherwise-impossible packet while +/// the exact retail pending-split identity is live; arbitrary unknown Position +/// packets remain rejected by the ordinary authority gate. +/// +internal sealed class InventoryWorldDropProjectionController : IDisposable +{ + private readonly ItemInteractionController _interaction; + private readonly ClientObjectTable _objects; + private readonly LiveEntityRuntime _runtime; + private readonly LiveEntityHydrationController _hydration; + private readonly PendingSplitToWorldProjection _pending; + private readonly Func _now; + private bool _disposed; + + public InventoryWorldDropProjectionController( + ItemInteractionController interaction, + ClientObjectTable objects, + LiveEntityRuntime runtime, + LiveEntityHydrationController hydration, + Func now) + { + _interaction = interaction + ?? throw new ArgumentNullException(nameof(interaction)); + _objects = objects ?? throw new ArgumentNullException(nameof(objects)); + _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); + _hydration = hydration + ?? throw new ArgumentNullException(nameof(hydration)); + _now = now ?? throw new ArgumentNullException(nameof(now)); + _pending = new PendingSplitToWorldProjection(); + + _interaction.WorldDropDispatched += OnWorldDropDispatched; + _objects.MoveRequestFailed += OnMoveRequestFailed; + _objects.Cleared += OnObjectsCleared; + } + + public bool TryRecoverUnknownPosition( + WorldSession.EntityPositionUpdate update) + { + bool known = _runtime.TryGetSnapshot(update.Guid, out _); + if (known + || !_pending.TryResolve(update, _now(), out WorldSession.EntitySpawn spawn)) + { + return false; + } + + // Consume before the synchronous CreateObject graph runs. Re-entrant + // callbacks cannot bind the same split intent to a second GUID. + _hydration.OnCreate(spawn); + return _runtime.TryGetSnapshot(update.Guid, out _); + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + _objects.Cleared -= OnObjectsCleared; + _objects.MoveRequestFailed -= OnMoveRequestFailed; + _interaction.WorldDropDispatched -= OnWorldDropDispatched; + _pending.Clear(); + } + + private void OnWorldDropDispatched(WorldDropDispatch dispatch) + { + bool hasSource = _runtime.TryGetSnapshot( + dispatch.Request.ItemId, + out WorldSession.EntitySpawn source); + if (dispatch.Request.Kind is not InventoryRequestKind.SplitToWorld + || dispatch.Amount == 0 + || !hasSource) + { + return; + } + + _pending.Record( + dispatch.Request.Token, + source, + dispatch.Amount, + _now()); + } + + private void OnMoveRequestFailed(MoveRequestFailure failure) => + _pending.ClearSource(failure.ItemId); + + private void OnObjectsCleared() => _pending.Clear(); +} + +/// +/// Presentation-independent pending identity for retail split-to-3D +/// recognition. Retail owns one static split identity and replaces it on the +/// next request; this owner intentionally does the same. +/// +internal sealed class PendingSplitToWorldProjection +{ + internal const double RetailRecognitionSeconds = 10.0; + + private PendingSplit? _value; + + internal bool HasPending => _value is not null; + + internal void Record( + ulong requestToken, + WorldSession.EntitySpawn source, + uint amount, + double now) + { + if (requestToken == 0 + || source.Guid == 0 + || amount == 0 + || !double.IsFinite(now)) + { + _value = null; + return; + } + + _value = new PendingSplit(requestToken, source, amount, now); + } + + internal bool TryResolve( + WorldSession.EntityPositionUpdate update, + double now, + out WorldSession.EntitySpawn spawn) + { + spawn = default; + if (_value is not { } pending) + return false; + + double age = now - pending.RequestTime; + if (!double.IsFinite(age) + || age < 0 + || age >= RetailRecognitionSeconds) + { + _value = null; + return false; + } + + if (update.Guid == 0 || update.Guid == pending.Source.Guid) + return false; + + _value = null; + spawn = BuildSpawn(pending.Source, pending.Amount, update); + return true; + } + + internal void ClearSource(uint sourceGuid) + { + if (_value is { } pending && pending.Source.Guid == sourceGuid) + _value = null; + } + + internal void Clear() => _value = null; + + private static WorldSession.EntitySpawn BuildSpawn( + WorldSession.EntitySpawn source, + uint amount, + WorldSession.EntityPositionUpdate update) + { + PhysicsSpawnData? physics = source.Physics is { } sourcePhysics + ? sourcePhysics with + { + Position = update.Position, + Parent = null, + Velocity = update.Velocity, + Timestamps = sourcePhysics.Timestamps with + { + Position = update.PositionSequence, + Teleport = update.TeleportSequence, + ForcePosition = update.ForcePositionSequence, + Instance = update.InstanceSequence, + }, + } + : null; + + return source with + { + Guid = update.Guid, + Position = update.Position, + StackSize = checked((int)amount), + ContainerId = 0u, + WielderId = 0u, + CurrentWieldedLocation = 0u, + InstanceSequence = update.InstanceSequence, + MovementSequence = 0, + ServerControlSequence = 0, + PositionSequence = update.PositionSequence, + ParentGuid = null, + ParentLocation = null, + PlacementId = update.PlacementId, + Physics = physics, + }; + } + + private readonly record struct PendingSplit( + ulong RequestToken, + WorldSession.EntitySpawn Source, + uint Amount, + double RequestTime); +} diff --git a/src/AcDream.App/World/LiveEntityHydrationController.cs b/src/AcDream.App/World/LiveEntityHydrationController.cs index 299d9b7b..2963bbb4 100644 --- a/src/AcDream.App/World/LiveEntityHydrationController.cs +++ b/src/AcDream.App/World/LiveEntityHydrationController.cs @@ -676,6 +676,66 @@ AppearanceSynchronization: } } + /// + /// Reconstructs the App projection for a canonical object whose accepted + /// retail Position moved it from inventory-only ownership into the world. + /// Logical identity, timestamps, setup defaults, and retained item state + /// already exist; this crosses only the ordinary materialization/ready + /// boundary. + /// + public bool RecoverCanonicalProjection( + RuntimeEntityRecord expectedCanonical, + ulong positionAuthorityVersion, + out LiveEntityRecord record) + { + ArgumentNullException.ThrowIfNull(expectedCanonical); + lock (_datLock) + { + record = null!; + if (!_runtime.IsCurrentPositionAuthority( + expectedCanonical, + positionAuthorityVersion)) + { + return false; + } + + if (_runtime.TryGetProjection( + expectedCanonical, + out record)) + { + return _runtime.IsCurrentPositionAuthority( + record, + positionAuthorityVersion); + } + + WorldSession.EntitySpawn canonicalSpawn = + expectedCanonical.Snapshot; + if (canonicalSpawn.Position is null) + return false; + + InitializeOriginAndRecoverLoaded(canonicalSpawn); + if (!_runtime.IsCurrentPositionAuthority( + expectedCanonical, + positionAuthorityVersion) + || !ProjectExact( + expectedCanonical, + canonicalSpawn, + LiveProjectionPurpose.SpatialRecovery, + expectedCanonical.CreateIntegrationVersion) + || !_runtime.TryGetProjection( + expectedCanonical, + out record)) + { + record = null!; + return false; + } + + return _runtime.IsCurrentPositionAuthority( + record, + positionAuthorityVersion); + } + } + public bool OnEntityReady(LiveEntityReadyCandidate candidate) { if (!PublishReady(candidate)) diff --git a/src/AcDream.App/World/LiveEntityRuntime.cs b/src/AcDream.App/World/LiveEntityRuntime.cs index a15f0134..c9dbc111 100644 --- a/src/AcDream.App/World/LiveEntityRuntime.cs +++ b/src/AcDream.App/World/LiveEntityRuntime.cs @@ -1154,6 +1154,29 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource return false; } + /// + /// Resolves a current top-level world projection even when its spatial + /// bucket is pending/nonresident. Unlike interaction eligibility, this + /// does not impose render visibility; unlike , + /// it rejects a retained leave-world owner whose pose is historical. + /// + public bool TryGetSpatiallyProjectedRecord( + uint serverGuid, + out LiveEntityRecord record) + { + if (_projections.TryGetCurrent(serverGuid, out LiveEntityRecord found) + && found.WorldEntity is not null + && found.ProjectionKind is LiveEntityProjectionKind.World + && found.IsSpatiallyProjected) + { + record = found; + return true; + } + + record = null!; + return false; + } + /// /// Resolves a top-level object that currently participates in picking, /// targeting, radar, and wire-driven MoveTo/Sticky establishment. @@ -1762,6 +1785,12 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource && ReferenceEquals(current, record) && current.PositionAuthorityVersion == authorityVersion; + internal bool IsCurrentPositionAuthority( + RuntimeEntityRecord canonical, + ulong authorityVersion) => + _directory.IsCurrent(canonical) + && canonical.PositionAuthorityVersion == authorityVersion; + internal bool IsCurrentStateAuthority( LiveEntityRecord record, ulong authorityVersion) => @@ -1783,6 +1812,12 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource && ReferenceEquals(current, record) && current.VelocityAuthorityVersion == authorityVersion; + internal bool IsCurrentVelocityAuthority( + RuntimeEntityRecord canonical, + ulong authorityVersion) => + _directory.IsCurrent(canonical) + && canonical.VelocityAuthorityVersion == authorityVersion; + internal bool IsCurrentMovementAuthority( LiveEntityRecord record, ulong authorityVersion) => diff --git a/src/AcDream.Core/Items/ClientObjectTable.cs b/src/AcDream.Core/Items/ClientObjectTable.cs index 3e19d941..b9101b9b 100644 --- a/src/AcDream.Core/Items/ClientObjectTable.cs +++ b/src/AcDream.Core/Items/ClientObjectTable.cs @@ -214,6 +214,40 @@ public sealed class ClientObjectTable public ClientObject? Get(uint objectId) => _objects.TryGetValue(objectId, out var item) ? item : null; + /// + /// Retail ACCWeenieObject::IsOwnedByObject @ 0x0058CEB0. + /// Direct containment/wielding counts, as does an item inside one of the + /// owner's authored pack containers or on one of the owner's locations. + /// + public bool IsOwnedByObject(uint objectId, uint ownerId) + { + if (ownerId == 0u + || !_objects.TryGetValue(objectId, out ClientObject? item)) + { + return false; + } + + if (item.ObjectId == ownerId + || item.ContainerId == ownerId + || item.WielderId == ownerId) + { + return true; + } + + if (!_objects.ContainsKey(ownerId)) + return false; + + if (item.ContainerId != 0u + && _containerIndex.TryGetValue(ownerId, out List? ownerContents) + && ownerContents.Contains(item.ContainerId)) + { + return true; + } + + return _equipmentIndex.TryGetValue(ownerId, out List? ownerLocations) + && ownerLocations.Contains(item.ObjectId); + } + /// /// Look up a container by object id, creating a lightweight stub if /// the id doesn't match any known container (defensive — avoids losing @@ -297,26 +331,43 @@ public sealed class ClientObjectTable newWielderId, newEquipLocation), containerTypeHint, - ClientObjectMoveOrigin.AuthoritativeResponse); + ClientObjectMoveOrigin.AuthoritativeResponse, + retailContainerInsert: true); } private bool ApplyPlacement( ClientObject item, ClientObjectPlacement current, uint? containerTypeHint, - ClientObjectMoveOrigin origin) + ClientObjectMoveOrigin origin, + bool retailContainerInsert = false) { ClientObjectPlacement previous = ClientObjectPlacement.From(item); - List? changedContainers = RemoveFromOtherContainerIndexes( - item.ObjectId, - current.ContainerId); + bool previousWasContainer = IsContainerListMember(item); + List? changedContainers; + if (retailContainerInsert) + { + changedContainers = RemoveFromAllContainerIndexes( + item.ObjectId, + current.ContainerId, + previousWasContainer); + } + else + { + changedContainers = RemoveFromOtherContainerIndexes( + item.ObjectId, + current.ContainerId); + } item.ContainerId = current.ContainerId; item.ContainerSlot = current.ContainerSlot; item.WielderId = current.WielderId; item.CurrentlyEquippedLocation = current.EquipLocation; if (containerTypeHint is { } hint) item.ContainerTypeHint = hint; - Reindex(item, previous.ContainerId); + if (retailContainerInsert && item.ContainerId != 0u) + InsertContainerMember(item, current.ContainerSlot); + else + Reindex(item, previous.ContainerId); PublishPlacementChange(item, previous, origin); PublishContainerContentsChanges(changedContainers); return true; @@ -927,6 +978,101 @@ public sealed class ClientObjectTable return changed; } + /// + /// Retail ACCWeenieObject::ServerSaysMoveItem @ 0x0058DBB0 + /// removes the object from its old IDList before + /// AddContent @ 0x0058CCE0 inserts it at the server-supplied list + /// index. Remove the canonical member as well as stale ViewContents + /// projections so an authoritative same-container move can perform that + /// exact remove-then-insert transition. + /// + private List? RemoveFromAllContainerIndexes( + uint itemId, + uint destinationContainerId, + bool wasContainer) + { + List? changed = null; + foreach ((uint containerId, List members) in _containerIndex) + { + if (!members.Remove(itemId)) + continue; + + RenumberContainerCategory(members, wasContainer); + if (containerId != destinationContainerId) + (changed ??= new List()).Add(containerId); + } + + return changed; + } + + /// + /// Port of retail ACCWeenieObject::AddContent @ 0x0058CCE0: + /// items and child containers have separate ordered IDLists and + /// IDList::AddAtNum clamps the requested index to the list length. + /// Our compact projection stores both categories in one list, so insertion + /// and renumbering operate only inside the matching retail category. + /// + private void InsertContainerMember(ClientObject item, int requestedSlot) + { + if (!_containerIndex.TryGetValue(item.ContainerId, out List? members)) + _containerIndex[item.ContainerId] = members = new List(); + + bool isContainer = IsContainerListMember(item); + int categoryCount = 0; + int insertionIndex = members.Count; + int requestedIndex = requestedSlot < 0 ? int.MaxValue : requestedSlot; + for (int i = 0; i < members.Count; i++) + { + if (!_objects.TryGetValue(members[i], out ClientObject? existing) + || IsContainerListMember(existing) != isContainer) + { + continue; + } + + if (categoryCount == requestedIndex) + { + insertionIndex = i; + break; + } + + categoryCount++; + insertionIndex = i + 1; + } + + members.Insert(insertionIndex, item.ObjectId); + int publishedSlot = requestedSlot < 0 ? categoryCount : requestedSlot; + RenumberContainerCategory( + members, + isContainer, + preservedItemId: item.ObjectId, + preservedSlot: publishedSlot); + } + + private void RenumberContainerCategory( + List members, + bool isContainer, + uint preservedItemId = 0u, + int preservedSlot = -1) + { + int slot = 0; + for (int i = 0; i < members.Count; i++) + { + if (_objects.TryGetValue(members[i], out ClientObject? member) + && IsContainerListMember(member) == isContainer) + { + member.ContainerSlot = member.ObjectId == preservedItemId + ? preservedSlot + : slot; + slot++; + } + } + } + + private static bool IsContainerListMember(ClientObject item) => + item.ContainerTypeHint != 0u + || (item.Type & ItemType.Container) != 0 + || item.ItemsCapacity > 0; + private void PublishContainerContentsChanges(List? changed) { if (changed is null || changed.Count == 0) diff --git a/tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs b/tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs index 95dddb93..bdafd341 100644 --- a/tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs +++ b/tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs @@ -17,6 +17,7 @@ public sealed class SelectionInteractionControllerTests { private const uint Player = 0x5000_0001u; private const uint Target = 0x7000_0001u; + private const uint GroundContainer = 0x7000_0010u; private sealed class Query : IWorldSelectionQuery { @@ -140,6 +141,7 @@ public sealed class SelectionInteractionControllerTests public readonly List CancelledPlacements = new(); public readonly ItemInteractionController Items; public readonly SelectionInteractionController Controller; + public uint GroundObjectId { get; set; } public Harness() { @@ -173,6 +175,7 @@ public sealed class SelectionInteractionControllerTests Query.Events.Add("examine"); Examines.Add(guid); }, + groundObjectId: () => GroundObjectId, placeInBackpack: (item, container, placement) => controller!.SendPickup(item, container, placement), requestUse: (guid, reservation) => @@ -495,6 +498,32 @@ public sealed class SelectionInteractionControllerTests Assert.Equal(new[] { (Target, Player, 0) }, h.Transport.Pickups); } + [Fact] + public void CorpseChildDoubleClickDispatchesWithoutAWorldProjection() + { + const uint loot = 0x7000_0011u; + var h = new Harness + { + GroundObjectId = GroundContainer, + }; + h.Objects.AddOrUpdate(new ClientObject + { + ObjectId = loot, + Name = "Corpse loot", + Type = ItemType.Misc, + ContainerId = GroundContainer, + }); + h.Query.Pickupable = false; + h.Query.Approach = null; + + Assert.True(h.Items.ActivateItem(loot)); + + Assert.Empty(h.Movement.Approaches); + Assert.Equal(new[] { (loot, Player, 0) }, h.Transport.Pickups); + Assert.Single(h.PendingPlacements); + Assert.Empty(h.CancelledPlacements); + } + [Fact] public void SessionResetClearsQueuedAndDeferredWork() { diff --git a/tests/AcDream.App.Tests/Interaction/WorldSelectionQueryTests.cs b/tests/AcDream.App.Tests/Interaction/WorldSelectionQueryTests.cs index e535f187..7af12843 100644 --- a/tests/AcDream.App.Tests/Interaction/WorldSelectionQueryTests.cs +++ b/tests/AcDream.App.Tests/Interaction/WorldSelectionQueryTests.cs @@ -319,6 +319,75 @@ public sealed class WorldSelectionQueryTests Assert.Equal(4f, radius); } + [Fact] + public void VividTargetMarkerWaitsForFreshProjectionWhenDropMovePrecedesPosition() + { + var h = new Harness(); + WorldEntity entity = + h.Add(Target, new Vector3(3f, 4f, 0f), ItemType.Misc); + + Assert.NotNull(h.Query.ResolveVividTargetInfo(Target)); + + Assert.True(h.Objects.MoveItem(Target, Player, newSlot: 0)); + Assert.True(h.Runtime.WithdrawLiveEntityProjection(Target)); + Assert.Null(h.Query.ResolveVividTargetInfo(Target)); + + // ACE may confirm InventoryPutObjectIn3D before sending the fresh + // Position. The retained entity still has the old ground pose here. + Assert.True(h.Objects.MoveItem(Target, 0u, newSlot: -1)); + Assert.Null(h.Query.ResolveVividTargetInfo(Target)); + + var newPosition = new Vector3(30f, 40f, 2f); + entity.SetPosition(newPosition); + Assert.True(h.Runtime.RebucketLiveEntity(Target, 0x0101_0001u)); + + var marker = h.Query.ResolveVividTargetInfo(Target); + Assert.NotNull(marker); + Assert.Equal(newPosition + Vector3.UnitX, marker.Value.SelectionSphereCenter); + } + + [Fact] + public void VividTargetMarkerUsesFreshPoseWhenPositionPrecedesDropMove() + { + var h = new Harness(); + WorldEntity entity = + h.Add(Target, new Vector3(3f, 4f, 0f), ItemType.Misc); + Assert.True(h.Objects.MoveItem(Target, Player, newSlot: 0)); + Assert.True(h.Runtime.WithdrawLiveEntityProjection(Target)); + + var newPosition = new Vector3(30f, 40f, 2f); + entity.SetPosition(newPosition); + Assert.True(h.Runtime.RebucketLiveEntity(Target, 0x0101_0001u)); + + // The spatial pose is current, but ownership continues to suppress the + // marker until ServerSaysMoveItem publishes the world placement. + Assert.Null(h.Query.ResolveVividTargetInfo(Target)); + + Assert.True(h.Objects.MoveItem(Target, 0u, newSlot: -1)); + var marker = h.Query.ResolveVividTargetInfo(Target); + Assert.NotNull(marker); + Assert.Equal(newPosition + Vector3.UnitX, marker.Value.SelectionSphereCenter); + } + + [Fact] + public void VividTargetMarkerRejectsRetainedProjectionInsideExternalContainer() + { + var h = new Harness(); + const uint corpse = 0x7000_0040u; + h.Add(Target, new Vector3(3f, 4f, 0f), ItemType.Misc); + h.Objects.AddOrUpdate(new ClientObject + { + ObjectId = corpse, + Name = "Corpse", + Type = ItemType.Container, + }); + + Assert.True(h.Objects.MoveItem(Target, corpse, newSlot: 0)); + + Assert.False(h.Objects.IsOwnedByObject(Target, Player)); + Assert.Null(h.Query.ResolveVividTargetInfo(Target)); + } + private static SelectionCameraSnapshot Camera() { Matrix4x4 view = Matrix4x4.CreateLookAt( diff --git a/tests/AcDream.App.Tests/Physics/LiveEntityInboundAuthorityGateTests.cs b/tests/AcDream.App.Tests/Physics/LiveEntityInboundAuthorityGateTests.cs index 868489a0..d2662229 100644 --- a/tests/AcDream.App.Tests/Physics/LiveEntityInboundAuthorityGateTests.cs +++ b/tests/AcDream.App.Tests/Physics/LiveEntityInboundAuthorityGateTests.cs @@ -159,6 +159,8 @@ public sealed class LiveEntityInboundAuthorityGateTests out AcceptedPositionNetworkUpdate accepted)); Assert.Equal(PositionTimestampDisposition.Apply, accepted.TimestampDisposition); Assert.Equal((ulong)2, accepted.PositionAuthorityVersion); + Assert.True(runtime.TryGetCanonical(Guid, out RuntimeEntityRecord canonical)); + Assert.Same(canonical, accepted.Canonical); Assert.Equal(1, publishCount); gate.ResetSessionState(); @@ -197,6 +199,33 @@ public sealed class LiveEntityInboundAuthorityGateTests out _)); } + [Fact] + public void Position_CanonicalInventoryObjectIsAcceptedBeforeProjectionExists() + { + LiveEntityRuntime runtime = Runtime(); + LiveEntityRegistrationResult registration = + runtime.RegisterLiveEntity(Spawn(Guid, instance: 5, position: 1)); + Assert.NotNull(registration.Canonical); + Assert.False(runtime.TryGetRecord(Guid, out _)); + int publishCount = 0; + var gate = new LiveEntityInboundAuthorityGate( + runtime, + (_, _) => publishCount++); + + Assert.True(gate.TryAcceptPosition( + Position(Guid, 5, 2, 0x01010002u), + localPlayerGuid: 0u, + forcePositionRotation: null, + currentLocalVelocity: null, + payloadIsValid: true, + out AcceptedPositionNetworkUpdate accepted)); + + Assert.Same(registration.Canonical, accepted.Canonical); + Assert.Equal((ulong)2, accepted.PositionAuthorityVersion); + Assert.Equal(1, publishCount); + Assert.False(runtime.TryGetRecord(Guid, out _)); + } + [Fact] public void Vector_NewerSameIncarnationPacketInvalidatesCapturedAuthority() { @@ -308,10 +337,10 @@ public sealed class LiveEntityInboundAuthorityGateTests payloadIsValid: true, out AcceptedPositionNetworkUpdate accepted)); Assert.True(runtime.IsCurrentPositionAuthority( - accepted.Record, + accepted.Canonical, accepted.PositionAuthorityVersion)); Assert.False(runtime.IsCurrentVelocityAuthority( - accepted.Record, + accepted.Canonical, accepted.VelocityAuthorityVersion)); } diff --git a/tests/AcDream.App.Tests/Rendering/EquippedChildProjectionWithdrawalTests.cs b/tests/AcDream.App.Tests/Rendering/EquippedChildProjectionWithdrawalTests.cs index f5318006..52ae83ce 100644 --- a/tests/AcDream.App.Tests/Rendering/EquippedChildProjectionWithdrawalTests.cs +++ b/tests/AcDream.App.Tests/Rendering/EquippedChildProjectionWithdrawalTests.cs @@ -1051,6 +1051,47 @@ public sealed class EquippedChildProjectionWithdrawalTests Assert.Single(fixture.Controller.AttachedEntityIds); } + [Fact] + public void InventoryPutObjectIn3D_DoesNotWithdrawRecoveredWorldProjection() + { + int withdrawals = 0; + ControllerFixture? fixture = null; + fixture = new ControllerFixture((record, positionVersion, projectionVersion) => + { + withdrawals++; + bool completed = fixture!.Live.WithdrawLiveEntityProjection( + record, + positionVersion, + projectionVersion); + return new( + completed + ? ExactProjectionWithdrawalDisposition.Completed + : ExactProjectionWithdrawalDisposition.Superseded, + Failure: null); + }); + using (fixture) + { + const uint guid = 0x7000026Au; + const uint player = 0x50000001u; + LiveEntityRecord dropped = fixture.Spawn(guid, generation: 1); + fixture.Objects.AddOrUpdate(new ClientObject + { + ObjectId = guid, + ContainerId = player, + CurrentlyEquippedLocation = EquipMask.None, + }); + + Assert.True(fixture.Objects.ApplyConfirmedServerMove( + guid, + newContainerId: 0u, + newWielderId: 0u)); + + Assert.Equal(0, withdrawals); + Assert.True(dropped.IsSpatiallyProjected); + Assert.Equal(LiveEntityProjectionKind.World, dropped.ProjectionKind); + } + } + [Fact] public void OrphanRollback_PendingFailureRetainsExactRetryOwner() { diff --git a/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs b/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs index 8f2d4ba7..11a9740a 100644 --- a/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs @@ -1489,6 +1489,8 @@ public sealed class ItemInteractionControllerTests { var h = new Harness(); h.AddContained(0x50000A70u, item => item.StackSize = 10); + WorldDropDispatch? dispatched = null; + h.Controller.WorldDropDispatched += value => dispatched = value; h.SelectedObject = 0x50000A70u; h.SplitQuantity.Reset(10u); h.SplitQuantity.SetValue(1u); @@ -1504,6 +1506,10 @@ public sealed class ItemInteractionControllerTests Assert.Empty(h.Drops); Assert.Equal(Pack, h.Objects.Get(0x50000A70u)!.ContainerId); Assert.Equal(10, h.Objects.Get(0x50000A70u)!.StackSize); + Assert.NotNull(dispatched); + Assert.Equal(InventoryRequestKind.SplitToWorld, dispatched.Value.Request.Kind); + Assert.Equal(0x50000A70u, dispatched.Value.Request.ItemId); + Assert.Equal(1u, dispatched.Value.Amount); } [Fact] diff --git a/tests/AcDream.App.Tests/World/GameWindowLiveEntityCompositionTests.cs b/tests/AcDream.App.Tests/World/GameWindowLiveEntityCompositionTests.cs index 6827ed6e..46cae8f9 100644 --- a/tests/AcDream.App.Tests/World/GameWindowLiveEntityCompositionTests.cs +++ b/tests/AcDream.App.Tests/World/GameWindowLiveEntityCompositionTests.cs @@ -3,7 +3,9 @@ using System.Reflection; using AcDream.App.Physics; using AcDream.App.Rendering; using AcDream.App.World; +using AcDream.Core.Net; using AcDream.Core.Physics; +using AcDream.Core.World; namespace AcDream.App.Tests.World; @@ -65,6 +67,42 @@ public sealed class GameWindowLiveEntityCompositionTests BindingFlags.NonPublic)); } + [Fact] + public void RetainedButWithdrawnWorldEntity_RequiresPositionProjectionRecovery() + { + LiveEntityRecord record = + LiveEntityTestFixture.CreateExactProjectionRecord( + new WorldSession.EntitySpawn( + Guid: 0x7000_00D0u, + Position: null, + SetupTableId: null, + AnimPartChanges: [], + TextureChanges: [], + SubPalettes: [], + BasePaletteId: null, + ObjScale: null, + Name: null, + ItemType: null, + MotionState: null, + MotionTableId: null, + InstanceSequence: 1)); + record.WorldEntity = new WorldEntity + { + Id = record.LocalEntityId!.Value, + ServerGuid = record.ServerGuid, + SourceGfxObjOrSetupId = 0x0200_0001u, + Position = System.Numerics.Vector3.Zero, + Rotation = System.Numerics.Quaternion.Identity, + MeshRefs = [], + }; + Assert.NotNull(record.WorldEntity); + Assert.False(record.IsSpatiallyProjected); + + Assert.True( + LiveEntityNetworkUpdateController + .RequiresSpatialProjectionRecovery(record)); + } + [Theory] [InlineData(typeof(LiveEntityCollisionBuilder))] [InlineData(typeof(LiveEntityDefaultPoseResolver))] diff --git a/tests/AcDream.App.Tests/World/LiveEntityHydrationControllerTests.cs b/tests/AcDream.App.Tests/World/LiveEntityHydrationControllerTests.cs index b108ae84..fdd55068 100644 --- a/tests/AcDream.App.Tests/World/LiveEntityHydrationControllerTests.cs +++ b/tests/AcDream.App.Tests/World/LiveEntityHydrationControllerTests.cs @@ -423,6 +423,85 @@ public sealed class LiveEntityHydrationControllerTests Assert.Equal(0, fixture.Resources.UnregisterCount); } + [Fact] + public void PositionAfterInventoryOnlyCreate_ConstructsFirstSpatialProjection() + { + using var fixture = new Fixture(originKnown: true); + fixture.Controller.OnCreate(CelllessSpawn( + PositionSequence: 1, + parentGuid: null)); + RuntimeEntityRecord canonical = fixture.Canonical; + Assert.False(fixture.Runtime.TryGetRecord(Guid, out _)); + Assert.Equal(0, fixture.Resources.RegisterCount); + + Assert.True(fixture.Runtime.TryApplyPosition( + PositionUpdate(sequence: 2, x: 14f), + isLocalPlayer: false, + forcePositionRotation: null, + currentLocalVelocity: null, + out _, + out _, + out _)); + ulong authorityVersion = canonical.PositionAuthorityVersion; + + Assert.True(fixture.Controller.RecoverCanonicalProjection( + canonical, + authorityVersion, + out LiveEntityRecord record)); + + Assert.NotNull(record.WorldEntity); + Assert.True(record.IsSpatiallyProjected); + Assert.True(record.InitialHydrationCompleted); + Assert.Equal(1, fixture.Resources.RegisterCount); + Assert.Equal(1, fixture.Ready.PublishCount); + Assert.Contains( + fixture.Materializer.Calls, + call => call.Purpose is LiveProjectionPurpose.SpatialRecovery); + + Assert.True(fixture.Controller.RecoverCanonicalProjection( + canonical, + authorityVersion, + out LiveEntityRecord same)); + Assert.Same(record, same); + Assert.Equal(1, fixture.Resources.RegisterCount); + Assert.Equal(1, fixture.Ready.PublishCount); + } + + [Fact] + public void CanonicalProjectionRecovery_RejectsSupersededPositionAuthority() + { + using var fixture = new Fixture(originKnown: true); + fixture.Controller.OnCreate(CelllessSpawn( + PositionSequence: 1, + parentGuid: null)); + RuntimeEntityRecord canonical = fixture.Canonical; + Assert.True(fixture.Runtime.TryApplyPosition( + PositionUpdate(sequence: 2, x: 14f), + isLocalPlayer: false, + forcePositionRotation: null, + currentLocalVelocity: null, + out _, + out _, + out _)); + ulong staleAuthorityVersion = canonical.PositionAuthorityVersion; + Assert.True(fixture.Runtime.TryApplyPosition( + PositionUpdate(sequence: 3, x: 18f), + isLocalPlayer: false, + forcePositionRotation: null, + currentLocalVelocity: null, + out _, + out _, + out _)); + + Assert.False(fixture.Controller.RecoverCanonicalProjection( + canonical, + staleAuthorityVersion, + out _)); + Assert.False(fixture.Runtime.TryGetRecord(Guid, out _)); + Assert.Equal(0, fixture.Resources.RegisterCount); + Assert.Equal(0, fixture.Ready.PublishCount); + } + [Fact] public void ParentEvent_IsRetainedByRelationshipOwnerAndAcceptedThroughHydrationGate() { @@ -2350,4 +2429,19 @@ public sealed class LiveEntityHydrationControllerTests PlacementId: parentGuid is null ? null : 1u, Physics: physics); } + + private static WorldSession.EntityPositionUpdate PositionUpdate( + ushort sequence, + float x) => + new( + Guid, + new CreateObject.ServerPosition( + Cell, x, 12f, 6f, 1f, 0f, 0f, 0f), + Velocity: null, + PlacementId: 0, + IsGrounded: true, + InstanceSequence: 1, + PositionSequence: sequence, + TeleportSequence: 0, + ForcePositionSequence: 0); } diff --git a/tests/AcDream.App.Tests/World/PendingSplitToWorldProjectionTests.cs b/tests/AcDream.App.Tests/World/PendingSplitToWorldProjectionTests.cs new file mode 100644 index 00000000..c0eed70b --- /dev/null +++ b/tests/AcDream.App.Tests/World/PendingSplitToWorldProjectionTests.cs @@ -0,0 +1,186 @@ +using System.Numerics; +using AcDream.App.World; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; + +namespace AcDream.App.Tests.World; + +public sealed class PendingSplitToWorldProjectionTests +{ + [Fact] + public void Resolve_ClonesSourceAppearanceIntoAuthoritativeGroundPlacement() + { + var pending = new PendingSplitToWorldProjection(); + WorldSession.EntitySpawn source = SourceSpawn(); + pending.Record(7u, source, amount: 3u, now: 12.0); + WorldSession.EntityPositionUpdate update = PositionUpdate( + guid: 0x80000435u, + instance: 4, + position: 9, + teleport: 2, + forcePosition: 3); + + Assert.True(pending.TryResolve(update, now: 12.25, out var spawn)); + + Assert.Equal(update.Guid, spawn.Guid); + Assert.Equal(source.SetupTableId, spawn.SetupTableId); + Assert.Equal(source.AnimPartChanges, spawn.AnimPartChanges); + Assert.Equal(source.TextureChanges, spawn.TextureChanges); + Assert.Equal(source.SubPalettes, spawn.SubPalettes); + Assert.Equal(source.BasePaletteId, spawn.BasePaletteId); + Assert.Equal(source.WeenieClassId, spawn.WeenieClassId); + Assert.Equal(3, spawn.StackSize); + Assert.Equal(0u, spawn.ContainerId); + Assert.Equal(0u, spawn.WielderId); + Assert.Equal(0u, spawn.CurrentWieldedLocation); + Assert.Null(spawn.ParentGuid); + Assert.Null(spawn.ParentLocation); + Assert.Equal(update.Position, spawn.Position); + Assert.Equal(update.PlacementId, spawn.PlacementId); + Assert.Equal(update.InstanceSequence, spawn.InstanceSequence); + Assert.Equal(update.PositionSequence, spawn.PositionSequence); + + PhysicsSpawnData physics = Assert.IsType(spawn.Physics); + Assert.Equal(update.Position, physics.Position); + Assert.Null(physics.Parent); + Assert.Equal(update.Velocity, physics.Velocity); + Assert.Equal(update.InstanceSequence, physics.Timestamps.Instance); + Assert.Equal(update.PositionSequence, physics.Timestamps.Position); + Assert.Equal(update.TeleportSequence, physics.Timestamps.Teleport); + Assert.Equal( + update.ForcePositionSequence, + physics.Timestamps.ForcePosition); + Assert.False(pending.HasPending); + } + + [Fact] + public void Resolve_ConsumesOneRetailPendingIdentity() + { + var pending = new PendingSplitToWorldProjection(); + pending.Record(1u, SourceSpawn(), amount: 1u, now: 5.0); + + Assert.True(pending.TryResolve( + PositionUpdate(0x80000435u), + now: 5.1, + out _)); + Assert.False(pending.TryResolve( + PositionUpdate(0x80000436u), + now: 5.2, + out _)); + } + + [Fact] + public void Resolve_RejectsAtRetailTenSecondBoundary() + { + var pending = new PendingSplitToWorldProjection(); + pending.Record(1u, SourceSpawn(), amount: 1u, now: 5.0); + + Assert.False(pending.TryResolve( + PositionUpdate(0x80000435u), + now: 15.0, + out _)); + Assert.False(pending.HasPending); + } + + [Fact] + public void Resolve_SourceGuidDoesNotConsumePendingIdentity() + { + var pending = new PendingSplitToWorldProjection(); + WorldSession.EntitySpawn source = SourceSpawn(); + pending.Record(1u, source, amount: 1u, now: 5.0); + + Assert.False(pending.TryResolve( + PositionUpdate(source.Guid), + now: 5.1, + out _)); + Assert.True(pending.HasPending); + } + + private static WorldSession.EntitySpawn SourceSpawn() + { + var timestamps = new PhysicsTimestamps( + Position: 1, + Movement: 2, + State: 3, + Vector: 4, + Teleport: 5, + ServerControlledMove: 6, + ForcePosition: 7, + ObjDesc: 8, + Instance: 9); + var physics = new PhysicsSpawnData( + RawState: 0x00000408u, + Position: null, + Movement: null, + AnimationFrame: null, + SetupTableId: 0x0200030Bu, + MotionTableId: 0x09000001u, + SoundTableId: null, + PhysicsScriptTableId: null, + Parent: new PhysicsAttachment(0x50000001u, 0u), + Children: null, + Scale: 1f, + Friction: 0.5f, + Elasticity: 0.05f, + Translucency: null, + Velocity: Vector3.Zero, + Acceleration: null, + AngularVelocity: null, + DefaultScriptType: null, + DefaultScriptIntensity: null, + Timestamps: timestamps); + + return new WorldSession.EntitySpawn( + Guid: 0x50000010u, + Position: null, + SetupTableId: 0x0200030Bu, + AnimPartChanges: Array.Empty(), + TextureChanges: Array.Empty(), + SubPalettes: Array.Empty(), + BasePaletteId: 0x04000001u, + ObjScale: 1f, + Name: "Copper Pea", + ItemType: 0x00000001u, + MotionState: null, + MotionTableId: 0x09000001u, + WeenieClassId: 273u, + StackSize: 100, + StackSizeMax: 1000, + ContainerId: 0x50000001u, + WielderId: 0u, + CurrentWieldedLocation: 0u, + InstanceSequence: 9, + PositionSequence: 1, + ParentGuid: 0x50000001u, + ParentLocation: 0u, + Physics: physics); + } + + private static WorldSession.EntityPositionUpdate PositionUpdate( + uint guid, + ushort instance = 4, + ushort position = 9, + ushort teleport = 2, + ushort forcePosition = 3) + { + var serverPosition = new CreateObject.ServerPosition( + 0xA9B4001Au, + 12f, + 34f, + 1.5f, + 1f, + 0f, + 0f, + 0f); + return new WorldSession.EntityPositionUpdate( + guid, + serverPosition, + Velocity: Vector3.Zero, + PlacementId: 0x65u, + IsGrounded: true, + InstanceSequence: instance, + PositionSequence: position, + TeleportSequence: teleport, + ForcePositionSequence: forcePosition); + } +} diff --git a/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs b/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs index 0fc6daba..ad0ae1ee 100644 --- a/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs +++ b/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs @@ -46,6 +46,49 @@ public sealed class ClientObjectTableTests Assert.Equal(1, propUpdateCount); } + [Fact] + public void IsOwnedByObjectMatchesRetailDirectPackAndLocationRules() + { + const uint player = 0x5000_0001u; + const uint pack = 0x4000_0001u; + var repo = new ClientObjectTable(); + repo.AddOrUpdate(MakeItem(player, "Player")); + repo.AddOrUpdate(new ClientObject + { + ObjectId = pack, + Name = "Pack", + Type = ItemType.Container, + ContainerId = player, + }); + repo.ReplaceContents( + player, + [new ContainerContentEntry(pack, ContainerType: 1u)]); + + ClientObject loose = MakeItem(100u, "Loose"); + loose.ContainerId = player; + repo.AddOrUpdate(loose); + + ClientObject packed = MakeItem(101u, "Packed"); + packed.ContainerId = pack; + repo.AddOrUpdate(packed); + + ClientObject equipped = MakeItem(102u, "Equipped"); + equipped.WielderId = player; + equipped.CurrentlyEquippedLocation = EquipMask.MeleeWeapon; + repo.AddOrUpdate(equipped); + + ClientObject foreign = MakeItem(103u, "Foreign"); + foreign.ContainerId = 0x6000_0001u; + repo.AddOrUpdate(foreign); + + Assert.True(repo.IsOwnedByObject(player, player)); + Assert.True(repo.IsOwnedByObject(loose.ObjectId, player)); + Assert.True(repo.IsOwnedByObject(packed.ObjectId, player)); + Assert.True(repo.IsOwnedByObject(equipped.ObjectId, player)); + Assert.False(repo.IsOwnedByObject(foreign.ObjectId, player)); + Assert.False(repo.IsOwnedByObject(packed.ObjectId, 0u)); + } + [Fact] public void StopViewingContents_RemovesOnlyTemporaryProjection() { @@ -673,6 +716,96 @@ public sealed class ClientObjectTableTests Assert.Equal(new[] { item }, table.GetContents(pack)); } + [Fact] + public void AuthoritativePickup_PlacementZeroInsertsAtRetailListHead() + { + var table = new ClientObjectTable(); + const uint corpse = 0x40000001u; + const uint pack = 0x50000001u; + const uint existingA = 0xA01u; + const uint existingB = 0xA02u; + const uint firstLoot = 0xA03u; + const uint secondLoot = 0xA04u; + + table.InitializeInventoryManifest(pack, new[] + { + new ContainerContentEntry(existingA, 0u), + new ContainerContentEntry(existingB, 0u), + }); + table.ReplaceContents(corpse, new[] { firstLoot, secondLoot }); + + Assert.True(table.ApplyConfirmedServerMove( + firstLoot, + pack, + newWielderId: 0u, + newSlot: 0, + containerTypeHint: 0u)); + Assert.Equal( + new[] { firstLoot, existingA, existingB }, + table.GetContents(pack)); + Assert.Equal( + new[] { 0, 1, 2 }, + table.GetContents(pack).Select(id => table.Get(id)!.ContainerSlot)); + + Assert.True(table.ApplyConfirmedServerMove( + secondLoot, + pack, + newWielderId: 0u, + newSlot: 0, + containerTypeHint: 0u)); + Assert.Equal( + new[] { secondLoot, firstLoot, existingA, existingB }, + table.GetContents(pack)); + Assert.Equal( + new[] { 0, 1, 2, 3 }, + table.GetContents(pack).Select(id => table.Get(id)!.ContainerSlot)); + Assert.Empty(table.GetContents(corpse)); + } + + [Fact] + public void AuthoritativeContainerMove_IndexesOnlyTheRetailContainerList() + { + var table = new ClientObjectTable(); + const uint pack = 0x50000001u; + const uint looseA = 0xA11u; + const uint looseB = 0xA12u; + const uint bagA = 0xA21u; + const uint bagB = 0xA22u; + + table.InitializeInventoryManifest(pack, new[] + { + new ContainerContentEntry(looseA, 0u), + new ContainerContentEntry(bagA, 1u), + new ContainerContentEntry(looseB, 0u), + }); + table.AddOrUpdate(new ClientObject + { + ObjectId = bagB, + Type = ItemType.Container, + ItemsCapacity = 24, + }); + + Assert.True(table.ApplyConfirmedServerMove( + bagB, + pack, + newWielderId: 0u, + newSlot: 0, + containerTypeHint: 1u)); + + uint[] loose = table.GetContents(pack) + .Where(id => table.Get(id)!.ContainerTypeHint == 0u + && (table.Get(id)!.Type & ItemType.Container) == 0) + .ToArray(); + uint[] bags = table.GetContents(pack) + .Where(id => table.Get(id)!.ContainerTypeHint != 0u + || (table.Get(id)!.Type & ItemType.Container) != 0) + .ToArray(); + Assert.Equal(new[] { looseA, looseB }, loose); + Assert.Equal(new[] { bagB, bagA }, bags); + Assert.Equal(0, table.Get(bagB)!.ContainerSlot); + Assert.Equal(1, table.Get(bagA)!.ContainerSlot); + } + [Fact] public void ProjectionEvictionNotification_ReentrantMoveSeesCommittedState() {