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
|
||||
|
||||
**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
|
||||
collision, restored overwritten paperdoll rows as AP-92/AP-93, and registered
|
||||
|
|
@ -221,6 +221,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
|
|||
| AP-121 | The `/framerate` command persists acdream's live ShowFps value in the modern `settings.json` Display bag after performing retail's live toggle/notice behavior. The named retail `DoFrameRate` body itself only flips `fShowFramerate` and sends `SetFramerateDisplay`; no equivalent persistence write occurs in that command body. | `src/AcDream.App/Settings/RuntimeSettingsController.cs` (`ToggleFrameRate`) | This preserves acdream's shipped L.0 cross-launch display preference while keeping the live retail mechanism and SmartBox visibility result exact; it changes no gameplay or wire state | A relaunch can retain FPS visibility where the named retail command body alone would not; a future port of retail's wider preference owner must avoid double-persisting or applying conflicting startup state | `ClientCommunicationSystem::DoFrameRate @ 0x005707D0`; `CM_UI::SendNotice_SetFramerateDisplay @ 0x0047A050`; `gmSmartBoxUI::RecvNotice_SetFramerateDisplay @ 0x004D65E0` |
|
||||
| AP-122 | The toolbar Use hand is Ghosted when selection is empty. Retail sets state 1 (Normal) and an empty-selection click enters generic `TARGET_MODE_USE`; selected-object enablement and activation remain retail-faithful. | `src/AcDream.App/UI/Layout/ToolbarController.cs` (`RefreshUseButton`) | User explicitly requires no selection to present as unavailable. The disabled retained button consumes the click without opening a cursor; every nonempty selection still uses the exact retail CombatUse/type/ItemUses predicate and the shared activation owner. | Generic pick-then-use mode cannot be entered from the empty toolbar hand; an object must first be selected, after which the same Use command is available. | `gmToolbarUI::HandleSelectionChanged @ 0x004BF380`; `gmToolbarUI::ListenToElementMessage @ 0x004BEE90` |
|
||||
| AP-123 | Item cooldowns use the retained toolkit's existing procedural `UiItemSlot` leaf rather than materializing retail's ten `m_elem_Icon_Cooldown_*` child elements. The group lookup, remaining-time formula, exact DAT sprites, step choice, and topmost ReadOrder-8 outcome are faithful. | `src/AcDream.App/UI/Layout/ItemCooldownUiController.cs`; `src/AcDream.App/UI/UiItemSlot.cs` | `UiItemSlot` already consumes/reproduces UIItem children procedurally under the IA-15 retained-toolkit architecture. Selecting one imported sprite at draw time gives every inventory/equipment/shortcut alias the same output without a parallel widget tree or per-cell timers. | A future feature that observes the individual cooldown child visibility/state rather than the rendered UIItem could see no child elements even though the cell looks and advances correctly. | `CEnchantmentRegistry::OnCooldown @ 0x005943C0`; `UIElement_UIItem::UpdateCooldownDisplay @ 0x004E1E20`; common UIItem prototype `0x1000033E` |
|
||||
| AP-124 | Local ACE omits the new object's CreateObject to the initiating session after `StackableSplitTo3D`, sending only F748 Position for the previously unknown GUID. acdream retains retail's one pending split source/count/time identity for ten seconds and, only for that otherwise-impossible unknown Position, hydrates a canonical clone of the source description with the new GUID and authoritative world placement. | `src/AcDream.App/World/InventoryWorldDropProjectionController.cs`; `src/AcDream.App/UI/ItemInteractionController.cs`; `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` | Nearby/reconnecting clients receive the ordinary CreateObject, while the initiator otherwise cannot render the authoritative object until relog. A server that sends CreateObject never enters this path; the pending identity is consumed before normal hydration and all other unknown Positions remain rejected. | ACE's F748 does not carry WCID or stack size, so an unrelated unknown Position arriving during the exact pending ten-second window could be associated with the split. Retail confirms WCID/count from CreateObject; removing the approximation requires ACE to send that packet to the initiator. | `ACCWeenieObject::UIAttemptSplitTo3D @ 0x0058D850`; `ACCWeenieObject::DeclareValid @ 0x0058E340`; ACE `Player.HandleActionStackableSplitTo3D` / `TryDropItem`; `docs/research/2026-07-26-retail-inventory-placement-and-world-drop-pseudocode.md` |
|
||||
|
||||
## 4. Temporary stopgap (TS) — 38 active rows + TS-37 historical note (TS-20 retired 2026-07-16 — the later named-retail audit disproved the proposed DrawingBSP polygon filter; TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-42 retired 2026-07-19 — semantic animation completion now precedes the ordered Target/Movement/PartArray/Position tail; TS-44 narrowed again 2026-07-19 — complete orientation joined interpolation, only during-stick enqueue suppression remains)
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
Fault(SessionPlayerCompositionPoint.HydrationCreated);
|
||||
|
||||
var worldDropProjection =
|
||||
new InventoryWorldDropProjectionController(
|
||||
interaction.ItemInteraction,
|
||||
d.EntityObjects.Objects,
|
||||
live.LiveEntities,
|
||||
hydration,
|
||||
() => d.UpdateClock.SimulationTimeSeconds);
|
||||
bindings.Adopt(
|
||||
"inventory world-drop projection",
|
||||
worldDropProjection);
|
||||
|
||||
var networkUpdates = new LiveEntityNetworkUpdateController(
|
||||
live.LiveEntities,
|
||||
d.EntityObjects.Objects,
|
||||
|
|
@ -531,7 +542,8 @@ internal sealed class SessionPlayerCompositionPhase
|
|||
d.UpdateClock,
|
||||
liveSessionSource,
|
||||
localPhysicsTimestamps.Publish,
|
||||
d.MovementDiagnostics);
|
||||
d.MovementDiagnostics,
|
||||
worldDropProjection);
|
||||
var liveness = new LiveEntityLivenessController(
|
||||
live.LiveEntities,
|
||||
d.PlayerIdentity,
|
||||
|
|
|
|||
|
|
@ -230,12 +230,6 @@ internal sealed class SelectionInteractionController
|
|||
CancelPickupPresentation(itemGuid);
|
||||
return;
|
||||
}
|
||||
if (!ValidatePickupTarget(itemGuid, showToast: true)
|
||||
|| !_query.TryGetApproach(itemGuid, out InteractionApproach approach))
|
||||
{
|
||||
CancelPickupPresentation(itemGuid);
|
||||
return;
|
||||
}
|
||||
|
||||
ulong pendingPlacementToken = _items.TryGetPendingBackpackPlacement(
|
||||
itemGuid,
|
||||
|
|
@ -251,6 +245,42 @@ internal sealed class SelectionInteractionController
|
|||
return;
|
||||
}
|
||||
|
||||
// Retail CPlayerSystem::PlaceInBackpack @ 0x0055D8C0 delegates
|
||||
// current-ground-object contents straight to
|
||||
// ItemHolder::AttemptToPlaceInContainer @ 0x00588140. A corpse or
|
||||
// chest child has no independent 3-D projection to approach.
|
||||
if (_items.IsInCurrentGroundObject(itemGuid))
|
||||
{
|
||||
var contained = new RuntimePendingPickup(
|
||||
Token: 0u,
|
||||
itemGuid,
|
||||
LocalEntityId: 0u,
|
||||
destinationContainerId,
|
||||
placement,
|
||||
pendingPlacementToken,
|
||||
ApproachToken: default);
|
||||
if (_transactions.TryDispatchPickup(
|
||||
contained,
|
||||
_transport,
|
||||
out uint containedSequence))
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"[B.5] contained pickup item=0x{itemGuid:X8} container=0x{destinationContainerId:X8} placement={placement} seq={containedSequence}");
|
||||
}
|
||||
else
|
||||
{
|
||||
CancelPickupPresentation(itemGuid, pendingPlacementToken);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ValidatePickupTarget(itemGuid, showToast: true)
|
||||
|| !_query.TryGetApproach(itemGuid, out InteractionApproach approach))
|
||||
{
|
||||
CancelPickupPresentation(itemGuid, pendingPlacementToken);
|
||||
return;
|
||||
}
|
||||
|
||||
if (approach.IsCloseRange)
|
||||
{
|
||||
bool armed = false;
|
||||
|
|
|
|||
|
|
@ -261,7 +261,19 @@ internal sealed class WorldSelectionQuery
|
|||
|
||||
public VividTargetInfo? ResolveVividTargetInfo(uint serverGuid)
|
||||
{
|
||||
if (!_liveEntities.TryGetWorldEntity(serverGuid, out _)
|
||||
uint playerGuid = _playerGuid();
|
||||
ClientObject? target = _objects.Get(serverGuid);
|
||||
|
||||
// VividTargetIndicator::SetSelected @ 0x004F5CE0 deliberately keeps
|
||||
// ACCWeenieObject::selectedID intact while suppressing its own marker
|
||||
// for self, player-owned objects, and objects whose current state is
|
||||
// IN_CONTAINER. ContainerId is our authoritative placement projection
|
||||
// for that retail state.
|
||||
if (serverGuid == playerGuid
|
||||
|| target is null
|
||||
|| _objects.IsOwnedByObject(serverGuid, playerGuid)
|
||||
|| target.ContainerId != 0u
|
||||
|| !_liveEntities.TryGetSpatiallyProjectedRecord(serverGuid, out _)
|
||||
|| !TryGetSelectionSphere(serverGuid, out Vector3 center, out float radius))
|
||||
{
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ internal readonly record struct AcceptedStateNetworkUpdate(
|
|||
|
||||
internal readonly record struct AcceptedPositionNetworkUpdate(
|
||||
WorldSession.EntityPositionUpdate Update,
|
||||
LiveEntityRecord Record,
|
||||
RuntimeEntityRecord Canonical,
|
||||
WorldSession.EntitySpawn Spawn,
|
||||
AcceptedPhysicsTimestamps Timestamps,
|
||||
PositionTimestampDisposition TimestampDisposition,
|
||||
|
|
@ -173,22 +173,22 @@ internal sealed class LiveEntityInboundAuthorityGate
|
|||
return false;
|
||||
}
|
||||
|
||||
LiveEntityRecord? record = null;
|
||||
RuntimeEntityRecord? canonical = null;
|
||||
ulong positionAuthorityVersion = 0;
|
||||
ulong velocityAuthorityVersion = 0;
|
||||
if (disposition is not PositionTimestampDisposition.Rejected)
|
||||
{
|
||||
if (!_liveEntities.TryGetRecord(update.Guid, out record))
|
||||
if (!_liveEntities.TryGetCanonical(update.Guid, out canonical))
|
||||
return false;
|
||||
positionAuthorityVersion = record.PositionAuthorityVersion;
|
||||
velocityAuthorityVersion = record.VelocityAuthorityVersion;
|
||||
positionAuthorityVersion = canonical.PositionAuthorityVersion;
|
||||
velocityAuthorityVersion = canonical.VelocityAuthorityVersion;
|
||||
}
|
||||
|
||||
_publishTimestamps(update.Guid, timestamps);
|
||||
if (disposition is PositionTimestampDisposition.Rejected)
|
||||
return false;
|
||||
if (!_liveEntities.IsCurrentPositionAuthority(
|
||||
record!,
|
||||
canonical!,
|
||||
positionAuthorityVersion))
|
||||
{
|
||||
return false;
|
||||
|
|
@ -196,7 +196,7 @@ internal sealed class LiveEntityInboundAuthorityGate
|
|||
|
||||
accepted = new AcceptedPositionNetworkUpdate(
|
||||
update,
|
||||
record!,
|
||||
canonical!,
|
||||
spawn,
|
||||
timestamps,
|
||||
disposition,
|
||||
|
|
|
|||
|
|
@ -61,6 +61,8 @@ internal sealed class LiveEntityNetworkUpdateController
|
|||
private readonly ILiveWorldSessionSource _session;
|
||||
private readonly LiveEntityInboundAuthorityGate _authorityGate;
|
||||
private readonly IMovementTruthDiagnosticSink _movementTruthDiagnostics;
|
||||
private readonly InventoryWorldDropProjectionController?
|
||||
_worldDropProjection;
|
||||
|
||||
private PlayerMovementController? _playerController => _playerControllerSource.Controller;
|
||||
private EntityPhysicsHost? _playerHost => _playerHostSource.Host;
|
||||
|
|
@ -100,7 +102,8 @@ internal sealed class LiveEntityNetworkUpdateController
|
|||
IPhysicsScriptTimeSource gameTime,
|
||||
ILiveWorldSessionSource session,
|
||||
Action<uint, AcceptedPhysicsTimestamps> publishTimestamps,
|
||||
IMovementTruthDiagnosticSink movementTruthDiagnostics)
|
||||
IMovementTruthDiagnosticSink movementTruthDiagnostics,
|
||||
InventoryWorldDropProjectionController? worldDropProjection = null)
|
||||
{
|
||||
_liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities));
|
||||
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
|
||||
|
|
@ -135,6 +138,7 @@ internal sealed class LiveEntityNetworkUpdateController
|
|||
publishTimestamps);
|
||||
_movementTruthDiagnostics = movementTruthDiagnostics
|
||||
?? throw new ArgumentNullException(nameof(movementTruthDiagnostics));
|
||||
_worldDropProjection = worldDropProjection;
|
||||
}
|
||||
|
||||
internal void ResetSessionState() => _authorityGate.ResetSessionState();
|
||||
|
|
@ -936,6 +940,11 @@ internal sealed class LiveEntityNetworkUpdateController
|
|||
|
||||
public void OnPosition(AcDream.Core.Net.WorldSession.EntityPositionUpdate update)
|
||||
{
|
||||
if (_worldDropProjection?.TryRecoverUnknownPosition(update) == true)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
bool payloadIsValid = _projectileController?.CanAcceptPositionPayload(
|
||||
update.Guid,
|
||||
update.Position,
|
||||
|
|
@ -957,11 +966,22 @@ internal sealed class LiveEntityNetworkUpdateController
|
|||
var timestampDisposition = accepted.TimestampDisposition;
|
||||
var acceptedSpawn = accepted.Spawn;
|
||||
var timestamps = accepted.Timestamps;
|
||||
LiveEntityRecord acceptedPositionRecord = accepted.Record;
|
||||
RuntimeEntityRecord acceptedPositionCanonical = accepted.Canonical;
|
||||
ulong acceptedPositionAuthorityVersion =
|
||||
accepted.PositionAuthorityVersion;
|
||||
ulong acceptedPositionVelocityAuthorityVersion =
|
||||
accepted.VelocityAuthorityVersion;
|
||||
if (!_liveEntities.TryGetProjection(
|
||||
acceptedPositionCanonical,
|
||||
out LiveEntityRecord acceptedPositionRecord)
|
||||
&& !_liveEntityHydration.RecoverCanonicalProjection(
|
||||
acceptedPositionCanonical,
|
||||
acceptedPositionAuthorityVersion,
|
||||
out acceptedPositionRecord))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
bool IsCurrentPositionOwner(
|
||||
AcDream.Core.World.WorldEntity? expectedEntity = null) =>
|
||||
_liveEntities.IsCurrentPositionAuthority(
|
||||
|
|
@ -1013,7 +1033,12 @@ internal sealed class LiveEntityNetworkUpdateController
|
|||
_playerController)))
|
||||
return;
|
||||
|
||||
if (!_liveEntities.ContainsWorldEntity(update.Guid))
|
||||
// A leave-world transition deliberately retains WorldEntity as the
|
||||
// logical/render-resource owner while IsSpatiallyProjected is false.
|
||||
// A fresh retail Position is the re-entry edge; testing only for the
|
||||
// retained object reference leaves dropped inventory permanently
|
||||
// invisible after InventoryPutObjectIn3D.
|
||||
if (RequiresSpatialProjectionRecovery(acceptedPositionRecord))
|
||||
{
|
||||
if (!IsCurrentPositionOwner())
|
||||
return;
|
||||
|
|
@ -1771,6 +1796,13 @@ internal sealed class LiveEntityNetworkUpdateController
|
|||
}
|
||||
}
|
||||
|
||||
internal static bool RequiresSpatialProjectionRecovery(
|
||||
LiveEntityRecord record)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(record);
|
||||
return record.WorldEntity is null || !record.IsSpatiallyProjected;
|
||||
}
|
||||
|
||||
// Retail teleport transit: the 7-state TAS drives portal/view-plane presentation, holds the
|
||||
// player in PortalSpace until the destination is resident (TeleportWorldReady), then
|
||||
// fires Place (materialize) and FireLoginComplete (regain control + ack the server).
|
||||
|
|
|
|||
|
|
@ -993,7 +993,13 @@ public sealed class EquippedChildRenderController : IDisposable
|
|||
|
||||
private void OnObjectMoved(ClientObjectMove move)
|
||||
{
|
||||
if (move.Current.EquipLocation == EquipMask.None)
|
||||
// ServerSaysMoveItem publishes both complete placements. Only an
|
||||
// equipped -> unequipped transition retires an attached paperdoll
|
||||
// projection. InventoryPutObjectIn3D also has EquipLocation=None, but
|
||||
// it is a world-entry confirmation and must never withdraw a Position
|
||||
// projection that arrived just before the F019A event.
|
||||
if (move.Previous.EquipLocation != EquipMask.None
|
||||
&& move.Current.EquipLocation == EquipMask.None)
|
||||
BeginDetachedRemoval(move.ItemId);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -179,6 +179,14 @@ public sealed class ItemInteractionController : IDisposable
|
|||
/// </summary>
|
||||
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 InteractionState InteractionState => _interactionState;
|
||||
|
|
@ -302,14 +310,10 @@ public sealed class ItemInteractionController : IDisposable
|
|||
|
||||
/// <summary>
|
||||
/// Retail <c>ACCWeenieObject::IsOwnedByPlayer</c> projection shared with
|
||||
/// toolbar shortcut creation. Ownership includes self, equipped items, and
|
||||
/// arbitrarily nested carried containers.
|
||||
/// toolbar shortcut creation.
|
||||
/// </summary>
|
||||
public bool IsOwnedByPlayer(uint itemGuid)
|
||||
=> _objects.Get(itemGuid) is { } item
|
||||
&& (item.ObjectId == _playerGuid()
|
||||
|| IsCarriedByPlayer(item)
|
||||
|| IsEquippedByPlayer(item));
|
||||
=> _objects.IsOwnedByObject(itemGuid, _playerGuid());
|
||||
|
||||
public bool IsCurrentTargetCompatible(uint targetGuid)
|
||||
{
|
||||
|
|
@ -721,6 +725,21 @@ public sealed class ItemInteractionController : IDisposable
|
|||
return item is not null;
|
||||
}
|
||||
|
||||
/// <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)
|
||||
=> ReferenceEquals(_objects.Get(objectId), item);
|
||||
|
||||
|
|
@ -983,6 +1002,14 @@ public sealed class ItemInteractionController : IDisposable
|
|||
if (_sendSplitToWorld is null)
|
||||
return false;
|
||||
_sendSplitToWorld(action.ObjectId, (uint)action.Amount);
|
||||
if (_transactions.TryGetPending(out PendingInventoryRequest pending)
|
||||
&& pending.Kind is InventoryRequestKind.SplitToWorld
|
||||
&& pending.ItemId == action.ObjectId)
|
||||
{
|
||||
WorldDropDispatched?.Invoke(new WorldDropDispatch(
|
||||
pending,
|
||||
(uint)action.Amount));
|
||||
}
|
||||
return true;
|
||||
});
|
||||
break;
|
||||
|
|
@ -1137,6 +1164,7 @@ public sealed class ItemInteractionController : IDisposable
|
|||
_transactions.ObjectTableCleared -= OnInventoryObjectsCleared;
|
||||
_transactions.RequestCompleted -= OnInventoryRequestCompleted;
|
||||
_transactions.StateChanged -= OnTransactionStateChanged;
|
||||
WorldDropDispatched = null;
|
||||
_autoWield.Dispose();
|
||||
}
|
||||
|
||||
|
|
|
|||
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)
|
||||
{
|
||||
if (!PublishReady(candidate))
|
||||
|
|
|
|||
|
|
@ -1154,6 +1154,29 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
|
|||
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>
|
||||
/// Resolves a top-level object that currently participates in picking,
|
||||
/// targeting, radar, and wire-driven MoveTo/Sticky establishment.
|
||||
|
|
@ -1762,6 +1785,12 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
|
|||
&& ReferenceEquals(current, record)
|
||||
&& current.PositionAuthorityVersion == authorityVersion;
|
||||
|
||||
internal bool IsCurrentPositionAuthority(
|
||||
RuntimeEntityRecord canonical,
|
||||
ulong authorityVersion) =>
|
||||
_directory.IsCurrent(canonical)
|
||||
&& canonical.PositionAuthorityVersion == authorityVersion;
|
||||
|
||||
internal bool IsCurrentStateAuthority(
|
||||
LiveEntityRecord record,
|
||||
ulong authorityVersion) =>
|
||||
|
|
@ -1783,6 +1812,12 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
|
|||
&& ReferenceEquals(current, record)
|
||||
&& current.VelocityAuthorityVersion == authorityVersion;
|
||||
|
||||
internal bool IsCurrentVelocityAuthority(
|
||||
RuntimeEntityRecord canonical,
|
||||
ulong authorityVersion) =>
|
||||
_directory.IsCurrent(canonical)
|
||||
&& canonical.VelocityAuthorityVersion == authorityVersion;
|
||||
|
||||
internal bool IsCurrentMovementAuthority(
|
||||
LiveEntityRecord record,
|
||||
ulong authorityVersion) =>
|
||||
|
|
|
|||
|
|
@ -214,6 +214,40 @@ public sealed class ClientObjectTable
|
|||
public ClientObject? Get(uint objectId) =>
|
||||
_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>
|
||||
/// Look up a container by object id, creating a lightweight stub if
|
||||
/// the id doesn't match any known container (defensive — avoids losing
|
||||
|
|
@ -297,25 +331,42 @@ public sealed class ClientObjectTable
|
|||
newWielderId,
|
||||
newEquipLocation),
|
||||
containerTypeHint,
|
||||
ClientObjectMoveOrigin.AuthoritativeResponse);
|
||||
ClientObjectMoveOrigin.AuthoritativeResponse,
|
||||
retailContainerInsert: true);
|
||||
}
|
||||
|
||||
private bool ApplyPlacement(
|
||||
ClientObject item,
|
||||
ClientObjectPlacement current,
|
||||
uint? containerTypeHint,
|
||||
ClientObjectMoveOrigin origin)
|
||||
ClientObjectMoveOrigin origin,
|
||||
bool retailContainerInsert = false)
|
||||
{
|
||||
ClientObjectPlacement previous = ClientObjectPlacement.From(item);
|
||||
List<uint>? changedContainers = RemoveFromOtherContainerIndexes(
|
||||
bool previousWasContainer = IsContainerListMember(item);
|
||||
List<uint>? changedContainers;
|
||||
if (retailContainerInsert)
|
||||
{
|
||||
changedContainers = RemoveFromAllContainerIndexes(
|
||||
item.ObjectId,
|
||||
current.ContainerId,
|
||||
previousWasContainer);
|
||||
}
|
||||
else
|
||||
{
|
||||
changedContainers = RemoveFromOtherContainerIndexes(
|
||||
item.ObjectId,
|
||||
current.ContainerId);
|
||||
}
|
||||
item.ContainerId = current.ContainerId;
|
||||
item.ContainerSlot = current.ContainerSlot;
|
||||
item.WielderId = current.WielderId;
|
||||
item.CurrentlyEquippedLocation = current.EquipLocation;
|
||||
if (containerTypeHint is { } hint)
|
||||
item.ContainerTypeHint = hint;
|
||||
if (retailContainerInsert && item.ContainerId != 0u)
|
||||
InsertContainerMember(item, current.ContainerSlot);
|
||||
else
|
||||
Reindex(item, previous.ContainerId);
|
||||
PublishPlacementChange(item, previous, origin);
|
||||
PublishContainerContentsChanges(changedContainers);
|
||||
|
|
@ -927,6 +978,101 @@ public sealed class ClientObjectTable
|
|||
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)
|
||||
{
|
||||
if (changed is null || changed.Count == 0)
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ public sealed class SelectionInteractionControllerTests
|
|||
{
|
||||
private const uint Player = 0x5000_0001u;
|
||||
private const uint Target = 0x7000_0001u;
|
||||
private const uint GroundContainer = 0x7000_0010u;
|
||||
|
||||
private sealed class Query : IWorldSelectionQuery
|
||||
{
|
||||
|
|
@ -140,6 +141,7 @@ public sealed class SelectionInteractionControllerTests
|
|||
public readonly List<PendingBackpackPlacement> CancelledPlacements = new();
|
||||
public readonly ItemInteractionController Items;
|
||||
public readonly SelectionInteractionController Controller;
|
||||
public uint GroundObjectId { get; set; }
|
||||
|
||||
public Harness()
|
||||
{
|
||||
|
|
@ -173,6 +175,7 @@ public sealed class SelectionInteractionControllerTests
|
|||
Query.Events.Add("examine");
|
||||
Examines.Add(guid);
|
||||
},
|
||||
groundObjectId: () => GroundObjectId,
|
||||
placeInBackpack: (item, container, placement) =>
|
||||
controller!.SendPickup(item, container, placement),
|
||||
requestUse: (guid, reservation) =>
|
||||
|
|
@ -495,6 +498,32 @@ public sealed class SelectionInteractionControllerTests
|
|||
Assert.Equal(new[] { (Target, Player, 0) }, h.Transport.Pickups);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CorpseChildDoubleClickDispatchesWithoutAWorldProjection()
|
||||
{
|
||||
const uint loot = 0x7000_0011u;
|
||||
var h = new Harness
|
||||
{
|
||||
GroundObjectId = GroundContainer,
|
||||
};
|
||||
h.Objects.AddOrUpdate(new ClientObject
|
||||
{
|
||||
ObjectId = loot,
|
||||
Name = "Corpse loot",
|
||||
Type = ItemType.Misc,
|
||||
ContainerId = GroundContainer,
|
||||
});
|
||||
h.Query.Pickupable = false;
|
||||
h.Query.Approach = null;
|
||||
|
||||
Assert.True(h.Items.ActivateItem(loot));
|
||||
|
||||
Assert.Empty(h.Movement.Approaches);
|
||||
Assert.Equal(new[] { (loot, Player, 0) }, h.Transport.Pickups);
|
||||
Assert.Single(h.PendingPlacements);
|
||||
Assert.Empty(h.CancelledPlacements);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SessionResetClearsQueuedAndDeferredWork()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -319,6 +319,75 @@ public sealed class WorldSelectionQueryTests
|
|||
Assert.Equal(4f, radius);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VividTargetMarkerWaitsForFreshProjectionWhenDropMovePrecedesPosition()
|
||||
{
|
||||
var h = new Harness();
|
||||
WorldEntity entity =
|
||||
h.Add(Target, new Vector3(3f, 4f, 0f), ItemType.Misc);
|
||||
|
||||
Assert.NotNull(h.Query.ResolveVividTargetInfo(Target));
|
||||
|
||||
Assert.True(h.Objects.MoveItem(Target, Player, newSlot: 0));
|
||||
Assert.True(h.Runtime.WithdrawLiveEntityProjection(Target));
|
||||
Assert.Null(h.Query.ResolveVividTargetInfo(Target));
|
||||
|
||||
// ACE may confirm InventoryPutObjectIn3D before sending the fresh
|
||||
// Position. The retained entity still has the old ground pose here.
|
||||
Assert.True(h.Objects.MoveItem(Target, 0u, newSlot: -1));
|
||||
Assert.Null(h.Query.ResolveVividTargetInfo(Target));
|
||||
|
||||
var newPosition = new Vector3(30f, 40f, 2f);
|
||||
entity.SetPosition(newPosition);
|
||||
Assert.True(h.Runtime.RebucketLiveEntity(Target, 0x0101_0001u));
|
||||
|
||||
var marker = h.Query.ResolveVividTargetInfo(Target);
|
||||
Assert.NotNull(marker);
|
||||
Assert.Equal(newPosition + Vector3.UnitX, marker.Value.SelectionSphereCenter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VividTargetMarkerUsesFreshPoseWhenPositionPrecedesDropMove()
|
||||
{
|
||||
var h = new Harness();
|
||||
WorldEntity entity =
|
||||
h.Add(Target, new Vector3(3f, 4f, 0f), ItemType.Misc);
|
||||
Assert.True(h.Objects.MoveItem(Target, Player, newSlot: 0));
|
||||
Assert.True(h.Runtime.WithdrawLiveEntityProjection(Target));
|
||||
|
||||
var newPosition = new Vector3(30f, 40f, 2f);
|
||||
entity.SetPosition(newPosition);
|
||||
Assert.True(h.Runtime.RebucketLiveEntity(Target, 0x0101_0001u));
|
||||
|
||||
// The spatial pose is current, but ownership continues to suppress the
|
||||
// marker until ServerSaysMoveItem publishes the world placement.
|
||||
Assert.Null(h.Query.ResolveVividTargetInfo(Target));
|
||||
|
||||
Assert.True(h.Objects.MoveItem(Target, 0u, newSlot: -1));
|
||||
var marker = h.Query.ResolveVividTargetInfo(Target);
|
||||
Assert.NotNull(marker);
|
||||
Assert.Equal(newPosition + Vector3.UnitX, marker.Value.SelectionSphereCenter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VividTargetMarkerRejectsRetainedProjectionInsideExternalContainer()
|
||||
{
|
||||
var h = new Harness();
|
||||
const uint corpse = 0x7000_0040u;
|
||||
h.Add(Target, new Vector3(3f, 4f, 0f), ItemType.Misc);
|
||||
h.Objects.AddOrUpdate(new ClientObject
|
||||
{
|
||||
ObjectId = corpse,
|
||||
Name = "Corpse",
|
||||
Type = ItemType.Container,
|
||||
});
|
||||
|
||||
Assert.True(h.Objects.MoveItem(Target, corpse, newSlot: 0));
|
||||
|
||||
Assert.False(h.Objects.IsOwnedByObject(Target, Player));
|
||||
Assert.Null(h.Query.ResolveVividTargetInfo(Target));
|
||||
}
|
||||
|
||||
private static SelectionCameraSnapshot Camera()
|
||||
{
|
||||
Matrix4x4 view = Matrix4x4.CreateLookAt(
|
||||
|
|
|
|||
|
|
@ -159,6 +159,8 @@ public sealed class LiveEntityInboundAuthorityGateTests
|
|||
out AcceptedPositionNetworkUpdate accepted));
|
||||
Assert.Equal(PositionTimestampDisposition.Apply, accepted.TimestampDisposition);
|
||||
Assert.Equal((ulong)2, accepted.PositionAuthorityVersion);
|
||||
Assert.True(runtime.TryGetCanonical(Guid, out RuntimeEntityRecord canonical));
|
||||
Assert.Same(canonical, accepted.Canonical);
|
||||
Assert.Equal(1, publishCount);
|
||||
|
||||
gate.ResetSessionState();
|
||||
|
|
@ -197,6 +199,33 @@ public sealed class LiveEntityInboundAuthorityGateTests
|
|||
out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Position_CanonicalInventoryObjectIsAcceptedBeforeProjectionExists()
|
||||
{
|
||||
LiveEntityRuntime runtime = Runtime();
|
||||
LiveEntityRegistrationResult registration =
|
||||
runtime.RegisterLiveEntity(Spawn(Guid, instance: 5, position: 1));
|
||||
Assert.NotNull(registration.Canonical);
|
||||
Assert.False(runtime.TryGetRecord(Guid, out _));
|
||||
int publishCount = 0;
|
||||
var gate = new LiveEntityInboundAuthorityGate(
|
||||
runtime,
|
||||
(_, _) => publishCount++);
|
||||
|
||||
Assert.True(gate.TryAcceptPosition(
|
||||
Position(Guid, 5, 2, 0x01010002u),
|
||||
localPlayerGuid: 0u,
|
||||
forcePositionRotation: null,
|
||||
currentLocalVelocity: null,
|
||||
payloadIsValid: true,
|
||||
out AcceptedPositionNetworkUpdate accepted));
|
||||
|
||||
Assert.Same(registration.Canonical, accepted.Canonical);
|
||||
Assert.Equal((ulong)2, accepted.PositionAuthorityVersion);
|
||||
Assert.Equal(1, publishCount);
|
||||
Assert.False(runtime.TryGetRecord(Guid, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Vector_NewerSameIncarnationPacketInvalidatesCapturedAuthority()
|
||||
{
|
||||
|
|
@ -308,10 +337,10 @@ public sealed class LiveEntityInboundAuthorityGateTests
|
|||
payloadIsValid: true,
|
||||
out AcceptedPositionNetworkUpdate accepted));
|
||||
Assert.True(runtime.IsCurrentPositionAuthority(
|
||||
accepted.Record,
|
||||
accepted.Canonical,
|
||||
accepted.PositionAuthorityVersion));
|
||||
Assert.False(runtime.IsCurrentVelocityAuthority(
|
||||
accepted.Record,
|
||||
accepted.Canonical,
|
||||
accepted.VelocityAuthorityVersion));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1051,6 +1051,47 @@ public sealed class EquippedChildProjectionWithdrawalTests
|
|||
Assert.Single(fixture.Controller.AttachedEntityIds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InventoryPutObjectIn3D_DoesNotWithdrawRecoveredWorldProjection()
|
||||
{
|
||||
int withdrawals = 0;
|
||||
ControllerFixture? fixture = null;
|
||||
fixture = new ControllerFixture((record, positionVersion, projectionVersion) =>
|
||||
{
|
||||
withdrawals++;
|
||||
bool completed = fixture!.Live.WithdrawLiveEntityProjection(
|
||||
record,
|
||||
positionVersion,
|
||||
projectionVersion);
|
||||
return new(
|
||||
completed
|
||||
? ExactProjectionWithdrawalDisposition.Completed
|
||||
: ExactProjectionWithdrawalDisposition.Superseded,
|
||||
Failure: null);
|
||||
});
|
||||
using (fixture)
|
||||
{
|
||||
const uint guid = 0x7000026Au;
|
||||
const uint player = 0x50000001u;
|
||||
LiveEntityRecord dropped = fixture.Spawn(guid, generation: 1);
|
||||
fixture.Objects.AddOrUpdate(new ClientObject
|
||||
{
|
||||
ObjectId = guid,
|
||||
ContainerId = player,
|
||||
CurrentlyEquippedLocation = EquipMask.None,
|
||||
});
|
||||
|
||||
Assert.True(fixture.Objects.ApplyConfirmedServerMove(
|
||||
guid,
|
||||
newContainerId: 0u,
|
||||
newWielderId: 0u));
|
||||
|
||||
Assert.Equal(0, withdrawals);
|
||||
Assert.True(dropped.IsSpatiallyProjected);
|
||||
Assert.Equal(LiveEntityProjectionKind.World, dropped.ProjectionKind);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OrphanRollback_PendingFailureRetainsExactRetryOwner()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1489,6 +1489,8 @@ public sealed class ItemInteractionControllerTests
|
|||
{
|
||||
var h = new Harness();
|
||||
h.AddContained(0x50000A70u, item => item.StackSize = 10);
|
||||
WorldDropDispatch? dispatched = null;
|
||||
h.Controller.WorldDropDispatched += value => dispatched = value;
|
||||
h.SelectedObject = 0x50000A70u;
|
||||
h.SplitQuantity.Reset(10u);
|
||||
h.SplitQuantity.SetValue(1u);
|
||||
|
|
@ -1504,6 +1506,10 @@ public sealed class ItemInteractionControllerTests
|
|||
Assert.Empty(h.Drops);
|
||||
Assert.Equal(Pack, h.Objects.Get(0x50000A70u)!.ContainerId);
|
||||
Assert.Equal(10, h.Objects.Get(0x50000A70u)!.StackSize);
|
||||
Assert.NotNull(dispatched);
|
||||
Assert.Equal(InventoryRequestKind.SplitToWorld, dispatched.Value.Request.Kind);
|
||||
Assert.Equal(0x50000A70u, dispatched.Value.Request.ItemId);
|
||||
Assert.Equal(1u, dispatched.Value.Amount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
|
|
@ -3,7 +3,9 @@ using System.Reflection;
|
|||
using AcDream.App.Physics;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.World;
|
||||
using AcDream.Core.Net;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.World;
|
||||
|
||||
namespace AcDream.App.Tests.World;
|
||||
|
||||
|
|
@ -65,6 +67,42 @@ public sealed class GameWindowLiveEntityCompositionTests
|
|||
BindingFlags.NonPublic));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RetainedButWithdrawnWorldEntity_RequiresPositionProjectionRecovery()
|
||||
{
|
||||
LiveEntityRecord record =
|
||||
LiveEntityTestFixture.CreateExactProjectionRecord(
|
||||
new WorldSession.EntitySpawn(
|
||||
Guid: 0x7000_00D0u,
|
||||
Position: null,
|
||||
SetupTableId: null,
|
||||
AnimPartChanges: [],
|
||||
TextureChanges: [],
|
||||
SubPalettes: [],
|
||||
BasePaletteId: null,
|
||||
ObjScale: null,
|
||||
Name: null,
|
||||
ItemType: null,
|
||||
MotionState: null,
|
||||
MotionTableId: null,
|
||||
InstanceSequence: 1));
|
||||
record.WorldEntity = new WorldEntity
|
||||
{
|
||||
Id = record.LocalEntityId!.Value,
|
||||
ServerGuid = record.ServerGuid,
|
||||
SourceGfxObjOrSetupId = 0x0200_0001u,
|
||||
Position = System.Numerics.Vector3.Zero,
|
||||
Rotation = System.Numerics.Quaternion.Identity,
|
||||
MeshRefs = [],
|
||||
};
|
||||
Assert.NotNull(record.WorldEntity);
|
||||
Assert.False(record.IsSpatiallyProjected);
|
||||
|
||||
Assert.True(
|
||||
LiveEntityNetworkUpdateController
|
||||
.RequiresSpatialProjectionRecovery(record));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(typeof(LiveEntityCollisionBuilder))]
|
||||
[InlineData(typeof(LiveEntityDefaultPoseResolver))]
|
||||
|
|
|
|||
|
|
@ -423,6 +423,85 @@ public sealed class LiveEntityHydrationControllerTests
|
|||
Assert.Equal(0, fixture.Resources.UnregisterCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PositionAfterInventoryOnlyCreate_ConstructsFirstSpatialProjection()
|
||||
{
|
||||
using var fixture = new Fixture(originKnown: true);
|
||||
fixture.Controller.OnCreate(CelllessSpawn(
|
||||
PositionSequence: 1,
|
||||
parentGuid: null));
|
||||
RuntimeEntityRecord canonical = fixture.Canonical;
|
||||
Assert.False(fixture.Runtime.TryGetRecord(Guid, out _));
|
||||
Assert.Equal(0, fixture.Resources.RegisterCount);
|
||||
|
||||
Assert.True(fixture.Runtime.TryApplyPosition(
|
||||
PositionUpdate(sequence: 2, x: 14f),
|
||||
isLocalPlayer: false,
|
||||
forcePositionRotation: null,
|
||||
currentLocalVelocity: null,
|
||||
out _,
|
||||
out _,
|
||||
out _));
|
||||
ulong authorityVersion = canonical.PositionAuthorityVersion;
|
||||
|
||||
Assert.True(fixture.Controller.RecoverCanonicalProjection(
|
||||
canonical,
|
||||
authorityVersion,
|
||||
out LiveEntityRecord record));
|
||||
|
||||
Assert.NotNull(record.WorldEntity);
|
||||
Assert.True(record.IsSpatiallyProjected);
|
||||
Assert.True(record.InitialHydrationCompleted);
|
||||
Assert.Equal(1, fixture.Resources.RegisterCount);
|
||||
Assert.Equal(1, fixture.Ready.PublishCount);
|
||||
Assert.Contains(
|
||||
fixture.Materializer.Calls,
|
||||
call => call.Purpose is LiveProjectionPurpose.SpatialRecovery);
|
||||
|
||||
Assert.True(fixture.Controller.RecoverCanonicalProjection(
|
||||
canonical,
|
||||
authorityVersion,
|
||||
out LiveEntityRecord same));
|
||||
Assert.Same(record, same);
|
||||
Assert.Equal(1, fixture.Resources.RegisterCount);
|
||||
Assert.Equal(1, fixture.Ready.PublishCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanonicalProjectionRecovery_RejectsSupersededPositionAuthority()
|
||||
{
|
||||
using var fixture = new Fixture(originKnown: true);
|
||||
fixture.Controller.OnCreate(CelllessSpawn(
|
||||
PositionSequence: 1,
|
||||
parentGuid: null));
|
||||
RuntimeEntityRecord canonical = fixture.Canonical;
|
||||
Assert.True(fixture.Runtime.TryApplyPosition(
|
||||
PositionUpdate(sequence: 2, x: 14f),
|
||||
isLocalPlayer: false,
|
||||
forcePositionRotation: null,
|
||||
currentLocalVelocity: null,
|
||||
out _,
|
||||
out _,
|
||||
out _));
|
||||
ulong staleAuthorityVersion = canonical.PositionAuthorityVersion;
|
||||
Assert.True(fixture.Runtime.TryApplyPosition(
|
||||
PositionUpdate(sequence: 3, x: 18f),
|
||||
isLocalPlayer: false,
|
||||
forcePositionRotation: null,
|
||||
currentLocalVelocity: null,
|
||||
out _,
|
||||
out _,
|
||||
out _));
|
||||
|
||||
Assert.False(fixture.Controller.RecoverCanonicalProjection(
|
||||
canonical,
|
||||
staleAuthorityVersion,
|
||||
out _));
|
||||
Assert.False(fixture.Runtime.TryGetRecord(Guid, out _));
|
||||
Assert.Equal(0, fixture.Resources.RegisterCount);
|
||||
Assert.Equal(0, fixture.Ready.PublishCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParentEvent_IsRetainedByRelationshipOwnerAndAcceptedThroughHydrationGate()
|
||||
{
|
||||
|
|
@ -2350,4 +2429,19 @@ public sealed class LiveEntityHydrationControllerTests
|
|||
PlacementId: parentGuid is null ? null : 1u,
|
||||
Physics: physics);
|
||||
}
|
||||
|
||||
private static WorldSession.EntityPositionUpdate PositionUpdate(
|
||||
ushort sequence,
|
||||
float x) =>
|
||||
new(
|
||||
Guid,
|
||||
new CreateObject.ServerPosition(
|
||||
Cell, x, 12f, 6f, 1f, 0f, 0f, 0f),
|
||||
Velocity: null,
|
||||
PlacementId: 0,
|
||||
IsGrounded: true,
|
||||
InstanceSequence: 1,
|
||||
PositionSequence: sequence,
|
||||
TeleportSequence: 0,
|
||||
ForcePositionSequence: 0);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsOwnedByObjectMatchesRetailDirectPackAndLocationRules()
|
||||
{
|
||||
const uint player = 0x5000_0001u;
|
||||
const uint pack = 0x4000_0001u;
|
||||
var repo = new ClientObjectTable();
|
||||
repo.AddOrUpdate(MakeItem(player, "Player"));
|
||||
repo.AddOrUpdate(new ClientObject
|
||||
{
|
||||
ObjectId = pack,
|
||||
Name = "Pack",
|
||||
Type = ItemType.Container,
|
||||
ContainerId = player,
|
||||
});
|
||||
repo.ReplaceContents(
|
||||
player,
|
||||
[new ContainerContentEntry(pack, ContainerType: 1u)]);
|
||||
|
||||
ClientObject loose = MakeItem(100u, "Loose");
|
||||
loose.ContainerId = player;
|
||||
repo.AddOrUpdate(loose);
|
||||
|
||||
ClientObject packed = MakeItem(101u, "Packed");
|
||||
packed.ContainerId = pack;
|
||||
repo.AddOrUpdate(packed);
|
||||
|
||||
ClientObject equipped = MakeItem(102u, "Equipped");
|
||||
equipped.WielderId = player;
|
||||
equipped.CurrentlyEquippedLocation = EquipMask.MeleeWeapon;
|
||||
repo.AddOrUpdate(equipped);
|
||||
|
||||
ClientObject foreign = MakeItem(103u, "Foreign");
|
||||
foreign.ContainerId = 0x6000_0001u;
|
||||
repo.AddOrUpdate(foreign);
|
||||
|
||||
Assert.True(repo.IsOwnedByObject(player, player));
|
||||
Assert.True(repo.IsOwnedByObject(loose.ObjectId, player));
|
||||
Assert.True(repo.IsOwnedByObject(packed.ObjectId, player));
|
||||
Assert.True(repo.IsOwnedByObject(equipped.ObjectId, player));
|
||||
Assert.False(repo.IsOwnedByObject(foreign.ObjectId, player));
|
||||
Assert.False(repo.IsOwnedByObject(packed.ObjectId, 0u));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StopViewingContents_RemovesOnlyTemporaryProjection()
|
||||
{
|
||||
|
|
@ -673,6 +716,96 @@ public sealed class ClientObjectTableTests
|
|||
Assert.Equal(new[] { item }, table.GetContents(pack));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AuthoritativePickup_PlacementZeroInsertsAtRetailListHead()
|
||||
{
|
||||
var table = new ClientObjectTable();
|
||||
const uint corpse = 0x40000001u;
|
||||
const uint pack = 0x50000001u;
|
||||
const uint existingA = 0xA01u;
|
||||
const uint existingB = 0xA02u;
|
||||
const uint firstLoot = 0xA03u;
|
||||
const uint secondLoot = 0xA04u;
|
||||
|
||||
table.InitializeInventoryManifest(pack, new[]
|
||||
{
|
||||
new ContainerContentEntry(existingA, 0u),
|
||||
new ContainerContentEntry(existingB, 0u),
|
||||
});
|
||||
table.ReplaceContents(corpse, new[] { firstLoot, secondLoot });
|
||||
|
||||
Assert.True(table.ApplyConfirmedServerMove(
|
||||
firstLoot,
|
||||
pack,
|
||||
newWielderId: 0u,
|
||||
newSlot: 0,
|
||||
containerTypeHint: 0u));
|
||||
Assert.Equal(
|
||||
new[] { firstLoot, existingA, existingB },
|
||||
table.GetContents(pack));
|
||||
Assert.Equal(
|
||||
new[] { 0, 1, 2 },
|
||||
table.GetContents(pack).Select(id => table.Get(id)!.ContainerSlot));
|
||||
|
||||
Assert.True(table.ApplyConfirmedServerMove(
|
||||
secondLoot,
|
||||
pack,
|
||||
newWielderId: 0u,
|
||||
newSlot: 0,
|
||||
containerTypeHint: 0u));
|
||||
Assert.Equal(
|
||||
new[] { secondLoot, firstLoot, existingA, existingB },
|
||||
table.GetContents(pack));
|
||||
Assert.Equal(
|
||||
new[] { 0, 1, 2, 3 },
|
||||
table.GetContents(pack).Select(id => table.Get(id)!.ContainerSlot));
|
||||
Assert.Empty(table.GetContents(corpse));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AuthoritativeContainerMove_IndexesOnlyTheRetailContainerList()
|
||||
{
|
||||
var table = new ClientObjectTable();
|
||||
const uint pack = 0x50000001u;
|
||||
const uint looseA = 0xA11u;
|
||||
const uint looseB = 0xA12u;
|
||||
const uint bagA = 0xA21u;
|
||||
const uint bagB = 0xA22u;
|
||||
|
||||
table.InitializeInventoryManifest(pack, new[]
|
||||
{
|
||||
new ContainerContentEntry(looseA, 0u),
|
||||
new ContainerContentEntry(bagA, 1u),
|
||||
new ContainerContentEntry(looseB, 0u),
|
||||
});
|
||||
table.AddOrUpdate(new ClientObject
|
||||
{
|
||||
ObjectId = bagB,
|
||||
Type = ItemType.Container,
|
||||
ItemsCapacity = 24,
|
||||
});
|
||||
|
||||
Assert.True(table.ApplyConfirmedServerMove(
|
||||
bagB,
|
||||
pack,
|
||||
newWielderId: 0u,
|
||||
newSlot: 0,
|
||||
containerTypeHint: 1u));
|
||||
|
||||
uint[] loose = table.GetContents(pack)
|
||||
.Where(id => table.Get(id)!.ContainerTypeHint == 0u
|
||||
&& (table.Get(id)!.Type & ItemType.Container) == 0)
|
||||
.ToArray();
|
||||
uint[] bags = table.GetContents(pack)
|
||||
.Where(id => table.Get(id)!.ContainerTypeHint != 0u
|
||||
|| (table.Get(id)!.Type & ItemType.Container) != 0)
|
||||
.ToArray();
|
||||
Assert.Equal(new[] { looseA, looseB }, loose);
|
||||
Assert.Equal(new[] { bagB, bagA }, bags);
|
||||
Assert.Equal(0, table.Get(bagB)!.ContainerSlot);
|
||||
Assert.Equal(1, table.Get(bagA)!.ContainerSlot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProjectionEvictionNotification_ReentrantMoveSeesCommittedState()
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue