fix(interaction): restore retail loot placement and world-drop projection

This commit is contained in:
Erik 2026-07-27 00:03:15 +02:00
parent 4d095be286
commit 921712f412
24 changed files with 1581 additions and 34 deletions

View file

@ -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 ## #246 — Prepared indoor cells stopped traversing their portals
**Status:** DONE — 2026-07-26; connected house/dungeon gate passed **Status:** DONE — 2026-07-26; connected house/dungeon gate passed

View file

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

View file

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

View file

@ -503,6 +503,17 @@ internal sealed class SessionPlayerCompositionPhase
live.LandblockLoaded.Bind(hydration)); live.LandblockLoaded.Bind(hydration));
Fault(SessionPlayerCompositionPoint.HydrationCreated); 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( var networkUpdates = new LiveEntityNetworkUpdateController(
live.LiveEntities, live.LiveEntities,
d.EntityObjects.Objects, d.EntityObjects.Objects,
@ -531,7 +542,8 @@ internal sealed class SessionPlayerCompositionPhase
d.UpdateClock, d.UpdateClock,
liveSessionSource, liveSessionSource,
localPhysicsTimestamps.Publish, localPhysicsTimestamps.Publish,
d.MovementDiagnostics); d.MovementDiagnostics,
worldDropProjection);
var liveness = new LiveEntityLivenessController( var liveness = new LiveEntityLivenessController(
live.LiveEntities, live.LiveEntities,
d.PlayerIdentity, d.PlayerIdentity,

View file

@ -230,12 +230,6 @@ internal sealed class SelectionInteractionController
CancelPickupPresentation(itemGuid); CancelPickupPresentation(itemGuid);
return; return;
} }
if (!ValidatePickupTarget(itemGuid, showToast: true)
|| !_query.TryGetApproach(itemGuid, out InteractionApproach approach))
{
CancelPickupPresentation(itemGuid);
return;
}
ulong pendingPlacementToken = _items.TryGetPendingBackpackPlacement( ulong pendingPlacementToken = _items.TryGetPendingBackpackPlacement(
itemGuid, itemGuid,
@ -251,6 +245,42 @@ internal sealed class SelectionInteractionController
return; 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) if (approach.IsCloseRange)
{ {
bool armed = false; bool armed = false;

View file

@ -261,7 +261,19 @@ internal sealed class WorldSelectionQuery
public VividTargetInfo? ResolveVividTargetInfo(uint serverGuid) 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)) || !TryGetSelectionSphere(serverGuid, out Vector3 center, out float radius))
{ {
return null; return null;

View file

@ -27,7 +27,7 @@ internal readonly record struct AcceptedStateNetworkUpdate(
internal readonly record struct AcceptedPositionNetworkUpdate( internal readonly record struct AcceptedPositionNetworkUpdate(
WorldSession.EntityPositionUpdate Update, WorldSession.EntityPositionUpdate Update,
LiveEntityRecord Record, RuntimeEntityRecord Canonical,
WorldSession.EntitySpawn Spawn, WorldSession.EntitySpawn Spawn,
AcceptedPhysicsTimestamps Timestamps, AcceptedPhysicsTimestamps Timestamps,
PositionTimestampDisposition TimestampDisposition, PositionTimestampDisposition TimestampDisposition,
@ -173,22 +173,22 @@ internal sealed class LiveEntityInboundAuthorityGate
return false; return false;
} }
LiveEntityRecord? record = null; RuntimeEntityRecord? canonical = null;
ulong positionAuthorityVersion = 0; ulong positionAuthorityVersion = 0;
ulong velocityAuthorityVersion = 0; ulong velocityAuthorityVersion = 0;
if (disposition is not PositionTimestampDisposition.Rejected) if (disposition is not PositionTimestampDisposition.Rejected)
{ {
if (!_liveEntities.TryGetRecord(update.Guid, out record)) if (!_liveEntities.TryGetCanonical(update.Guid, out canonical))
return false; return false;
positionAuthorityVersion = record.PositionAuthorityVersion; positionAuthorityVersion = canonical.PositionAuthorityVersion;
velocityAuthorityVersion = record.VelocityAuthorityVersion; velocityAuthorityVersion = canonical.VelocityAuthorityVersion;
} }
_publishTimestamps(update.Guid, timestamps); _publishTimestamps(update.Guid, timestamps);
if (disposition is PositionTimestampDisposition.Rejected) if (disposition is PositionTimestampDisposition.Rejected)
return false; return false;
if (!_liveEntities.IsCurrentPositionAuthority( if (!_liveEntities.IsCurrentPositionAuthority(
record!, canonical!,
positionAuthorityVersion)) positionAuthorityVersion))
{ {
return false; return false;
@ -196,7 +196,7 @@ internal sealed class LiveEntityInboundAuthorityGate
accepted = new AcceptedPositionNetworkUpdate( accepted = new AcceptedPositionNetworkUpdate(
update, update,
record!, canonical!,
spawn, spawn,
timestamps, timestamps,
disposition, disposition,

View file

@ -61,6 +61,8 @@ internal sealed class LiveEntityNetworkUpdateController
private readonly ILiveWorldSessionSource _session; private readonly ILiveWorldSessionSource _session;
private readonly LiveEntityInboundAuthorityGate _authorityGate; private readonly LiveEntityInboundAuthorityGate _authorityGate;
private readonly IMovementTruthDiagnosticSink _movementTruthDiagnostics; private readonly IMovementTruthDiagnosticSink _movementTruthDiagnostics;
private readonly InventoryWorldDropProjectionController?
_worldDropProjection;
private PlayerMovementController? _playerController => _playerControllerSource.Controller; private PlayerMovementController? _playerController => _playerControllerSource.Controller;
private EntityPhysicsHost? _playerHost => _playerHostSource.Host; private EntityPhysicsHost? _playerHost => _playerHostSource.Host;
@ -100,7 +102,8 @@ internal sealed class LiveEntityNetworkUpdateController
IPhysicsScriptTimeSource gameTime, IPhysicsScriptTimeSource gameTime,
ILiveWorldSessionSource session, ILiveWorldSessionSource session,
Action<uint, AcceptedPhysicsTimestamps> publishTimestamps, Action<uint, AcceptedPhysicsTimestamps> publishTimestamps,
IMovementTruthDiagnosticSink movementTruthDiagnostics) IMovementTruthDiagnosticSink movementTruthDiagnostics,
InventoryWorldDropProjectionController? worldDropProjection = null)
{ {
_liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities)); _liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities));
_objects = objects ?? throw new ArgumentNullException(nameof(objects)); _objects = objects ?? throw new ArgumentNullException(nameof(objects));
@ -135,6 +138,7 @@ internal sealed class LiveEntityNetworkUpdateController
publishTimestamps); publishTimestamps);
_movementTruthDiagnostics = movementTruthDiagnostics _movementTruthDiagnostics = movementTruthDiagnostics
?? throw new ArgumentNullException(nameof(movementTruthDiagnostics)); ?? throw new ArgumentNullException(nameof(movementTruthDiagnostics));
_worldDropProjection = worldDropProjection;
} }
internal void ResetSessionState() => _authorityGate.ResetSessionState(); internal void ResetSessionState() => _authorityGate.ResetSessionState();
@ -936,6 +940,11 @@ internal sealed class LiveEntityNetworkUpdateController
public void OnPosition(AcDream.Core.Net.WorldSession.EntityPositionUpdate update) public void OnPosition(AcDream.Core.Net.WorldSession.EntityPositionUpdate update)
{ {
if (_worldDropProjection?.TryRecoverUnknownPosition(update) == true)
{
return;
}
bool payloadIsValid = _projectileController?.CanAcceptPositionPayload( bool payloadIsValid = _projectileController?.CanAcceptPositionPayload(
update.Guid, update.Guid,
update.Position, update.Position,
@ -957,11 +966,22 @@ internal sealed class LiveEntityNetworkUpdateController
var timestampDisposition = accepted.TimestampDisposition; var timestampDisposition = accepted.TimestampDisposition;
var acceptedSpawn = accepted.Spawn; var acceptedSpawn = accepted.Spawn;
var timestamps = accepted.Timestamps; var timestamps = accepted.Timestamps;
LiveEntityRecord acceptedPositionRecord = accepted.Record; RuntimeEntityRecord acceptedPositionCanonical = accepted.Canonical;
ulong acceptedPositionAuthorityVersion = ulong acceptedPositionAuthorityVersion =
accepted.PositionAuthorityVersion; accepted.PositionAuthorityVersion;
ulong acceptedPositionVelocityAuthorityVersion = ulong acceptedPositionVelocityAuthorityVersion =
accepted.VelocityAuthorityVersion; accepted.VelocityAuthorityVersion;
if (!_liveEntities.TryGetProjection(
acceptedPositionCanonical,
out LiveEntityRecord acceptedPositionRecord)
&& !_liveEntityHydration.RecoverCanonicalProjection(
acceptedPositionCanonical,
acceptedPositionAuthorityVersion,
out acceptedPositionRecord))
{
return;
}
bool IsCurrentPositionOwner( bool IsCurrentPositionOwner(
AcDream.Core.World.WorldEntity? expectedEntity = null) => AcDream.Core.World.WorldEntity? expectedEntity = null) =>
_liveEntities.IsCurrentPositionAuthority( _liveEntities.IsCurrentPositionAuthority(
@ -1013,7 +1033,12 @@ internal sealed class LiveEntityNetworkUpdateController
_playerController))) _playerController)))
return; 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()) if (!IsCurrentPositionOwner())
return; 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 // Retail teleport transit: the 7-state TAS drives portal/view-plane presentation, holds the
// player in PortalSpace until the destination is resident (TeleportWorldReady), then // player in PortalSpace until the destination is resident (TeleportWorldReady), then
// fires Place (materialize) and FireLoginComplete (regain control + ack the server). // fires Place (materialize) and FireLoginComplete (regain control + ack the server).

View file

@ -993,7 +993,13 @@ public sealed class EquippedChildRenderController : IDisposable
private void OnObjectMoved(ClientObjectMove move) 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); BeginDetachedRemoval(move.ItemId);
} }

View file

@ -179,6 +179,14 @@ public sealed class ItemInteractionController : IDisposable
/// </summary> /// </summary>
public event Action<PendingBackpackPlacement>? PendingBackpackPlacementResolved; public event Action<PendingBackpackPlacement>? PendingBackpackPlacementResolved;
/// <summary>
/// Publishes the exact split-to-world request after its wire send has
/// succeeded. Retail <c>ACCWeenieObject::UIAttemptSplitTo3D @
/// 0x0058D850</c> retains the source class, selected amount, and request
/// time so the subsequently created ground stack can be recognized.
/// </summary>
public event Action<WorldDropDispatch>? WorldDropDispatched;
public uint PlayerGuid => _playerGuid(); public uint PlayerGuid => _playerGuid();
public InteractionState InteractionState => _interactionState; public InteractionState InteractionState => _interactionState;
@ -302,14 +310,10 @@ public sealed class ItemInteractionController : IDisposable
/// <summary> /// <summary>
/// Retail <c>ACCWeenieObject::IsOwnedByPlayer</c> projection shared with /// Retail <c>ACCWeenieObject::IsOwnedByPlayer</c> projection shared with
/// toolbar shortcut creation. Ownership includes self, equipped items, and /// toolbar shortcut creation.
/// arbitrarily nested carried containers.
/// </summary> /// </summary>
public bool IsOwnedByPlayer(uint itemGuid) public bool IsOwnedByPlayer(uint itemGuid)
=> _objects.Get(itemGuid) is { } item => _objects.IsOwnedByObject(itemGuid, _playerGuid());
&& (item.ObjectId == _playerGuid()
|| IsCarriedByPlayer(item)
|| IsEquippedByPlayer(item));
public bool IsCurrentTargetCompatible(uint targetGuid) public bool IsCurrentTargetCompatible(uint targetGuid)
{ {
@ -721,6 +725,21 @@ public sealed class ItemInteractionController : IDisposable
return item is not null; return item is not null;
} }
/// <summary>
/// Returns whether the object is a member of retail's current
/// <c>ClientUISystem::groundObject</c>. Such objects are container
/// contents, not independently projected world objects, so
/// <c>CPlayerSystem::PlaceInBackpack @ 0x0055D8C0</c> sends their
/// container transfer directly without a 3-D approach lookup.
/// </summary>
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) internal bool IsCurrentObjectIdentity(uint objectId, ClientObject item)
=> ReferenceEquals(_objects.Get(objectId), item); => ReferenceEquals(_objects.Get(objectId), item);
@ -983,6 +1002,14 @@ public sealed class ItemInteractionController : IDisposable
if (_sendSplitToWorld is null) if (_sendSplitToWorld is null)
return false; return false;
_sendSplitToWorld(action.ObjectId, (uint)action.Amount); _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; return true;
}); });
break; break;
@ -1137,6 +1164,7 @@ public sealed class ItemInteractionController : IDisposable
_transactions.ObjectTableCleared -= OnInventoryObjectsCleared; _transactions.ObjectTableCleared -= OnInventoryObjectsCleared;
_transactions.RequestCompleted -= OnInventoryRequestCompleted; _transactions.RequestCompleted -= OnInventoryRequestCompleted;
_transactions.StateChanged -= OnTransactionStateChanged; _transactions.StateChanged -= OnTransactionStateChanged;
WorldDropDispatched = null;
_autoWield.Dispose(); _autoWield.Dispose();
} }

View file

@ -0,0 +1,11 @@
using AcDream.Core.Items;
namespace AcDream.App.UI;
/// <summary>
/// One successfully dispatched inventory-to-world request together with the
/// quantity selected by the retained UI.
/// </summary>
public readonly record struct WorldDropDispatch(
PendingInventoryRequest Request,
uint Amount);

View file

@ -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;
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// Retail records <c>splitClassID</c>, <c>splitStackSize</c>, and
/// <c>splitTime</c> in <c>ACCWeenieObject::UIAttemptSplitTo3D @
/// 0x0058D850</c>, then recognizes the created stack from
/// <c>ACCWeenieObject::DeclareValid @ 0x0058E340</c>. 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.
/// </remarks>
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<double> _now;
private bool _disposed;
public InventoryWorldDropProjectionController(
ItemInteractionController interaction,
ClientObjectTable objects,
LiveEntityRuntime runtime,
LiveEntityHydrationController hydration,
Func<double> 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();
}
/// <summary>
/// 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.
/// </summary>
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);
}

View file

@ -676,6 +676,66 @@ AppearanceSynchronization:
} }
} }
/// <summary>
/// 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.
/// </summary>
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) public bool OnEntityReady(LiveEntityReadyCandidate candidate)
{ {
if (!PublishReady(candidate)) if (!PublishReady(candidate))

View file

@ -1154,6 +1154,29 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
return false; return false;
} }
/// <summary>
/// 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 <see cref="TryGetWorldEntity"/>,
/// it rejects a retained leave-world owner whose pose is historical.
/// </summary>
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;
}
/// <summary> /// <summary>
/// Resolves a top-level object that currently participates in picking, /// Resolves a top-level object that currently participates in picking,
/// targeting, radar, and wire-driven MoveTo/Sticky establishment. /// targeting, radar, and wire-driven MoveTo/Sticky establishment.
@ -1762,6 +1785,12 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
&& ReferenceEquals(current, record) && ReferenceEquals(current, record)
&& current.PositionAuthorityVersion == authorityVersion; && current.PositionAuthorityVersion == authorityVersion;
internal bool IsCurrentPositionAuthority(
RuntimeEntityRecord canonical,
ulong authorityVersion) =>
_directory.IsCurrent(canonical)
&& canonical.PositionAuthorityVersion == authorityVersion;
internal bool IsCurrentStateAuthority( internal bool IsCurrentStateAuthority(
LiveEntityRecord record, LiveEntityRecord record,
ulong authorityVersion) => ulong authorityVersion) =>
@ -1783,6 +1812,12 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
&& ReferenceEquals(current, record) && ReferenceEquals(current, record)
&& current.VelocityAuthorityVersion == authorityVersion; && current.VelocityAuthorityVersion == authorityVersion;
internal bool IsCurrentVelocityAuthority(
RuntimeEntityRecord canonical,
ulong authorityVersion) =>
_directory.IsCurrent(canonical)
&& canonical.VelocityAuthorityVersion == authorityVersion;
internal bool IsCurrentMovementAuthority( internal bool IsCurrentMovementAuthority(
LiveEntityRecord record, LiveEntityRecord record,
ulong authorityVersion) => ulong authorityVersion) =>

View file

@ -214,6 +214,40 @@ public sealed class ClientObjectTable
public ClientObject? Get(uint objectId) => public ClientObject? Get(uint objectId) =>
_objects.TryGetValue(objectId, out var item) ? item : null; _objects.TryGetValue(objectId, out var item) ? item : null;
/// <summary>
/// Retail <c>ACCWeenieObject::IsOwnedByObject @ 0x0058CEB0</c>.
/// 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.
/// </summary>
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<uint>? ownerContents)
&& ownerContents.Contains(item.ContainerId))
{
return true;
}
return _equipmentIndex.TryGetValue(ownerId, out List<uint>? ownerLocations)
&& ownerLocations.Contains(item.ObjectId);
}
/// <summary> /// <summary>
/// Look up a container by object id, creating a lightweight stub if /// Look up a container by object id, creating a lightweight stub if
/// the id doesn't match any known container (defensive — avoids losing /// the id doesn't match any known container (defensive — avoids losing
@ -297,25 +331,42 @@ public sealed class ClientObjectTable
newWielderId, newWielderId,
newEquipLocation), newEquipLocation),
containerTypeHint, containerTypeHint,
ClientObjectMoveOrigin.AuthoritativeResponse); ClientObjectMoveOrigin.AuthoritativeResponse,
retailContainerInsert: true);
} }
private bool ApplyPlacement( private bool ApplyPlacement(
ClientObject item, ClientObject item,
ClientObjectPlacement current, ClientObjectPlacement current,
uint? containerTypeHint, uint? containerTypeHint,
ClientObjectMoveOrigin origin) ClientObjectMoveOrigin origin,
bool retailContainerInsert = false)
{ {
ClientObjectPlacement previous = ClientObjectPlacement.From(item); ClientObjectPlacement previous = ClientObjectPlacement.From(item);
List<uint>? changedContainers = RemoveFromOtherContainerIndexes( bool previousWasContainer = IsContainerListMember(item);
List<uint>? changedContainers;
if (retailContainerInsert)
{
changedContainers = RemoveFromAllContainerIndexes(
item.ObjectId,
current.ContainerId,
previousWasContainer);
}
else
{
changedContainers = RemoveFromOtherContainerIndexes(
item.ObjectId, item.ObjectId,
current.ContainerId); current.ContainerId);
}
item.ContainerId = current.ContainerId; item.ContainerId = current.ContainerId;
item.ContainerSlot = current.ContainerSlot; item.ContainerSlot = current.ContainerSlot;
item.WielderId = current.WielderId; item.WielderId = current.WielderId;
item.CurrentlyEquippedLocation = current.EquipLocation; item.CurrentlyEquippedLocation = current.EquipLocation;
if (containerTypeHint is { } hint) if (containerTypeHint is { } hint)
item.ContainerTypeHint = hint; item.ContainerTypeHint = hint;
if (retailContainerInsert && item.ContainerId != 0u)
InsertContainerMember(item, current.ContainerSlot);
else
Reindex(item, previous.ContainerId); Reindex(item, previous.ContainerId);
PublishPlacementChange(item, previous, origin); PublishPlacementChange(item, previous, origin);
PublishContainerContentsChanges(changedContainers); PublishContainerContentsChanges(changedContainers);
@ -927,6 +978,101 @@ public sealed class ClientObjectTable
return changed; return changed;
} }
/// <summary>
/// Retail <c>ACCWeenieObject::ServerSaysMoveItem @ 0x0058DBB0</c>
/// removes the object from its old <c>IDList</c> before
/// <c>AddContent @ 0x0058CCE0</c> 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.
/// </summary>
private List<uint>? RemoveFromAllContainerIndexes(
uint itemId,
uint destinationContainerId,
bool wasContainer)
{
List<uint>? changed = null;
foreach ((uint containerId, List<uint> members) in _containerIndex)
{
if (!members.Remove(itemId))
continue;
RenumberContainerCategory(members, wasContainer);
if (containerId != destinationContainerId)
(changed ??= new List<uint>()).Add(containerId);
}
return changed;
}
/// <summary>
/// Port of retail <c>ACCWeenieObject::AddContent @ 0x0058CCE0</c>:
/// items and child containers have separate ordered <c>IDList</c>s and
/// <c>IDList::AddAtNum</c> 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.
/// </summary>
private void InsertContainerMember(ClientObject item, int requestedSlot)
{
if (!_containerIndex.TryGetValue(item.ContainerId, out List<uint>? members))
_containerIndex[item.ContainerId] = members = new List<uint>();
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<uint> 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<uint>? changed) private void PublishContainerContentsChanges(List<uint>? changed)
{ {
if (changed is null || changed.Count == 0) if (changed is null || changed.Count == 0)

View file

@ -17,6 +17,7 @@ public sealed class SelectionInteractionControllerTests
{ {
private const uint Player = 0x5000_0001u; private const uint Player = 0x5000_0001u;
private const uint Target = 0x7000_0001u; private const uint Target = 0x7000_0001u;
private const uint GroundContainer = 0x7000_0010u;
private sealed class Query : IWorldSelectionQuery private sealed class Query : IWorldSelectionQuery
{ {
@ -140,6 +141,7 @@ public sealed class SelectionInteractionControllerTests
public readonly List<PendingBackpackPlacement> CancelledPlacements = new(); public readonly List<PendingBackpackPlacement> CancelledPlacements = new();
public readonly ItemInteractionController Items; public readonly ItemInteractionController Items;
public readonly SelectionInteractionController Controller; public readonly SelectionInteractionController Controller;
public uint GroundObjectId { get; set; }
public Harness() public Harness()
{ {
@ -173,6 +175,7 @@ public sealed class SelectionInteractionControllerTests
Query.Events.Add("examine"); Query.Events.Add("examine");
Examines.Add(guid); Examines.Add(guid);
}, },
groundObjectId: () => GroundObjectId,
placeInBackpack: (item, container, placement) => placeInBackpack: (item, container, placement) =>
controller!.SendPickup(item, container, placement), controller!.SendPickup(item, container, placement),
requestUse: (guid, reservation) => requestUse: (guid, reservation) =>
@ -495,6 +498,32 @@ public sealed class SelectionInteractionControllerTests
Assert.Equal(new[] { (Target, Player, 0) }, h.Transport.Pickups); 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] [Fact]
public void SessionResetClearsQueuedAndDeferredWork() public void SessionResetClearsQueuedAndDeferredWork()
{ {

View file

@ -319,6 +319,75 @@ public sealed class WorldSelectionQueryTests
Assert.Equal(4f, radius); 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() private static SelectionCameraSnapshot Camera()
{ {
Matrix4x4 view = Matrix4x4.CreateLookAt( Matrix4x4 view = Matrix4x4.CreateLookAt(

View file

@ -159,6 +159,8 @@ public sealed class LiveEntityInboundAuthorityGateTests
out AcceptedPositionNetworkUpdate accepted)); out AcceptedPositionNetworkUpdate accepted));
Assert.Equal(PositionTimestampDisposition.Apply, accepted.TimestampDisposition); Assert.Equal(PositionTimestampDisposition.Apply, accepted.TimestampDisposition);
Assert.Equal((ulong)2, accepted.PositionAuthorityVersion); Assert.Equal((ulong)2, accepted.PositionAuthorityVersion);
Assert.True(runtime.TryGetCanonical(Guid, out RuntimeEntityRecord canonical));
Assert.Same(canonical, accepted.Canonical);
Assert.Equal(1, publishCount); Assert.Equal(1, publishCount);
gate.ResetSessionState(); gate.ResetSessionState();
@ -197,6 +199,33 @@ public sealed class LiveEntityInboundAuthorityGateTests
out _)); 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] [Fact]
public void Vector_NewerSameIncarnationPacketInvalidatesCapturedAuthority() public void Vector_NewerSameIncarnationPacketInvalidatesCapturedAuthority()
{ {
@ -308,10 +337,10 @@ public sealed class LiveEntityInboundAuthorityGateTests
payloadIsValid: true, payloadIsValid: true,
out AcceptedPositionNetworkUpdate accepted)); out AcceptedPositionNetworkUpdate accepted));
Assert.True(runtime.IsCurrentPositionAuthority( Assert.True(runtime.IsCurrentPositionAuthority(
accepted.Record, accepted.Canonical,
accepted.PositionAuthorityVersion)); accepted.PositionAuthorityVersion));
Assert.False(runtime.IsCurrentVelocityAuthority( Assert.False(runtime.IsCurrentVelocityAuthority(
accepted.Record, accepted.Canonical,
accepted.VelocityAuthorityVersion)); accepted.VelocityAuthorityVersion));
} }

View file

@ -1051,6 +1051,47 @@ public sealed class EquippedChildProjectionWithdrawalTests
Assert.Single(fixture.Controller.AttachedEntityIds); 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] [Fact]
public void OrphanRollback_PendingFailureRetainsExactRetryOwner() public void OrphanRollback_PendingFailureRetainsExactRetryOwner()
{ {

View file

@ -1489,6 +1489,8 @@ public sealed class ItemInteractionControllerTests
{ {
var h = new Harness(); var h = new Harness();
h.AddContained(0x50000A70u, item => item.StackSize = 10); h.AddContained(0x50000A70u, item => item.StackSize = 10);
WorldDropDispatch? dispatched = null;
h.Controller.WorldDropDispatched += value => dispatched = value;
h.SelectedObject = 0x50000A70u; h.SelectedObject = 0x50000A70u;
h.SplitQuantity.Reset(10u); h.SplitQuantity.Reset(10u);
h.SplitQuantity.SetValue(1u); h.SplitQuantity.SetValue(1u);
@ -1504,6 +1506,10 @@ public sealed class ItemInteractionControllerTests
Assert.Empty(h.Drops); Assert.Empty(h.Drops);
Assert.Equal(Pack, h.Objects.Get(0x50000A70u)!.ContainerId); Assert.Equal(Pack, h.Objects.Get(0x50000A70u)!.ContainerId);
Assert.Equal(10, h.Objects.Get(0x50000A70u)!.StackSize); 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] [Fact]

View file

@ -3,7 +3,9 @@ using System.Reflection;
using AcDream.App.Physics; using AcDream.App.Physics;
using AcDream.App.Rendering; using AcDream.App.Rendering;
using AcDream.App.World; using AcDream.App.World;
using AcDream.Core.Net;
using AcDream.Core.Physics; using AcDream.Core.Physics;
using AcDream.Core.World;
namespace AcDream.App.Tests.World; namespace AcDream.App.Tests.World;
@ -65,6 +67,42 @@ public sealed class GameWindowLiveEntityCompositionTests
BindingFlags.NonPublic)); 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] [Theory]
[InlineData(typeof(LiveEntityCollisionBuilder))] [InlineData(typeof(LiveEntityCollisionBuilder))]
[InlineData(typeof(LiveEntityDefaultPoseResolver))] [InlineData(typeof(LiveEntityDefaultPoseResolver))]

View file

@ -423,6 +423,85 @@ public sealed class LiveEntityHydrationControllerTests
Assert.Equal(0, fixture.Resources.UnregisterCount); 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] [Fact]
public void ParentEvent_IsRetainedByRelationshipOwnerAndAcceptedThroughHydrationGate() public void ParentEvent_IsRetainedByRelationshipOwnerAndAcceptedThroughHydrationGate()
{ {
@ -2350,4 +2429,19 @@ public sealed class LiveEntityHydrationControllerTests
PlacementId: parentGuid is null ? null : 1u, PlacementId: parentGuid is null ? null : 1u,
Physics: physics); 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);
} }

View file

@ -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<PhysicsSpawnData>(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<CreateObject.AnimPartChange>(),
TextureChanges: Array.Empty<CreateObject.TextureChange>(),
SubPalettes: Array.Empty<CreateObject.SubPaletteSwap>(),
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);
}
}

View file

@ -46,6 +46,49 @@ public sealed class ClientObjectTableTests
Assert.Equal(1, propUpdateCount); 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] [Fact]
public void StopViewingContents_RemovesOnlyTemporaryProjection() public void StopViewingContents_RemovesOnlyTemporaryProjection()
{ {
@ -673,6 +716,96 @@ public sealed class ClientObjectTableTests
Assert.Equal(new[] { item }, table.GetContents(pack)); 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] [Fact]
public void ProjectionEvictionNotification_ReentrantMoveSeesCommittedState() public void ProjectionEvictionNotification_ReentrantMoveSeesCommittedState()
{ {