fix(interaction): restore retail loot placement and world-drop projection
This commit is contained in:
parent
4d095be286
commit
921712f412
24 changed files with 1581 additions and 34 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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`
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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).
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
11
src/AcDream.App/UI/WorldDropDispatch.cs
Normal file
11
src/AcDream.App/UI/WorldDropDispatch.cs
Normal 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);
|
||||||
216
src/AcDream.App/World/InventoryWorldDropProjectionController.cs
Normal file
216
src/AcDream.App/World/InventoryWorldDropProjectionController.cs
Normal 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);
|
||||||
|
}
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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) =>
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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))]
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue