feat(net): port retail physics spawn and event timestamps
Parse the complete PhysicsDesc plus F754/F755 packets, correct every PhysicsState bit, and gate all nine retail update channels with generation-safe immutable snapshots. Preserve ForcePosition, teleport, placement, velocity, parent, pickup, delete, and same-generation CreateObject ordering from the named client. Separate accepted logical lifecycle notifications from retained UI qualities, make GUID replacement and session reset clear every projection exactly once, and add packet, wraparound, malformed-input, parent FIFO, canonical-position, reconnect, and GUID-reuse conformance coverage. Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
parent
d53fe30ffe
commit
8a5d77f7f4
50 changed files with 3809 additions and 649 deletions
|
|
@ -188,7 +188,8 @@ src/AcDream.App/
|
|||
├── Net/
|
||||
│ └── LiveSessionController.cs # owns WorldSession lifecycle, login/handshake, reconnect
|
||||
├── World/
|
||||
│ └── LiveEntityRuntime.cs # owns per-entity state dicts + ServerGuid↔entity.Id translation
|
||||
│ ├── InboundPhysicsStateController.cs # shipped: timestamps + accepted spawn snapshots
|
||||
│ └── LiveEntityRuntime.cs # target: full lifecycle + ServerGuid↔entity.Id translation
|
||||
├── Interaction/
|
||||
│ └── SelectionInteractionController.cs # owns WorldPicker, selection state, Use/PickUp dispatch
|
||||
├── Streaming/ # LandblockStreamer + immutable LandblockBuild completion
|
||||
|
|
@ -225,6 +226,17 @@ The eventual `GameEntity` aggregation (target state described in
|
|||
Until then, the parallel-dicts problem is bounded inside one class
|
||||
instead of spread across `GameWindow`.
|
||||
|
||||
`InboundPhysicsStateController` is the first shipped slice of that boundary:
|
||||
it owns the nine-channel retail timestamp gates and the latest accepted
|
||||
immutable CreateObject snapshot. It deliberately owns no renderer adapter,
|
||||
local entity ID, physics body, animation, effect, or spatial bucket; those
|
||||
move together in the `LiveEntityRuntime` extraction.
|
||||
|
||||
`ParentAttachmentState` is a focused interim pending queue for ParentEvent:
|
||||
it keys unresolved relations by child and parent generation, distinguishes
|
||||
atomic incarnation replacement from true deletion, and migrates into
|
||||
`LiveEntityRuntime` with the general mixed packet queue.
|
||||
|
||||
---
|
||||
|
||||
## 4. Extraction sequence — safest first
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@ accepted-divergence entries (#96, #49, #50).
|
|||
| AD-29 | `ClientObjectTable` fires global `ObjectAdded`/`ObjectUpdated`/`ObjectRemoved` events; consumers filter by guid on their end. Retail dispatches per-object via `NoticeRegistrar` observer dispatch — each UI cell observes only its specific object guid | `src/AcDream.Core/Items/ClientObjectTable.cs:48` (events); `src/AcDream.App/UI/Layout/ToolbarController.cs:115` (guid filter) | `NoticeRegistrar` is inside keystone.dll with no PDB/decomp; global broadcast + consumer-side filter is functionally equivalent for the current panel count and object volumes seen in practice | At high object counts (>1 000 objects), every `ObjectUpdated` wakes every subscribed consumer — O(n·m) notification cost instead of retail's O(1) per-observer dispatch; a consumer that forgets the guid filter processes all objects (a latent correctness bug) | `NoticeRegistrar` (keystone.dll, no PDB); retail per-object observer registration in `CObjectMaint` |
|
||||
| AD-30 | Cell-march preserves seed landblock id when `TryGetTerrainOrigin` returns false for an outdoor seed (#145 D, 2026-06-22): `BuildCellSetAndPickContaining` returns `currentCellId` verbatim rather than marching via `blockOrigin=(0,0,0)`; retail never encounters this state (cells stored block-local, no streaming-gap concept) | `src/AcDream.Core/Physics/CellTransit.cs:765` | Equivalence argument: "preserve-verbatim when unregistered" is the same contract as `PhysicsEngine.Resolve`'s NO-LANDBLOCK branch; the player's cell stays the last known-correct cell until the landblock's terrain registers — no march, no lbX=0 wire | A body whose seed landblock is genuinely absent for >1 physics tick holds its last-known cell rather than discovering the true containing cell; transient only — corrects the instant terrain registers; an indoor seed is explicitly excluded from the guard (outdoor low < 0x100 gate) | `CObjCell::find_cell_list` + block-local storage (retail has no streaming gap); `TryGetTerrainOrigin` pc path |
|
||||
| AD-31 | Teleport transit covered by a full-screen black FADE (`FadeOverlay` + `TeleportAnimSequencer`) instead of retail's 3D portal-tunnel swirl (2026-06-22, spec C). Opaque black holds through the tunnel states; the world ramps back in on `WorldFadeIn`. | `src/AcDream.App/Rendering/FadeOverlay.cs` + `GameWindow.cs` (TAS transit tick; `_teleportFadeAlpha = ShowTunnel ? 1 : FadeAlpha`) | The fade is a functional cover that hides the (now-fast) destination load + the post-materialization object flood; the TAS state machine + golden timing constants are retail-verbatim — only the tunnel *graphic* is approximated. Sibling to AP-49 (fade-curve). | Visual-only: the transit shows a black cover, not the animated swirl. Retire by porting the `gmSmartBoxUI` 3D tunnel render. | `gmSmartBoxUI::UseTime` 0x004d6e30 (tunnel render, unported); `TELEPORT_ANIM_*` golden constants (spec §2.1) |
|
||||
| AD-32 | Movement-event staleness gate ADOPTS a newer-incarnation instance stamp and applies the event immediately; retail queues the blob for the not-yet-created object (`SmartBox::QueueBlobForObject`, dispatch return 4) and replays it once that incarnation exists (L.2g S1, 2026-07-02) | `src/AcDream.Core/Physics/MotionSequenceGate.cs:105` | acdream has no per-object blob queue; a newer instance seq means the new incarnation's CreateObject is in flight, and that CreateObject re-seeds the same gate (advance-only), so old-incarnation stragglers still drop | A motion event for a new incarnation applies to the OLD body for up to one CreateObject round-trip — brief wrong-cycle flicker on respawn/re-instance if the new incarnation's motion table differs | 0xF74C dispatch pc:357214-357239 (`is_newer(update_times[8], seq)` + `QueueBlobForObject`) |
|
||||
| AD-32 | Movement, Position, State, Vector, Pickup, Delete, and ObjDesc packets for a FUTURE object incarnation are dropped until its CreateObject arrives; retail queues each blob for the not-yet-created object (`SmartBox::QueueBlobForObject`, dispatch return 4) and replays it after construction. Older-incarnation packets drop in both clients. ParentEvent is no longer part of this divergence: `ParentAttachmentState` retains it by parent generation and replays it after both objects exist. | `src/AcDream.Core/Physics/PhysicsTimestampGate.cs` (`TryAcceptInstance`); `src/AcDream.App/World/InboundPhysicsStateController.cs`; Parent exception `src/AcDream.App/Rendering/ParentAttachmentState.cs` | acdream has no general per-object blob queue yet. Dropping is generation-safe: a future packet cannot mutate the old body or advance its stamps, and the newer CreateObject wholesale-seeds all nine channels. Parent's focused interim queue migrates into Step 2's `LiveEntityRuntime`, the owning seam for the complete mixed pending queue. | The first queued non-Parent change for a new incarnation can be absent until a later update if it arrived before CreateObject and was not already represented in that CreateObject | `SmartBox::HandleSetState` 0x004533E0; `HandleVectorUpdate` 0x00453480; `HandleParentEvent` 0x004535D0; `HandlePickupEvent` 0x00453530; `HandleDeleteObject` 0x00451EA0; `HandleObjDescEvent` 0x00453340; `UnpackPositionEvent` 0x004542C0; 0xF74C dispatch pc:357214-357239 |
|
||||
| AD-33 | `CSequence.frame_number` at C# `double` (64-bit); retail is x87 `long double` (80-bit extended) — every frame-boundary comparison ran at extended precision on retail (Phase R1, 2026-07-02) | `src/AcDream.Core/Physics/Motion/CSequence.cs` (`FrameNumber`) | `double` is the widest C# float type; the R1 port removes ACE-style boundary epsilons so comparisons are exact-int against bare boundaries, minimizing ULP sensitivity (ACE's `float` is far worse) | A frame landing within 1 double-ULP of an integer boundary could classify differently than retail's 80-bit compare — sub-frame timing skew at pathological framerate×dt combinations | `acclient.h:30747` (`long double frame_number`) |
|
||||
| AD-34 | Retail's intrusive `DLListBase`/`DLListData` lists are managed `LinkedList<T>`s; node identity via `LinkedListNode<>` references (Phase R1 anim list, 2026-07-02; extended R2-Q3 to `MotionTableManager.pending_animations`; extended R4-V2 to `MoveToManager.pending_actions` — whose node type, retail `MoveToManager::MovementNode`, is RENAMED `MoveToNode` to avoid colliding with R2's `Motion/MotionNode.cs` pending_motions node) | `src/AcDream.Core/Physics/Motion/CSequence.cs` (`_animList`); `src/AcDream.Core/Physics/Motion/MotionTableManager.cs` (`_pendingAnimations`); `src/AcDream.Core/Physics/Motion/MoveToManager.cs` (`_pendingActions`) + `MoveToNode.cs` | Same topology + cursor semantics (curr_anim/first_cyclic/tail-anchored scans are node references); unlink/delete becomes `Remove(node)`; conformance tests pin the surgery state tables | Any retail behavior depending on the −4 pointer adjustment or node memory reuse (none observed in the decomp) would diverge; a reader grepping for retail's `MovementNode` name must find it via this row | `acclient.h` DLListBase; `r1-csequence-decomp.md` §0; `r2-motiontable-decomp.md` §11; `r4-moveto-decomp.md` node factories §4a |
|
||||
| AD-35 | `MotionTableManager.PerformMovement`'s unhandled-type default case returns the named sentinel `0xFFFFFFFF` (`MotionTableManagerError.NotHandled`); retail's compiled code leaks the `CSequence*` pointer reinterpreted as the return code (BN-confirmed artifact, dead/unreachable — callers gate on type first) (R2-Q3, 2026-07-02) | `src/AcDream.Core/Physics/Motion/MotionTableManager.cs` (`PerformMovement` default case) | No retail caller consults the return value for unhandled types (RawCommand/StopRawCommand/MoveTo\*/TurnTo\* route elsewhere); returning a stable non-zero sentinel preserves the only observable contract (non-zero = not success) without fabricating a pointer-shaped number | If a future port wires a caller that passes unhandled types AND branches on the exact return value, it would see `0xFFFFFFFF` where retail saw an arbitrary pointer — flag at that port | `PerformMovement` 0x0051c0b0 (`r2-motiontable-decomp.md` §11 default-case note) |
|
||||
|
|
@ -154,7 +154,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
|
|||
| AP-40 | Chat uses one fixed `0.75` outer opacity and has no descendant-focus-driven active/default opacity transition | `src/AcDream.App/Rendering/GameWindow.cs` chat mount; `ChatWindowController.cs` | Font resolution is now live and per-element; only the opacity behavior remains deferred to the shared window/focus runtime | Focused chat remains too translucent and idle chat never restores the configured default alpha | `ChatInterface::SetOpacity @ 0x004F3120`; `SetDefaultOpacity @ 0x004F3BC0`; `SetActiveOpacity @ 0x004F3C40` |
|
||||
| AP-41 | Scrollbar thumb 3-slice cap fallback only: single-tile draw (`0x06004C63`) used only when `ThumbTopSprite`/`ThumbBotSprite` are unset; the chat controller passes all three cap ids so the 3-slice path is drawn in practice | `src/AcDream.App/UI/UiScrollbar.cs:35` | The fallback single-tile path is unreachable when caps are bound (chat controller always sets them); the 3-slice path is the active code path | Only if a future caller omits the cap ids will the fallback fire — no visual regression in the chat window | `UIElement_Scrollbar::UpdateLayout @0x4710d0`; cap sprites `0x06004C60` (top) + `0x06004C66` (bottom) from base layout `0x2100003E` |
|
||||
| AP-42 | `UiMenu` item model is flat (label + opaque payload, single-level popup); retail `UIElement_Menu::MakePopup @0x46d310` supports hierarchical nested submenus via recursive popup chain | `src/AcDream.App/UI/UiMenu.cs` | The chat talk-focus menu is single-level (14 rows, 2 columns, no submenu); hierarchy is latent and unreachable through the chat window — no behavioral difference in the current usage | A future menu with nested submenus would render flat (only the top-level items drawn, no drill-down) | `UIElement_Menu::MakePopup` @0x46d310 |
|
||||
| AP-45 | `PublicUpdatePropertyInt (0x02CE)` sequence byte parsed-past but not honored; last update wins (no freshness check against sequence number) | `src/AcDream.Core.Net/Messages/PublicUpdatePropertyInt.cs` | Loopback ACE rarely reorders; latest-wins matches `PrivateUpdateVital`/`UpdatePosition`'s existing non-sequence behavior. Sequence tracking added when needed alongside TS-26. | A reordered 0x02CE on a real network could apply a stale UiEffects value — item icon temporarily shows the wrong effect state, corrected on next update | `PublicUpdatePropertyInt` sequence byte (ACE GameMessagePublicUpdatePropertyInt) |
|
||||
| AP-45 | `PublicUpdatePropertyInt (0x02CE)` sequence byte parsed-past but not honored; last update wins (no freshness check against sequence number) | `src/AcDream.Core.Net/Messages/PublicUpdatePropertyInt.cs` | Loopback ACE rarely reorders; this property stream has not yet joined the per-object freshness owner introduced for physics messages. | A reordered 0x02CE on a real network could apply a stale UiEffects value — item icon temporarily shows the wrong effect state, corrected on next update | `PublicUpdatePropertyInt` sequence byte (ACE GameMessagePublicUpdatePropertyInt) |
|
||||
| AP-48 | Inventory burden reads the player's wire `EncumbranceVal` (PropertyInt 5) when present — delivered by B-Wire (login PD-bundle `UpsertProperties` + live `PrivateUpdatePropertyInt 0x02CD`); `ClientObjectTable.SumCarriedBurden` remains only as a defensive fallback for when the server omits it. | `src/AcDream.App/UI/Layout/InventoryController.cs` (`RefreshBurden`); `src/AcDream.Core.Net/ObjectTableWiring.cs` (player-int route) | B-Wire ports the retail read (`CACQualities::InqLoad` reads the server value); the client sum is now fallback-only, kept defensively. CONFIRM the server actually sends EncumbranceVal at the B-Wire visual gate, then DELETE this row. | If the server omits EncumbranceVal, the bar falls back to the client sum (the original drift) until the first 0x02CD — confirm at the gate. | `CACQualities::InqLoad` 0x0058f130; ACE PropertyInt.EncumbranceVal=5 |
|
||||
| AP-49 | Carry-capacity augmentation (PropertyInt `0xE6`) read from the player's wire property bundle when present (B-Wire PD `UpsertProperties`); defaults to 0 (correct for un-augmented characters) when absent. | `src/AcDream.App/UI/Layout/InventoryController.cs` (`RefreshBurden`) caller of `BurdenMath.EncumbranceCapacity` | B-Wire delivers the player's full PropertyInt table (incl. `0xE6` when the server sets it) via `UpsertProperties`; aug=0 is the correct no-aug default. CONFIRM the server sends `0xE6` for an augmented char at the gate, then DELETE. | An augmented character whose server omits `0xE6` reads slightly low capacity (bar fills higher than retail); un-augmented chars are exact. | `EncumbranceSystem::EncumbranceCapacity` decomp 256393 (0x004fcc00); retail `0xE6` PropertyInt augmentation |
|
||||
| AP-50 | Burden meter orientation/direction set programmatically (`Vertical=true`, `FillFromBottom=true`) rather than reading retail's `m_eDirection` property from the LayoutDesc element (property id `0x6f`). | `src/AcDream.App/UI/Layout/InventoryController.cs`; `src/AcDream.App/UI/UiMeter.cs` (`Vertical`/`FillFromBottom`) | The `m_eDirection` property is not yet read by `ElementReader`; bottom-up fill is visually confirmed against retail's burden bar art. Retire when `0x6f` is wired through `ElementReader`→`DatWidgetFactory`. | Wrong fill direction (top-down instead of bottom-up, or horizontal) if a future meter whose art requires a different direction is forced through the same code path. | `UIElement_Meter::DrawChildren` @0x46fbd0; property `0x6f` (`m_eDirection`) in the LayoutDesc |
|
||||
|
|
@ -174,7 +174,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
|
|||
| ~~AP-66~~ | **RETIRED 2026-07-13 — authored paperdoll empty-slot presentation.** The earlier “no silhouettes” conclusion inspected the ItemList elements' own media but missed `UIElement_ItemList::InternalCreateItem`, which clones a distinct `UIElement_UIItem` catalog prototype for each location. All 21 supported jewelry, weapon, ammo, shield, clothing, cloak, trinket, and armor lists now resolve their exact `ItemSlot_Empty` surface from live DAT; `PostInit` confirms non-armor lists remain visible while the nine armor lists toggle with Slots. | `src/AcDream.App/UI/Layout/PaperdollSlotBackgrounds.cs`; `ItemListCellTemplate.cs`; `PaperdollController.cs` | — | — | `gmPaperDollUI::GetLocationInfoFromElementID @ 0x004A37F0`; `PostInit @ 0x004A5360`; `UIElement_ItemList::InternalCreateItem @ 0x004E3570`; `LayoutDesc 0x21000037` |
|
||||
| AP-67 | Server-spawned weenie fixtures (lanterns, braziers, glowing items carrying `Setup.Lights`) register their lights at their SPAWN-frame world position via `RegisterOwnedLight` in `OnLiveEntitySpawnedLocked` (mirroring the dat-static path in `ApplyLoadedTerrainLocked`); the light does NOT follow the object if it later moves. Torn down on despawn/respawn by the now-unconditional `UnregisterOwner` in `RemoveLiveEntityByServerGuid`. | `src/AcDream.App/Rendering/GameWindow.cs` (`OnLiveEntitySpawnedLocked` weenie-light block; `RemoveLiveEntityByServerGuid`) | Closes the prior gap where ONLY dat-baked statics registered lights, so server-placed lanterns cast nothing; the overwhelming majority of light-bearing weenies (wall lanterns, braziers, candelabra) are stationary, so spawn-frame placement matches retail; retail's `add_dynamic_light` re-places the light from the object frame each frame | A moving light-bearing weenie (e.g. a torch-carrying NPC) leaves its light at the spawn position instead of following — rare; the faithful fix is to re-place on `OnLivePositionUpdated` | retail object-borne lights `insert_light` 0x0054d1b0 / `add_dynamic_light` 0x0054d420 (per-frame object frame) |
|
||||
| AP-68 | acdream keeps the 128 nearest-to-CAMERA point lights live (`MaxGlobalLights=128`, `BuildPointLightSnapshot`) and selects per cell CAMERA-INDEPENDENTLY (by the cell's own bounds), so a building interior stays lit at any distance within a town; retail keeps only the 40 nearest-to-PLAYER static lights (`Render::max_static_lights=0x28`, distance-sorted replace-farthest `insert_light`) and re-bakes a cell when its live light set changes, so distant interiors are baked dark and "light up" only as the player approaches and their torches enter the live 40. INTENTIONAL — acdream's always-lit interiors are the preferred behavior (no 1999-era light-budget pop-in); user-confirmed 2026-06-20. | `src/AcDream.Core/Lighting/LightManager.cs` (`MaxGlobalLights=128`, `BuildPointLightSnapshot`, `SelectForObject`) | The retail pop-in is a fixed-function light-budget artifact, not an intended aesthetic; revert to retail by clamping the global set to 40 + distance-to-player sort if ever desired | Distant town interiors are lit in acdream where retail's are dark until approached — a deliberate, user-preferred divergence | `Render::max_static_lights` 0x28; `insert_light` 0x0054d1b0 (distance-sorted, replace-farthest); bake re-trigger `SetStaticLightingVertexColors` cache `burnedInStaticLights != num_static_lights` |
|
||||
| AP-69 | On a landblock (re)load, acdream re-projects its retained server-object spawns (`GameWindow._lastSpawnByGuid` — our `weenie_object_table` for world objects, carrying position + Setup + appearance) into the render world via `LandblockEntityRehydrator`. The dungeon collapse (#133/#135) and Near→Far demote drop a landblock's RENDER entities for FPS but keep the parsed spawns; ACE will NOT re-broadcast objects whose guid is still in its per-player `KnownObjects` set (never cleared on a normal teleport — ACE relies on the client retaining its table + doing its own visibility cull). Re-projecting from our own table is the retail "client keeps its weenie_object_table and re-renders from it" model. DIVERGENCE: acdream has NO retail 25 s / 384 m visibility cull — the retained spawn table is pruned ONLY by an explicit server `DeleteObject` (0xF747) or a spawn de-dup, never by distance/time. (#138) | `src/AcDream.App/Streaming/LandblockEntityRehydrator.cs` + `GameWindow.RehydrateServerEntitiesForLandblock` + `StreamingController` (`onLandblockLoaded`, Loaded + Promoted) | Re-projection from our own table is retail-faithful AND the only reliable path (ACE won't re-send a known object); fixes the #138 "doors/NPCs/portals gone after a dungeon exit" symptom independent of any server re-broadcast | An object the server silently stopped tracking WITHOUT a `DeleteObject` (e.g. a creature that wandered out of PVS) is re-hydrated at its last-known position on reload, where retail's 25 s cull would have dropped it — a stale ghost until a real `DeleteObject` or the next session. Close it by porting holtburger's 25 s/384 m `ACE_DESTRUCTION_TIMEOUT` self-cull | ACE `ObjectMaint.KnownObjects` never cleared on teleport (`Physics/Common/ObjectMaint.cs`, `WorldObjects/Player_Tracking.cs`); holtburger client 25 s cull `liveness.rs` `ACE_DESTRUCTION_TIMEOUT_SECS`; retail client weenie_object_table re-render |
|
||||
| AP-69 | On a landblock (re)load, acdream re-projects retained server-object snapshots (`InboundPhysicsStateController.Snapshots` — our current `weenie_object_table` projection, carrying position + Setup + appearance) into the render world via `LandblockEntityRehydrator`. The dungeon collapse (#133/#135) and Near→Far demote drop a landblock's RENDER entities for FPS but keep the accepted snapshots; ACE will NOT re-broadcast objects whose guid is still in its per-player `KnownObjects` set (never cleared on a normal teleport — ACE relies on the client retaining its table + doing its own visibility cull). Re-projecting from our own table is the retail "client keeps its weenie_object_table and re-renders from it" model. DIVERGENCE: acdream has NO retail 25 s / 384 m visibility cull — the retained table is pruned ONLY by an explicit server `DeleteObject` (0xF747), never by distance/time. (#138) | `src/AcDream.App/World/InboundPhysicsStateController.cs`; `src/AcDream.App/Streaming/LandblockEntityRehydrator.cs`; `GameWindow.RehydrateServerEntitiesForLandblock`; `StreamingController` (`onLandblockLoaded`, Loaded + Promoted) | Re-projection from our own table is retail-faithful AND the only reliable path (ACE won't re-send a known object); fixes the #138 "doors/NPCs/portals gone after a dungeon exit" symptom independent of any server re-broadcast | An object the server silently stopped tracking WITHOUT a `DeleteObject` (e.g. a creature that wandered out of PVS) is re-hydrated at its last-known position on reload, where retail's 25 s cull would have dropped it — a stale ghost until a real `DeleteObject` or the next session. Close it by porting holtburger's 25 s/384 m `ACE_DESTRUCTION_TIMEOUT` self-cull | ACE `ObjectMaint.KnownObjects` never cleared on teleport (`Physics/Common/ObjectMaint.cs`, `WorldObjects/Player_Tracking.cs`); holtburger client 25 s cull `liveness.rs` `ACE_DESTRUCTION_TIMEOUT_SECS`; retail client weenie_object_table re-render |
|
||||
| AP-70 | `TeleportAnimSequencer.ComputeFadeAlpha` uses a smoothstep curve for all fade states; retail's `gmSmartBoxUI::UseTime` drives fade alpha through a 1024-entry `GetAnimLevel` lookup table whose contents are unrecovered | `src/AcDream.Core/World/TeleportAnimSequencer.cs` (`ComputeFadeAlpha`) | `GetAnimLevel` table address and contents not yet extracted via cdb; smoothstep is a perceptually-reasonable S-curve that closely approximates a typical gamma-corrected fade; the visual difference is imperceptible under normal teleport conditions | Fade timing differs subtly from retail — ramp-in or ramp-out may be slightly too fast/slow at the black-fade edges; retire by reading the `GetAnimLevel` table via cdb and replacing smoothstep with a direct 1024-entry lookup (spec §8) | `gmSmartBoxUI::UseTime` 0x004d6e30; `GetAnimLevel` 1024-entry table (address unrecovered — spec §8 cdb trace) |
|
||||
| AP-71 | **`CEnvCell::find_env_collisions` omits the `CObjCell::check_entry_restrictions` gate** — retail's `CEnvCell::find_env_collisions` (pc:309576) calls `CObjCell::check_entry_restrictions` (pc:309576) FIRST and returns `COLLIDED` when an access-locked cell's `restriction_obj` entity rejects the mover (`CanMoveInto` fails AND `CanBypassMoveRestrictions` is false). acdream's `FindEnvCollisions` (`src/AcDream.Core/Physics/TransitionTypes.cs:2088`) goes straight to BSP collision. | `src/AcDream.Core/Physics/TransitionTypes.cs:2088` | **No access-restriction cell data exists in acdream.** `CellPhysics` (`src/AcDream.Core/Physics/PhysicsDataCache.cs:540`) has no `restriction_obj` field; `DatReaderWriter` does not model per-cell access locks; no weenie-object-table exists on the client for the gate's `CanMoveInto` / `CanBypassMoveRestrictions` dispatch. The gap is **inert** in all dev content (ACE starter area has no access-locked env cells). | If access-locked dungeon cells are ever modeled (dat + wire), the player will walk through the restriction barrier without being blocked — wrong PK/housing gating. | `CObjCell::check_entry_restrictions` pc:309576; `CEnvCell::find_env_collisions` pc:309573–309597 |
|
||||
| AP-72 | **Cursor art falls back to OS standard cursors when dat resolution fails** — retail always renders MediaDescCursor / EnumIDMap-resolved dat cursor art; acdream's `RetailCursorManager.Apply` falls back to Silk `StandardCursor` (IBeam/crosshair/not-allowed/…) when the EnumIDMap chain or RenderSurface decode fails, and `RetailCursorResolver`/`RetailCursorManager` permanently negative-cache the failed enum/surface id for the session. | `src/AcDream.App/Rendering/RetailCursorManager.cs:47` (`ApplyStandard`), `RetailCursorResolver.cs:47` (negative cache) | Fallback triggers only when the dat lacks the asset — nominal EoR dats always resolve the 0x27/0x28/0x29 chain; an OS cursor keeps the UI usable rather than showing nothing. | A dat-read or decode regression silently shows OS-native cursors instead of surfacing an error — masked failure class; check the `[D.2b]` cursor log lines before suspecting art. | `ClientUISystem::UpdateCursorState` 0x00564630 |
|
||||
|
|
@ -244,7 +244,6 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
|
|||
| TS-23 | PK/PKLite/Impenetrable mover bits never set (PlayerKillerStatus not parsed from PD); moverFlags always `IsPlayer ∣ EdgeSlide` — for BOTH the LOCAL player mover and, as of **#184 Slice 2b**, every remote-PLAYER dead-reckoning mover | `src/AcDream.App/Input/PlayerMovementController.cs:1177`; `src/AcDream.App/Physics/RemotePhysicsUpdater.cs` (`Tick` sweep, `IsPlayerGuid` branch) | Non-PK pair walks through other non-PK players — retail's default for ACE's character-creation defaults. Slice 2b gave the remote-player mover `IsPlayer` (was bare `EdgeSlide`) so remote-vs-remote non-PK players WALK THROUGH exactly like the local player and like retail (they still collide with monsters + terrain + walls); without it Slice 2b would have de-overlapped players (MORE solid than retail) | On a PK/PKLite character the client lets players walk through where retail collides — now for the local player AND remote-vs-remote — the moment PvP statuses enter play (M2+) | PWD._bitfield acclient.h:6431-6463; pc:406898-406918; FindObjCollisions PvP block pc:276812 (mover IsPlayer via OBJECTINFO::init 0x0050cf30 `state\|=0x100`) |
|
||||
| TS-24 | RawMotionState action list always empty at runtime — the packer emits `num_actions` (bits 11–15) + per-action u16 pairs (L.2b, `RawMotionState::Pack` 0x0051ed10), and R3-W1 gives `RawMotionState`/`InterpretedMotionState` the retail-faithful action FIFO (`AddAction`/`RemoveAction`/`ApplyMotion`/`RemoveMotion`, `src/AcDream.Core/Physics/RawMotionState.cs` + `MotionInterpreter.cs`), but nothing calls `AddAction` yet — the outbound caller still builds an empty `Actions` list, so discrete motion events (emotes, one-shots) are still never broadcast | `src/AcDream.App/Rendering/GameWindow.cs:8297` (empty Actions); packer `src/AcDream.Core.Net/Messages/RawMotionStatePacker.cs:91`; FIFO capability `src/AcDream.Core/Physics/RawMotionState.cs` | Discrete client-initiated motions (D2) not wired yet; packer-ready, state-ready (W1), runtime emission lands with R3-W2's `add_to_queue`/`DoInterpretedMotion` population | When player-triggered emotes land, they silently never broadcast — observers see idle while the local client animates | `RawMotionState::Pack` 0x0051ed10; num_actions `PackBitfield` acclient.h:46487 |
|
||||
| TS-25 | `current_style` (stance, flag bit 0x2) never populated at runtime — the packer now emits it when it differs from the retail default 0x8000003D (L.2b), but the outbound caller leaves `CurrentStyle` at default (stance not tracked here) | `src/AcDream.App/Rendering/GameWindow.cs:8286` (CurrentStyle left default); packer `src/AcDream.Core.Net/Messages/RawMotionStatePacker.cs:80` | Stance switching is M2 combat scope | Once combat-mode switching ships, mid-stance MoveToStates omit the style — server/observers keep the stale stance, wrong cycle family for every subsequent movement | `RawMotionState::Pack` current_style 0x0051ed10 |
|
||||
| TS-26 | UpdatePosition's four u16 sequence numbers parsed but never checked for freshness; retail rejects stale/out-of-order packets. (The 0xF74C UpdateMotion side of this gap CLOSED 2026-07-02 — L.2g S1 `MotionSequenceGate` ports the retail instance/movement/server-control gate; the UP side + teleport/force-position stamps remain open) | `src/AcDream.Core.Net/Messages/UpdatePosition.cs:30` | Loopback ACE rarely reorders, so the gap is invisible in the dev loop | On a real network, a reordered/post-teleport straggler applies as-is — remotes snap backward / flicker; a teleport-vs-position race renders an entity in the wrong cell | PositionPack trailer (ACE PositionPack.cs::Write); `CPhysicsObj::newer_event` 0x00451b10 |
|
||||
| TS-27 | Retransmit handling absent: `RetransmitRequests`/`RejectRetransmit` parsed, but nothing re-sends lost outbound or requests missing inbound sequences (class-doc gap list otherwise stale — ack/position/chat exist) | `src/AcDream.Core.Net/WorldSession.cs:29` | Deferred since the one-shot test harness; dev loop is loopback (no loss) | On any lossy link a dropped fragment is gone forever — entities never spawn, chat vanishes, reassembly stalls; server retransmit requests ignored until session timeout. Stale doc list also misleads readers | PacketHeaderFlags RequestRetransmit 0x1000 / Retransmission 0x1 |
|
||||
| TS-28 | LoginComplete sent on PlayerCreate (0xF746) arrival; retail sends it after the portal-space transition animation finishes (no such animation exists yet) | `src/AcDream.Core.Net/Messages/GameActionLoginComplete.cs:30` | acdream has no portal-space animation; "InWorld" phrasing in the file is slightly stale (trigger is PlayerCreate) | Server flips the character out of the loading state and pushes initial updates while the client may still be streaming — server logic assuming retail's load-screen duration fires against a half-initialized client | retail post-EnterWorld flow (holtburger messages.rs:391-422) |
|
||||
| TS-29 | Background music (MIDI) + ambient loops not ported: PlayMusic/StopMusic no-op; StartAmbient reserves a handle that never plays | `src/AcDream.App/Audio/OpenAlAudioEngine.cs:331` | Explicitly outside R5 audio-phase scope; a landblock-attached ambient system is planned separately | Silent world where retail has music/atmosphere; code trusting StartAmbient's handle to mean "playing" is already subtly wrong (StopAmbient looks up a never-created source) | retail MIDI + ambient system (r05) |
|
||||
|
|
@ -261,7 +260,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
|
|||
| TS-44 | NPC UpdatePosition reconciliation (position + orientation) is SUPPRESSED while the entity is stuck (`PositionManager.GetStickyObjectId() != 0`). **#184 (2026-07-07) UNIFIED the NPC path onto the interp queue** — the retire-condition mechanism is now ported: the grounded UP routes through `CPhysicsObj::MoveOrTeleport` (Enqueue) and the per-tick catch-up SEEDS the `PositionManager::adjust_offset` chain where `StickyManager::adjust_offset` OVERWRITES it while armed (0x00555190 / 0x00555430 assigns m_fOrigin), exactly like the player-remote branch — so POSITION is now handled retail-faithfully by the compose-overwrite whether the gate is on or off. The `snapSuppressedByStick` gate is RETAINED for two residuals: (a) it suppresses the during-stick **Enqueue** so a stale server waypoint never enters the queue, and (b) it gates the **orientation** hard-snap (still outside the interp chain — retail's sticky owns facing). Bookkeeping (`LastServerPos/Time`, cell) still records; server truth reasserts on the first UP after unstick | `src/AcDream.App/Rendering/GameWindow.cs` (`OnLivePositionUpdated` NPC section, `snapSuppressedByStick` gate) | Retail's position mechanism (sticky-overwrites-interp) is now REACHABLE for NPCs (#184 gave them the interp-queue architecture); the gate remains only for orientation + during-stick Enqueue cleanliness | A stick that stays armed while ACE moves the monster far (shouldn't happen — sticks follow the target) keeps a stale orientation until unstick+next UP; bounded by the 1 s lease | `PositionManager::adjust_offset` 0x00555190; `CPhysicsObj::MoveOrTeleport` 0x00516330 (NPC UP routing, #184); retire the orientation residual in R6 |
|
||||
| TS-46 | Player/remote collision spheres are passed as TWO SCALARS (radius, capsule-top height) and reconstructed by `SpherePath.InitPath` (foot center at `radius`, head center at `height − radius`) — retail passes the Setup's SPHERE LIST verbatim (`CPhysicsObj::transition` 0x00512dc0 → `init_sphere(GetNumSphere, GetSphere, m_scale)`, ≤2 spheres, each origin AND radius × m_scale). With the corrected callers (0.48, 1.835 = Setup.Height) the reconstruction sits 5 mm off the dat: foot center 0.480 vs dat 0.475, head center 1.355 vs dat 1.350 (human Setup 0x02000001). **#184 Slice 3 (2026-07-07) NARROWED this: the remote de-overlap sweep now derives its scalars from the creature's OWN Setup (`GetSetupCylinder` = `setup.Radius`/`setup.Height` × ObjScale) — remotes NO LONGER use human dims regardless of Setup/scale.** RESIDUAL: (a) it is still the two-SCALAR reconstruction, not retail's ≤2-sphere LIST (lossy for creatures whose foot/head spheres differ), for both player and remotes; (b) the remote sweep's `stepUpHeight`/`stepDownHeight` stay a hardcoded 0.4 m, where retail derives them from `setup->step_up_height`/`step_down_height` (0x005180d0/0x005180f0, 0.04 m fallback, `radius×0.5` clamp) — an adjacent non-Setup divergence left for a later slice. (The pre-2026-07-06 value 1.2f put the head TOP at 1.2 m — the #137 window climb; fixed same day.) | `src/AcDream.Core/Physics/TransitionTypes.cs` (`InitPath`); `src/AcDream.App/Input/PlayerMovementController.cs` (player, human — correct as-is); `src/AcDream.App/Rendering/GameWindow.cs` (remote sweep now `GetSetupCylinder`-fed with a shapeless→human fallback) | The scalar API predates the Setup ingestion; 5 mm is below the visual/feel threshold; the remote scalars are now the creature's real (radius,height)×ObjScale, consistent with the shadow registration's entScale and the moveto/sticky radii | Marginal r−ε/r+ε grazes still flip on the 5 mm scalar offset; a creature whose head sphere is wider than its foot de-overlaps by the single (radius) approximation, not the true 2-sphere profile; the 0.4 m step heights are non-Setup for all movers | `CPhysicsObj::transition` 0x00512dc0; `SPHEREPATH::init_sphere` 0x0050c670 (≤2, ×m_scale); `set_description` 0x00514f40 (m_scale from wire ObjScale); retire by plumbing the full Setup sphere list into `InitPath` |
|
||||
| ~~TS-45~~ | **RETIRED 2026-07-07** — the hand-rolled `SphereCollision` (forced `combinedR+1 cm` radial de-penetration + leaked `SetSlidingNormal` + always-Slid, head-sphere ignored) is REPLACED by the faithful `CSphere::intersects_sphere` family port (branch dispatcher 0x00537A80 + `step_sphere_up`/`slide_sphere`/`land_on_sphere`/`collide_with_point`/`step_sphere_down`), routing the grounded slide through the shared crease `SlideSphere` (0x00537440). Humanoid creatures collide via body Spheres, so this was the player-vs-monster crowd path; the radial de-penetration was the "can't wiggle free in a packed crowd" wedge. `SphereCollisionFamilyTests` (slide-around, block, ethereal) + `docs/research/2026-07-07-csphere-collision-family-pseudocode.md`. Residual `AP-84` (PerfectClip TOI dead in M1.5). | — | — | — | `CSphere::intersects_sphere` 0x00537A80 (pc:321678) |
|
||||
| TS-43 | Remote teleport has no `teleport_hook` equivalent: retail tears down the position managers on every teleport (`CPhysicsObj::teleport_hook` 0x00514ed0 — `CancelMoveTo(0x3c)` @0x00514edf, `PositionManager::UnStick` @0x00514eee, `StopInterpolating`/`UnConstrain`); acdream's remote teleport is a bare UP hard-snap, so a stuck/chasing remote that the server teleports keeps its stick/moveto for up to the 1 s sticky lease / next UM. The LOCAL player side IS wired (R5-V3: `PlayerMovementController.SetPosition` → `PositionManager.UnStick`; the moveto cancel was already there via `StopCompletely`; the teleport-arrival site also fires the hook's tail — `EntityPhysicsHost.NotifyTeleported` = `TargetManager::ClearTarget` + `NotifyVoyeurOfEvent(Teleported)` @0x00514f1b-0x00514f28, which is what makes mobs stuck to the player drop their sticks on a recall) | `src/AcDream.App/Rendering/GameWindow.cs` (`OnLivePositionUpdated` — no teleport-flag manager teardown for remotes) | Remote teleports are rare (recalls/summons); the sticky 1 s lease + UP hard-snaps self-correct within a second; wiring it properly wants the UP teleport-stamp plumbing (TS-26's stamp work) | A teleported-away attacker briefly steers toward its pre-teleport target from the new location (≤1 s) before the lease/next-UM corrects it | `CPhysicsObj::teleport_hook` 0x00514ed0; retire with the TS-26 UP-stamp port |
|
||||
| TS-43 | Remote teleport has no `teleport_hook` equivalent: retail tears down the position managers on every teleport (`CPhysicsObj::teleport_hook` 0x00514ed0 — `CancelMoveTo(0x3c)` @0x00514edf, `PositionManager::UnStick` @0x00514eee, `StopInterpolating`/`UnConstrain`); acdream's remote teleport is a bare UP hard-snap, so a stuck/chasing remote that the server teleports keeps its stick/moveto for up to the 1 s sticky lease / next UM. The LOCAL player side IS wired (R5-V3: `PlayerMovementController.SetPosition` → `PositionManager.UnStick`; the moveto cancel was already there via `StopCompletely`; the teleport-arrival site also fires the hook's tail — `EntityPhysicsHost.NotifyTeleported` = `TargetManager::ClearTarget` + `NotifyVoyeurOfEvent(Teleported)` @0x00514f1b-0x00514f28, which is what makes mobs stuck to the player drop their sticks on a recall) | `src/AcDream.App/Rendering/GameWindow.cs` (`OnLivePositionUpdated` — teleport freshness is gated, but no remote manager teardown) | Remote teleports are rare (recalls/summons); the sticky 1 s lease + UP hard-snaps self-correct within a second. The retail timestamp distinction now exists; Step 8 must connect its accepted-teleport disposition to the hook teardown. | A teleported-away attacker briefly steers toward its pre-teleport target from the new location (≤1 s) before the lease/next-UM corrects it | `CPhysicsObj::teleport_hook` 0x00514ed0; `SmartBox::HandleReceivedPosition` 0x00453FD0 |
|
||||
| TS-47 | **NARROWED 2026-07-13** — typed routing now ports the named-retail recall/house/PK travel, age/birth, local display/location, UI persistence, AFK/consent, emote, friends, squelch/filter, and fill-components families. Retail-owned verbs outside the researched family set still fall through to ACE until individually verified. | `src/AcDream.UI.Abstractions/Panels/Chat/RetailClientCommandCatalog.cs`; `src/AcDream.App/UI/ClientCommandController.cs`; `src/AcDream.Core.Net/Messages/ClientCommandRequests.cs` | The high-use researched families have decomp pseudocode, typed actions, and conformance tests; unresearched registry entries must follow the same evidence-first path | An unported retail-owned verb can still produce ACE unknown-command output or server-specific behavior instead of its client action | `ClientCommunicationSystem` command-table construction around `0x00581A40..0x005850A0`; `docs/research/2026-07-13-retail-client-command-routing-pseudocode.md`; `docs/research/2026-07-13-retail-client-command-families-pseudocode.md` |
|
||||
| TS-48 | Dragging an item onto another player honors the authoritative `DragItemOnPlayerOpensSecureTrade` option, but the option's default-true branch stops at the existing unavailable toast because the secure-trade transaction and UI are not ported. Direct player giving through `GiveObjectRequest 0x00CD` works when the option is disabled; NPC giving is complete. | `src/AcDream.App/UI/ItemInteractionController.cs` (`PlaceIn3D`, `PolicyActionMessage`); `src/AcDream.Core/Items/ItemInteractionPolicy.cs` | The player/NPC distinction and character preference are now faithful; inventing a direct gift while the option requests secure trade would be a worse behavioral divergence. Secure trade is a separate multi-party state machine beyond the starter-dungeon NPC-give slice. | With retail's default character options, an item dragged onto another player cannot be exchanged until the secure-trade subsystem lands. | `ItemHolder::AttemptPlaceIn3D @ 0x00588600`; `PlayerModule::DragItemOnPlayerOpensSecureTrade @ 0x005D31B0`; `ClientTradeSystem`; `docs/research/2026-07-13-retail-give-item-pseudocode.md` |
|
||||
|
||||
|
|
@ -295,7 +294,6 @@ WITH that phase, not before.
|
|||
3. **TS-4 — Path-6 steep slide-tangent shortcut** — landing/contact state diverges on every airborne-steep hit; the L.5+ retail-strict followup is already filed with the missing-ingredient analysis.
|
||||
4. **UN-1 — CheckOtherCells iteration order** — behavior-bearing halt order with a log-cosmetics justification; trivial to fix (iterate CELLARRAY build order, sort only in probe output).
|
||||
5. **TS-1 — PrecipiceSlide stop-at-edge** — visible movement mismatch at every cliff/roof edge; diagnostic already records which ingredient is missing.
|
||||
6. **TS-26 — Position sequence freshness** — real-network correctness; pairs naturally with TS-27 in one transport-hardening pass.
|
||||
7. **UN-6 — 200 ms ConnectResponse sleep** — unexplained constant on every login with an intermittent-failure shape; either find the ACE race and cite it, or replace with an acknowledged-ready check.
|
||||
8. **UN-4 — GfxObj sides/negative-surface logic** — diagnose against the retail-cited CellStruct interpretation on a known double-sided GfxObj; promote to AP with a citation or align it.
|
||||
9. **TS-8 — MagicUpdateEnchantment StatMod parse (#7/#12)** — vitals wrong for the whole session after any buff; parser shape is known from holtburger.
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ behavior, just with an apparatus artifact in place of clean walkthrough.
|
|||
**indoor cell 0xA9B40150**, target (132.43, 17.20, 94). Live
|
||||
result.Position = target with `collisionNormalValid = false`. Door
|
||||
centered at world XY (132.57, 16.99), BSP radius 1.975, state
|
||||
`0x00010008` = `PERSISTENT_PS | 0x8` (NO `ETHEREAL_PS = 0x4` →
|
||||
`0x00010008` = `HAS_PHYSICS_BSP_PS | REPORT_COLLISIONS_PS` (NO `ETHEREAL_PS = 0x4` →
|
||||
**CLOSED**).
|
||||
- **Tick 22760** — the working block. Player at (133.14, 18.02, 94)
|
||||
in **outdoor cell 0xA9B40029**, target (133.10, 17.60, 94). Live
|
||||
|
|
@ -68,7 +68,7 @@ Confirmed the door state at the time of every walkthrough:
|
|||
| Log line | Event |
|
||||
|---|---|
|
||||
| 10796 | `[setstate]` door state → `0x0001000C` (PERSISTENT + ETHEREAL = OPEN) |
|
||||
| 10993 | `[setstate]` door state → `0x00010008` (PERSISTENT, NOT ethereal = CLOSED) |
|
||||
| 10993 | `[setstate]` door state → `0x00010008` (physics BSP + collision reports, NOT ethereal = CLOSED) |
|
||||
| 10995–11071 | First and last `[bsp-test]` line on door 0x000F4246. All `state=0x00010008` |
|
||||
|
||||
So every `[bsp-test]` hit on the door, and every walkthrough event in
|
||||
|
|
|
|||
|
|
@ -173,6 +173,99 @@ stamp wins. At exactly `0x8000` and throughout the wrap half, the numerically
|
|||
lower stamp wins. This exact boundary must be ported; a generic half-range
|
||||
subtraction helper can choose the opposite result at `0x8000`.
|
||||
|
||||
### 3.1 Event freshness and object generations
|
||||
|
||||
Additional oracles:
|
||||
|
||||
- `SmartBox::HandleReceivedPosition` at `0x00453FD0`
|
||||
- `SmartBox::HandleObjDescEvent` at `0x00453340` and
|
||||
`SmartBox::UpdateVisualDesc` at `0x00451F40`
|
||||
- `SmartBox::HandleDeleteObject` at `0x00451EA0`
|
||||
|
||||
```text
|
||||
on CreateObject(desc):
|
||||
if no live generation: copy all nine desc timestamps wholesale
|
||||
else if desc.Instance is newer: begin a new generation and replace all nine
|
||||
else if current Instance is newer: reject the stale CreateObject
|
||||
else: keep the object and route each PhysicsDesc branch through its
|
||||
ordinary per-channel handler; do not authorize the whole snapshot
|
||||
|
||||
on State / Vector / Movement / ObjDesc:
|
||||
require the exact live Instance
|
||||
require a strictly newer channel timestamp
|
||||
apply only after both gates pass
|
||||
|
||||
on DeleteObject:
|
||||
reject unconditionally when guid is the local player
|
||||
require the exact live Instance before either object projection is removed
|
||||
|
||||
on ParentEvent(parent, child):
|
||||
require the parent's exact live Instance
|
||||
strictly advance the child's Position timestamp
|
||||
apply parent + placement without creating a second position counter
|
||||
|
||||
on PickupEvent(object):
|
||||
require the exact live Instance
|
||||
strictly advance the shared Position timestamp
|
||||
unparent and leave the world, retaining the object and its timestamps
|
||||
|
||||
on Position(localPlayer):
|
||||
require the exact live Instance
|
||||
if ForcePosition is newer:
|
||||
store ForcePosition
|
||||
if incoming Teleport exactly equals stored Teleport:
|
||||
assign Position directly, even when equal or older
|
||||
preserve the current heading
|
||||
do not advance Teleport
|
||||
BlipPlayer via SetPositionSimple (preserve active motion, velocity,
|
||||
contact state, and PositionManager stick)
|
||||
if Contact AND OnWalkable are set and position is valid:
|
||||
send PositionEvent immediately from the post-blip canonical Position
|
||||
return
|
||||
|
||||
tentatively advance Position, requiring strictly newer
|
||||
if stored Teleport is newer than incoming Teleport:
|
||||
restore the old Position timestamp and reject
|
||||
if incoming Teleport is newer: advance Teleport
|
||||
PositionPack supplies placement 0 and velocity (0,0,0) when their flags are absent
|
||||
unset the parent
|
||||
if the object has no active animations: apply the decoded placement id
|
||||
if object is remote: MoveOrTeleport receives the decoded velocity
|
||||
else if Teleport advanced: teleport player and explicitly zero velocity
|
||||
else: constrain/interpolate without installing packet velocity
|
||||
apply the position
|
||||
```
|
||||
|
||||
`PositionPack::UnPack` at `0x00516740` is the source of the absent-as-zero
|
||||
defaults; retaining a prior placement or remote velocity when the wire flag is
|
||||
absent is not retail behavior. The immutable parsed type still preserves
|
||||
presence so diagnostics and conformance tests can distinguish absent from an
|
||||
explicit zero. `HandleReceivedPosition` consumes those defaults only after the
|
||||
timestamp gate admits the normal path. ForcePosition returns before placement
|
||||
or packet velocity is consumed and therefore preserves the player's live
|
||||
heading and velocity.
|
||||
|
||||
`SmartBox::HandlePlayerTeleport` at `0x00452150` handles standalone F751:
|
||||
|
||||
```text
|
||||
on PlayerTeleport(sequence):
|
||||
if sequence is older than current Teleport timestamp: reject
|
||||
equality and newer sequences are accepted
|
||||
enter the waiting/portal state
|
||||
do not advance Teleport here
|
||||
```
|
||||
|
||||
The accepted destination Position packet advances `TELEPORT_TS`. Parsing F751
|
||||
must therefore never publish its sequence directly into outbound AP state.
|
||||
|
||||
Retail queues a packet whose Instance belongs to a not-yet-created future
|
||||
generation. `ParentAttachmentState` now does so for ParentEvent, retaining the
|
||||
parent generation through atomic object replacement. Until `LiveEntityRuntime`
|
||||
owns the complete mixed pending queue, acdream still drops future Movement,
|
||||
Position, State, Vector, Pickup, Delete, and ObjDesc packets without touching
|
||||
the current generation (AD-32); critically, it never adopts the future Instance
|
||||
into the current object.
|
||||
|
||||
## 4. Direct and typed effect packets
|
||||
|
||||
Oracles:
|
||||
|
|
|
|||
|
|
@ -395,6 +395,10 @@ public sealed class PlayerMovementController
|
|||
/// strict subset of the funnel's Contact+OnWalkable gate).</summary>
|
||||
internal bool BodyInContact => _body.InContact;
|
||||
|
||||
/// <summary>Retail <c>SendPositionEvent</c> admission gate: both Contact
|
||||
/// and OnWalkable must be present on the local physics body.</summary>
|
||||
internal bool CanSendPositionEvent => _body.InContact && _body.OnWalkable;
|
||||
|
||||
/// <summary>R4-V5: body orientation for the <see cref="MoveTo"/>
|
||||
/// manager's position seam (re-derived from <see cref="Yaw"/> every
|
||||
/// Update — heading reads/writes go through Yaw, not this).</summary>
|
||||
|
|
@ -508,7 +512,7 @@ public sealed class PlayerMovementController
|
|||
_body.SnapToCell(cellId, pos, cellLocal);
|
||||
_prevPhysicsPos = pos;
|
||||
_currPhysicsPos = pos;
|
||||
UpdateCellId(cellId, "teleport");
|
||||
UpdateCellId(_body.CellPosition.ObjCellId, "teleport");
|
||||
|
||||
// Treat as grounded after a server-side position snap.
|
||||
_body.TransientState = TransientStateFlags.Contact | TransientStateFlags.OnWalkable;
|
||||
|
|
@ -547,6 +551,20 @@ public sealed class PlayerMovementController
|
|||
_physicsAccum = 0f;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>SmartBox::BlipPlayer</c> (0x00453940): apply a server
|
||||
/// FORCE_POSITION correction through <c>CPhysicsObj::SetPositionSimple</c>
|
||||
/// without the teleport hook. Active motion, velocity, contact state, and
|
||||
/// PositionManager stick relationships deliberately survive the blip.
|
||||
/// </summary>
|
||||
public void BlipPosition(Vector3 pos, uint cellId, Vector3 cellLocal)
|
||||
{
|
||||
_body.SnapToCell(cellId, pos, cellLocal);
|
||||
_prevPhysicsPos = pos;
|
||||
_currPhysicsPos = pos;
|
||||
UpdateCellId(_body.CellPosition.ObjCellId, "force-position");
|
||||
}
|
||||
|
||||
private Vector3 ComputeRenderPosition()
|
||||
{
|
||||
float alpha = Math.Clamp(_physicsAccum / PhysicsBody.MinQuantum, 0f, 1f);
|
||||
|
|
@ -1196,9 +1214,7 @@ public sealed class PlayerMovementController
|
|||
// Grounded-on-walkable gate per acclient_2013_pseudo_c.txt:700327
|
||||
// (`(state & 1) != 0 && (state & 2) != 0`). Both flags must be
|
||||
// set simultaneously, NOT a bitwise-OR mask test.
|
||||
bool groundedOnWalkable = _body.InContact && _body.OnWalkable;
|
||||
|
||||
HeartbeatDue = groundedOnWalkable && sendThisFrame;
|
||||
HeartbeatDue = CanSendPositionEvent && sendThisFrame;
|
||||
|
||||
// R3-W6: the K-fix5 LocalAnimationSpeed synthesis is DELETED — the
|
||||
// run pacing now comes from the ported machinery itself
|
||||
|
|
|
|||
|
|
@ -27,12 +27,11 @@ public sealed class EquippedChildRenderController : IDisposable
|
|||
private readonly GpuWorldState _worldState;
|
||||
private readonly Func<uint, WorldEntity?> _resolveEntity;
|
||||
private readonly Func<uint, WorldSession.EntitySpawn?> _resolveSpawn;
|
||||
private readonly Func<ParentEvent.Parsed, bool> _acceptParent;
|
||||
private readonly Func<uint> _nextEntityId;
|
||||
|
||||
private readonly Dictionary<uint, PendingAttachment> _pendingByChild = new();
|
||||
private readonly Dictionary<uint, PendingAttachment> _lastRelationByChild = new();
|
||||
private readonly ParentAttachmentState _relations = new();
|
||||
private readonly Dictionary<uint, AttachedChild> _attachedByChild = new();
|
||||
private readonly Dictionary<uint, ushort> _positionSequenceByChild = new();
|
||||
|
||||
public IEnumerable<uint> AttachedEntityIds
|
||||
{
|
||||
|
|
@ -50,6 +49,7 @@ public sealed class EquippedChildRenderController : IDisposable
|
|||
GpuWorldState worldState,
|
||||
Func<uint, WorldEntity?> resolveEntity,
|
||||
Func<uint, WorldSession.EntitySpawn?> resolveSpawn,
|
||||
Func<ParentEvent.Parsed, bool> acceptParent,
|
||||
Func<uint> nextEntityId)
|
||||
{
|
||||
_dats = dats ?? throw new ArgumentNullException(nameof(dats));
|
||||
|
|
@ -58,11 +58,12 @@ public sealed class EquippedChildRenderController : IDisposable
|
|||
_worldState = worldState ?? throw new ArgumentNullException(nameof(worldState));
|
||||
_resolveEntity = resolveEntity ?? throw new ArgumentNullException(nameof(resolveEntity));
|
||||
_resolveSpawn = resolveSpawn ?? throw new ArgumentNullException(nameof(resolveSpawn));
|
||||
_acceptParent = acceptParent ?? throw new ArgumentNullException(nameof(acceptParent));
|
||||
_nextEntityId = nextEntityId ?? throw new ArgumentNullException(nameof(nextEntityId));
|
||||
|
||||
_objects.ObjectMoved += OnObjectMoved;
|
||||
_objects.MoveRolledBack += OnMoveRolledBack;
|
||||
_objects.ObjectRemoved += OnObjectRemoved;
|
||||
_objects.ObjectRemovalClassified += OnObjectRemovalClassified;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -72,100 +73,106 @@ public sealed class EquippedChildRenderController : IDisposable
|
|||
/// </summary>
|
||||
public void OnSpawn(WorldSession.EntitySpawn spawn)
|
||||
{
|
||||
_positionSequenceByChild[spawn.Guid] = spawn.PositionSequence;
|
||||
|
||||
if (spawn.ParentGuid is { } parentGuid and not 0
|
||||
&& spawn.ParentLocation is { } parentLocation
|
||||
&& spawn.PlacementId is { } placementId)
|
||||
{
|
||||
var relation = new PendingAttachment(
|
||||
parentGuid,
|
||||
spawn.Guid,
|
||||
parentLocation,
|
||||
placementId,
|
||||
spawn.InstanceSequence,
|
||||
spawn.PositionSequence,
|
||||
FromCreateObject: true);
|
||||
_pendingByChild[spawn.Guid] = relation;
|
||||
_lastRelationByChild[spawn.Guid] = relation;
|
||||
}
|
||||
|
||||
lock (_datLock)
|
||||
{
|
||||
if (spawn.ParentGuid is { } parentGuid and not 0
|
||||
&& spawn.ParentLocation is { } parentLocation
|
||||
&& spawn.PlacementId is { } placementId)
|
||||
{
|
||||
_relations.AcceptCreateObjectRelation(new ParentAttachmentRelation(
|
||||
parentGuid,
|
||||
spawn.Guid,
|
||||
parentLocation,
|
||||
placementId,
|
||||
ParentInstanceSequence: 0,
|
||||
spawn.PositionSequence));
|
||||
}
|
||||
|
||||
ResolveRelations(spawn.Guid);
|
||||
TryRealize(spawn.Guid);
|
||||
|
||||
// ParentEvent can precede the parent's CreateObject. Revisit every
|
||||
// child waiting specifically on the object that just arrived.
|
||||
uint[] waiting = _pendingByChild.Values
|
||||
.Where(p => p.ParentGuid == spawn.Guid)
|
||||
.Select(p => p.ChildGuid)
|
||||
.ToArray();
|
||||
for (int i = 0; i < waiting.Length; i++)
|
||||
IReadOnlyList<uint> waiting = _relations.ChildrenWaitingForParent(spawn.Guid);
|
||||
for (int i = 0; i < waiting.Count; i++)
|
||||
{
|
||||
ResolveRelations(waiting[i]);
|
||||
TryRealize(waiting[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Apply/queue a live ParentEvent using retail's two sequence gates:
|
||||
/// parent instance must match, child position must advance strictly.
|
||||
/// Completes attachment projection after a root world entity has been
|
||||
/// registered. Relation acceptance intentionally happens before root
|
||||
/// projection selection; this notification only satisfies render-pose
|
||||
/// dependencies for children waiting on that root.
|
||||
/// </summary>
|
||||
public void OnWorldEntityRegistered(uint guid)
|
||||
{
|
||||
lock (_datLock)
|
||||
{
|
||||
IReadOnlyList<uint> waiting = _relations.ChildrenWaitingForParent(guid);
|
||||
for (int i = 0; i < waiting.Count; i++)
|
||||
{
|
||||
ResolveRelations(waiting[i]);
|
||||
TryRealize(waiting[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Apply/queue a live ParentEvent after the live-entity owner has exact-
|
||||
/// gated the parent's INSTANCE_TS and advanced the child's shared
|
||||
/// POSITION_TS. This controller owns only the render relationship.
|
||||
/// </summary>
|
||||
public void OnParentEvent(ParentEvent.Parsed update)
|
||||
{
|
||||
WorldSession.EntitySpawn? parentSpawn = _resolveSpawn(update.ParentGuid);
|
||||
if (parentSpawn is { } knownParent
|
||||
&& knownParent.InstanceSequence != update.ParentInstanceSequence)
|
||||
{
|
||||
// Known parent newer than the event: stale. Event newer than the
|
||||
// known parent: retain until its CreateObject arrives.
|
||||
if (MotionSequenceGate.IsNewer(
|
||||
update.ParentInstanceSequence,
|
||||
knownParent.InstanceSequence))
|
||||
return;
|
||||
}
|
||||
|
||||
if (_positionSequenceByChild.TryGetValue(update.ChildGuid, out ushort current)
|
||||
&& !MotionSequenceGate.IsNewer(current, update.ChildPositionSequence))
|
||||
return;
|
||||
|
||||
var relation = new PendingAttachment(
|
||||
update.ParentGuid,
|
||||
update.ChildGuid,
|
||||
update.ParentLocation,
|
||||
update.PlacementId,
|
||||
update.ParentInstanceSequence,
|
||||
update.ChildPositionSequence,
|
||||
FromCreateObject: false);
|
||||
_pendingByChild[update.ChildGuid] = relation;
|
||||
_lastRelationByChild[update.ChildGuid] = relation;
|
||||
|
||||
lock (_datLock)
|
||||
{
|
||||
_relations.Enqueue(update);
|
||||
ResolveRelations(update.ChildGuid);
|
||||
TryRealize(update.ChildGuid);
|
||||
}
|
||||
}
|
||||
|
||||
public void OnObjectDeleted(uint guid)
|
||||
public void OnGenerationReplaced(uint guid, ushort replacementGeneration)
|
||||
{
|
||||
Remove(guid);
|
||||
TearDownObjectProjections(guid);
|
||||
_relations.EndGeneration(guid, replacementGeneration);
|
||||
}
|
||||
|
||||
uint[] children = _attachedByChild.Values
|
||||
.Where(c => c.ParentGuid == guid)
|
||||
.Select(c => c.ChildGuid)
|
||||
.ToArray();
|
||||
for (int i = 0; i < children.Length; i++)
|
||||
Remove(children[i]);
|
||||
public void OnGenerationDeleted(uint guid, ushort deletedGeneration)
|
||||
{
|
||||
TearDownObjectProjections(guid);
|
||||
_relations.DeleteGeneration(guid, deletedGeneration);
|
||||
}
|
||||
|
||||
uint[] pendingChildren = _lastRelationByChild.Values
|
||||
.Where(c => c.ParentGuid == guid)
|
||||
.Select(c => c.ChildGuid)
|
||||
.ToArray();
|
||||
for (int i = 0; i < pendingChildren.Length; i++)
|
||||
{
|
||||
_pendingByChild.Remove(pendingChildren[i]);
|
||||
_lastRelationByChild.Remove(pendingChildren[i]);
|
||||
}
|
||||
private void OnObjectRemovalClassified(ClientObjectRemoval removal)
|
||||
{
|
||||
if (removal.Reason is not ClientObjectRemovalReason.Ordinary)
|
||||
return;
|
||||
|
||||
_pendingByChild.Remove(guid);
|
||||
_lastRelationByChild.Remove(guid);
|
||||
_positionSequenceByChild.Remove(guid);
|
||||
uint guid = removal.Object.ObjectId;
|
||||
TearDownObjectProjections(guid);
|
||||
// InventoryRemoveObject removes the item from the UI view, not the
|
||||
// live physics generation. Preserve fresher pending ParentEvents so a
|
||||
// same-generation world/parent update can still replay them. Logical
|
||||
// delete/replacement are driven directly by the accepted inbound
|
||||
// lifecycle and never depend on this table.
|
||||
_relations.EndChildProjection(guid);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A fresh Position or Pickup unparented this child. Remove the accepted
|
||||
/// attachment/rollback projection while retaining fresher unresolved
|
||||
/// ParentEvents; unlike logical deletion, do not disturb child-addressed
|
||||
/// pending state or children that may themselves reference it.
|
||||
/// </summary>
|
||||
public void OnChildBecameUnparented(uint childGuid)
|
||||
{
|
||||
Remove(childGuid);
|
||||
_relations.EndChildProjection(childGuid);
|
||||
}
|
||||
|
||||
/// <summary>Recompose every child after the parent's animation tick.</summary>
|
||||
|
|
@ -197,37 +204,15 @@ public sealed class EquippedChildRenderController : IDisposable
|
|||
|
||||
private void TryRealize(uint childGuid)
|
||||
{
|
||||
if (!_pendingByChild.TryGetValue(childGuid, out PendingAttachment pending))
|
||||
if (!_relations.TryGetProjection(childGuid, out ParentAttachmentRelation pending))
|
||||
return;
|
||||
|
||||
// A ParentEvent queued before the child existed is replayed only if
|
||||
// its position stamp still advances the child's CreateObject seed.
|
||||
// Retail reaches the same check inside DoParentEvent after its blob
|
||||
// queue resolves both objects.
|
||||
if (!pending.FromCreateObject
|
||||
&& _positionSequenceByChild.TryGetValue(childGuid, out ushort childPosition)
|
||||
&& !MotionSequenceGate.IsNewer(childPosition, pending.ChildPositionSequence))
|
||||
{
|
||||
_pendingByChild.Remove(childGuid);
|
||||
return;
|
||||
}
|
||||
|
||||
WorldEntity? parentEntity = _resolveEntity(pending.ParentGuid);
|
||||
WorldSession.EntitySpawn? parentSpawn = _resolveSpawn(pending.ParentGuid);
|
||||
WorldSession.EntitySpawn? childSpawn = _resolveSpawn(childGuid);
|
||||
if (parentEntity is null || parentSpawn is null || childSpawn is null)
|
||||
return;
|
||||
|
||||
if (!pending.FromCreateObject
|
||||
&& parentSpawn.Value.InstanceSequence != pending.ParentInstanceSequence)
|
||||
{
|
||||
if (MotionSequenceGate.IsNewer(
|
||||
pending.ParentInstanceSequence,
|
||||
parentSpawn.Value.InstanceSequence))
|
||||
_pendingByChild.Remove(childGuid);
|
||||
return;
|
||||
}
|
||||
|
||||
if (parentSpawn.Value.SetupTableId is not { } parentSetupId
|
||||
|| childSpawn.Value.SetupTableId is not { } childSetupId
|
||||
|| parentEntity.ParentCellId is not { } parentCellId)
|
||||
|
|
@ -283,8 +268,16 @@ public sealed class EquippedChildRenderController : IDisposable
|
|||
Console.WriteLine(
|
||||
$"equipment: attached child=0x{childGuid:X8} parent=0x{pending.ParentGuid:X8} " +
|
||||
$"location={parentLocation} placement={placement}");
|
||||
_positionSequenceByChild[childGuid] = pending.ChildPositionSequence;
|
||||
_pendingByChild.Remove(childGuid);
|
||||
_relations.MarkProjected(childGuid);
|
||||
}
|
||||
|
||||
private void ResolveRelations(uint childGuid)
|
||||
{
|
||||
_relations.Resolve(
|
||||
childGuid,
|
||||
guid => _resolveSpawn(guid) is not null,
|
||||
guid => _resolveSpawn(guid)?.InstanceSequence,
|
||||
_acceptParent);
|
||||
}
|
||||
|
||||
private IReadOnlyList<MeshRef> BuildPartTemplate(
|
||||
|
|
@ -377,16 +370,13 @@ public sealed class EquippedChildRenderController : IDisposable
|
|||
// A rejected unwield restores the equipped location without a fresh
|
||||
// wire ParentEvent; reinstall the last accepted relationship only for
|
||||
// this explicit rollback signal.
|
||||
if (_lastRelationByChild.TryGetValue(item.ObjectId, out PendingAttachment relation))
|
||||
if (_relations.RestoreLastAccepted(item.ObjectId))
|
||||
{
|
||||
_pendingByChild[item.ObjectId] = relation;
|
||||
lock (_datLock)
|
||||
TryRealize(item.ObjectId);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnObjectRemoved(ClientObject item) => OnObjectDeleted(item.ObjectId);
|
||||
|
||||
private void Remove(uint childGuid)
|
||||
{
|
||||
if (!_attachedByChild.Remove(childGuid))
|
||||
|
|
@ -394,21 +384,33 @@ public sealed class EquippedChildRenderController : IDisposable
|
|||
_worldState.RemoveEntityByServerGuid(childGuid);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
private void TearDownObjectProjections(uint guid)
|
||||
{
|
||||
_objects.ObjectMoved -= OnObjectMoved;
|
||||
_objects.MoveRolledBack -= OnMoveRolledBack;
|
||||
_objects.ObjectRemoved -= OnObjectRemoved;
|
||||
Remove(guid);
|
||||
|
||||
uint[] children = _attachedByChild.Values
|
||||
.Where(child => child.ParentGuid == guid)
|
||||
.Select(child => child.ChildGuid)
|
||||
.ToArray();
|
||||
for (int i = 0; i < children.Length; i++)
|
||||
Remove(children[i]);
|
||||
}
|
||||
|
||||
private readonly record struct PendingAttachment(
|
||||
uint ParentGuid,
|
||||
uint ChildGuid,
|
||||
uint ParentLocation,
|
||||
uint PlacementId,
|
||||
ushort ParentInstanceSequence,
|
||||
ushort ChildPositionSequence,
|
||||
bool FromCreateObject);
|
||||
public void Clear()
|
||||
{
|
||||
uint[] attached = _attachedByChild.Keys.ToArray();
|
||||
for (int i = 0; i < attached.Length; i++)
|
||||
Remove(attached[i]);
|
||||
_relations.Clear();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Clear();
|
||||
_objects.ObjectMoved -= OnObjectMoved;
|
||||
_objects.MoveRolledBack -= OnMoveRolledBack;
|
||||
_objects.ObjectRemovalClassified -= OnObjectRemovalClassified;
|
||||
}
|
||||
|
||||
private sealed record AttachedChild(
|
||||
uint ParentGuid,
|
||||
|
|
|
|||
|
|
@ -422,7 +422,7 @@ public sealed class GameWindow : IDisposable
|
|||
/// top of <see cref="OnLiveMotionUpdated"/>, dropped with the entity in
|
||||
/// <see cref="OnLiveEntityDeleted"/>.
|
||||
/// </summary>
|
||||
private readonly Dictionary<uint, AcDream.Core.Physics.MotionSequenceGate> _motionSequenceGates = new();
|
||||
private readonly AcDream.App.World.InboundPhysicsStateController _inboundPhysics = new();
|
||||
|
||||
/// <summary>
|
||||
/// Per-remote-entity physics + motion stack — verbatim application of
|
||||
|
|
@ -1028,7 +1028,8 @@ public sealed class GameWindow : IDisposable
|
|||
/// <see cref="OnLiveAppearanceUpdated"/> reuses the cached position/setup/motion
|
||||
/// fields when a 0xF625 ObjDescEvent arrives carrying only updated visuals.
|
||||
/// </summary>
|
||||
private readonly Dictionary<uint, AcDream.Core.Net.WorldSession.EntitySpawn> _lastSpawnByGuid = new();
|
||||
private IReadOnlyDictionary<uint, AcDream.Core.Net.WorldSession.EntitySpawn> LastSpawns =>
|
||||
_inboundPhysics.Snapshots;
|
||||
// B.6/B.7 (2026-05-16): pending close-range action that will be fired
|
||||
// once the local auto-walk overlay reports arrival (body has finished
|
||||
// rotating to face the target). Only set for close-range Use/PickUp;
|
||||
|
|
@ -1241,7 +1242,7 @@ public sealed class GameWindow : IDisposable
|
|||
// cellReady is the faithful indoor equivalent (#106/#107, AD-2).
|
||||
// (Before #135 this only passed by accident: the 25×25 window
|
||||
// happened to stream the neighbour terrain.)
|
||||
if (_lastSpawnByGuid.TryGetValue(_playerServerGuid, out var sp)
|
||||
if (LastSpawns.TryGetValue(_playerServerGuid, out var sp)
|
||||
&& sp.Position is { } spawnClaim
|
||||
&& spawnClaim.LandblockId != 0
|
||||
&& (spawnClaim.LandblockId & 0xFFFFu) >= 0x0100u
|
||||
|
|
@ -1523,7 +1524,7 @@ public sealed class GameWindow : IDisposable
|
|||
uint rawItemType = (uint)LiveItemType(guid);
|
||||
uint pwdBits = 0;
|
||||
uint? useability = null;
|
||||
if (_lastSpawnByGuid.TryGetValue(guid, out var spawn))
|
||||
if (LastSpawns.TryGetValue(guid, out var spawn))
|
||||
{
|
||||
if (spawn.ObjectDescriptionFlags is { } odf) pwdBits = odf;
|
||||
useability = spawn.Useability;
|
||||
|
|
@ -2098,7 +2099,7 @@ public sealed class GameWindow : IDisposable
|
|||
var radarSnapshotProvider = new AcDream.App.UI.Layout.RadarSnapshotProvider(
|
||||
Objects,
|
||||
_entitiesByServerGuid,
|
||||
_lastSpawnByGuid,
|
||||
LastSpawns,
|
||||
playerGuid: () => _playerServerGuid,
|
||||
playerYawRadians: () => _playerController?.Yaw ?? 0f,
|
||||
playerCellId: () => _playerController?.CellId ?? 0u,
|
||||
|
|
@ -2313,9 +2314,10 @@ public sealed class GameWindow : IDisposable
|
|||
Objects,
|
||||
_worldState,
|
||||
guid => _entitiesByServerGuid.TryGetValue(guid, out var entity) ? entity : null,
|
||||
guid => _lastSpawnByGuid.TryGetValue(guid, out var spawn)
|
||||
guid => LastSpawns.TryGetValue(guid, out var spawn)
|
||||
? spawn
|
||||
: (AcDream.Core.Net.WorldSession.EntitySpawn?)null,
|
||||
TryAcceptParentForRender,
|
||||
() => _liveEntityIdCounter++);
|
||||
|
||||
_wbDrawDispatcher = new AcDream.App.Rendering.Wb.WbDrawDispatcher(
|
||||
|
|
@ -2457,6 +2459,8 @@ public sealed class GameWindow : IDisposable
|
|||
|
||||
private void TryStartLiveSession()
|
||||
{
|
||||
ClearInboundEntityState();
|
||||
|
||||
// Step 2 (2026-05-16): delegate pre-Connect setup to LiveSessionController.
|
||||
// The controller owns DNS resolution + WorldSession instantiation + the
|
||||
// wireEvents callback; this method keeps the Connect → CharacterList →
|
||||
|
|
@ -2487,6 +2491,7 @@ public sealed class GameWindow : IDisposable
|
|||
_liveSessionController.Dispose();
|
||||
_liveSessionController = null;
|
||||
_liveSession = null;
|
||||
ClearInboundEntityState();
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -2521,9 +2526,20 @@ public sealed class GameWindow : IDisposable
|
|||
_liveSessionController?.Dispose();
|
||||
_liveSessionController = null;
|
||||
_liveSession = null;
|
||||
ClearInboundEntityState();
|
||||
}
|
||||
}
|
||||
|
||||
private void ClearInboundEntityState()
|
||||
{
|
||||
// Attachment projections own GL-backed world registrations, so tear
|
||||
// them down before dropping the canonical GUID/timestamp snapshots.
|
||||
_equippedChildRenderer?.Clear();
|
||||
Objects.Clear();
|
||||
_selection.Reset();
|
||||
_inboundPhysics.Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Step 2 helper: subscribes the live <paramref name="session"/> to all
|
||||
/// the parsers / handlers / translators that <c>GameWindow</c> needs.
|
||||
|
|
@ -2534,19 +2550,20 @@ public sealed class GameWindow : IDisposable
|
|||
private void WireLiveSessionEvents(AcDream.Core.Net.WorldSession session)
|
||||
{
|
||||
_liveSession = session;
|
||||
// D.5.4: ingest CreateObject into the object table (upsert) and wire Delete +
|
||||
// UiEffects live update. Wire BEFORE EntitySpawned += OnLiveEntitySpawned so
|
||||
// the table is populated before the render handler runs.
|
||||
// D.5.4: wire non-lifecycle object quality/property updates. The live
|
||||
// handlers below apply Create/Delete to both projections only after
|
||||
// their canonical timestamp decision.
|
||||
AcDream.Core.Net.ObjectTableWiring.Wire(
|
||||
session, Objects, () => _playerServerGuid, LocalPlayer);
|
||||
AcDream.Core.Net.CombatStateWiring.Wire(session, Combat);
|
||||
_liveSession.EntitySpawned += OnLiveEntitySpawned;
|
||||
_liveSession.EntityDeleted += OnLiveEntityDeleted;
|
||||
_liveSession.EntityPickedUp += OnLiveEntityPickedUp;
|
||||
_liveSession.MotionUpdated += OnLiveMotionUpdated;
|
||||
_liveSession.PositionUpdated += OnLivePositionUpdated;
|
||||
_liveSession.VectorUpdated += OnLiveVectorUpdated;
|
||||
_liveSession.StateUpdated += OnLiveStateUpdated;
|
||||
_liveSession.ParentUpdated += update => _equippedChildRenderer?.OnParentEvent(update);
|
||||
_liveSession.ParentUpdated += OnLiveParentUpdated;
|
||||
_liveSession.TeleportStarted += OnTeleportStarted;
|
||||
_liveSession.AppearanceUpdated += OnLiveAppearanceUpdated;
|
||||
|
||||
|
|
@ -2558,7 +2575,7 @@ public sealed class GameWindow : IDisposable
|
|||
// retail uses for spell casts, combat flinches, emote
|
||||
// gestures, AND — per Agent #5 research — lightning
|
||||
// flashes during stormy weather.
|
||||
_liveSession.PlayScriptReceived += OnPlayScriptReceived;
|
||||
_liveSession.PlayPhysicsScriptReceived += OnPlayScriptReceived;
|
||||
|
||||
// Phase 5d — AdminEnvirons (0xEA60): fog presets + sound
|
||||
// cues. Fog types (0x00..0x06) set WeatherSystem.Override;
|
||||
|
|
@ -2916,7 +2933,32 @@ public sealed class GameWindow : IDisposable
|
|||
// with BuildLandblockForStreaming on the worker thread.
|
||||
lock (_datLock)
|
||||
{
|
||||
OnLiveEntitySpawnedLocked(spawn);
|
||||
AcDream.App.World.InboundCreateResult result = _inboundPhysics.AcceptCreate(spawn);
|
||||
if (result.Disposition is AcDream.Core.Physics.CreateObjectTimestampDisposition.StaleGeneration)
|
||||
return;
|
||||
|
||||
PublishLocalPhysicsTimestamps(spawn.Guid, result.Timestamps);
|
||||
|
||||
if (result.Disposition is AcDream.Core.Physics.CreateObjectTimestampDisposition.NewGeneration)
|
||||
{
|
||||
_equippedChildRenderer?.OnGenerationReplaced(
|
||||
spawn.Guid,
|
||||
result.Snapshot.InstanceSequence);
|
||||
}
|
||||
|
||||
AcDream.Core.Net.ObjectTableWiring.ApplyEntitySpawn(
|
||||
Objects,
|
||||
spawn,
|
||||
replaceGeneration: result.Disposition is AcDream.Core.Physics.CreateObjectTimestampDisposition.NewGeneration);
|
||||
|
||||
if (result.Disposition is AcDream.Core.Physics.CreateObjectTimestampDisposition.ExistingGeneration)
|
||||
{
|
||||
if (result.SameGenerationEvents is { } refresh)
|
||||
RouteSameGenerationCreateObject(refresh);
|
||||
return;
|
||||
}
|
||||
|
||||
OnLiveEntitySpawnedLocked(result.Snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2928,7 +2970,7 @@ public sealed class GameWindow : IDisposable
|
|||
///
|
||||
/// <para>
|
||||
/// The dungeon collapse (and Near→Far demote) drops a landblock's render
|
||||
/// entities for FPS but keeps the parsed spawns in <see cref="_lastSpawnByGuid"/>
|
||||
/// entities for FPS but keeps the parsed spawns in <see cref="LastSpawns"/>
|
||||
/// (our <c>weenie_object_table</c> for world objects). ACE never
|
||||
/// re-broadcasts objects it believes we still know — its per-player
|
||||
/// <c>KnownObjects</c> set is not cleared on a normal teleport (verified
|
||||
|
|
@ -2950,7 +2992,7 @@ public sealed class GameWindow : IDisposable
|
|||
/// </summary>
|
||||
private void RehydrateServerEntitiesForLandblock(uint loadedLandblockId)
|
||||
{
|
||||
if (_lastSpawnByGuid.Count == 0) return;
|
||||
if (LastSpawns.Count == 0) return;
|
||||
|
||||
// Server guids that already have a live render entity. The gate keys on
|
||||
// GpuWorldState (the render projection), NOT _entitiesByServerGuid, which
|
||||
|
|
@ -2959,11 +3001,10 @@ public sealed class GameWindow : IDisposable
|
|||
foreach (var e in _worldState.Entities)
|
||||
if (e.ServerGuid != 0) present.Add(e.ServerGuid);
|
||||
|
||||
// Snapshot the retained spawns — the replay mutates _lastSpawnByGuid
|
||||
// (remove then re-add), so we must not iterate it live.
|
||||
// Snapshot retained spawns before render projection changes.
|
||||
var retained = new List<AcDream.App.Streaming.LandblockEntityRehydrator.RetainedSpawn>(
|
||||
_lastSpawnByGuid.Count);
|
||||
foreach (var kv in _lastSpawnByGuid)
|
||||
LastSpawns.Count);
|
||||
foreach (var kv in LastSpawns)
|
||||
{
|
||||
var sp = kv.Value;
|
||||
bool hasWorldMesh = sp.Position is not null && sp.SetupTableId is not null;
|
||||
|
|
@ -2982,7 +3023,7 @@ public sealed class GameWindow : IDisposable
|
|||
lock (_datLock)
|
||||
{
|
||||
foreach (var guid in guids)
|
||||
if (_lastSpawnByGuid.TryGetValue(guid, out var spawn))
|
||||
if (LastSpawns.TryGetValue(guid, out var spawn))
|
||||
OnLiveEntitySpawnedLocked(spawn);
|
||||
}
|
||||
|
||||
|
|
@ -3010,18 +3051,6 @@ public sealed class GameWindow : IDisposable
|
|||
{
|
||||
_liveSpawnReceived++;
|
||||
|
||||
// L.2g S1 (DEV-6): seed the movement-event staleness gate from the
|
||||
// CreateObject PhysicsDesc timestamp block, as retail seeds
|
||||
// update_times at object creation. Seed() adopts wholesale on first
|
||||
// sight and advance-only afterward, so the #138 rehydrate replay of
|
||||
// a RETAINED spawn through this handler cannot regress live stamps.
|
||||
if (!_motionSequenceGates.TryGetValue(spawn.Guid, out var seqGate))
|
||||
{
|
||||
seqGate = new AcDream.Core.Physics.MotionSequenceGate();
|
||||
_motionSequenceGates[spawn.Guid] = seqGate;
|
||||
}
|
||||
seqGate.Seed(spawn.InstanceSequence, spawn.MovementSequence, spawn.ServerControlSequence);
|
||||
|
||||
// De-dup: the server re-sends CreateObject for the same guid in
|
||||
// several situations (visibility refresh, landblock crossing,
|
||||
// appearance update). Without cleanup the OLD copy remains in
|
||||
|
|
@ -3041,9 +3070,15 @@ public sealed class GameWindow : IDisposable
|
|||
// weapon CreateObjects are intentionally no-position and carry their
|
||||
// Parent + Placement in PhysicsData, so cache before the renderability
|
||||
// gate and offer the relationship to the focused child controller.
|
||||
_lastSpawnByGuid[spawn.Guid] = spawn;
|
||||
_equippedChildRenderer?.OnSpawn(spawn);
|
||||
|
||||
// A ParentEvent may have arrived before this CreateObject. Resolving
|
||||
// it above can synchronously advance the canonical child POSITION_TS
|
||||
// and turn this object into an attachment. Select the projection from
|
||||
// that post-callback snapshot, never from the stale method argument.
|
||||
if (_inboundPhysics.TryGetSnapshot(spawn.Guid, out var canonicalSpawn))
|
||||
spawn = canonicalSpawn;
|
||||
|
||||
// When requested, log every spawn that arrives so we can inventory what the server
|
||||
// sends (including the ones we can't render yet). The Name field
|
||||
// is the critical one — we can grep the log for "Nullified Statue
|
||||
|
|
@ -3599,7 +3634,6 @@ public sealed class GameWindow : IDisposable
|
|||
if (visualUpdate.Animation is { } animation)
|
||||
RebindAnimatedEntityForAppearance(animation, existing, setup, scale, meshRefs);
|
||||
_classificationCache.InvalidateEntity(existing.Id);
|
||||
_lastSpawnByGuid[spawn.Guid] = spawn;
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -3677,10 +3711,9 @@ public sealed class GameWindow : IDisposable
|
|||
// UpdateMotion / UpdatePosition events can reseat this entity by guid.
|
||||
_entitiesByServerGuid[spawn.Guid] = entity;
|
||||
|
||||
// Cache the spawn so OnLiveAppearanceUpdated can replay it with new
|
||||
// appearance fields when a later 0xF625 ObjDescEvent arrives.
|
||||
_lastSpawnByGuid[spawn.Guid] = spawn;
|
||||
_equippedChildRenderer?.OnSpawn(spawn);
|
||||
// The root now exists, so parent relations that arrived before this
|
||||
// object's render projection can compose their child meshes.
|
||||
_equippedChildRenderer?.OnWorldEntityRegistered(spawn.Guid);
|
||||
|
||||
// Commit B 2026-04-29 — live-entity collision registration.
|
||||
// The local player is the simulator (its PhysicsBody is the source of
|
||||
|
|
@ -3875,25 +3908,17 @@ public sealed class GameWindow : IDisposable
|
|||
|
||||
private void OnLiveEntityDeleted(AcDream.Core.Net.Messages.DeleteObject.Parsed delete)
|
||||
{
|
||||
// L.2g S1: drop the staleness gate with the entity — a subsequent
|
||||
// re-create adopts fresh stamps from its CreateObject (retail's
|
||||
// update_times die with the CPhysicsObj).
|
||||
_motionSequenceGates.Remove(delete.Guid);
|
||||
_equippedChildRenderer?.OnObjectDeleted(delete.Guid);
|
||||
if (!_inboundPhysics.TryDelete(
|
||||
delete,
|
||||
isLocalPlayer: delete.Guid == _playerServerGuid))
|
||||
return;
|
||||
|
||||
// Snapshot before RemoveLiveEntityByServerGuid clears the render-side
|
||||
// cache. Pickup removes only the 3-D projection; the weenie persists
|
||||
// in inventory and may later become a parented weapon.
|
||||
AcDream.Core.Net.WorldSession.EntitySpawn? pickedUp =
|
||||
delete.FromPickup && _lastSpawnByGuid.TryGetValue(delete.Guid, out var cached)
|
||||
? cached with { Position = null }
|
||||
: null;
|
||||
_equippedChildRenderer?.OnGenerationDeleted(
|
||||
delete.Guid,
|
||||
delete.InstanceSequence);
|
||||
AcDream.Core.Net.ObjectTableWiring.ApplyEntityDelete(Objects, delete);
|
||||
|
||||
bool removed = RemoveLiveEntityByServerGuid(delete.Guid);
|
||||
if (pickedUp is { } retained)
|
||||
_lastSpawnByGuid[delete.Guid] = retained;
|
||||
else if (!delete.FromPickup)
|
||||
_lastSpawnByGuid.Remove(delete.Guid);
|
||||
|
||||
if (removed
|
||||
&& Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1")
|
||||
|
|
@ -3916,7 +3941,7 @@ public sealed class GameWindow : IDisposable
|
|||
/// </summary>
|
||||
private void OnLiveAppearanceUpdated(AcDream.Core.Net.Messages.ObjDescEvent.Parsed update)
|
||||
{
|
||||
if (!_lastSpawnByGuid.TryGetValue(update.Guid, out var oldSpawn))
|
||||
if (!_inboundPhysics.TryApplyObjDesc(update, out var newSpawn))
|
||||
{
|
||||
// Server can broadcast ObjDescEvent before we've seen a
|
||||
// CreateObject for this guid (race on landblock entry, or
|
||||
|
|
@ -3926,14 +3951,6 @@ public sealed class GameWindow : IDisposable
|
|||
return;
|
||||
}
|
||||
|
||||
var md = update.ModelData;
|
||||
var newSpawn = oldSpawn with
|
||||
{
|
||||
AnimPartChanges = md.AnimPartChanges,
|
||||
TextureChanges = md.TextureChanges,
|
||||
SubPalettes = md.SubPalettes,
|
||||
BasePaletteId = md.BasePaletteId,
|
||||
};
|
||||
lock (_datLock)
|
||||
{
|
||||
AppearanceUpdateState? appearanceState = CaptureLiveAppearanceState(update.Guid);
|
||||
|
|
@ -4249,6 +4266,82 @@ public sealed class GameWindow : IDisposable
|
|||
}
|
||||
}
|
||||
|
||||
private void RouteSameGenerationCreateObject(
|
||||
AcDream.App.World.SameGenerationCreateObjectEvents refresh)
|
||||
{
|
||||
OnLiveAppearanceUpdated(refresh.Appearance);
|
||||
|
||||
if (refresh.Parent is { } parent
|
||||
&& _inboundPhysics.TryApplyCreateParent(parent, out var acceptedParent))
|
||||
{
|
||||
RemoveLiveEntityByServerGuid(parent.ChildGuid);
|
||||
_equippedChildRenderer?.OnSpawn(acceptedParent);
|
||||
}
|
||||
else if (refresh.Position is { } position)
|
||||
{
|
||||
OnLivePositionUpdated(position);
|
||||
}
|
||||
else if (refresh.Pickup is { } pickup)
|
||||
{
|
||||
OnLiveEntityPickedUp(pickup);
|
||||
}
|
||||
|
||||
if (refresh.Movement is { } movement)
|
||||
OnLiveMotionUpdated(movement);
|
||||
OnLiveStateUpdated(refresh.State);
|
||||
OnLiveVectorUpdated(refresh.Vector);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail PickupEvent is a POSITION_TS update, not object destruction.
|
||||
/// It leaves the world projection while retaining the weenie, generation,
|
||||
/// and every other timestamp for later inventory/parent events.
|
||||
/// </summary>
|
||||
private void OnLiveEntityPickedUp(AcDream.Core.Net.Messages.PickupEvent.Parsed pickup)
|
||||
{
|
||||
if (!_inboundPhysics.TryApplyPickup(pickup, out _))
|
||||
return;
|
||||
|
||||
_equippedChildRenderer?.OnChildBecameUnparented(pickup.Guid);
|
||||
bool removed = RemoveLiveEntityByServerGuid(pickup.Guid);
|
||||
|
||||
if (removed
|
||||
&& Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1")
|
||||
{
|
||||
Console.WriteLine(
|
||||
$"live: pickup guid=0x{pickup.Guid:X8} instSeq={pickup.InstanceSequence} posSeq={pickup.PositionSequence}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ParentEvent may precede either CreateObject. The focused child renderer
|
||||
/// retains it; its realization callback performs the canonical timestamp
|
||||
/// acceptance immediately before changing the projection.
|
||||
/// </summary>
|
||||
private void OnLiveParentUpdated(AcDream.Core.Net.Messages.ParentEvent.Parsed update)
|
||||
{
|
||||
_equippedChildRenderer?.OnParentEvent(update);
|
||||
}
|
||||
|
||||
private bool TryAcceptParentForRender(AcDream.Core.Net.Messages.ParentEvent.Parsed update)
|
||||
{
|
||||
if (!_inboundPhysics.TryApplyParent(update, out _)) return false;
|
||||
RemoveLiveEntityByServerGuid(update.ChildGuid);
|
||||
return true;
|
||||
}
|
||||
|
||||
private void PublishLocalPhysicsTimestamps(
|
||||
uint guid,
|
||||
AcDream.App.World.AcceptedPhysicsTimestamps timestamps)
|
||||
{
|
||||
if (guid != _playerServerGuid || _liveSession is null) return;
|
||||
_liveSession.PublishAcceptedLocalPhysicsTimestamps(
|
||||
timestamps.Instance,
|
||||
timestamps.ServerControlledMove,
|
||||
timestamps.Teleport,
|
||||
timestamps.ForcePosition);
|
||||
}
|
||||
|
||||
private void SyncLocalPlayerShadow(
|
||||
AcDream.Core.World.WorldEntity playerEntity,
|
||||
uint cellId,
|
||||
|
|
@ -4545,7 +4638,7 @@ public sealed class GameWindow : IDisposable
|
|||
// a server-scaled creature variant read unscaled radii and kept the
|
||||
// #171 interpenetration exactly for scaled bodies.
|
||||
float scale =
|
||||
_lastSpawnByGuid.TryGetValue(serverGuid, out var sp)
|
||||
LastSpawns.TryGetValue(serverGuid, out var sp)
|
||||
&& sp.ObjScale is { } objScale && objScale > 0f
|
||||
? objScale
|
||||
: (entity.Scale > 0f ? entity.Scale : 1f);
|
||||
|
|
@ -4629,7 +4722,6 @@ public sealed class GameWindow : IDisposable
|
|||
_remoteDeadReckon.Remove(serverGuid);
|
||||
_remoteLastMove.Remove(serverGuid);
|
||||
_entitiesByServerGuid.Remove(serverGuid);
|
||||
_lastSpawnByGuid.Remove(serverGuid);
|
||||
if (_selection.SelectedObjectId == serverGuid)
|
||||
{
|
||||
_selection.Clear(
|
||||
|
|
@ -4785,27 +4877,18 @@ public sealed class GameWindow : IDisposable
|
|||
/// </summary>
|
||||
private void OnLiveMotionUpdated(AcDream.Core.Net.WorldSession.EntityMotionUpdate update)
|
||||
{
|
||||
if (_dats is null) return;
|
||||
if (!_entitiesByServerGuid.TryGetValue(update.Guid, out var entity)) return;
|
||||
if (!_animatedEntities.TryGetValue(entity.Id, out var ae)) return;
|
||||
|
||||
// L.2g S1 (DEV-6): retail staleness gate — BEFORE any state mutation.
|
||||
// Retail drops stale/duplicate/superseded movement events at
|
||||
// DispatchSmartBoxEvent (INSTANCE_TS, pseudo-C:357214) +
|
||||
// CPhysics::SetObjectMovement (MOVEMENT_TS strictly-newer +
|
||||
// SERVER_CONTROLLED_MOVE_TS, 0x00509690). Without this, a reordered
|
||||
// straggler re-applies an old gait or un-stops a stop.
|
||||
if (!_motionSequenceGates.TryGetValue(update.Guid, out var seqGate))
|
||||
{
|
||||
// UM for an entity whose CreateObject we never parsed (rare —
|
||||
// the entity lookup above implies a spawn). Adopt-on-first-seed
|
||||
// keeps the gate correct from this event onward.
|
||||
seqGate = new AcDream.Core.Physics.MotionSequenceGate();
|
||||
_motionSequenceGates[update.Guid] = seqGate;
|
||||
seqGate.Seed(update.InstanceSequence, update.MovementSequence, update.ServerControlSequence);
|
||||
}
|
||||
else if (!seqGate.TryAcceptMovementEvent(
|
||||
update.InstanceSequence, update.MovementSequence, update.ServerControlSequence))
|
||||
bool retainPayload = update.Guid != _playerServerGuid || !update.IsAutonomous;
|
||||
if (!_inboundPhysics.TryApplyMotion(
|
||||
update,
|
||||
retainPayload,
|
||||
out _,
|
||||
out var timestamps))
|
||||
{
|
||||
if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1"
|
||||
|| Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1")
|
||||
|
|
@ -4817,6 +4900,8 @@ public sealed class GameWindow : IDisposable
|
|||
return;
|
||||
}
|
||||
|
||||
PublishLocalPhysicsTimestamps(update.Guid, timestamps);
|
||||
|
||||
// R4-V5 (pin P1): retail CPhysics::SetObjectMovement's autonomous
|
||||
// gate (0x00509690 @0050972e, raw 271370-271431) — a movement event
|
||||
// whose wire autonomous byte is set is DROPPED ENTIRELY (no state
|
||||
|
|
@ -4834,9 +4919,13 @@ public sealed class GameWindow : IDisposable
|
|||
// retail's own feeds: PlayerDescription skills (SetCharacterSkills,
|
||||
// K-fix7) + the mt-6/7 my_run_rate wire write below (M13) — the
|
||||
// former ApplyServerRunRate echo tap is deleted, not gated.
|
||||
if (update.Guid == _playerServerGuid && update.IsAutonomous)
|
||||
if (!retainPayload)
|
||||
return;
|
||||
|
||||
if (_dats is null) return;
|
||||
if (!_entitiesByServerGuid.TryGetValue(update.Guid, out var entity)) return;
|
||||
if (!_animatedEntities.TryGetValue(entity.Id, out var ae)) return;
|
||||
|
||||
// Re-resolve using the new stance/command. Keep the setup and
|
||||
// motion-table we already know about — the server's motion
|
||||
// updates override state within the same table, not swap tables.
|
||||
|
|
@ -5285,6 +5374,10 @@ public sealed class GameWindow : IDisposable
|
|||
/// </summary>
|
||||
private void OnLiveVectorUpdated(AcDream.Core.Net.Messages.VectorUpdate.Parsed update)
|
||||
{
|
||||
if (!_inboundPhysics.TryApplyVector(update, out _)) return;
|
||||
|
||||
if (!_entitiesByServerGuid.ContainsKey(update.Guid)) return;
|
||||
|
||||
if (update.Guid == _playerServerGuid) return; // local jump uses our own physics
|
||||
if (!_remoteDeadReckon.TryGetValue(update.Guid, out var rm)) return;
|
||||
|
||||
|
|
@ -5351,6 +5444,10 @@ public sealed class GameWindow : IDisposable
|
|||
/// </summary>
|
||||
private void OnLiveStateUpdated(AcDream.Core.Net.Messages.SetState.Parsed parsed)
|
||||
{
|
||||
if (!_inboundPhysics.TryApplyState(parsed, out _)) return;
|
||||
|
||||
if (!_entitiesByServerGuid.TryGetValue(parsed.Guid, out var entity)) return;
|
||||
|
||||
// L.2g slice 1c (2026-05-13): the server addresses entities by
|
||||
// ServerGuid (parsed.Guid, e.g. 0x7A9B4015), but
|
||||
// ShadowObjectRegistry's cell index is keyed by local entity.Id
|
||||
|
|
@ -5358,9 +5455,7 @@ public sealed class GameWindow : IDisposable
|
|||
// mutating the registry — otherwise the lookup misses and the
|
||||
// state flip silently no-ops, leaving doors blocked even though
|
||||
// ACE flipped the ETHEREAL bit.
|
||||
uint registryKey = parsed.Guid;
|
||||
if (_entitiesByServerGuid.TryGetValue(parsed.Guid, out var entity))
|
||||
registryKey = entity.Id;
|
||||
uint registryKey = entity.Id;
|
||||
|
||||
_physicsEngine.ShadowObjects.UpdatePhysicsState(registryKey, parsed.PhysicsState);
|
||||
|
||||
|
|
@ -5436,8 +5531,107 @@ public sealed class GameWindow : IDisposable
|
|||
ae.Sequencer.SetCycle(style, plan.Motion, plan.SpeedMod);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail's local FORCE_POSITION branch calls SendPositionEvent
|
||||
/// immediately after BlipPlayer. This acknowledgement uses the accepted
|
||||
/// timestamps and preserves the player's current heading.
|
||||
/// </summary>
|
||||
private void SendImmediateLocalPositionEvent()
|
||||
{
|
||||
if (_liveSession is null
|
||||
|| _playerController is null
|
||||
|| !_playerController.CanSendPositionEvent)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
AcDream.Core.Physics.Position canonical = _playerController.CellPosition;
|
||||
uint cellId = canonical.ObjCellId;
|
||||
System.Numerics.Vector3 position = canonical.Frame.Origin;
|
||||
System.Numerics.Quaternion rotation = YawToAcQuaternion(_playerController.Yaw);
|
||||
if (!AcDream.Core.Physics.PositionFrameValidation.IsValid(
|
||||
cellId, position, rotation))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
uint sequence = _liveSession.NextGameActionSequence();
|
||||
var body = AcDream.Core.Net.Messages.AutonomousPosition.Build(
|
||||
gameActionSequence: sequence,
|
||||
cellId: cellId,
|
||||
position: position,
|
||||
rotation: rotation,
|
||||
instanceSequence: _liveSession.InstanceSequence,
|
||||
serverControlSequence: _liveSession.ServerControlSequence,
|
||||
teleportSequence: _liveSession.TeleportSequence,
|
||||
forcePositionSequence: _liveSession.ForcePositionSequence,
|
||||
lastContact: 1);
|
||||
_liveSession.SendGameAction(body);
|
||||
_playerController.NotePositionSent(
|
||||
_playerController.Position,
|
||||
cellId,
|
||||
_playerController.ContactPlane,
|
||||
_playerController.SimTimeSeconds);
|
||||
}
|
||||
|
||||
private void OnLivePositionUpdated(AcDream.Core.Net.WorldSession.EntityPositionUpdate update)
|
||||
{
|
||||
bool known = _inboundPhysics.TryApplyPosition(
|
||||
update,
|
||||
isLocalPlayer: update.Guid == _playerServerGuid,
|
||||
forcePositionRotation: update.Guid == _playerServerGuid && _playerController is not null
|
||||
? YawToAcQuaternion(_playerController.Yaw)
|
||||
: null,
|
||||
currentLocalVelocity: update.Guid == _playerServerGuid && _playerController is not null
|
||||
? _playerController.BodyVelocity
|
||||
: null,
|
||||
out var timestampDisposition,
|
||||
out var acceptedSpawn,
|
||||
out var timestamps);
|
||||
if (!known)
|
||||
{
|
||||
// Preserve the local streaming observer hint, but do not consume
|
||||
// a per-object timestamp until a live object can receive the
|
||||
// packet. Retail queues the full blob in this case (AD-32).
|
||||
if (update.Guid == _playerServerGuid)
|
||||
_lastLivePlayerLandblockId = update.Position.LandblockId;
|
||||
return;
|
||||
}
|
||||
|
||||
PublishLocalPhysicsTimestamps(update.Guid, timestamps);
|
||||
|
||||
if (timestampDisposition is AcDream.Core.Physics.PositionTimestampDisposition.Rejected)
|
||||
return;
|
||||
|
||||
var p = update.Position;
|
||||
int lbX = (int)((p.LandblockId >> 24) & 0xFFu);
|
||||
int lbY = (int)((p.LandblockId >> 16) & 0xFFu);
|
||||
var origin = new System.Numerics.Vector3(
|
||||
(lbX - _liveCenterX) * 192f,
|
||||
(lbY - _liveCenterY) * 192f,
|
||||
0f);
|
||||
var worldPos = new System.Numerics.Vector3(p.PositionX, p.PositionY, p.PositionZ) + origin;
|
||||
|
||||
bool forceLocal = timestampDisposition is AcDream.Core.Physics.PositionTimestampDisposition.ForcePosition
|
||||
&& update.Guid == _playerServerGuid
|
||||
&& _playerController is not null;
|
||||
if (forceLocal)
|
||||
{
|
||||
var cellLocal = new System.Numerics.Vector3(
|
||||
p.PositionX, p.PositionY, p.PositionZ);
|
||||
_playerController!.BlipPosition(worldPos, p.LandblockId, cellLocal);
|
||||
SendImmediateLocalPositionEvent();
|
||||
}
|
||||
|
||||
if (!_entitiesByServerGuid.ContainsKey(update.Guid))
|
||||
{
|
||||
_equippedChildRenderer?.OnChildBecameUnparented(update.Guid);
|
||||
lock (_datLock)
|
||||
OnLiveEntitySpawnedLocked(acceptedSpawn);
|
||||
}
|
||||
|
||||
if (!_entitiesByServerGuid.TryGetValue(update.Guid, out var entity)) return;
|
||||
|
||||
// Phase A.1 / #135: track the PLAYER's last server-known landblock so the
|
||||
// streaming controller can follow the player in the fly-camera / pre-player-mode
|
||||
// (login hold) views. Filtered to our OWN character guid — resolving the original
|
||||
|
|
@ -5451,17 +5645,6 @@ public sealed class GameWindow : IDisposable
|
|||
if (update.Guid == _playerServerGuid)
|
||||
_lastLivePlayerLandblockId = update.Position.LandblockId;
|
||||
|
||||
if (!_entitiesByServerGuid.TryGetValue(update.Guid, out var entity)) return;
|
||||
|
||||
var p = update.Position;
|
||||
int lbX = (int)((p.LandblockId >> 24) & 0xFFu);
|
||||
int lbY = (int)((p.LandblockId >> 16) & 0xFFu);
|
||||
var origin = new System.Numerics.Vector3(
|
||||
(lbX - _liveCenterX) * 192f,
|
||||
(lbY - _liveCenterY) * 192f,
|
||||
0f);
|
||||
var worldPos = new System.Numerics.Vector3(p.PositionX, p.PositionY, p.PositionZ) + origin;
|
||||
|
||||
// B.6 slice 1 (2026-05-14): trace inbound UpdatePosition cadence for
|
||||
// the local player. Combined with [autowalk-mt] this answers
|
||||
// whether ACE's broadcast frequency during a server-initiated
|
||||
|
|
@ -5477,16 +5660,11 @@ public sealed class GameWindow : IDisposable
|
|||
Console.WriteLine(System.FormattableString.Invariant(
|
||||
$"[autowalk-up] cell=0x{p.LandblockId:X8} pos=({p.PositionX:F2},{p.PositionY:F2},{p.PositionZ:F2}) world=({worldPos.X:F2},{worldPos.Y:F2},{worldPos.Z:F2}) {velStr} grounded={update.IsGrounded}"));
|
||||
}
|
||||
var rot = new System.Numerics.Quaternion(p.RotationX, p.RotationY, p.RotationZ, p.RotationW);
|
||||
var rot = timestampDisposition is AcDream.Core.Physics.PositionTimestampDisposition.ForcePosition
|
||||
? entity.Rotation
|
||||
: new System.Numerics.Quaternion(p.RotationX, p.RotationY, p.RotationZ, p.RotationW);
|
||||
DumpMovementTruthServerEcho(update, worldPos);
|
||||
|
||||
// Keep the cached spawn's Position in sync with server truth so a
|
||||
// later ObjDescEvent (which only carries new appearance, not new
|
||||
// position) re-applies at the entity's CURRENT location instead of
|
||||
// popping back to its login spot. See OnLiveAppearanceUpdated.
|
||||
if (_lastSpawnByGuid.TryGetValue(update.Guid, out var cached))
|
||||
_lastSpawnByGuid[update.Guid] = cached with { Position = update.Position };
|
||||
|
||||
// Capture the pre-update render position for the soft-snap residual
|
||||
// calculation below. Assign entity.Position to the server truth up
|
||||
// front; if we then compute a snap residual, we restore the rendered
|
||||
|
|
@ -6224,6 +6402,9 @@ public sealed class GameWindow : IDisposable
|
|||
/// </summary>
|
||||
private void OnTeleportStarted(uint sequence)
|
||||
{
|
||||
if (!_inboundPhysics.IsFreshTeleportStart(_playerServerGuid, (ushort)sequence))
|
||||
return;
|
||||
|
||||
if (_playerController is not null)
|
||||
_playerController.State = AcDream.App.Input.PlayerState.PortalSpace;
|
||||
_teleportInProgress = true;
|
||||
|
|
@ -6251,7 +6432,7 @@ public sealed class GameWindow : IDisposable
|
|||
/// sufficient.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private void OnPlayScriptReceived(uint guid, uint scriptId)
|
||||
private void OnPlayScriptReceived(AcDream.Core.Net.Messages.PlayPhysicsScript message)
|
||||
{
|
||||
if (_scriptRunner is null) return;
|
||||
|
||||
|
|
@ -6262,7 +6443,7 @@ public sealed class GameWindow : IDisposable
|
|||
camWorldPos = new System.Numerics.Vector3(iv.M41, iv.M42, iv.M43);
|
||||
}
|
||||
|
||||
_scriptRunner.Play(scriptId, guid, camWorldPos);
|
||||
_scriptRunner.Play(message.ScriptDid, message.Guid, camWorldPos);
|
||||
}
|
||||
|
||||
private void UpdateSkyPes(
|
||||
|
|
@ -12094,7 +12275,7 @@ public sealed class GameWindow : IDisposable
|
|||
float? pickUseRadius = null;
|
||||
float pickScale = 1f;
|
||||
uint? pickSetupId = null;
|
||||
if (_lastSpawnByGuid.TryGetValue(guid, out var spawn))
|
||||
if (LastSpawns.TryGetValue(guid, out var spawn))
|
||||
{
|
||||
if (spawn.ObjectDescriptionFlags is { } odf) pwdBits = odf;
|
||||
pickUseability = spawn.Useability;
|
||||
|
|
@ -12394,7 +12575,7 @@ public sealed class GameWindow : IDisposable
|
|||
{
|
||||
useRadius = 3.0f;
|
||||
}
|
||||
else if (_lastSpawnByGuid.TryGetValue(targetGuid, out var spawn)
|
||||
else if (LastSpawns.TryGetValue(targetGuid, out var spawn)
|
||||
&& spawn.ObjectDescriptionFlags is { } odf)
|
||||
{
|
||||
const uint LargeFlatMask = 0x1000u | 0x4000u | 0x40000u | 0x2000u;
|
||||
|
|
@ -12423,7 +12604,7 @@ public sealed class GameWindow : IDisposable
|
|||
{
|
||||
useRadius = 3.0f;
|
||||
}
|
||||
else if (_lastSpawnByGuid.TryGetValue(targetGuid, out var spawn)
|
||||
else if (LastSpawns.TryGetValue(targetGuid, out var spawn)
|
||||
&& spawn.ObjectDescriptionFlags is { } odf)
|
||||
{
|
||||
// BF_DOOR | BF_LIFESTONE | BF_PORTAL | BF_CORPSE
|
||||
|
|
@ -12611,7 +12792,7 @@ public sealed class GameWindow : IDisposable
|
|||
worldRadius = 0f;
|
||||
|
||||
if (!_entitiesByServerGuid.TryGetValue(guid, out var entity)) return false;
|
||||
if (!_lastSpawnByGuid.TryGetValue(guid, out var spawn)) return false;
|
||||
if (!LastSpawns.TryGetValue(guid, out var spawn)) return false;
|
||||
if (spawn.SetupTableId is not uint setupId) return false;
|
||||
if (_dats is null) return false;
|
||||
if (!_dats.TryGet<DatReaderWriter.DBObjs.Setup>(setupId, out var setup)) return false;
|
||||
|
|
@ -12670,7 +12851,7 @@ public sealed class GameWindow : IDisposable
|
|||
/// </summary>
|
||||
private bool IsUseableTarget(uint guid)
|
||||
{
|
||||
if (_lastSpawnByGuid.TryGetValue(guid, out var spawn))
|
||||
if (LastSpawns.TryGetValue(guid, out var spawn))
|
||||
{
|
||||
// Authoritative path: server published Useability.
|
||||
// 2026-05-16 — retail-faithful gate per ItemUses::IsUseable
|
||||
|
|
@ -12800,7 +12981,7 @@ public sealed class GameWindow : IDisposable
|
|||
/// </summary>
|
||||
private bool IsPickupableTarget(uint guid)
|
||||
{
|
||||
if (!_lastSpawnByGuid.TryGetValue(guid, out var spawn))
|
||||
if (!LastSpawns.TryGetValue(guid, out var spawn))
|
||||
return false;
|
||||
|
||||
// 2026-05-16 — primary discriminator is the BF_STUCK
|
||||
|
|
@ -13232,10 +13413,10 @@ public sealed class GameWindow : IDisposable
|
|||
//
|
||||
// Fall back to the old sentinel when no spawn record is cached
|
||||
// (defensive — should never fire in live play because the
|
||||
// _lastSpawnByGuid entry was written by OnLiveEntitySpawnedLocked
|
||||
// The inbound-state snapshot was accepted before render hydration.
|
||||
// before EnterPlayerModeNow could possibly be reached).
|
||||
uint pinitCellId;
|
||||
if (_lastSpawnByGuid.TryGetValue(_playerServerGuid, out var playerSpawn)
|
||||
if (LastSpawns.TryGetValue(_playerServerGuid, out var playerSpawn)
|
||||
&& playerSpawn.Position is { } spawnPos
|
||||
&& spawnPos.LandblockId != 0)
|
||||
{
|
||||
|
|
@ -13566,7 +13747,7 @@ public sealed class GameWindow : IDisposable
|
|||
$"deferred={_streamingController?.DeferredApplyBacklog ?? 0} " +
|
||||
$"forceReload={_streamingController?.ForceReloadCount ?? 0}" +
|
||||
$"(drop={_streamingController?.LastForceReloadDropCount ?? 0}) " +
|
||||
$"esg={_entitiesByServerGuid.Count} spawn={_lastSpawnByGuid.Count} " +
|
||||
$"esg={_entitiesByServerGuid.Count} spawn={LastSpawns.Count} " +
|
||||
$"resident={_worldState.Entities.Count} walked={walked}");
|
||||
_frameDiagMaxAppliesPerUpdate = 0; // reset the per-window burst tracker
|
||||
|
||||
|
|
@ -13648,6 +13829,7 @@ public sealed class GameWindow : IDisposable
|
|||
_equippedChildRenderer?.Dispose();
|
||||
_liveSessionController?.Dispose();
|
||||
_liveSession = null;
|
||||
_inboundPhysics.Clear();
|
||||
_audioEngine?.Dispose(); // Phase E.2: stop all voices, close AL context
|
||||
_wbDrawDispatcher?.Dispose();
|
||||
_envCellRenderer?.Dispose(); // Phase A8
|
||||
|
|
|
|||
288
src/AcDream.App/Rendering/ParentAttachmentState.cs
Normal file
288
src/AcDream.App/Rendering/ParentAttachmentState.cs
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Core.Physics;
|
||||
|
||||
namespace AcDream.App.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// Update-thread state machine for parent relations that may arrive before
|
||||
/// either CreateObject. Unaccepted wire events remain in arrival order;
|
||||
/// rollback history contains only relations that passed the canonical retail
|
||||
/// timestamp gate.
|
||||
/// </summary>
|
||||
public sealed class ParentAttachmentState
|
||||
{
|
||||
private readonly Dictionary<uint, Queue<ParentAttachmentRelation>> _unresolvedByChild = new();
|
||||
private readonly Dictionary<uint, ParentAttachmentRelation> _projectionByChild = new();
|
||||
private readonly Dictionary<uint, ParentAttachmentRelation> _lastAcceptedByChild = new();
|
||||
|
||||
public void AcceptCreateObjectRelation(ParentAttachmentRelation relation)
|
||||
{
|
||||
_projectionByChild[relation.ChildGuid] = relation;
|
||||
_lastAcceptedByChild[relation.ChildGuid] = relation;
|
||||
}
|
||||
|
||||
public void Enqueue(ParentEvent.Parsed update)
|
||||
{
|
||||
if (!_unresolvedByChild.TryGetValue(update.ChildGuid, out Queue<ParentAttachmentRelation>? queue))
|
||||
{
|
||||
queue = new Queue<ParentAttachmentRelation>();
|
||||
_unresolvedByChild.Add(update.ChildGuid, queue);
|
||||
}
|
||||
|
||||
queue.Enqueue(new ParentAttachmentRelation(
|
||||
update.ParentGuid,
|
||||
update.ChildGuid,
|
||||
update.ParentLocation,
|
||||
update.PlacementId,
|
||||
update.ParentInstanceSequence,
|
||||
update.ChildPositionSequence));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves every now-addressable relation for one child in wire order.
|
||||
/// A future parent generation remains queued; an older generation or a
|
||||
/// rejected child POSITION_TS is discarded. Every accepted relation
|
||||
/// supersedes the preceding render projection.
|
||||
/// </summary>
|
||||
public void Resolve(
|
||||
uint childGuid,
|
||||
Func<uint, bool> isObjectKnown,
|
||||
Func<uint, ushort?> resolveInstance,
|
||||
Func<ParentEvent.Parsed, bool> accept)
|
||||
{
|
||||
if (!_unresolvedByChild.TryGetValue(childGuid, out Queue<ParentAttachmentRelation>? queue))
|
||||
return;
|
||||
int candidateCount = queue.Count;
|
||||
for (int i = 0; i < candidateCount; i++)
|
||||
{
|
||||
ParentAttachmentRelation relation = queue.Dequeue();
|
||||
ushort? parentInstance = resolveInstance(relation.ParentGuid);
|
||||
if (parentInstance is null)
|
||||
{
|
||||
queue.Enqueue(relation with { WaitOwner = ParentAttachmentWaitOwner.Parent });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (parentInstance.Value != relation.ParentInstanceSequence)
|
||||
{
|
||||
// Current parent newer than the packet: discard the stale
|
||||
// relation. Packet newer than current: retain it until that
|
||||
// parent generation is constructed.
|
||||
if (PhysicsTimestampGate.IsNewer(
|
||||
relation.ParentInstanceSequence,
|
||||
parentInstance.Value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
queue.Enqueue(relation with { WaitOwner = ParentAttachmentWaitOwner.Parent });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isObjectKnown(childGuid))
|
||||
{
|
||||
queue.Enqueue(relation with { WaitOwner = ParentAttachmentWaitOwner.Child });
|
||||
continue;
|
||||
}
|
||||
|
||||
var update = new ParentEvent.Parsed(
|
||||
relation.ParentGuid,
|
||||
relation.ChildGuid,
|
||||
relation.ParentLocation,
|
||||
relation.PlacementId,
|
||||
relation.ParentInstanceSequence,
|
||||
relation.ChildPositionSequence);
|
||||
if (!accept(update))
|
||||
continue;
|
||||
|
||||
_projectionByChild[childGuid] = relation;
|
||||
_lastAcceptedByChild[childGuid] = relation;
|
||||
}
|
||||
|
||||
if (queue.Count == 0)
|
||||
_unresolvedByChild.Remove(childGuid);
|
||||
}
|
||||
|
||||
public bool TryGetProjection(uint childGuid, out ParentAttachmentRelation relation) =>
|
||||
_projectionByChild.TryGetValue(childGuid, out relation);
|
||||
|
||||
public void MarkProjected(uint childGuid) => _projectionByChild.Remove(childGuid);
|
||||
|
||||
public bool RestoreLastAccepted(uint childGuid)
|
||||
{
|
||||
if (!_lastAcceptedByChild.TryGetValue(childGuid, out ParentAttachmentRelation relation))
|
||||
return false;
|
||||
_projectionByChild[childGuid] = relation;
|
||||
return true;
|
||||
}
|
||||
|
||||
public IReadOnlyList<uint> ChildrenWaitingForParent(uint parentGuid)
|
||||
{
|
||||
var result = new HashSet<uint>();
|
||||
foreach ((uint childGuid, ParentAttachmentRelation relation) in _projectionByChild)
|
||||
{
|
||||
if (relation.ParentGuid == parentGuid)
|
||||
result.Add(childGuid);
|
||||
}
|
||||
|
||||
foreach ((uint childGuid, Queue<ParentAttachmentRelation> queue) in _unresolvedByChild)
|
||||
{
|
||||
if (queue.Any(relation => relation.ParentGuid == parentGuid))
|
||||
result.Add(childGuid);
|
||||
}
|
||||
|
||||
return result.ToArray();
|
||||
}
|
||||
|
||||
public void RemoveObject(uint guid)
|
||||
{
|
||||
_projectionByChild.Remove(guid);
|
||||
_lastAcceptedByChild.Remove(guid);
|
||||
_unresolvedByChild.Remove(guid);
|
||||
|
||||
RemoveParentReferences(_projectionByChild, guid);
|
||||
RemoveParentReferences(_lastAcceptedByChild, guid);
|
||||
|
||||
uint[] children = _unresolvedByChild.Keys.ToArray();
|
||||
for (int i = 0; i < children.Length; i++)
|
||||
{
|
||||
uint childGuid = children[i];
|
||||
Queue<ParentAttachmentRelation> retained = new(
|
||||
_unresolvedByChild[childGuid].Where(relation => relation.ParentGuid != guid));
|
||||
if (retained.Count == 0)
|
||||
_unresolvedByChild.Remove(childGuid);
|
||||
else
|
||||
_unresolvedByChild[childGuid] = retained;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ends one logical incarnation without discarding unresolved wire events
|
||||
/// that may explicitly address the replacement/future parent generation.
|
||||
/// </summary>
|
||||
public void EndGeneration(uint guid, ushort replacementGeneration)
|
||||
{
|
||||
FilterChildCandidates(
|
||||
guid,
|
||||
relation => relation.WaitOwner is ParentAttachmentWaitOwner.Parent);
|
||||
_projectionByChild.Remove(guid);
|
||||
_lastAcceptedByChild.Remove(guid);
|
||||
RemoveParentReferences(_projectionByChild, guid);
|
||||
RemoveParentReferences(_lastAcceptedByChild, guid);
|
||||
FilterParentCandidates(
|
||||
guid,
|
||||
relation => relation.ParentInstanceSequence == replacementGeneration
|
||||
|| PhysicsTimestampGate.IsNewer(
|
||||
replacementGeneration,
|
||||
relation.ParentInstanceSequence));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes one exact incarnation. Child-addressed candidates die with the
|
||||
/// child; candidates addressed to a strictly newer parent incarnation
|
||||
/// survive for retail's post-Create blob replay.
|
||||
/// </summary>
|
||||
public void DeleteGeneration(uint guid, ushort deletedGeneration)
|
||||
{
|
||||
FilterChildCandidates(
|
||||
guid,
|
||||
relation => relation.WaitOwner is ParentAttachmentWaitOwner.Parent);
|
||||
_projectionByChild.Remove(guid);
|
||||
_lastAcceptedByChild.Remove(guid);
|
||||
RemoveParentReferences(_projectionByChild, guid);
|
||||
RemoveParentReferences(_lastAcceptedByChild, guid);
|
||||
FilterParentCandidates(
|
||||
guid,
|
||||
relation => PhysicsTimestampGate.IsNewer(
|
||||
deletedGeneration,
|
||||
relation.ParentInstanceSequence));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears an accepted attachment projection after Pickup or a world
|
||||
/// Position without destroying fresher unresolved ParentEvents.
|
||||
/// </summary>
|
||||
public void EndChildProjection(uint childGuid)
|
||||
{
|
||||
_projectionByChild.Remove(childGuid);
|
||||
_lastAcceptedByChild.Remove(childGuid);
|
||||
}
|
||||
|
||||
public void RemoveChild(uint childGuid)
|
||||
{
|
||||
_projectionByChild.Remove(childGuid);
|
||||
_lastAcceptedByChild.Remove(childGuid);
|
||||
_unresolvedByChild.Remove(childGuid);
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
_unresolvedByChild.Clear();
|
||||
_projectionByChild.Clear();
|
||||
_lastAcceptedByChild.Clear();
|
||||
}
|
||||
|
||||
private static void RemoveParentReferences(
|
||||
Dictionary<uint, ParentAttachmentRelation> relations,
|
||||
uint parentGuid)
|
||||
{
|
||||
uint[] children = relations
|
||||
.Where(pair => pair.Value.ParentGuid == parentGuid)
|
||||
.Select(pair => pair.Key)
|
||||
.ToArray();
|
||||
for (int i = 0; i < children.Length; i++)
|
||||
relations.Remove(children[i]);
|
||||
}
|
||||
|
||||
private void FilterParentCandidates(
|
||||
uint parentGuid,
|
||||
Func<ParentAttachmentRelation, bool> retain)
|
||||
{
|
||||
uint[] children = _unresolvedByChild.Keys.ToArray();
|
||||
for (int i = 0; i < children.Length; i++)
|
||||
{
|
||||
uint childGuid = children[i];
|
||||
Queue<ParentAttachmentRelation> queue = _unresolvedByChild[childGuid];
|
||||
var retained = new Queue<ParentAttachmentRelation>(
|
||||
queue.Where(relation => relation.ParentGuid != parentGuid || retain(relation)));
|
||||
if (retained.Count == 0)
|
||||
_unresolvedByChild.Remove(childGuid);
|
||||
else
|
||||
_unresolvedByChild[childGuid] = retained;
|
||||
}
|
||||
}
|
||||
|
||||
private void FilterChildCandidates(
|
||||
uint childGuid,
|
||||
Func<ParentAttachmentRelation, bool> retain)
|
||||
{
|
||||
if (!_unresolvedByChild.TryGetValue(
|
||||
childGuid,
|
||||
out Queue<ParentAttachmentRelation>? queue))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var retained = new Queue<ParentAttachmentRelation>(queue.Where(retain));
|
||||
if (retained.Count == 0)
|
||||
_unresolvedByChild.Remove(childGuid);
|
||||
else
|
||||
_unresolvedByChild[childGuid] = retained;
|
||||
}
|
||||
}
|
||||
|
||||
public readonly record struct ParentAttachmentRelation(
|
||||
uint ParentGuid,
|
||||
uint ChildGuid,
|
||||
uint ParentLocation,
|
||||
uint PlacementId,
|
||||
ushort ParentInstanceSequence,
|
||||
ushort ChildPositionSequence,
|
||||
ParentAttachmentWaitOwner WaitOwner = ParentAttachmentWaitOwner.Unknown);
|
||||
|
||||
public enum ParentAttachmentWaitOwner
|
||||
{
|
||||
Unknown,
|
||||
Parent,
|
||||
Child,
|
||||
}
|
||||
|
|
@ -20,9 +20,9 @@ namespace AcDream.App.Streaming;
|
|||
///
|
||||
/// <para>
|
||||
/// acdream's dungeon-collapse (and Near→Far demote) drops a landblock's
|
||||
/// RENDER entities for FPS but retains the parsed spawns
|
||||
/// (<c>GameWindow._lastSpawnByGuid</c> — the table is only pruned by an
|
||||
/// explicit server <c>DeleteObject</c> or a spawn de-dup). On reload, ACE will
|
||||
/// RENDER entities for FPS but retains the accepted immutable spawns in
|
||||
/// <c>InboundPhysicsStateController.Snapshots</c> (pruned by an explicit server
|
||||
/// <c>DeleteObject</c> or session teardown). On reload, ACE will
|
||||
/// not re-send the objects it still thinks we have, so the render side stays
|
||||
/// empty — the #138 symptom. This selects the retained spawns whose render
|
||||
/// entity is currently absent so <c>GameWindow</c> can replay them, making
|
||||
|
|
|
|||
|
|
@ -172,6 +172,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
|||
_objects.ObjectMoved += OnObjectMoved;
|
||||
_objects.ObjectRemoved += OnObjectRemoved;
|
||||
_objects.ObjectUpdated += OnObjectChanged;
|
||||
_objects.Cleared += OnObjectsCleared;
|
||||
_selection.Changed += OnSelectionChanged;
|
||||
if (_itemInteraction is not null)
|
||||
_itemInteraction.StateChanged += OnInteractionStateChanged;
|
||||
|
|
@ -242,6 +243,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
|||
{ if (Concerns(o) || from == _playerGuid() || to == _playerGuid()) Populate(); }
|
||||
private void OnInteractionStateChanged() => ApplyIndicators();
|
||||
private void OnSelectionChanged(SelectionTransition _) => ApplyIndicators();
|
||||
private void OnObjectsCleared() => Populate();
|
||||
|
||||
/// <summary>True if the object is in (or wielded by) the player — i.e. a rebuild is warranted.</summary>
|
||||
private bool Concerns(ClientObject o)
|
||||
|
|
@ -702,6 +704,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
|
|||
_objects.ObjectMoved -= OnObjectMoved;
|
||||
_objects.ObjectRemoved -= OnObjectRemoved;
|
||||
_objects.ObjectUpdated -= OnObjectChanged;
|
||||
_objects.Cleared -= OnObjectsCleared;
|
||||
_selection.Changed -= OnSelectionChanged;
|
||||
if (_itemInteraction is not null)
|
||||
_itemInteraction.StateChanged -= OnInteractionStateChanged;
|
||||
|
|
|
|||
|
|
@ -117,6 +117,7 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo
|
|||
_objects.ObjectMoved += OnObjectMoved;
|
||||
_objects.ObjectRemoved += OnObjectChanged;
|
||||
_objects.ObjectUpdated += OnObjectChanged;
|
||||
_objects.Cleared += OnObjectsCleared;
|
||||
_selection.Changed += OnSelectionChanged;
|
||||
|
||||
// ── Slots-toggle wiring ───────────────────────────────────────────────────────────────────
|
||||
|
|
@ -211,6 +212,11 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo
|
|||
private void OnObjectMoved(ClientObject o, uint from, uint to)
|
||||
{ if (Concerns(o) || from == _playerGuid() || to == _playerGuid()) Populate(); }
|
||||
private void OnSelectionChanged(SelectionTransition _) => ApplySelectionIndicators();
|
||||
private void OnObjectsCleared()
|
||||
{
|
||||
ApplyAetheriaVisibility();
|
||||
Populate();
|
||||
}
|
||||
|
||||
/// <summary>The object belongs to the player (wielded gear or pack contents) — so a change to it may
|
||||
/// add/remove/repaint a doll slot. Player-scoped: an NPC's or vendor's wielded item (which also carries
|
||||
|
|
@ -343,6 +349,7 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo
|
|||
_objects.ObjectMoved -= OnObjectMoved;
|
||||
_objects.ObjectRemoved -= OnObjectChanged;
|
||||
_objects.ObjectUpdated -= OnObjectChanged;
|
||||
_objects.Cleared -= OnObjectsCleared;
|
||||
_selection.Changed -= OnSelectionChanged;
|
||||
if (_dollViewport is UiViewport doll)
|
||||
doll.ClickedAt = null;
|
||||
|
|
|
|||
|
|
@ -204,6 +204,7 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
|
|||
repo.ObjectUpdated += OnRepositoryObjectChanged;
|
||||
repo.ObjectRemoved += OnRepositoryObjectChanged;
|
||||
repo.ObjectMoved += OnRepositoryObjectMoved;
|
||||
repo.Cleared += OnRepositoryCleared;
|
||||
RefreshAmmo();
|
||||
}
|
||||
|
||||
|
|
@ -225,6 +226,12 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
|
|||
Populate();
|
||||
}
|
||||
|
||||
private void OnRepositoryCleared()
|
||||
{
|
||||
RefreshAmmo();
|
||||
Populate();
|
||||
}
|
||||
|
||||
private bool IsAmmoRelated(ClientObject obj)
|
||||
{
|
||||
uint player = _playerGuid?.Invoke() ?? 0u;
|
||||
|
|
@ -710,5 +717,6 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
|
|||
_repo.ObjectUpdated -= OnRepositoryObjectChanged;
|
||||
_repo.ObjectRemoved -= OnRepositoryObjectChanged;
|
||||
_repo.ObjectMoved -= OnRepositoryObjectMoved;
|
||||
_repo.Cleared -= OnRepositoryCleared;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
647
src/AcDream.App/World/InboundPhysicsStateController.cs
Normal file
647
src/AcDream.App/World/InboundPhysicsStateController.cs
Normal file
|
|
@ -0,0 +1,647 @@
|
|||
using AcDream.Core.Net;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Core.Physics;
|
||||
|
||||
namespace AcDream.App.World;
|
||||
|
||||
/// <summary>
|
||||
/// Update-thread owner of inbound retail physics timestamps and the latest
|
||||
/// accepted immutable spawn description. This is deliberately narrower than
|
||||
/// <c>LiveEntityRuntime</c>: it owns no renderer, local entity ID, physics body,
|
||||
/// animation, effect, or spatial-bucket resource.
|
||||
/// </summary>
|
||||
public sealed class InboundPhysicsStateController
|
||||
{
|
||||
private readonly Dictionary<uint, PhysicsTimestampGate> _gates = new();
|
||||
private readonly Dictionary<uint, WorldSession.EntitySpawn> _snapshots = new();
|
||||
|
||||
public IReadOnlyDictionary<uint, WorldSession.EntitySpawn> Snapshots => _snapshots;
|
||||
|
||||
public bool TryGetSnapshot(uint guid, out WorldSession.EntitySpawn spawn) =>
|
||||
_snapshots.TryGetValue(guid, out spawn);
|
||||
|
||||
public InboundCreateResult AcceptCreate(WorldSession.EntitySpawn incoming)
|
||||
{
|
||||
if (!_gates.TryGetValue(incoming.Guid, out PhysicsTimestampGate? gate))
|
||||
{
|
||||
gate = new PhysicsTimestampGate();
|
||||
_gates.Add(incoming.Guid, gate);
|
||||
}
|
||||
|
||||
PhysicsTimestamps timestamps = SpawnTimestamps(incoming);
|
||||
CreateObjectTimestampDisposition disposition = gate.SeedForCreateObject(
|
||||
timestamps.Position,
|
||||
timestamps.Movement,
|
||||
timestamps.State,
|
||||
timestamps.Vector,
|
||||
timestamps.Teleport,
|
||||
timestamps.ServerControlledMove,
|
||||
timestamps.ForcePosition,
|
||||
timestamps.ObjDesc,
|
||||
timestamps.Instance);
|
||||
|
||||
if (disposition is CreateObjectTimestampDisposition.StaleGeneration)
|
||||
return new InboundCreateResult(disposition, default, null, Current(gate));
|
||||
|
||||
if (disposition is not CreateObjectTimestampDisposition.ExistingGeneration
|
||||
|| !_snapshots.TryGetValue(incoming.Guid, out WorldSession.EntitySpawn retained))
|
||||
{
|
||||
_snapshots[incoming.Guid] = incoming;
|
||||
return new InboundCreateResult(disposition, incoming, null, Current(gate));
|
||||
}
|
||||
|
||||
WorldSession.EntitySpawn merged = MergeUntimestampedCreate(retained, incoming);
|
||||
_snapshots[incoming.Guid] = merged;
|
||||
return new InboundCreateResult(
|
||||
disposition,
|
||||
merged,
|
||||
incoming.Physics is null ? null : BuildSameGenerationEvents(incoming),
|
||||
Current(gate));
|
||||
}
|
||||
|
||||
public bool TryDelete(DeleteObject.Parsed delete, bool isLocalPlayer)
|
||||
{
|
||||
if (!_gates.TryGetValue(delete.Guid, out PhysicsTimestampGate? gate))
|
||||
return false;
|
||||
|
||||
if (!gate.TryAcceptDeleteEvent(delete.InstanceSequence, isLocalPlayer))
|
||||
return false;
|
||||
|
||||
_gates.Remove(delete.Guid);
|
||||
_snapshots.Remove(delete.Guid);
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool TryApplyObjDesc(
|
||||
ObjDescEvent.Parsed update,
|
||||
out WorldSession.EntitySpawn accepted)
|
||||
{
|
||||
if (!TryGet(update.Guid, out PhysicsTimestampGate? gate, out WorldSession.EntitySpawn old)
|
||||
|| !gate.TryAcceptObjDescEvent(update.InstanceSequence, update.ObjDescSequence))
|
||||
{
|
||||
accepted = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
PhysicsSpawnData? physics = old.Physics;
|
||||
if (physics is { } desc)
|
||||
physics = desc with
|
||||
{
|
||||
Timestamps = desc.Timestamps with { ObjDesc = update.ObjDescSequence },
|
||||
};
|
||||
|
||||
accepted = old with
|
||||
{
|
||||
AnimPartChanges = update.ModelData.AnimPartChanges,
|
||||
TextureChanges = update.ModelData.TextureChanges,
|
||||
SubPalettes = update.ModelData.SubPalettes,
|
||||
BasePaletteId = update.ModelData.BasePaletteId,
|
||||
Physics = physics,
|
||||
};
|
||||
_snapshots[update.Guid] = accepted;
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool TryApplyPickup(
|
||||
PickupEvent.Parsed update,
|
||||
out WorldSession.EntitySpawn accepted)
|
||||
{
|
||||
if (!TryGet(update.Guid, out PhysicsTimestampGate? gate, out WorldSession.EntitySpawn old)
|
||||
|| !gate.TryAcceptPositionChannelEvent(
|
||||
update.InstanceSequence, update.PositionSequence))
|
||||
{
|
||||
accepted = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
accepted = ApplyUnparentedPosition(old, null, update.PositionSequence);
|
||||
_snapshots[update.Guid] = accepted;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies the parent branch embedded in a same-generation PhysicsDesc.
|
||||
/// Unlike standalone ParentEvent it carries no parent INSTANCE_TS, so only
|
||||
/// the child's shared POSITION_TS participates in freshness.
|
||||
/// </summary>
|
||||
public bool TryApplyCreateParent(
|
||||
CreateParentUpdate update,
|
||||
out WorldSession.EntitySpawn accepted)
|
||||
{
|
||||
if (!TryGet(update.ChildGuid, out PhysicsTimestampGate? childGate, out WorldSession.EntitySpawn child)
|
||||
|| !childGate.TryAcceptPositionChannelEvent(
|
||||
update.ChildInstanceSequence, update.ChildPositionSequence))
|
||||
{
|
||||
accepted = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
accepted = ApplyParent(
|
||||
child,
|
||||
update.ParentGuid,
|
||||
update.ParentLocation,
|
||||
update.PlacementId,
|
||||
update.ChildPositionSequence);
|
||||
_snapshots[update.ChildGuid] = accepted;
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool TryApplyParent(
|
||||
ParentEvent.Parsed update,
|
||||
out WorldSession.EntitySpawn accepted)
|
||||
{
|
||||
if (!_gates.TryGetValue(update.ParentGuid, out PhysicsTimestampGate? parentGate)
|
||||
|| !parentGate.IsCurrentInstance(update.ParentInstanceSequence)
|
||||
|| !TryGet(update.ChildGuid, out PhysicsTimestampGate? childGate, out WorldSession.EntitySpawn child)
|
||||
|| !childGate.TryAcceptPositionChannelEvent(
|
||||
childGate.InstanceTimestamp, update.ChildPositionSequence))
|
||||
{
|
||||
accepted = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
accepted = ApplyParent(
|
||||
child,
|
||||
update.ParentGuid,
|
||||
update.ParentLocation,
|
||||
update.PlacementId,
|
||||
update.ChildPositionSequence);
|
||||
_snapshots[update.ChildGuid] = accepted;
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool TryApplyMotion(
|
||||
WorldSession.EntityMotionUpdate update,
|
||||
bool retainPayload,
|
||||
out WorldSession.EntitySpawn accepted,
|
||||
out AcceptedPhysicsTimestamps timestamps)
|
||||
{
|
||||
if (!TryGet(update.Guid, out PhysicsTimestampGate? gate, out WorldSession.EntitySpawn old))
|
||||
{
|
||||
accepted = default;
|
||||
timestamps = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
bool applyPayload = gate.TryAcceptMovementEvent(
|
||||
update.InstanceSequence,
|
||||
update.MovementSequence,
|
||||
update.ServerControlSequence);
|
||||
timestamps = Current(gate);
|
||||
WorldSession.EntitySpawn stamped = MirrorGateTimestamps(old, gate) with
|
||||
{
|
||||
MovementSequence = gate.MovementTimestamp,
|
||||
ServerControlSequence = gate.ServerControlledMoveTimestamp,
|
||||
};
|
||||
_snapshots[update.Guid] = stamped;
|
||||
|
||||
// Retail consumes MOVEMENT_TS before it discovers that the
|
||||
// SERVER_CONTROLLED_MOVE_TS is stale. Preserve that timestamp-only
|
||||
// mutation in the canonical snapshot even though no motion payload
|
||||
// is applied.
|
||||
if (!applyPayload)
|
||||
{
|
||||
accepted = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!retainPayload)
|
||||
{
|
||||
accepted = stamped;
|
||||
return true;
|
||||
}
|
||||
|
||||
PhysicsSpawnData? physics = stamped.Physics;
|
||||
if (physics is { } desc)
|
||||
physics = desc with
|
||||
{
|
||||
Movement = new PhysicsMovementData(
|
||||
ReadOnlyMemory<byte>.Empty,
|
||||
update.MotionState,
|
||||
update.IsAutonomous),
|
||||
Timestamps = desc.Timestamps with
|
||||
{
|
||||
Movement = gate.MovementTimestamp,
|
||||
ServerControlledMove = gate.ServerControlledMoveTimestamp,
|
||||
},
|
||||
};
|
||||
|
||||
accepted = stamped with
|
||||
{
|
||||
MotionState = update.MotionState,
|
||||
Physics = physics,
|
||||
};
|
||||
_snapshots[update.Guid] = accepted;
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool TryApplyVector(
|
||||
VectorUpdate.Parsed update,
|
||||
out WorldSession.EntitySpawn accepted)
|
||||
{
|
||||
if (!TryGet(update.Guid, out PhysicsTimestampGate? gate, out WorldSession.EntitySpawn old)
|
||||
|| !gate.TryAcceptVectorEvent(update.InstanceSequence, update.VectorSequence))
|
||||
{
|
||||
accepted = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
PhysicsSpawnData? physics = old.Physics;
|
||||
if (physics is { } desc)
|
||||
physics = desc with
|
||||
{
|
||||
Velocity = update.Velocity,
|
||||
AngularVelocity = update.Omega,
|
||||
Timestamps = desc.Timestamps with { Vector = update.VectorSequence },
|
||||
};
|
||||
|
||||
accepted = old with { Physics = physics };
|
||||
_snapshots[update.Guid] = accepted;
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool TryApplyState(
|
||||
SetState.Parsed update,
|
||||
out WorldSession.EntitySpawn accepted)
|
||||
{
|
||||
if (!TryGet(update.Guid, out PhysicsTimestampGate? gate, out WorldSession.EntitySpawn old)
|
||||
|| !gate.TryAcceptStateEvent(update.InstanceSequence, update.StateSequence))
|
||||
{
|
||||
accepted = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
PhysicsSpawnData? physics = old.Physics;
|
||||
if (physics is { } desc)
|
||||
physics = desc with
|
||||
{
|
||||
RawState = update.PhysicsState,
|
||||
Timestamps = desc.Timestamps with { State = update.StateSequence },
|
||||
};
|
||||
|
||||
accepted = old with
|
||||
{
|
||||
PhysicsState = update.PhysicsState,
|
||||
Physics = physics,
|
||||
};
|
||||
_snapshots[update.Guid] = accepted;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true when the addressed live incarnation exists, even when the
|
||||
/// position payload is rejected. This lets callers publish a freshly
|
||||
/// consumed FORCE_POSITION_TS without applying a stale pose.
|
||||
/// </summary>
|
||||
public bool TryApplyPosition(
|
||||
WorldSession.EntityPositionUpdate update,
|
||||
bool isLocalPlayer,
|
||||
System.Numerics.Quaternion? forcePositionRotation,
|
||||
System.Numerics.Vector3? currentLocalVelocity,
|
||||
out PositionTimestampDisposition disposition,
|
||||
out WorldSession.EntitySpawn accepted,
|
||||
out AcceptedPhysicsTimestamps timestamps)
|
||||
{
|
||||
if (!TryGet(update.Guid, out PhysicsTimestampGate? gate, out WorldSession.EntitySpawn old))
|
||||
{
|
||||
disposition = PositionTimestampDisposition.Rejected;
|
||||
accepted = default;
|
||||
timestamps = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
bool advancesTeleport = PhysicsTimestampGate.IsNewer(
|
||||
gate.TeleportTimestamp,
|
||||
update.TeleportSequence);
|
||||
disposition = gate.TryAcceptPositionEvent(
|
||||
update.InstanceSequence,
|
||||
update.PositionSequence,
|
||||
update.TeleportSequence,
|
||||
update.ForcePositionSequence,
|
||||
isLocalPlayer);
|
||||
timestamps = Current(gate);
|
||||
if (disposition is PositionTimestampDisposition.Rejected)
|
||||
{
|
||||
accepted = MirrorGateTimestamps(old, gate);
|
||||
_snapshots[update.Guid] = accepted;
|
||||
return true;
|
||||
}
|
||||
|
||||
CreateObject.ServerPosition appliedPosition = update.Position;
|
||||
if (disposition is PositionTimestampDisposition.ForcePosition
|
||||
&& forcePositionRotation is { } preserved)
|
||||
{
|
||||
appliedPosition = update.Position with
|
||||
{
|
||||
RotationW = preserved.W,
|
||||
RotationX = preserved.X,
|
||||
RotationY = preserved.Y,
|
||||
RotationZ = preserved.Z,
|
||||
};
|
||||
}
|
||||
|
||||
// PositionPack::UnPack (0x00516740) initializes an absent placement
|
||||
// id to zero; HandleReceivedPosition (0x00453FD0) forwards that exact
|
||||
// value to SetPlacementFrame on a normal accepted update.
|
||||
uint? appliedPlacement = disposition is PositionTimestampDisposition.Apply
|
||||
? update.PlacementId ?? 0u
|
||||
: old.PlacementId;
|
||||
|
||||
System.Numerics.Vector3? appliedVelocity = disposition switch
|
||||
{
|
||||
// ForcePosition returns immediately after BlipPlayer and retains
|
||||
// the local physics object's velocity.
|
||||
PositionTimestampDisposition.ForcePosition =>
|
||||
currentLocalVelocity ?? old.Physics?.Velocity,
|
||||
|
||||
// A fresh local teleport explicitly installs zero velocity. A
|
||||
// normal local correction does not consume PositionPack velocity.
|
||||
PositionTimestampDisposition.Apply when isLocalPlayer =>
|
||||
advancesTeleport
|
||||
? System.Numerics.Vector3.Zero
|
||||
: currentLocalVelocity ?? old.Physics?.Velocity,
|
||||
|
||||
// Remote MoveOrTeleport receives the unpacked vector; an absent
|
||||
// PositionPack velocity is initialized to zero by retail.
|
||||
PositionTimestampDisposition.Apply =>
|
||||
update.Velocity ?? System.Numerics.Vector3.Zero,
|
||||
|
||||
_ => old.Physics?.Velocity,
|
||||
};
|
||||
PhysicsSpawnData? physics = old.Physics;
|
||||
if (physics is { } desc)
|
||||
physics = desc with
|
||||
{
|
||||
Position = appliedPosition,
|
||||
AnimationFrame = appliedPlacement,
|
||||
Velocity = appliedVelocity,
|
||||
Parent = null,
|
||||
Timestamps = desc.Timestamps with
|
||||
{
|
||||
Position = gate.PositionTimestamp,
|
||||
Teleport = gate.TeleportTimestamp,
|
||||
ForcePosition = gate.ForcePositionTimestamp,
|
||||
},
|
||||
};
|
||||
|
||||
accepted = old with
|
||||
{
|
||||
Position = appliedPosition,
|
||||
PositionSequence = gate.PositionTimestamp,
|
||||
ParentGuid = null,
|
||||
ParentLocation = null,
|
||||
PlacementId = appliedPlacement,
|
||||
Physics = physics,
|
||||
};
|
||||
_snapshots[update.Guid] = accepted;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// F751 is a notification gate only. Retail compares it to TELEPORT_TS but
|
||||
/// advances that timestamp later, with the accepted Position packet.
|
||||
/// </summary>
|
||||
public bool IsFreshTeleportStart(uint localPlayerGuid, ushort teleportSequence) =>
|
||||
_gates.TryGetValue(localPlayerGuid, out PhysicsTimestampGate? gate)
|
||||
&& gate.IsFreshTeleportStart(teleportSequence);
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
_gates.Clear();
|
||||
_snapshots.Clear();
|
||||
}
|
||||
|
||||
private bool TryGet(
|
||||
uint guid,
|
||||
out PhysicsTimestampGate gate,
|
||||
out WorldSession.EntitySpawn spawn)
|
||||
{
|
||||
if (_gates.TryGetValue(guid, out gate!)
|
||||
&& _snapshots.TryGetValue(guid, out spawn))
|
||||
return true;
|
||||
|
||||
gate = null!;
|
||||
spawn = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static PhysicsTimestamps SpawnTimestamps(WorldSession.EntitySpawn spawn) =>
|
||||
spawn.Physics?.Timestamps
|
||||
?? new PhysicsTimestamps(
|
||||
spawn.PositionSequence,
|
||||
spawn.MovementSequence,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
spawn.ServerControlSequence,
|
||||
0,
|
||||
0,
|
||||
spawn.InstanceSequence);
|
||||
|
||||
private static AcceptedPhysicsTimestamps Current(PhysicsTimestampGate gate) => new(
|
||||
gate.InstanceTimestamp,
|
||||
gate.ServerControlledMoveTimestamp,
|
||||
gate.TeleportTimestamp,
|
||||
gate.ForcePositionTimestamp);
|
||||
|
||||
private static WorldSession.EntitySpawn MirrorGateTimestamps(
|
||||
WorldSession.EntitySpawn spawn,
|
||||
PhysicsTimestampGate gate)
|
||||
{
|
||||
if (spawn.Physics is not { } desc)
|
||||
return spawn;
|
||||
|
||||
return spawn with
|
||||
{
|
||||
Physics = desc with
|
||||
{
|
||||
Timestamps = new PhysicsTimestamps(
|
||||
gate.PositionTimestamp,
|
||||
gate.MovementTimestamp,
|
||||
gate.StateTimestamp,
|
||||
gate.VectorTimestamp,
|
||||
gate.TeleportTimestamp,
|
||||
gate.ServerControlledMoveTimestamp,
|
||||
gate.ForcePositionTimestamp,
|
||||
gate.ObjDescTimestamp,
|
||||
gate.InstanceTimestamp),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static WorldSession.EntitySpawn MergeUntimestampedCreate(
|
||||
WorldSession.EntitySpawn retained,
|
||||
WorldSession.EntitySpawn incoming) =>
|
||||
incoming with
|
||||
{
|
||||
Position = retained.Position,
|
||||
SetupTableId = retained.SetupTableId,
|
||||
AnimPartChanges = retained.AnimPartChanges,
|
||||
TextureChanges = retained.TextureChanges,
|
||||
SubPalettes = retained.SubPalettes,
|
||||
BasePaletteId = retained.BasePaletteId,
|
||||
ObjScale = retained.ObjScale,
|
||||
MotionState = retained.MotionState,
|
||||
MotionTableId = retained.MotionTableId,
|
||||
PhysicsState = retained.PhysicsState,
|
||||
Friction = retained.Friction,
|
||||
Elasticity = retained.Elasticity,
|
||||
InstanceSequence = retained.InstanceSequence,
|
||||
MovementSequence = retained.MovementSequence,
|
||||
ServerControlSequence = retained.ServerControlSequence,
|
||||
PositionSequence = retained.PositionSequence,
|
||||
ParentGuid = retained.ParentGuid,
|
||||
ParentLocation = retained.ParentLocation,
|
||||
PlacementId = retained.PlacementId,
|
||||
Physics = retained.Physics,
|
||||
};
|
||||
|
||||
private static SameGenerationCreateObjectEvents BuildSameGenerationEvents(
|
||||
WorldSession.EntitySpawn incoming)
|
||||
{
|
||||
PhysicsSpawnData physics = incoming.Physics!.Value;
|
||||
PhysicsTimestamps ts = physics.Timestamps;
|
||||
|
||||
CreateParentUpdate? parent = physics.Parent is { } attachment
|
||||
? new CreateParentUpdate(
|
||||
incoming.Guid,
|
||||
attachment.Guid,
|
||||
attachment.LocationId,
|
||||
physics.AnimationFrame ?? 0u,
|
||||
ts.Instance,
|
||||
ts.Position)
|
||||
: null;
|
||||
WorldSession.EntityPositionUpdate? position = parent is null
|
||||
&& physics.Position is { } p
|
||||
&& p.LandblockId != 0
|
||||
? new WorldSession.EntityPositionUpdate(
|
||||
incoming.Guid,
|
||||
p,
|
||||
physics.Velocity,
|
||||
physics.AnimationFrame,
|
||||
IsGrounded: true,
|
||||
ts.Instance,
|
||||
ts.Position,
|
||||
ts.Teleport,
|
||||
ts.ForcePosition)
|
||||
: null;
|
||||
PickupEvent.Parsed? pickup = parent is null && position is null
|
||||
? new PickupEvent.Parsed(incoming.Guid, ts.Instance, ts.Position)
|
||||
: null;
|
||||
WorldSession.EntityMotionUpdate? movement =
|
||||
physics.Movement is { } movementData
|
||||
&& !movementData.RawData.IsEmpty
|
||||
&& movementData.MotionState is { } motionState
|
||||
? new WorldSession.EntityMotionUpdate(
|
||||
incoming.Guid,
|
||||
motionState,
|
||||
ts.Instance,
|
||||
ts.Movement,
|
||||
ts.ServerControlledMove,
|
||||
movementData.IsAutonomous is true)
|
||||
: null;
|
||||
|
||||
return new SameGenerationCreateObjectEvents(
|
||||
new ObjDescEvent.Parsed(
|
||||
incoming.Guid,
|
||||
new CreateObject.ModelData(
|
||||
incoming.BasePaletteId,
|
||||
incoming.SubPalettes,
|
||||
incoming.TextureChanges,
|
||||
incoming.AnimPartChanges),
|
||||
ts.Instance,
|
||||
ts.ObjDesc),
|
||||
parent,
|
||||
position,
|
||||
pickup,
|
||||
movement,
|
||||
new SetState.Parsed(incoming.Guid, physics.RawState, ts.Instance, ts.State),
|
||||
new VectorUpdate.Parsed(
|
||||
incoming.Guid,
|
||||
physics.Velocity ?? System.Numerics.Vector3.Zero,
|
||||
physics.AngularVelocity ?? System.Numerics.Vector3.Zero,
|
||||
ts.Instance,
|
||||
ts.Vector));
|
||||
}
|
||||
|
||||
private static WorldSession.EntitySpawn ApplyUnparentedPosition(
|
||||
WorldSession.EntitySpawn old,
|
||||
CreateObject.ServerPosition? position,
|
||||
ushort positionSequence)
|
||||
{
|
||||
PhysicsSpawnData? physics = old.Physics;
|
||||
if (physics is { } desc)
|
||||
physics = desc with
|
||||
{
|
||||
Position = position,
|
||||
Parent = null,
|
||||
Timestamps = desc.Timestamps with { Position = positionSequence },
|
||||
};
|
||||
|
||||
return old with
|
||||
{
|
||||
Position = position,
|
||||
ParentGuid = null,
|
||||
ParentLocation = null,
|
||||
PositionSequence = positionSequence,
|
||||
Physics = physics,
|
||||
};
|
||||
}
|
||||
|
||||
private static WorldSession.EntitySpawn ApplyParent(
|
||||
WorldSession.EntitySpawn old,
|
||||
uint parentGuid,
|
||||
uint parentLocation,
|
||||
uint placementId,
|
||||
ushort positionSequence)
|
||||
{
|
||||
PhysicsSpawnData? physics = old.Physics;
|
||||
if (physics is { } desc)
|
||||
physics = desc with
|
||||
{
|
||||
Position = null,
|
||||
Parent = new PhysicsAttachment(parentGuid, parentLocation),
|
||||
AnimationFrame = placementId,
|
||||
Timestamps = desc.Timestamps with { Position = positionSequence },
|
||||
};
|
||||
|
||||
return old with
|
||||
{
|
||||
Position = null,
|
||||
ParentGuid = parentGuid,
|
||||
ParentLocation = parentLocation,
|
||||
PlacementId = placementId,
|
||||
PositionSequence = positionSequence,
|
||||
Physics = physics,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public readonly record struct AcceptedPhysicsTimestamps(
|
||||
ushort Instance,
|
||||
ushort ServerControlledMove,
|
||||
ushort Teleport,
|
||||
ushort ForcePosition);
|
||||
|
||||
public readonly record struct CreateParentUpdate(
|
||||
uint ChildGuid,
|
||||
uint ParentGuid,
|
||||
uint ParentLocation,
|
||||
uint PlacementId,
|
||||
ushort ChildInstanceSequence,
|
||||
ushort ChildPositionSequence);
|
||||
|
||||
public readonly record struct SameGenerationCreateObjectEvents(
|
||||
ObjDescEvent.Parsed Appearance,
|
||||
CreateParentUpdate? Parent,
|
||||
WorldSession.EntityPositionUpdate? Position,
|
||||
PickupEvent.Parsed? Pickup,
|
||||
WorldSession.EntityMotionUpdate? Movement,
|
||||
SetState.Parsed State,
|
||||
VectorUpdate.Parsed Vector);
|
||||
|
||||
public readonly record struct InboundCreateResult(
|
||||
CreateObjectTimestampDisposition Disposition,
|
||||
WorldSession.EntitySpawn Snapshot,
|
||||
SameGenerationCreateObjectEvents? SameGenerationEvents,
|
||||
AcceptedPhysicsTimestamps Timestamps);
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
using System.Buffers.Binary;
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
|
||||
namespace AcDream.Core.Net.Messages;
|
||||
|
||||
|
|
@ -10,8 +11,9 @@ namespace AcDream.Core.Net.Messages;
|
|||
/// weenies like the Holtburg foundry statue) in the client's loaded area.
|
||||
///
|
||||
/// <para>
|
||||
/// acdream's parser extracts only the fields needed to hand the spawn off
|
||||
/// to <c>IGameState</c>:
|
||||
/// The parser preserves the complete PhysicsDesc needed to construct a retail
|
||||
/// physics object, while retaining legacy convenience fields during the
|
||||
/// LiveEntityRuntime migration:
|
||||
/// </para>
|
||||
/// <list type="bullet">
|
||||
/// <item><b>GUID</b> — always at the start of the body (after the opcode).</item>
|
||||
|
|
@ -25,13 +27,9 @@ namespace AcDream.Core.Net.Messages;
|
|||
/// </list>
|
||||
///
|
||||
/// <para>
|
||||
/// Most other fields (extended weenie header, object description, motion tables,
|
||||
/// palettes, texture overrides, animation frames, velocity, ...) are
|
||||
/// consumed-but-ignored so the parse position ends up wherever the
|
||||
/// client-side caller wanted — a <c>Parse</c> call doesn't need to reach
|
||||
/// the end of the body to return useful output. We read through the fixed
|
||||
/// WeenieHeader prefix for Name/ItemType, then stop before optional header
|
||||
/// tails.
|
||||
/// Every PhysicsDesc field is captured, including presence-preserving zero
|
||||
/// values and all nine timestamps. The PublicWeenieDesc parser continues to
|
||||
/// retain its supported presentation/gameplay subset.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
|
|
@ -120,7 +118,7 @@ public static class CreateObject
|
|||
ushort ServerControlSequence = 0,
|
||||
ushort ForcePositionSequence = 0,
|
||||
// L.2g S1 (DEV-6): ObjectMovement stamp (timestamp block index 1)
|
||||
// seeds MotionSequenceGate's MOVEMENT_TS at spawn.
|
||||
// seeds PhysicsTimestampGate's MOVEMENT_TS at spawn.
|
||||
ushort MovementSequence = 0,
|
||||
// Parent/placement bootstrap for equipped child objects. These are
|
||||
// the CreateObject equivalents of ParentEvent 0xF749.
|
||||
|
|
@ -208,7 +206,11 @@ public static class CreateObject
|
|||
uint? PetOwnerId = null,
|
||||
// PublicWeenieDesc._ammoType, gated by WeenieHeader flag 0x100.
|
||||
// AMMO_NONE is the explicit wire value zero; null means absent.
|
||||
ushort? AmmoType = null);
|
||||
ushort? AmmoType = null,
|
||||
// Complete immutable PhysicsDesc projection. Kept alongside the
|
||||
// legacy convenience fields while live-entity ownership migrates to
|
||||
// LiveEntityRuntime in Step 2.
|
||||
PhysicsSpawnData? Physics = null);
|
||||
|
||||
/// <summary>
|
||||
/// The relevant subset of the server-sent <c>MovementData</c> /
|
||||
|
|
@ -479,8 +481,8 @@ public static class CreateObject
|
|||
/// Parse a reassembled CreateObject body. <paramref name="body"/> must
|
||||
/// start with the 4-byte opcode. Returns <c>null</c> if the body is
|
||||
/// malformed (truncated field); returns a populated <see cref="Parsed"/>
|
||||
/// on success. The parser stops at the end of PhysicsData; subsequent
|
||||
/// weenie-header fields are deliberately not consumed.
|
||||
/// on success after consuming the supported PhysicsDesc and public
|
||||
/// WeenieHeader fields.
|
||||
/// </summary>
|
||||
public static Parsed? TryParse(ReadOnlySpan<byte> body)
|
||||
{
|
||||
|
|
@ -492,6 +494,17 @@ public static class CreateObject
|
|||
float? objScale = null;
|
||||
ServerMotionState? motionState = null;
|
||||
uint? motionTableId = null;
|
||||
PhysicsMovementData? movement = null;
|
||||
uint? soundTableId = null;
|
||||
uint? physicsScriptTableId = null;
|
||||
ReadOnlyMemory<PhysicsAttachment>? children = null;
|
||||
float? translucency = null;
|
||||
Vector3? velocity = null;
|
||||
Vector3? acceleration = null;
|
||||
Vector3? angularVelocity = null;
|
||||
uint? defaultScriptType = null;
|
||||
float? defaultScriptIntensity = null;
|
||||
PhysicsSpawnData? physics = null;
|
||||
// Commit A 2026-04-29 — live-entity collision plumbing. PhysicsState
|
||||
// (acclient.h:2815) was previously skipped at line ~337; the PWD
|
||||
// _bitfield (acclient.h:6431-6463) was previously discarded as
|
||||
|
|
@ -547,11 +560,16 @@ public static class CreateObject
|
|||
{
|
||||
if (body.Length - pos < (int)movementLen) return null;
|
||||
int movementStart = pos;
|
||||
motionState = TryParseMovementData(body.Slice(movementStart, (int)movementLen));
|
||||
ReadOnlySpan<byte> movementBytes = body.Slice(movementStart, (int)movementLen);
|
||||
motionState = TryParseMovementData(movementBytes);
|
||||
pos = movementStart + (int)movementLen;
|
||||
if (body.Length - pos < 4) return null;
|
||||
pos += 4; // isAutonomous u32
|
||||
bool isAutonomous = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos)) != 0;
|
||||
pos += 4;
|
||||
movement = new PhysicsMovementData(movementBytes.ToArray(), motionState, isAutonomous);
|
||||
}
|
||||
else
|
||||
movement = new PhysicsMovementData(ReadOnlyMemory<byte>.Empty, null, null);
|
||||
}
|
||||
else if ((physicsFlags & PhysicsDescriptionFlag.AnimationFrame) != 0)
|
||||
{
|
||||
|
|
@ -585,12 +603,14 @@ public static class CreateObject
|
|||
if ((physicsFlags & PhysicsDescriptionFlag.STable) != 0)
|
||||
{
|
||||
if (body.Length - pos < 4) return null;
|
||||
soundTableId = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
|
||||
pos += 4;
|
||||
}
|
||||
|
||||
if ((physicsFlags & PhysicsDescriptionFlag.PeTable) != 0)
|
||||
{
|
||||
if (body.Length - pos < 4) return null;
|
||||
physicsScriptTableId = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
|
||||
pos += 4;
|
||||
}
|
||||
|
||||
|
|
@ -616,19 +636,27 @@ public static class CreateObject
|
|||
if (body.Length - pos < 4) return null;
|
||||
int childCount = BinaryPrimitives.ReadInt32LittleEndian(body.Slice(pos));
|
||||
pos += 4;
|
||||
if (childCount < 0 || childCount > 1024) return PartialResult();
|
||||
if (childCount < 0 || childCount > 1024) return null;
|
||||
if (body.Length - pos < childCount * 8) return null;
|
||||
pos += childCount * 8; // each child = guid u32 + locationId u32
|
||||
var parsedChildren = new PhysicsAttachment[childCount];
|
||||
for (int i = 0; i < parsedChildren.Length; i++)
|
||||
{
|
||||
parsedChildren[i] = new PhysicsAttachment(
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos)),
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos + 4)));
|
||||
pos += 8;
|
||||
}
|
||||
children = parsedChildren;
|
||||
}
|
||||
if ((physicsFlags & PhysicsDescriptionFlag.ObjScale) != 0)
|
||||
{
|
||||
if (body.Length - pos < 4) return PartialResult();
|
||||
if (body.Length - pos < 4) return null;
|
||||
objScale = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
|
||||
pos += 4;
|
||||
}
|
||||
if ((physicsFlags & PhysicsDescriptionFlag.Friction) != 0)
|
||||
{
|
||||
if (body.Length - pos < 4) return PartialResult();
|
||||
if (body.Length - pos < 4) return null;
|
||||
friction = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
|
||||
pos += 4;
|
||||
}
|
||||
|
|
@ -641,31 +669,89 @@ public static class CreateObject
|
|||
// Was previously dropped — every object got the default
|
||||
// 0.05f, so server-set bouncier surfaces felt identical to
|
||||
// walls.
|
||||
if (body.Length - pos < 4) return PartialResult();
|
||||
if (body.Length - pos < 4) return null;
|
||||
elasticity = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
|
||||
pos += 4;
|
||||
}
|
||||
if ((physicsFlags & PhysicsDescriptionFlag.Translucency) != 0) pos += 4;
|
||||
if ((physicsFlags & PhysicsDescriptionFlag.Velocity) != 0) pos += 12; // vec3
|
||||
if ((physicsFlags & PhysicsDescriptionFlag.Acceleration) != 0) pos += 12;
|
||||
if ((physicsFlags & PhysicsDescriptionFlag.Omega) != 0) pos += 12;
|
||||
if ((physicsFlags & PhysicsDescriptionFlag.DefaultScript) != 0) pos += 4;
|
||||
if ((physicsFlags & PhysicsDescriptionFlag.DefaultScriptIntensity) != 0) pos += 4;
|
||||
if ((physicsFlags & PhysicsDescriptionFlag.Translucency) != 0)
|
||||
{
|
||||
if (body.Length - pos < 4) return null;
|
||||
translucency = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
|
||||
pos += 4;
|
||||
}
|
||||
if ((physicsFlags & PhysicsDescriptionFlag.Velocity) != 0)
|
||||
{
|
||||
if (!TryReadVector3(body, ref pos, out Vector3 value)) return null;
|
||||
velocity = value;
|
||||
}
|
||||
if ((physicsFlags & PhysicsDescriptionFlag.Acceleration) != 0)
|
||||
{
|
||||
if (!TryReadVector3(body, ref pos, out Vector3 value)) return null;
|
||||
acceleration = value;
|
||||
}
|
||||
if ((physicsFlags & PhysicsDescriptionFlag.Omega) != 0)
|
||||
{
|
||||
if (!TryReadVector3(body, ref pos, out Vector3 value)) return null;
|
||||
angularVelocity = value;
|
||||
}
|
||||
if ((physicsFlags & PhysicsDescriptionFlag.DefaultScript) != 0)
|
||||
{
|
||||
if (body.Length - pos < 4) return null;
|
||||
defaultScriptType = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
|
||||
pos += 4;
|
||||
}
|
||||
if ((physicsFlags & PhysicsDescriptionFlag.DefaultScriptIntensity) != 0)
|
||||
{
|
||||
if (body.Length - pos < 4) return null;
|
||||
defaultScriptIntensity = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
|
||||
pos += 4;
|
||||
}
|
||||
|
||||
// 9 sequence timestamps, always present at end of PhysicsData.
|
||||
// PhysicsTimeStamp enum order (acclient.h:6084; ACE
|
||||
// WorldObject_Networking.cs:411-420): 0=position, 1=movement,
|
||||
// 4=teleport, 5=serverControl, 6=forcePosition, 8=instance.
|
||||
if (body.Length - pos < 9 * 2) return PartialResult();
|
||||
if (body.Length - pos < 9 * 2) return null;
|
||||
var seqSpan = body.Slice(pos, 9 * 2);
|
||||
ushort instanceSeq = BinaryPrimitives.ReadUInt16LittleEndian(seqSpan.Slice(8 * 2));
|
||||
ushort positionSeq = BinaryPrimitives.ReadUInt16LittleEndian(seqSpan.Slice(0 * 2));
|
||||
ushort movementSeq = BinaryPrimitives.ReadUInt16LittleEndian(seqSpan.Slice(1 * 2));
|
||||
ushort stateSeq = BinaryPrimitives.ReadUInt16LittleEndian(seqSpan.Slice(2 * 2));
|
||||
ushort vectorSeq = BinaryPrimitives.ReadUInt16LittleEndian(seqSpan.Slice(3 * 2));
|
||||
ushort teleportSeq = BinaryPrimitives.ReadUInt16LittleEndian(seqSpan.Slice(4 * 2));
|
||||
ushort serverControlSeq = BinaryPrimitives.ReadUInt16LittleEndian(seqSpan.Slice(5 * 2));
|
||||
ushort forcePositionSeq = BinaryPrimitives.ReadUInt16LittleEndian(seqSpan.Slice(6 * 2));
|
||||
ushort objDescSeq = BinaryPrimitives.ReadUInt16LittleEndian(seqSpan.Slice(7 * 2));
|
||||
ushort instanceSeq = BinaryPrimitives.ReadUInt16LittleEndian(seqSpan.Slice(8 * 2));
|
||||
pos += 9 * 2;
|
||||
AlignTo4(ref pos);
|
||||
if (pos > body.Length) return null;
|
||||
|
||||
var timestamps = new PhysicsTimestamps(
|
||||
positionSeq, movementSeq, stateSeq, vectorSeq, teleportSeq,
|
||||
serverControlSeq, forcePositionSeq, objDescSeq, instanceSeq);
|
||||
physics = new PhysicsSpawnData(
|
||||
RawState: physicsState.Value,
|
||||
Position: position,
|
||||
Movement: movement,
|
||||
AnimationFrame: placementId,
|
||||
SetupTableId: setupTableId,
|
||||
MotionTableId: motionTableId,
|
||||
SoundTableId: soundTableId,
|
||||
PhysicsScriptTableId: physicsScriptTableId,
|
||||
Parent: parentGuid.HasValue && parentLocation.HasValue
|
||||
? new PhysicsAttachment(parentGuid.Value, parentLocation.Value)
|
||||
: null,
|
||||
Children: children,
|
||||
Scale: objScale,
|
||||
Friction: friction,
|
||||
Elasticity: elasticity,
|
||||
Translucency: translucency,
|
||||
Velocity: velocity,
|
||||
Acceleration: acceleration,
|
||||
AngularVelocity: angularVelocity,
|
||||
DefaultScriptType: defaultScriptType,
|
||||
DefaultScriptIntensity: defaultScriptIntensity,
|
||||
Timestamps: timestamps);
|
||||
|
||||
// --- WeenieHeader: read the fixed prefix fields we need. ---
|
||||
// ACE WorldObject_Networking.SerializeCreateObject writes:
|
||||
|
|
@ -1026,17 +1112,8 @@ public static class CreateObject
|
|||
CombatUse: combatUse,
|
||||
PluralName: pluralName,
|
||||
PetOwnerId: petOwnerId,
|
||||
AmmoType: ammoType);
|
||||
|
||||
// Local helper: if we ran out of fields past PhysicsData, still
|
||||
// return the useful prefix (guid/position/setup/animParts/textures/palettes/scale/motion).
|
||||
Parsed PartialResult() => new(
|
||||
guid, position, setupTableId, animParts,
|
||||
textureChanges, subPalettes, basePaletteId, objScale, null, null, motionState, motionTableId,
|
||||
PhysicsState: physicsState,
|
||||
ObjectDescriptionFlags: objectDescriptionFlags,
|
||||
Friction: friction,
|
||||
Elasticity: elasticity);
|
||||
AmmoType: ammoType,
|
||||
Physics: physics);
|
||||
}
|
||||
catch
|
||||
{
|
||||
|
|
@ -1391,4 +1468,20 @@ public static class CreateObject
|
|||
runRate = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos));
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryReadVector3(ReadOnlySpan<byte> body, ref int pos, out Vector3 value)
|
||||
{
|
||||
if (body.Length - pos < 12)
|
||||
{
|
||||
value = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
value = new Vector3(
|
||||
BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos)),
|
||||
BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos + 4)),
|
||||
BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos + 8)));
|
||||
pos += 12;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,23 +17,14 @@ public static class DeleteObject
|
|||
{
|
||||
public const uint Opcode = 0xF747u;
|
||||
|
||||
/// <param name="FromPickup">
|
||||
/// <see langword="true"/> when this delete was sourced from a
|
||||
/// <c>PickupEvent (0xF74A)</c> — the object left the 3-D world view but
|
||||
/// the weenie record still exists (it is moving into a container).
|
||||
/// <see langword="false"/> (default) when sourced from <c>DeleteObject
|
||||
/// (0xF747)</c> — the weenie was destroyed and must be evicted from
|
||||
/// <see cref="AcDream.Core.Items.ClientObjectTable"/>.
|
||||
/// Retail two-table model: PickupEvent removes from <c>object_table</c>;
|
||||
/// DeleteObject removes from <c>weenie_object_table</c>.
|
||||
/// </param>
|
||||
public readonly record struct Parsed(uint Guid, ushort InstanceSequence, bool FromPickup = false);
|
||||
/// <summary>A true object-destruction event for one exact incarnation.</summary>
|
||||
public readonly record struct Parsed(uint Guid, ushort InstanceSequence);
|
||||
|
||||
/// <summary>
|
||||
/// Parse a 0xF747 body. <paramref name="body"/> must start with the
|
||||
/// 4-byte opcode, matching every other parser in this namespace.
|
||||
/// The returned <see cref="Parsed.FromPickup"/> is always <see langword="false"/>
|
||||
/// — this parser handles true destroys only.
|
||||
/// PickupEvent has a distinct parser and runtime event because it advances
|
||||
/// POSITION_TS and retains the logical object.
|
||||
/// </summary>
|
||||
public static Parsed? TryParse(ReadOnlySpan<byte> body)
|
||||
{
|
||||
|
|
@ -46,6 +37,6 @@ public static class DeleteObject
|
|||
|
||||
uint guid = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(4, 4));
|
||||
ushort instanceSequence = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(8, 2));
|
||||
return new Parsed(guid, instanceSequence, FromPickup: false);
|
||||
return new Parsed(guid, instanceSequence);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,8 +26,8 @@ namespace AcDream.Core.Net.Messages;
|
|||
/// <item>u32 opcode (0xF625)</item>
|
||||
/// <item>u32 guid — target object</item>
|
||||
/// <item>ModelData block — see <see cref="CreateObject.ReadModelData"/></item>
|
||||
/// <item>u32 instanceSequence</item>
|
||||
/// <item>u32 visualDescSequence</item>
|
||||
/// <item>u16 instanceSequence</item>
|
||||
/// <item>u16 visualDescSequence (OBJDESC_TS)</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public static class ObjDescEvent
|
||||
|
|
@ -35,11 +35,14 @@ public static class ObjDescEvent
|
|||
public const uint Opcode = 0xF625u;
|
||||
|
||||
/// <summary>
|
||||
/// One ObjDescEvent: target guid + the new ModelData. Sequence
|
||||
/// counters are read but not surfaced (subscribers don't need them
|
||||
/// — the event always carries the full new appearance).
|
||||
/// One ObjDescEvent: target guid, new ModelData, and retail's packed
|
||||
/// INSTANCE_TS/OBJDESC_TS pair.
|
||||
/// </summary>
|
||||
public readonly record struct Parsed(uint Guid, CreateObject.ModelData ModelData);
|
||||
public readonly record struct Parsed(
|
||||
uint Guid,
|
||||
CreateObject.ModelData ModelData,
|
||||
ushort InstanceSequence,
|
||||
ushort ObjDescSequence);
|
||||
|
||||
/// <summary>
|
||||
/// Parse an ObjDescEvent body (must start with the 4-byte opcode).
|
||||
|
|
@ -61,10 +64,13 @@ public static class ObjDescEvent
|
|||
|
||||
var modelData = CreateObject.ReadModelData(body, ref pos);
|
||||
|
||||
// Trailing instanceSeq + visualDescSeq are read for completeness
|
||||
// but not surfaced — subscribers re-render unconditionally on
|
||||
// every event since each carries the full appearance.
|
||||
return new Parsed(guid, modelData);
|
||||
// PhysicsTimestampPack::UnPack 0x00516F50 is exactly two u16s.
|
||||
// Reject a missing or overlong tail so cursor mistakes cannot turn
|
||||
// into an apparently valid, ungated appearance update.
|
||||
if (body.Length - pos != 4) return null;
|
||||
ushort instance = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos));
|
||||
ushort objDesc = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos + 2));
|
||||
return new Parsed(guid, modelData, instance, objDesc);
|
||||
}
|
||||
catch
|
||||
{
|
||||
|
|
|
|||
64
src/AcDream.Core.Net/Messages/PhysicsSpawnData.cs
Normal file
64
src/AcDream.Core.Net/Messages/PhysicsSpawnData.cs
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
using System.Numerics;
|
||||
using AcDream.Core.Physics;
|
||||
|
||||
namespace AcDream.Core.Net.Messages;
|
||||
|
||||
/// <summary>
|
||||
/// The nine retail <c>PhysicsTimeStamp</c> channels stored at the tail of a
|
||||
/// <c>PhysicsDesc</c>. Field order is verbatim from <c>acclient.h:6084</c>.
|
||||
/// </summary>
|
||||
public readonly record struct PhysicsTimestamps(
|
||||
ushort Position,
|
||||
ushort Movement,
|
||||
ushort State,
|
||||
ushort Vector,
|
||||
ushort Teleport,
|
||||
ushort ServerControlledMove,
|
||||
ushort ForcePosition,
|
||||
ushort ObjDesc,
|
||||
ushort Instance);
|
||||
|
||||
/// <summary>A parent or child attachment carried by <c>PhysicsDesc</c>.</summary>
|
||||
public readonly record struct PhysicsAttachment(uint Guid, uint LocationId);
|
||||
|
||||
/// <summary>
|
||||
/// A present Movement field. The wrapper is retained even when
|
||||
/// <see cref="RawData"/> is empty so callers can distinguish absent Movement
|
||||
/// from an explicitly present zero-length movement block.
|
||||
/// </summary>
|
||||
public readonly record struct PhysicsMovementData(
|
||||
ReadOnlyMemory<byte> RawData,
|
||||
CreateObject.ServerMotionState? MotionState,
|
||||
bool? IsAutonomous);
|
||||
|
||||
/// <summary>
|
||||
/// Immutable, lossless projection of the retail <c>PhysicsDesc</c> embedded in
|
||||
/// CreateObject (0xF745). Nullable members preserve absent versus present-zero.
|
||||
/// Network acceleration is retained for protocol fidelity; retail recalculates
|
||||
/// normal acceleration after applying the final physics state.
|
||||
/// </summary>
|
||||
public readonly record struct PhysicsSpawnData(
|
||||
uint RawState,
|
||||
CreateObject.ServerPosition? Position,
|
||||
PhysicsMovementData? Movement,
|
||||
uint? AnimationFrame,
|
||||
uint? SetupTableId,
|
||||
uint? MotionTableId,
|
||||
uint? SoundTableId,
|
||||
uint? PhysicsScriptTableId,
|
||||
PhysicsAttachment? Parent,
|
||||
ReadOnlyMemory<PhysicsAttachment>? Children,
|
||||
float? Scale,
|
||||
float? Friction,
|
||||
float? Elasticity,
|
||||
float? Translucency,
|
||||
Vector3? Velocity,
|
||||
Vector3? Acceleration,
|
||||
Vector3? AngularVelocity,
|
||||
uint? DefaultScriptType,
|
||||
float? DefaultScriptIntensity,
|
||||
PhysicsTimestamps Timestamps)
|
||||
{
|
||||
/// <summary>The corrected retail flag view of <see cref="RawState"/>.</summary>
|
||||
public PhysicsStateFlags State => (PhysicsStateFlags)RawState;
|
||||
}
|
||||
|
|
@ -9,9 +9,9 @@ namespace AcDream.Core.Net.Messages;
|
|||
/// ACE emits this from <c>Player_Tracking.RemoveTrackedObject(wo, fromPickup: true)</c>
|
||||
/// when a player picks up a world item — distinguishes the despawn
|
||||
/// from a generic <c>0xF747 DeleteObject</c> (timeout / death /
|
||||
/// out-of-LOS). Downstream effect on the client view is the same
|
||||
/// (remove the entity from the world), so <see cref="WorldSession"/>
|
||||
/// routes both opcodes to the same <c>EntityDeleted</c> event.
|
||||
/// out-of-LOS). Pickup removes only the object's world projection while
|
||||
/// retaining its logical weenie and timestamp owner, so <see cref="WorldSession"/>
|
||||
/// publishes it separately through <c>EntityPickedUp</c>.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
|
|
|
|||
25
src/AcDream.Core.Net/Messages/PlayPhysicsScript.cs
Normal file
25
src/AcDream.Core.Net/Messages/PlayPhysicsScript.cs
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
using System.Buffers.Binary;
|
||||
|
||||
namespace AcDream.Core.Net.Messages;
|
||||
|
||||
/// <summary>
|
||||
/// Retail PlayScriptID (0xF754): play the supplied PhysicsScript DID directly
|
||||
/// on the addressed object. This packet never performs a typed-table lookup.
|
||||
/// Named-retail handler: <c>SmartBox::HandlePlayScriptID</c> 0x00452020.
|
||||
/// </summary>
|
||||
public readonly record struct PlayPhysicsScript(uint Guid, uint ScriptDid)
|
||||
{
|
||||
public const uint Opcode = 0xF754u;
|
||||
public const int WireSize = 12;
|
||||
|
||||
public static PlayPhysicsScript? TryParse(ReadOnlySpan<byte> body)
|
||||
{
|
||||
if (body.Length != WireSize
|
||||
|| BinaryPrimitives.ReadUInt32LittleEndian(body) != Opcode)
|
||||
return null;
|
||||
|
||||
return new PlayPhysicsScript(
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(body[4..]),
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(body[8..]));
|
||||
}
|
||||
}
|
||||
31
src/AcDream.Core.Net/Messages/PlayPhysicsScriptType.cs
Normal file
31
src/AcDream.Core.Net/Messages/PlayPhysicsScriptType.cs
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
using System.Buffers.Binary;
|
||||
|
||||
namespace AcDream.Core.Net.Messages;
|
||||
|
||||
/// <summary>
|
||||
/// Retail PlayScriptType (0xF755): resolve the raw script type and exact
|
||||
/// incoming intensity through the object's current PhysicsScriptTable.
|
||||
/// Unknown type values and non-finite floats are retained losslessly for the
|
||||
/// resolver to reject without corrupting packet delivery.
|
||||
/// Named-retail handler: <c>SmartBox::HandlePlayScriptType</c> 0x00452070.
|
||||
/// </summary>
|
||||
public readonly record struct PlayPhysicsScriptType(
|
||||
uint Guid,
|
||||
uint RawScriptType,
|
||||
float Intensity)
|
||||
{
|
||||
public const uint Opcode = 0xF755u;
|
||||
public const int WireSize = 16;
|
||||
|
||||
public static PlayPhysicsScriptType? TryParse(ReadOnlySpan<byte> body)
|
||||
{
|
||||
if (body.Length != WireSize
|
||||
|| BinaryPrimitives.ReadUInt32LittleEndian(body) != Opcode)
|
||||
return null;
|
||||
|
||||
return new PlayPhysicsScriptType(
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(body[4..]),
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(body[8..]),
|
||||
BinaryPrimitives.ReadSingleLittleEndian(body[12..]));
|
||||
}
|
||||
}
|
||||
|
|
@ -73,7 +73,7 @@ public static class UpdateMotion
|
|||
///
|
||||
/// <para>L.2g S1 (DEV-6): the three staleness stamps + autonomy flag are
|
||||
/// exposed for the retail gate (<see cref="AcDream.Core.Physics"/>
|
||||
/// <c>MotionSequenceGate</c>) — retail compares them against
|
||||
/// <c>PhysicsTimestampGate</c>) — retail compares them against
|
||||
/// <c>update_times[INSTANCE_TS/MOVEMENT_TS/SERVER_CONTROLLED_MOVE_TS]</c>
|
||||
/// and stores <c>last_move_was_autonomous</c>
|
||||
/// (<c>CPhysics::SetObjectMovement</c> 0x00509690).</para>
|
||||
|
|
|
|||
|
|
@ -27,8 +27,7 @@ namespace AcDream.Core.Net.Messages;
|
|||
/// <item><b>Velocity</b> — 3xf32 if HasVelocity set</item>
|
||||
/// <item><b>PlacementID</b> — u32 if HasPlacementID set</item>
|
||||
/// <item><b>Four u16 sequence numbers</b> — instance, position, teleport,
|
||||
/// forcePosition. We don't currently check these for freshness but
|
||||
/// we must consume them to walk the buffer correctly.</item>
|
||||
/// forcePosition. Runtime freshness gates consume all four.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public static class UpdatePosition
|
||||
|
|
@ -66,6 +65,7 @@ public static class UpdatePosition
|
|||
uint? PlacementId,
|
||||
bool IsGrounded,
|
||||
ushort InstanceSequence = 0,
|
||||
ushort PositionSequence = 0,
|
||||
ushort TeleportSequence = 0,
|
||||
ushort ForcePositionSequence = 0);
|
||||
|
||||
|
|
@ -149,15 +149,12 @@ public static class UpdatePosition
|
|||
}
|
||||
|
||||
// Four u16 sequence numbers: instance, position, teleport, forcePosition.
|
||||
ushort instSeq = 0, teleSeq = 0, forceSeq = 0;
|
||||
if (body.Length - pos >= 8)
|
||||
{
|
||||
instSeq = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos));
|
||||
// pos+2 = positionSequence (not tracked by movement)
|
||||
teleSeq = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos + 4));
|
||||
forceSeq = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos + 6));
|
||||
pos += 8;
|
||||
}
|
||||
if (body.Length - pos < 8) return null;
|
||||
ushort instSeq = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos));
|
||||
ushort posSeq = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos + 2));
|
||||
ushort teleSeq = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos + 4));
|
||||
ushort forceSeq = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos + 6));
|
||||
pos += 8;
|
||||
|
||||
var serverPos = new CreateObject.ServerPosition(
|
||||
LandblockId: cellId,
|
||||
|
|
@ -166,7 +163,7 @@ public static class UpdatePosition
|
|||
|
||||
return new Parsed(guid, serverPos, velocity, placementId,
|
||||
IsGrounded: (flags & PositionFlags.IsGrounded) != 0,
|
||||
instSeq, teleSeq, forceSeq);
|
||||
instSeq, posSeq, teleSeq, forceSeq);
|
||||
}
|
||||
catch
|
||||
{
|
||||
|
|
|
|||
|
|
@ -4,21 +4,20 @@ using AcDream.Core.Player;
|
|||
namespace AcDream.Core.Net;
|
||||
|
||||
/// <summary>
|
||||
/// Wires WorldSession GameMessage-level object events into the client object
|
||||
/// table: CreateObject (0xF745) = canonical merge-upsert, DeleteObject (0xF747)
|
||||
/// = evict, PublicUpdatePropertyInt (0x02CE) = live int apply, PrivateUpdatePropertyInt
|
||||
/// Wires WorldSession quality/property events into the client object table:
|
||||
/// PublicUpdatePropertyInt (0x02CE) = live int apply, PrivateUpdatePropertyInt
|
||||
/// (0x02CD) = player int (burden), PrivateUpdatePropertyInt64 (0x02CF) = player
|
||||
/// 64-bit qualities (experience), SetStackSize (0x0197) = stack count,
|
||||
/// InventoryRemoveObject (0x0024) = inventory-view removal.
|
||||
/// Keeps object ingestion in Core.Net (pure data, no GL) and off GameWindow.
|
||||
/// CreateObject/DeleteObject application remains exposed as pure helpers so
|
||||
/// the App live-entity owner can freshness-gate the shared projections first.
|
||||
/// Retail: ACCObjectMaint::CreateObject / DeleteObject (the weenie_object_table side).
|
||||
/// </summary>
|
||||
public static class ObjectTableWiring
|
||||
{
|
||||
/// <summary>
|
||||
/// Subscribe <paramref name="table"/> to the three object-lifecycle events
|
||||
/// on <paramref name="session"/>. Call this BEFORE the render handler subscribes
|
||||
/// to EntitySpawned so the table is populated before the render path runs.
|
||||
/// Subscribe <paramref name="table"/> to quality, inventory, and property
|
||||
/// updates whose freshness is independent of the physics timestamp pack.
|
||||
/// </summary>
|
||||
public static void Wire(
|
||||
WorldSession session,
|
||||
|
|
@ -29,14 +28,9 @@ public static class ObjectTableWiring
|
|||
ArgumentNullException.ThrowIfNull(session);
|
||||
ArgumentNullException.ThrowIfNull(table);
|
||||
|
||||
session.EntitySpawned += s => table.Ingest(ToWeenieData(s));
|
||||
// Retail two-table model:
|
||||
// PickupEvent (0xF74A) → object left the 3-D world view; the weenie persists
|
||||
// (it is moving into a container, e.g. unwield-to-pack). Remove the 3-D
|
||||
// render entity only — do NOT evict from ClientObjectTable.
|
||||
// DeleteObject (0xF747) → weenie is DESTROYED; evict from both tables.
|
||||
// FromPickup = true guards the weenie eviction so it only fires for true destroys.
|
||||
session.EntityDeleted += d => { if (!d.FromPickup) table.Remove(d.Guid); };
|
||||
// Create/Delete/Pickup are deliberately not subscribed here. Their
|
||||
// shared live-object timestamps must be accepted before either the
|
||||
// retained weenie projection or the render projection mutates.
|
||||
|
||||
// B-Wire: apply EVERY PropertyInt update on a visible object (0x02CE), not just
|
||||
// UiEffects — the server is the authority on object properties. UpdateIntProperty
|
||||
|
|
@ -72,6 +66,34 @@ public static class ObjectTableWiring
|
|||
session.InventoryObjectRemoved += guid => table.Remove(guid);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies one CreateObject to the retained object projection after the
|
||||
/// owning runtime accepts its physics generation. Internal for a
|
||||
/// socket-free conformance test of the subscription body.
|
||||
/// </summary>
|
||||
public static void ApplyEntitySpawn(
|
||||
ClientObjectTable table,
|
||||
WorldSession.EntitySpawn spawn,
|
||||
bool replaceGeneration = false)
|
||||
{
|
||||
WeenieData data = ToWeenieData(spawn);
|
||||
if (replaceGeneration)
|
||||
table.ReplaceGeneration(data, spawn.InstanceSequence);
|
||||
else
|
||||
table.Ingest(data);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies a true DeleteObject only after the owning runtime accepts the
|
||||
/// exact object incarnation. Pickup still removes only the 3-D projection.
|
||||
/// </summary>
|
||||
public static void ApplyEntityDelete(
|
||||
ClientObjectTable table,
|
||||
Messages.DeleteObject.Parsed delete)
|
||||
{
|
||||
table.RemoveLogicalGeneration(delete.Guid, delete.InstanceSequence);
|
||||
}
|
||||
|
||||
/// <summary>Shared application step kept internal for byte-to-state conformance tests.</summary>
|
||||
internal static void ApplyPlayerInt64PropertyUpdate(
|
||||
ClientObjectTable table,
|
||||
|
|
|
|||
|
|
@ -113,7 +113,7 @@ public sealed class WorldSession : IDisposable
|
|||
int? MaxStructure = null,
|
||||
float? Workmanship = null,
|
||||
// L.2g S1 (DEV-6): PhysicsDesc timestamp-block stamps that seed the
|
||||
// per-entity MotionSequenceGate (retail update_times INSTANCE_TS /
|
||||
// per-entity PhysicsTimestampGate (retail update_times INSTANCE_TS /
|
||||
// MOVEMENT_TS / SERVER_CONTROLLED_MOVE_TS).
|
||||
ushort InstanceSequence = 0,
|
||||
ushort MovementSequence = 0,
|
||||
|
|
@ -130,7 +130,8 @@ public sealed class WorldSession : IDisposable
|
|||
byte? CombatUse = null,
|
||||
string? PluralName = null,
|
||||
uint? PetOwnerId = null,
|
||||
ushort? AmmoType = null);
|
||||
ushort? AmmoType = null,
|
||||
PhysicsSpawnData? Physics = null);
|
||||
|
||||
/// <summary>
|
||||
/// Projects the wire-level CreateObject result into the stable session
|
||||
|
|
@ -189,7 +190,8 @@ public sealed class WorldSession : IDisposable
|
|||
CombatUse: parsed.CombatUse,
|
||||
PluralName: parsed.PluralName,
|
||||
PetOwnerId: parsed.PetOwnerId,
|
||||
AmmoType: parsed.AmmoType);
|
||||
AmmoType: parsed.AmmoType,
|
||||
Physics: parsed.Physics);
|
||||
|
||||
/// <summary>Fires when the session finishes parsing a CreateObject.</summary>
|
||||
public event Action<EntitySpawn>? EntitySpawned;
|
||||
|
|
@ -204,6 +206,13 @@ public sealed class WorldSession : IDisposable
|
|||
/// </summary>
|
||||
public event Action<DeleteObject.Parsed>? EntityDeleted;
|
||||
|
||||
/// <summary>
|
||||
/// Fires for retail PickupEvent (0xF74A). Pickup advances the object's
|
||||
/// shared POSITION_TS and removes only its world projection; it is not a
|
||||
/// DeleteObject and does not destroy the timestamp owner or weenie.
|
||||
/// </summary>
|
||||
public event Action<PickupEvent.Parsed>? EntityPickedUp;
|
||||
|
||||
/// <summary>
|
||||
/// Payload for <see cref="MotionUpdated"/>: the server guid of the entity
|
||||
/// whose motion changed and its new server-side stance + forward command.
|
||||
|
|
@ -235,7 +244,12 @@ public sealed class WorldSession : IDisposable
|
|||
uint Guid,
|
||||
CreateObject.ServerPosition Position,
|
||||
System.Numerics.Vector3? Velocity,
|
||||
bool IsGrounded);
|
||||
uint? PlacementId,
|
||||
bool IsGrounded,
|
||||
ushort InstanceSequence,
|
||||
ushort PositionSequence,
|
||||
ushort TeleportSequence,
|
||||
ushort ForcePositionSequence);
|
||||
|
||||
/// <summary>
|
||||
/// Fires when the session parses a 0xF748 UpdatePosition game message.
|
||||
|
|
@ -433,7 +447,10 @@ public sealed class WorldSession : IDisposable
|
|||
/// runtime). See <c>docs/research/2026-04-23-lightning-real.md</c>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public event Action<uint /*guid*/, uint /*scriptId*/>? PlayScriptReceived;
|
||||
public event Action<PlayPhysicsScript>? PlayPhysicsScriptReceived;
|
||||
|
||||
/// <summary>Fires for retail typed PhysicsScript playback (0xF755).</summary>
|
||||
public event Action<PlayPhysicsScriptType>? PlayPhysicsScriptTypeReceived;
|
||||
|
||||
/// <summary>
|
||||
/// Phase 5d — retail's <c>AdminEnvirons</c> packet (opcode
|
||||
|
|
@ -453,7 +470,7 @@ public sealed class WorldSession : IDisposable
|
|||
/// </description></item>
|
||||
/// <item><description>
|
||||
/// <c>0x76..0x7B</c> — Thunder1..Thunder6 sounds. Paired with
|
||||
/// a separate <see cref="PlayScriptReceived"/> from the server
|
||||
/// a separate <see cref="PlayPhysicsScriptReceived"/> from the server
|
||||
/// carrying the lightning-flash PhysicsScript.
|
||||
/// </description></item>
|
||||
/// </list>
|
||||
|
|
@ -505,6 +522,26 @@ public sealed class WorldSession : IDisposable
|
|||
public ushort ServerControlSequence => _serverControlSequence;
|
||||
public ushort TeleportSequence => _teleportSequence;
|
||||
public ushort ForcePositionSequence => _forcePositionSequence;
|
||||
|
||||
/// <summary>
|
||||
/// Publishes the local player's canonical, freshness-accepted physics
|
||||
/// timestamps for subsequent outbound movement messages. The App runtime
|
||||
/// calls this only after <c>PhysicsTimestampGate</c> commits the matching
|
||||
/// CreateObject/Movement/Position event; parsing alone never changes
|
||||
/// outbound authority.
|
||||
/// </summary>
|
||||
public void PublishAcceptedLocalPhysicsTimestamps(
|
||||
ushort instance,
|
||||
ushort serverControlledMove,
|
||||
ushort teleport,
|
||||
ushort forcePosition)
|
||||
{
|
||||
_instanceSequence = instance;
|
||||
_serverControlSequence = serverControlledMove;
|
||||
_teleportSequence = teleport;
|
||||
_forcePositionSequence = forcePosition;
|
||||
}
|
||||
|
||||
public CharacterList.Parsed? Characters { get; private set; }
|
||||
|
||||
private readonly NetClient _net;
|
||||
|
|
@ -536,7 +573,7 @@ public sealed class WorldSession : IDisposable
|
|||
// Movement sequence counters — echoed back in every MoveToState and
|
||||
// AutonomousPosition so the server can detect stale/reordered packets.
|
||||
// Initialized from CreateObject PhysicsData timestamps, updated by
|
||||
// UpdatePosition/UpdateMotion/PlayerTeleport. Per holtburger:
|
||||
// accepted UpdatePosition/UpdateMotion packets. Per holtburger:
|
||||
// instance=slot 8, teleport=slot 4, serverControl=slot 5, forcePosition=slot 6.
|
||||
private ushort _instanceSequence;
|
||||
private ushort _serverControlSequence;
|
||||
|
|
@ -878,15 +915,6 @@ public sealed class WorldSession : IDisposable
|
|||
var parsed = CreateObject.TryParse(body);
|
||||
if (parsed is not null)
|
||||
{
|
||||
// Initialize sequence counters from the player's own CreateObject.
|
||||
if (parsed.Value.Guid == Characters?.Characters.FirstOrDefault().Id)
|
||||
{
|
||||
_instanceSequence = parsed.Value.InstanceSequence;
|
||||
_teleportSequence = parsed.Value.TeleportSequence;
|
||||
_serverControlSequence = parsed.Value.ServerControlSequence;
|
||||
_forcePositionSequence = parsed.Value.ForcePositionSequence;
|
||||
}
|
||||
|
||||
EntitySpawned?.Invoke(ToEntitySpawn(parsed.Value));
|
||||
}
|
||||
}
|
||||
|
|
@ -898,18 +926,11 @@ public sealed class WorldSession : IDisposable
|
|||
}
|
||||
else if (op == PickupEvent.Opcode)
|
||||
{
|
||||
// ACE sends PickupEvent (0xF74A) when an object leaves the 3-D world view
|
||||
// (player picks it up, or a player unwields gear that goes back to their pack).
|
||||
// The WEENIE is NOT destroyed — it is moving into a container. We adapt to
|
||||
// DeleteObject.Parsed so the render/entity layer (GameWindow.OnLiveEntityDeleted)
|
||||
// still removes the 3-D WorldEntity, but FromPickup = true tells ObjectTableWiring
|
||||
// to skip the ClientObjectTable eviction (retail two-table model: PickupEvent
|
||||
// targets object_table only; DeleteObject 0xF747 targets weenie_object_table).
|
||||
// Pickup has its own POSITION_TS gate and retains the logical
|
||||
// object; do not collapse it into DeleteObject.
|
||||
var parsed = PickupEvent.TryParse(body);
|
||||
if (parsed is not null)
|
||||
EntityDeleted?.Invoke(
|
||||
new DeleteObject.Parsed(
|
||||
parsed.Value.Guid, parsed.Value.InstanceSequence, FromPickup: true));
|
||||
EntityPickedUp?.Invoke(parsed.Value);
|
||||
}
|
||||
else if (op == ParentEvent.Opcode)
|
||||
{
|
||||
|
|
@ -947,19 +968,16 @@ public sealed class WorldSession : IDisposable
|
|||
var posUpdate = UpdatePosition.TryParse(body);
|
||||
if (posUpdate is not null)
|
||||
{
|
||||
// Update sequence counters from the player's own position updates.
|
||||
if (posUpdate.Value.Guid == Characters?.Characters.FirstOrDefault().Id)
|
||||
{
|
||||
_instanceSequence = posUpdate.Value.InstanceSequence;
|
||||
_teleportSequence = posUpdate.Value.TeleportSequence;
|
||||
_forcePositionSequence = posUpdate.Value.ForcePositionSequence;
|
||||
}
|
||||
|
||||
PositionUpdated?.Invoke(new EntityPositionUpdate(
|
||||
posUpdate.Value.Guid,
|
||||
posUpdate.Value.Position,
|
||||
posUpdate.Value.Velocity,
|
||||
posUpdate.Value.IsGrounded));
|
||||
posUpdate.Value.PlacementId,
|
||||
posUpdate.Value.IsGrounded,
|
||||
posUpdate.Value.InstanceSequence,
|
||||
posUpdate.Value.PositionSequence,
|
||||
posUpdate.Value.TeleportSequence,
|
||||
posUpdate.Value.ForcePositionSequence));
|
||||
}
|
||||
}
|
||||
else if (op == VectorUpdate.Opcode)
|
||||
|
|
@ -1130,21 +1148,17 @@ public sealed class WorldSession : IDisposable
|
|||
EnvironChanged?.Invoke(envType);
|
||||
}
|
||||
}
|
||||
else if (op == 0xF754u) // PlayScript — server triggers a PhysicsScript on a target guid
|
||||
else if (op == PlayPhysicsScript.Opcode)
|
||||
{
|
||||
// Phase 6b: wire format `[u32 opcode][u32 guid][u32 scriptId]`
|
||||
// per chunk_006A0000.c:12320 disassembly. Dispatch the
|
||||
// event; GameWindow subscribes and feeds its
|
||||
// PhysicsScriptRunner. This is the channel retail uses for
|
||||
// lightning flashes, spell casts, emotes, combat FX, etc.
|
||||
if (body.Length >= 12)
|
||||
{
|
||||
uint targetGuid = System.Buffers.Binary.BinaryPrimitives
|
||||
.ReadUInt32LittleEndian(body.AsSpan(4, 4));
|
||||
uint scriptId = System.Buffers.Binary.BinaryPrimitives
|
||||
.ReadUInt32LittleEndian(body.AsSpan(8, 4));
|
||||
PlayScriptReceived?.Invoke(targetGuid, scriptId);
|
||||
}
|
||||
var script = PlayPhysicsScript.TryParse(body);
|
||||
if (script is not null)
|
||||
PlayPhysicsScriptReceived?.Invoke(script.Value);
|
||||
}
|
||||
else if (op == PlayPhysicsScriptType.Opcode)
|
||||
{
|
||||
var script = PlayPhysicsScriptType.TryParse(body);
|
||||
if (script is not null)
|
||||
PlayPhysicsScriptTypeReceived?.Invoke(script.Value);
|
||||
}
|
||||
else if (op == 0xF751u) // PlayerTeleport — server is moving us through a portal
|
||||
{
|
||||
|
|
@ -1157,12 +1171,12 @@ public sealed class WorldSession : IDisposable
|
|||
// movement; the LoginComplete is sent from GameWindow once
|
||||
// the destination UpdatePosition is received and the player
|
||||
// has been snapped to the new cell.
|
||||
ushort sequence = 0;
|
||||
if (body.Length >= 6)
|
||||
sequence = System.Buffers.Binary.BinaryPrimitives.ReadUInt16LittleEndian(
|
||||
{
|
||||
ushort sequence = System.Buffers.Binary.BinaryPrimitives.ReadUInt16LittleEndian(
|
||||
body.AsSpan(4, 2));
|
||||
_teleportSequence = sequence; // track for outbound movement messages
|
||||
TeleportStarted?.Invoke(sequence);
|
||||
TeleportStarted?.Invoke(sequence);
|
||||
}
|
||||
}
|
||||
else if (op == ObjDescEvent.Opcode)
|
||||
{
|
||||
|
|
@ -1172,7 +1186,7 @@ public sealed class WorldSession : IDisposable
|
|||
// RecipeManager.cs:403, GameActionSetSingleCharacterOption.cs:27).
|
||||
// Retail handler: SmartBox::HandleObjDescEvent (named-retail
|
||||
// 0x453340). Body layout: u32 opcode | u32 guid | ModelData |
|
||||
// u32 instanceSeq | u32 visualDescSeq.
|
||||
// u16 instanceSeq | u16 visualDescSeq.
|
||||
var parsed = ObjDescEvent.TryParse(body);
|
||||
if (parsed is not null)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -21,6 +21,18 @@ public readonly record struct MoveRequestFailure(
|
|||
uint WeenieError,
|
||||
bool RolledBack);
|
||||
|
||||
public enum ClientObjectRemovalReason
|
||||
{
|
||||
Ordinary,
|
||||
LogicalDelete,
|
||||
GenerationReplacement,
|
||||
}
|
||||
|
||||
public readonly record struct ClientObjectRemoval(
|
||||
ClientObject Object,
|
||||
ClientObjectRemovalReason Reason,
|
||||
ushort Generation);
|
||||
|
||||
/// <summary>
|
||||
/// The client's table of every server object (retail <c>weenie_object_table</c> /
|
||||
/// <c>CObjectMaint</c>). Resolve by guid via <c>Get</c>.
|
||||
|
|
@ -103,6 +115,13 @@ public sealed class ClientObjectTable
|
|||
/// <summary>Fires when an object is removed from the session.</summary>
|
||||
public event Action<ClientObject>? ObjectRemoved;
|
||||
|
||||
/// <summary>
|
||||
/// Generation-aware removal stream for runtime owners. UI consumers keep
|
||||
/// using <see cref="ObjectRemoved"/>; runtime owners use the reason and
|
||||
/// generation to preserve only packets addressed to future incarnations.
|
||||
/// </summary>
|
||||
public event Action<ClientObjectRemoval>? ObjectRemovalClassified;
|
||||
|
||||
/// <summary>Fires when an object's properties are updated (typically after Appraise).</summary>
|
||||
public event Action<ClientObject>? ObjectUpdated;
|
||||
|
||||
|
|
@ -321,15 +340,47 @@ public sealed class ClientObjectTable
|
|||
/// space, stolen, etc).
|
||||
/// </summary>
|
||||
public bool Remove(uint itemId)
|
||||
=> RemoveCore(itemId, ClientObjectRemovalReason.Ordinary, 0, notifyObjectRemoved: true);
|
||||
|
||||
/// <summary>Removes an exact accepted server object incarnation.</summary>
|
||||
public bool RemoveLogicalGeneration(uint itemId, ushort generation)
|
||||
=> RemoveCore(
|
||||
itemId,
|
||||
ClientObjectRemovalReason.LogicalDelete,
|
||||
generation,
|
||||
notifyObjectRemoved: true);
|
||||
|
||||
private bool RemoveCore(
|
||||
uint itemId,
|
||||
ClientObjectRemovalReason reason,
|
||||
ushort generation,
|
||||
bool notifyObjectRemoved)
|
||||
{
|
||||
if (!_objects.TryRemove(itemId, out var item)) return false;
|
||||
if (item.ContainerId != 0 && _containerIndex.TryGetValue(item.ContainerId, out var l))
|
||||
l.Remove(itemId);
|
||||
_pendingMoves.Remove(itemId); // a destroyed item must not leave a snapshot that mis-rolls-back a recycled guid
|
||||
ObjectRemoved?.Invoke(item);
|
||||
if (notifyObjectRemoved)
|
||||
ObjectRemoved?.Invoke(item);
|
||||
ObjectRemovalClassified?.Invoke(new ClientObjectRemoval(item, reason, generation));
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Atomically replaces the retained qualities for a newer server object
|
||||
/// incarnation while publishing a generation-specific teardown event.
|
||||
/// </summary>
|
||||
public ClientObject ReplaceGeneration(WeenieData data, ushort generation)
|
||||
{
|
||||
RemoveCore(
|
||||
data.Guid,
|
||||
ClientObjectRemovalReason.GenerationReplacement,
|
||||
generation,
|
||||
notifyObjectRemoved: true);
|
||||
|
||||
return Ingest(data);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Apply a <see cref="PropertyBundle"/> patch (e.g. from an
|
||||
/// <c>IdentifyObjectResponse</c>) to an existing object. Individual
|
||||
|
|
|
|||
|
|
@ -1,119 +0,0 @@
|
|||
using System;
|
||||
|
||||
namespace AcDream.Core.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// Per-entity staleness gate for inbound movement events (0xF74C), ported
|
||||
/// from retail (L.2g S1, closes deviation DEV-6 for the UM path).
|
||||
///
|
||||
/// <para>Retail keeps a <c>update_times[NUM_PHYSICS_TS]</c> array of u16
|
||||
/// stamps on every <c>CPhysicsObj</c> (acclient.h:6084 —
|
||||
/// <c>PhysicsTimeStamp</c>) and rejects out-of-order network events with a
|
||||
/// wraparound-aware compare. Three of those stamps gate movement events:</para>
|
||||
///
|
||||
/// <list type="bullet">
|
||||
/// <item><b>INSTANCE_TS (8)</b> — checked at dispatch
|
||||
/// (<c>ACSmartBox::DispatchSmartBoxEvent</c> case 0xF74C,
|
||||
/// acclient_2013_pseudo_c.txt:357214-357239): an event stamped with an
|
||||
/// OLDER object incarnation than the one we know is dropped before any
|
||||
/// other stamp is touched. Retail additionally QUEUES events for a NEWER
|
||||
/// incarnation (<c>SmartBox::QueueBlobForObject</c>) until that object
|
||||
/// version exists; acdream adopts-and-applies instead — register row
|
||||
/// AD-32 records the divergence.</item>
|
||||
/// <item><b>MOVEMENT_TS (1)</b> — <c>CPhysics::SetObjectMovement</c>
|
||||
/// (0x00509690, acclient_2013_pseudo_c.txt:271370) applies an event only
|
||||
/// when its movement sequence is STRICTLY newer than the stored stamp
|
||||
/// (equal = duplicate delivery = drop), and stamps BEFORE evaluating the
|
||||
/// server-control gate — a movement sequence is consumed even when the
|
||||
/// event is subsequently dropped for stale server control.</item>
|
||||
/// <item><b>SERVER_CONTROLLED_MOVE_TS (5)</b> — same function: the event is
|
||||
/// dropped when the STORED server-control stamp is strictly newer than
|
||||
/// the incoming one (a newer server-control era has already been seen);
|
||||
/// equal passes and re-stamps.</item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para>The compare itself is <c>CPhysicsObj::is_newer</c> (0x00451ad0);
|
||||
/// the Binary Ninja pseudo-C mangles its setcc returns, so the port follows
|
||||
/// ACE's verbatim <c>PhysicsObj.is_newer</c> (PhysicsObj.cs:2853-2859),
|
||||
/// cross-checked against the decomp's branch structure.</para>
|
||||
/// </summary>
|
||||
public sealed class MotionSequenceGate
|
||||
{
|
||||
private ushort _instanceTs; // update_times[INSTANCE_TS = 8]
|
||||
private ushort _movementTs; // update_times[MOVEMENT_TS = 1]
|
||||
private ushort _serverControlTs; // update_times[SERVER_CONTROLLED_MOVE_TS = 5]
|
||||
private bool _seeded;
|
||||
|
||||
/// <summary>
|
||||
/// <c>CPhysicsObj::is_newer</c> (0x00451ad0): true when
|
||||
/// <paramref name="newStamp"/> is newer than <paramref name="oldStamp"/>
|
||||
/// under u16 wraparound — when the absolute difference exceeds 0x7fff
|
||||
/// the numerically SMALLER value is the newer one (the counter wrapped).
|
||||
/// </summary>
|
||||
public static bool IsNewer(ushort oldStamp, ushort newStamp)
|
||||
{
|
||||
if (Math.Abs(newStamp - oldStamp) > short.MaxValue)
|
||||
return newStamp < oldStamp;
|
||||
return oldStamp < newStamp;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Seed the stamps from the entity's CreateObject PhysicsDesc timestamp
|
||||
/// block. Retail initializes <c>update_times</c> wholesale from the 9
|
||||
/// u16s at the tail of PhysicsDesc (ACE
|
||||
/// <c>WorldObject_Networking.cs:411-420</c> writes them in
|
||||
/// PhysicsTimeStamp enum order); without this, an entity whose movement
|
||||
/// sequence is already past 0x8000 at spawn would have every subsequent
|
||||
/// movement event judged stale against a zero stamp.
|
||||
///
|
||||
/// <para>The first Seed adopts the stamps wholesale (fresh object);
|
||||
/// subsequent Seeds only move stamps FORWARD. This makes the #138
|
||||
/// rehydrate path (which replays a RETAINED CreateObject through the
|
||||
/// spawn handler) a no-op instead of a stamp regression, while a genuine
|
||||
/// wire re-create (server sequences only advance) still seeds correctly.
|
||||
/// Retail has no equivalent replay path — its objects keep their stamps
|
||||
/// for their whole lifetime — so advance-only is the faithful mapping.</para>
|
||||
/// </summary>
|
||||
public void Seed(ushort instanceSeq, ushort movementSeq, ushort serverControlSeq)
|
||||
{
|
||||
if (!_seeded)
|
||||
{
|
||||
_instanceTs = instanceSeq;
|
||||
_movementTs = movementSeq;
|
||||
_serverControlTs = serverControlSeq;
|
||||
_seeded = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (IsNewer(_instanceTs, instanceSeq)) _instanceTs = instanceSeq;
|
||||
if (IsNewer(_movementTs, movementSeq)) _movementTs = movementSeq;
|
||||
if (IsNewer(_serverControlTs, serverControlSeq)) _serverControlTs = serverControlSeq;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Apply the retail three-stamp gate to an inbound movement event.
|
||||
/// Returns true when the event should be applied (stamps updated),
|
||||
/// false when it must be dropped as stale/duplicate/superseded.
|
||||
/// </summary>
|
||||
public bool TryAcceptMovementEvent(ushort instanceSeq, ushort movementSeq, ushort serverControlSeq)
|
||||
{
|
||||
// Dispatch-level incarnation gate — runs before any other stamp is
|
||||
// touched (retail drops at DispatchSmartBoxEvent, never reaching
|
||||
// SetObjectMovement).
|
||||
if (IsNewer(instanceSeq, _instanceTs))
|
||||
return false;
|
||||
_instanceTs = instanceSeq; // adopt equal-or-newer (AD-32; retail queues newer)
|
||||
|
||||
// Gate 1: movement sequence must be strictly newer.
|
||||
if (!IsNewer(_movementTs, movementSeq))
|
||||
return false;
|
||||
_movementTs = movementSeq; // stamped BEFORE the server-control gate, per retail
|
||||
|
||||
// Gate 2: drop when a newer server-control era has already been seen.
|
||||
if (IsNewer(serverControlSeq, _serverControlTs))
|
||||
return false;
|
||||
_serverControlTs = serverControlSeq;
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -19,18 +19,27 @@ namespace AcDream.Core.Physics;
|
|||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// State flags stored at struct offset +0xA8 (PhysicsState).
|
||||
/// Only the flags relevant to this simulation layer are included.
|
||||
/// State flags stored at struct offset +0xA8 (<c>PhysicsState</c>), verbatim
|
||||
/// from the retail <c>PhysicsState</c> enum in <c>acclient.h:2815</c>.
|
||||
/// </summary>
|
||||
[Flags]
|
||||
public enum PhysicsStateFlags : uint
|
||||
{
|
||||
None = 0,
|
||||
Static = 0x00000001, // bit 0 — never moves
|
||||
Ethereal = 0x00000004, // bit 2 — no collision
|
||||
ReportCollisions = 0x00000010,
|
||||
Gravity = 0x00000400, // bit 10 — apply downward gravity
|
||||
Hidden = 0x00001000,
|
||||
None = 0x00000000,
|
||||
Static = 0x00000001,
|
||||
Ethereal = 0x00000004,
|
||||
ReportCollisions = 0x00000008,
|
||||
IgnoreCollisions = 0x00000010,
|
||||
NoDraw = 0x00000020,
|
||||
Missile = 0x00000040,
|
||||
Pushable = 0x00000080,
|
||||
AlignPath = 0x00000100,
|
||||
PathClipped = 0x00000200,
|
||||
Gravity = 0x00000400,
|
||||
Lighting = 0x00000800,
|
||||
ParticleEmitter = 0x00001000,
|
||||
Hidden = 0x00004000,
|
||||
ScriptedCollision = 0x00008000,
|
||||
/// <summary>
|
||||
/// A6.P7 (2026-05-25): retail HAS_PHYSICS_BSP_PS bit
|
||||
/// (acclient.h:2833). When set, the entity exposes a per-Setup
|
||||
|
|
@ -43,7 +52,7 @@ public enum PhysicsStateFlags : uint
|
|||
/// state 0x10008 (STATIC | REPORT_COLLISIONS | HAS_PHYSICS_BSP).
|
||||
/// ACE name: <c>PhysicsState.HasPhysicsBSP</c>.
|
||||
/// </summary>
|
||||
HasPhysicsBsp = 0x00010000, // bit 16 — retail HAS_PHYSICS_BSP_PS
|
||||
HasPhysicsBsp = 0x00010000,
|
||||
/// <summary>
|
||||
/// L.3a (2026-04-30): retail INELASTIC_PS bit (acclient.h:2834).
|
||||
/// When set, wall-collisions zero the velocity instead of reflecting.
|
||||
|
|
@ -51,8 +60,14 @@ public enum PhysicsStateFlags : uint
|
|||
/// impact rather than bounce. The player NEVER has this flag set —
|
||||
/// player wall-hits use the reflection path with elasticity ~0.05.
|
||||
/// </summary>
|
||||
Inelastic = 0x00020000, // bit 17 — retail INELASTIC_PS
|
||||
Sledding = 0x00800000, // bit 23 — sledding (modified friction)
|
||||
Inelastic = 0x00020000,
|
||||
HasDefaultAnim = 0x00040000,
|
||||
HasDefaultScript = 0x00080000,
|
||||
Cloaked = 0x00100000,
|
||||
ReportAsEnvironment = 0x00200000,
|
||||
EdgeSlide = 0x00400000,
|
||||
Sledding = 0x00800000,
|
||||
Frozen = 0x01000000,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
216
src/AcDream.Core/Physics/PhysicsTimestampGate.cs
Normal file
216
src/AcDream.Core/Physics/PhysicsTimestampGate.cs
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
using System;
|
||||
|
||||
namespace AcDream.Core.Physics;
|
||||
|
||||
public enum CreateObjectTimestampDisposition
|
||||
{
|
||||
StaleGeneration,
|
||||
InitialGeneration,
|
||||
ExistingGeneration,
|
||||
NewGeneration,
|
||||
}
|
||||
|
||||
public enum PositionTimestampDisposition
|
||||
{
|
||||
Rejected,
|
||||
Apply,
|
||||
ForcePosition,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-object retail <c>update_times[NUM_PHYSICS_TS]</c> gate. Channel order
|
||||
/// is <c>PhysicsTimeStamp</c> from <c>acclient.h:6084</c>. The comparator and
|
||||
/// channel mutation order are ported from <c>CPhysicsObj::is_newer</c>
|
||||
/// 0x00451AD0, <c>CPhysicsObj::newer_event</c> 0x00451B10,
|
||||
/// <c>SmartBox::DoSetState</c> 0x004520D0,
|
||||
/// <c>SmartBox::DoVectorUpdate</c> 0x004521C0, and
|
||||
/// <c>SmartBox::HandleReceivedPosition</c> 0x00453FD0.
|
||||
/// </summary>
|
||||
public sealed class PhysicsTimestampGate
|
||||
{
|
||||
private readonly ushort[] _timestamps = new ushort[9];
|
||||
private bool _seeded;
|
||||
|
||||
private const int Position = 0;
|
||||
private const int Movement = 1;
|
||||
private const int State = 2;
|
||||
private const int Vector = 3;
|
||||
private const int Teleport = 4;
|
||||
private const int ServerControlledMove = 5;
|
||||
private const int ForcePosition = 6;
|
||||
private const int ObjDesc = 7;
|
||||
private const int Instance = 8;
|
||||
|
||||
public ushort PositionTimestamp => _timestamps[Position];
|
||||
public ushort MovementTimestamp => _timestamps[Movement];
|
||||
public ushort StateTimestamp => _timestamps[State];
|
||||
public ushort VectorTimestamp => _timestamps[Vector];
|
||||
public ushort TeleportTimestamp => _timestamps[Teleport];
|
||||
public ushort ServerControlledMoveTimestamp => _timestamps[ServerControlledMove];
|
||||
public ushort ForcePositionTimestamp => _timestamps[ForcePosition];
|
||||
public ushort ObjDescTimestamp => _timestamps[ObjDesc];
|
||||
public ushort InstanceTimestamp => _timestamps[Instance];
|
||||
|
||||
/// <summary>
|
||||
/// Retail u16 wrap comparison. At the exact half-range ambiguity
|
||||
/// (0x8000), the numerically lower stamp is considered newer.
|
||||
/// </summary>
|
||||
public static bool IsNewer(ushort oldStamp, ushort newStamp)
|
||||
{
|
||||
int distance = Math.Abs((int)newStamp - oldStamp);
|
||||
return distance > 0x7FFF ? newStamp < oldStamp : oldStamp < newStamp;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Begins CreateObject processing. A newer INSTANCE_TS starts a fresh
|
||||
/// generation and replaces every channel wholesale. A same-generation
|
||||
/// CreateObject leaves all channels untouched so its Position, Parent,
|
||||
/// Pickup, Movement, State, Vector, and ObjDesc branches can pass through
|
||||
/// the same per-channel methods used by their standalone packets.
|
||||
/// </summary>
|
||||
public CreateObjectTimestampDisposition SeedForCreateObject(
|
||||
ushort position,
|
||||
ushort movement,
|
||||
ushort state,
|
||||
ushort vector,
|
||||
ushort teleport,
|
||||
ushort serverControlledMove,
|
||||
ushort forcePosition,
|
||||
ushort objDesc,
|
||||
ushort instance)
|
||||
{
|
||||
Span<ushort> incoming =
|
||||
[position, movement, state, vector, teleport, serverControlledMove, forcePosition, objDesc, instance];
|
||||
|
||||
if (!_seeded)
|
||||
{
|
||||
incoming.CopyTo(_timestamps);
|
||||
_seeded = true;
|
||||
return CreateObjectTimestampDisposition.InitialGeneration;
|
||||
}
|
||||
|
||||
ushort currentInstance = _timestamps[Instance];
|
||||
if (IsNewer(currentInstance, instance))
|
||||
{
|
||||
incoming.CopyTo(_timestamps);
|
||||
return CreateObjectTimestampDisposition.NewGeneration;
|
||||
}
|
||||
|
||||
if (IsNewer(instance, currentInstance))
|
||||
return CreateObjectTimestampDisposition.StaleGeneration;
|
||||
|
||||
return CreateObjectTimestampDisposition.ExistingGeneration;
|
||||
}
|
||||
|
||||
public bool TryAcceptMovementEvent(
|
||||
ushort instance,
|
||||
ushort movement,
|
||||
ushort serverControlledMove)
|
||||
{
|
||||
if (!TryAcceptInstance(instance)) return false;
|
||||
if (!AdvanceStrict(Movement, movement)) return false;
|
||||
|
||||
// Retail consumes MOVEMENT_TS before checking the server-control
|
||||
// era. Equal server-control stamps pass.
|
||||
if (IsNewer(serverControlledMove, _timestamps[ServerControlledMove]))
|
||||
return false;
|
||||
_timestamps[ServerControlledMove] = serverControlledMove;
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool TryAcceptStateEvent(ushort instance, ushort state) =>
|
||||
TryAcceptInstance(instance) && AdvanceStrict(State, state);
|
||||
|
||||
public bool TryAcceptVectorEvent(ushort instance, ushort vector) =>
|
||||
TryAcceptInstance(instance) && AdvanceStrict(Vector, vector);
|
||||
|
||||
public bool TryAcceptObjDescEvent(ushort instance, ushort objDesc) =>
|
||||
TryAcceptInstance(instance) && AdvanceStrict(ObjDesc, objDesc);
|
||||
|
||||
/// <summary>
|
||||
/// ParentEvent and PickupEvent share POSITION_TS with UpdatePosition.
|
||||
/// Retail exact-gates INSTANCE_TS, then strictly advances POSITION_TS.
|
||||
/// </summary>
|
||||
public bool TryAcceptPositionChannelEvent(ushort instance, ushort position) =>
|
||||
TryAcceptInstance(instance) && AdvanceStrict(Position, position);
|
||||
|
||||
public bool IsCurrentInstance(ushort instance) => TryAcceptInstance(instance);
|
||||
|
||||
/// <summary>
|
||||
/// Standalone PlayerTeleport (F751) is admitted when it is equal to or
|
||||
/// newer than the current TELEPORT_TS. It does not advance that channel;
|
||||
/// the destination Position packet does.
|
||||
/// </summary>
|
||||
public bool IsFreshTeleportStart(ushort teleport) =>
|
||||
_seeded && !IsNewer(teleport, _timestamps[Teleport]);
|
||||
|
||||
/// <summary>
|
||||
/// Delete/Pickup events only affect the exact live incarnation. Retail
|
||||
/// drops older deletes and queues newer-incarnation deletes without
|
||||
/// touching the current object.
|
||||
/// </summary>
|
||||
public bool TryAcceptDeleteEvent(ushort instance, bool isLocalPlayer = false) =>
|
||||
!isLocalPlayer && _seeded && instance == _timestamps[Instance];
|
||||
|
||||
/// <summary>
|
||||
/// Ports the timestamp portion of <c>HandleReceivedPosition</c>. POSITION
|
||||
/// is tentatively advanced, then restored when a newer TELEPORT stamp is
|
||||
/// already known. A fresh teleport advances its own channel. The local
|
||||
/// player's FORCE_POSITION channel is independent; non-player objects do
|
||||
/// not consume it.
|
||||
/// </summary>
|
||||
public PositionTimestampDisposition TryAcceptPositionEvent(
|
||||
ushort instance,
|
||||
ushort position,
|
||||
ushort teleport,
|
||||
ushort forcePosition,
|
||||
bool isLocalPlayer)
|
||||
{
|
||||
if (!TryAcceptInstance(instance)) return PositionTimestampDisposition.Rejected;
|
||||
|
||||
if (isLocalPlayer && IsNewer(_timestamps[ForcePosition], forcePosition))
|
||||
{
|
||||
_timestamps[ForcePosition] = forcePosition;
|
||||
|
||||
// SmartBox::HandleReceivedPosition 0x00453FD0: a fresh local
|
||||
// FORCE_POSITION whose teleport is exactly equal blips immediately,
|
||||
// assigns POSITION_TS directly (even equal/older), preserves the
|
||||
// current heading, sends a position event, and returns WITHOUT
|
||||
// advancing TELEPORT_TS.
|
||||
if (teleport == _timestamps[Teleport])
|
||||
{
|
||||
_timestamps[Position] = position;
|
||||
return PositionTimestampDisposition.ForcePosition;
|
||||
}
|
||||
}
|
||||
|
||||
ushort previousPosition = _timestamps[Position];
|
||||
if (!AdvanceStrict(Position, position)) return PositionTimestampDisposition.Rejected;
|
||||
|
||||
if (IsNewer(teleport, _timestamps[Teleport]))
|
||||
{
|
||||
_timestamps[Position] = previousPosition;
|
||||
return PositionTimestampDisposition.Rejected;
|
||||
}
|
||||
|
||||
if (IsNewer(_timestamps[Teleport], teleport))
|
||||
_timestamps[Teleport] = teleport;
|
||||
return PositionTimestampDisposition.Apply;
|
||||
}
|
||||
|
||||
private bool AdvanceStrict(int channel, ushort incoming)
|
||||
{
|
||||
if (!IsNewer(_timestamps[channel], incoming)) return false;
|
||||
_timestamps[channel] = incoming;
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool TryAcceptInstance(ushort incoming)
|
||||
{
|
||||
// Retail applies only the current incarnation: older packets drop and
|
||||
// future-incarnation packets queue. Until LiveEntityRuntime owns that
|
||||
// queue, acdream drops both mismatches (AD-32) so a future packet can
|
||||
// never mutate or poison the current generation's channel stamps.
|
||||
return _seeded && incoming == _timestamps[Instance];
|
||||
}
|
||||
}
|
||||
30
src/AcDream.Core/Physics/PositionFrameValidation.cs
Normal file
30
src/AcDream.Core/Physics/PositionFrameValidation.cs
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
using System.Numerics;
|
||||
|
||||
namespace AcDream.Core.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>Position::IsValid</c> 0x005A9480 composed with
|
||||
/// <c>Frame::IsValid</c> 0x00534ED0. The quaternion test is performed on its
|
||||
/// squared norm with retail's exact <c>0.0002 * 5</c> tolerance.
|
||||
/// </summary>
|
||||
public static class PositionFrameValidation
|
||||
{
|
||||
public static bool IsValid(uint cellId, Vector3 origin, Quaternion rotation)
|
||||
{
|
||||
if (!LandDefs.InboundValidCellId(cellId)
|
||||
|| float.IsNaN(origin.X)
|
||||
|| float.IsNaN(origin.Y)
|
||||
|| float.IsNaN(origin.Z)
|
||||
|| float.IsNaN(rotation.W)
|
||||
|| float.IsNaN(rotation.X)
|
||||
|| float.IsNaN(rotation.Y)
|
||||
|| float.IsNaN(rotation.Z))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
float normSquared = rotation.LengthSquared();
|
||||
return !float.IsNaN(normSquared)
|
||||
&& MathF.Abs(normSquared - 1f) <= 0.001f;
|
||||
}
|
||||
}
|
||||
|
|
@ -21,6 +21,7 @@ public enum SelectionChangeReason
|
|||
SelectedObjectRemoved,
|
||||
CombatTargetDied,
|
||||
PreviousSelection,
|
||||
SessionReset,
|
||||
}
|
||||
|
||||
public readonly record struct SelectionTransition(
|
||||
|
|
@ -68,6 +69,30 @@ public sealed class SelectionState : ISelectionService
|
|||
=> PreviousObjectId is uint previous
|
||||
&& Set(previous, source, SelectionChangeReason.PreviousSelection);
|
||||
|
||||
/// <summary>
|
||||
/// Ends a world session. Unlike an ordinary selection clear, no previous
|
||||
/// object survives for the next session: server GUIDs may be reused by a
|
||||
/// different logical incarnation after reconnect.
|
||||
/// </summary>
|
||||
public bool Reset(SelectionChangeSource source = SelectionChangeSource.System)
|
||||
{
|
||||
uint? old = SelectedObjectId;
|
||||
SelectedObjectId = null;
|
||||
PreviousObjectId = null;
|
||||
PreviousValidObjectId = null;
|
||||
if (old is null)
|
||||
return false;
|
||||
|
||||
var transition = new SelectionTransition(
|
||||
old,
|
||||
null,
|
||||
source,
|
||||
SelectionChangeReason.SessionReset);
|
||||
Changed?.Invoke(transition);
|
||||
DispatchPluginChanged(new SelectionChangedEvent(old, null));
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ISelectionService.Select(uint objectId)
|
||||
=> Select(objectId, SelectionChangeSource.Plugin);
|
||||
|
||||
|
|
|
|||
71
tests/AcDream.App.Tests/UI/AutoWieldGenerationTests.cs
Normal file
71
tests/AcDream.App.Tests/UI/AutoWieldGenerationTests.cs
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
using AcDream.App.UI;
|
||||
using AcDream.Core.Items;
|
||||
|
||||
namespace AcDream.App.Tests.UI;
|
||||
|
||||
public sealed class AutoWieldGenerationTests
|
||||
{
|
||||
private const uint Player = 0x50000001u;
|
||||
|
||||
[Fact]
|
||||
public void GenerationReplacementCancelsPendingWeaponSwitch()
|
||||
{
|
||||
var objects = new ClientObjectTable();
|
||||
var blocker = new ClientObject
|
||||
{
|
||||
ObjectId = 0x60000001u,
|
||||
WielderId = Player,
|
||||
ValidLocations = EquipMask.MeleeWeapon,
|
||||
};
|
||||
objects.AddOrUpdate(blocker);
|
||||
objects.MoveItem(
|
||||
blocker.ObjectId,
|
||||
Player,
|
||||
newSlot: -1,
|
||||
newEquipLocation: EquipMask.MeleeWeapon);
|
||||
var requested = new ClientObject
|
||||
{
|
||||
ObjectId = 0x60000002u,
|
||||
ContainerId = Player,
|
||||
ValidLocations = EquipMask.MeleeWeapon,
|
||||
};
|
||||
objects.AddOrUpdate(requested);
|
||||
|
||||
using var controller = new AutoWieldController(
|
||||
objects,
|
||||
() => Player,
|
||||
sendWield: null,
|
||||
sendPutItemInContainer: (_, _, _) => { },
|
||||
toast: null);
|
||||
Assert.True(controller.TryWield(requested));
|
||||
Assert.True(controller.IsBusy);
|
||||
|
||||
objects.ReplaceGeneration(WorldReplacement(blocker.ObjectId), generation: 2);
|
||||
|
||||
Assert.False(controller.IsBusy);
|
||||
}
|
||||
|
||||
private static WeenieData WorldReplacement(uint guid) => new(
|
||||
Guid: guid,
|
||||
Name: "replacement",
|
||||
Type: ItemType.Misc,
|
||||
WeenieClassId: 1,
|
||||
IconId: 0,
|
||||
IconOverlayId: 0,
|
||||
IconUnderlayId: 0,
|
||||
Effects: 0,
|
||||
Value: null,
|
||||
StackSize: null,
|
||||
StackSizeMax: null,
|
||||
Burden: null,
|
||||
ContainerId: null,
|
||||
WielderId: null,
|
||||
ValidLocations: null,
|
||||
CurrentWieldedLocation: null,
|
||||
Priority: null,
|
||||
ItemsCapacity: null,
|
||||
ContainersCapacity: null,
|
||||
Structure: null,
|
||||
MaxStructure: null,
|
||||
Workmanship: null);
|
||||
}
|
||||
|
|
@ -125,6 +125,40 @@ public class InventoryControllerTests
|
|||
Assert.Equal(0u, containers.GetItem(1)!.ItemId); // padded empty frame
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SessionTableClearRebuildsInventoryWithoutOldObjects()
|
||||
{
|
||||
var (layout, grid, containers, _, _, _, _, _) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
SeedContained(objects, 0xAu, Player, slot: 0);
|
||||
SeedBag(objects, 0xCu, slot: 1);
|
||||
Bind(layout, objects);
|
||||
Assert.Equal(0xAu, grid.GetItem(0)!.ItemId);
|
||||
Assert.Equal(0xCu, containers.GetItem(0)!.ItemId);
|
||||
|
||||
objects.Clear();
|
||||
|
||||
Assert.Equal(0u, grid.GetItem(0)!.ItemId);
|
||||
Assert.Equal(0u, containers.GetItem(0)!.ItemId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerationReplacementRemovesOldOwnedProjectionAndSelection()
|
||||
{
|
||||
var (layout, grid, _, _, _, _, _, _) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
SeedContained(objects, 0xAu, Player, slot: 0);
|
||||
var selection = new SelectionState();
|
||||
selection.Select(0xAu, SelectionChangeSource.Inventory);
|
||||
Bind(layout, objects, selection: selection);
|
||||
Assert.Equal(0xAu, grid.GetItem(0)!.ItemId);
|
||||
|
||||
objects.ReplaceGeneration(WorldReplacement(0xAu), generation: 2);
|
||||
|
||||
Assert.Equal(0u, grid.GetItem(0)!.ItemId);
|
||||
Assert.Null(selection.SelectedObjectId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Populate_uses_manifest_container_hint_before_create_object_details()
|
||||
{
|
||||
|
|
@ -557,6 +591,30 @@ public class InventoryControllerTests
|
|||
|
||||
private static ItemDragPayload Payload(uint obj) => new(obj, ItemDragSource.Inventory, 0, new UiItemSlot());
|
||||
|
||||
private static WeenieData WorldReplacement(uint guid) => new(
|
||||
Guid: guid,
|
||||
Name: "replacement",
|
||||
Type: ItemType.Misc,
|
||||
WeenieClassId: 1,
|
||||
IconId: 0,
|
||||
IconOverlayId: 0,
|
||||
IconUnderlayId: 0,
|
||||
Effects: 0,
|
||||
Value: null,
|
||||
StackSize: null,
|
||||
StackSizeMax: null,
|
||||
Burden: null,
|
||||
ContainerId: null,
|
||||
WielderId: null,
|
||||
ValidLocations: null,
|
||||
CurrentWieldedLocation: null,
|
||||
Priority: null,
|
||||
ItemsCapacity: null,
|
||||
ContainersCapacity: null,
|
||||
Structure: null,
|
||||
MaxStructure: null,
|
||||
Workmanship: null);
|
||||
|
||||
[Fact]
|
||||
public void Drop_onOccupiedGridCell_insertsBefore_andMovesLocally()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -64,6 +64,30 @@ public class PaperdollControllerTests
|
|||
t.MoveItem(guid, Pack, newSlot: 0);
|
||||
}
|
||||
|
||||
private static WeenieData WorldReplacement(uint guid) => new(
|
||||
Guid: guid,
|
||||
Name: "replacement",
|
||||
Type: ItemType.Misc,
|
||||
WeenieClassId: 1,
|
||||
IconId: 0,
|
||||
IconOverlayId: 0,
|
||||
IconUnderlayId: 0,
|
||||
Effects: 0,
|
||||
Value: null,
|
||||
StackSize: null,
|
||||
StackSizeMax: null,
|
||||
Burden: null,
|
||||
ContainerId: null,
|
||||
WielderId: null,
|
||||
ValidLocations: null,
|
||||
CurrentWieldedLocation: null,
|
||||
Priority: null,
|
||||
ItemsCapacity: null,
|
||||
ContainersCapacity: null,
|
||||
Structure: null,
|
||||
MaxStructure: null,
|
||||
Workmanship: null);
|
||||
|
||||
[Fact]
|
||||
public void Populate_shows_equipped_item_in_its_slot()
|
||||
{
|
||||
|
|
@ -75,6 +99,41 @@ public class PaperdollControllerTests
|
|||
Assert.Equal(0u, lists[ShieldSlot].Cell.ItemId); // shield slot empty
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SessionTableClearRemovesOldEquipmentAndLocksAetheriaSlots()
|
||||
{
|
||||
var (layout, lists) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
SeedEquipped(objects, 0xA01u, EquipMask.HeadWear);
|
||||
objects.AddOrUpdate(new ClientObject { ObjectId = Player });
|
||||
objects.UpdateIntProperty(
|
||||
Player,
|
||||
AetheriaUnlocks.PropertyId,
|
||||
(int)AetheriaUnlockState.Blue);
|
||||
Bind(layout, objects);
|
||||
Assert.Equal(0xA01u, lists[HeadSlot].Cell.ItemId);
|
||||
Assert.True(lists[BlueAetheriaSlot].Visible);
|
||||
|
||||
objects.Clear();
|
||||
|
||||
Assert.Equal(0u, lists[HeadSlot].Cell.ItemId);
|
||||
Assert.False(lists[BlueAetheriaSlot].Visible);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerationReplacementRemovesOldEquippedProjection()
|
||||
{
|
||||
var (layout, lists) = BuildLayout();
|
||||
var objects = new ClientObjectTable();
|
||||
SeedEquipped(objects, 0xA01u, EquipMask.HeadWear);
|
||||
Bind(layout, objects);
|
||||
Assert.Equal(0xA01u, lists[HeadSlot].Cell.ItemId);
|
||||
|
||||
objects.ReplaceGeneration(WorldReplacement(0xA01u), generation: 2);
|
||||
|
||||
Assert.Equal(0u, lists[HeadSlot].Cell.ItemId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClickEquippedItem_updatesSharedSelection()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -115,6 +115,34 @@ public class ToolbarControllerTests
|
|||
Assert.Equal(0u, slots[Row1[1]].Cell.ItemId); // others empty
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SessionTableClearRemovesResolvedShortcutPresentation()
|
||||
{
|
||||
var (layout, slots, _) = FakeToolbar();
|
||||
var repo = new ClientObjectTable();
|
||||
repo.AddOrUpdate(new ClientObject
|
||||
{
|
||||
ObjectId = 0x5001u,
|
||||
WeenieClassId = 1u,
|
||||
IconId = 0x06001234u,
|
||||
});
|
||||
var shortcuts = new List<ShortcutEntry>
|
||||
{
|
||||
new(Index: 0, ObjectId: 0x5001u, SpellId: 0),
|
||||
};
|
||||
ToolbarController.Bind(
|
||||
layout,
|
||||
repo,
|
||||
() => shortcuts,
|
||||
iconIds: (_, _, _, _, _) => 0x77u,
|
||||
useItem: _ => { });
|
||||
Assert.Equal(0x5001u, slots[Row1[0]].Cell.ItemId);
|
||||
|
||||
repo.Clear();
|
||||
|
||||
Assert.Equal(0u, slots[Row1[0]].Cell.ItemId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeferredRebind_whenItemArrivesLate()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,490 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.World;
|
||||
using AcDream.Core.Net;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Core.Physics;
|
||||
|
||||
namespace AcDream.App.Tests.World;
|
||||
|
||||
public sealed class InboundPhysicsStateControllerTests
|
||||
{
|
||||
[Fact]
|
||||
public void SameGenerationCreate_RoutesChannelsIndependently()
|
||||
{
|
||||
var controller = new InboundPhysicsStateController();
|
||||
CreateObject.ServerPosition original = Position(0x0101FFFFu, 10f);
|
||||
CreateObject.ServerPosition incoming = Position(0x0101FFFFu, 20f);
|
||||
controller.AcceptCreate(Spawn(0x70000001u, 1, 10, 10, original, 0x408u));
|
||||
|
||||
InboundCreateResult refresh = controller.AcceptCreate(
|
||||
Spawn(0x70000001u, 1, 9, 11, incoming, 0x448u));
|
||||
|
||||
Assert.Equal(CreateObjectTimestampDisposition.ExistingGeneration, refresh.Disposition);
|
||||
Assert.NotNull(refresh.SameGenerationEvents);
|
||||
SameGenerationCreateObjectEvents events = refresh.SameGenerationEvents.Value;
|
||||
Assert.True(controller.TryApplyPosition(
|
||||
events.Position!.Value,
|
||||
isLocalPlayer: false,
|
||||
forcePositionRotation: null,
|
||||
currentLocalVelocity: null,
|
||||
out PositionTimestampDisposition positionResult,
|
||||
out _,
|
||||
out _));
|
||||
Assert.Equal(PositionTimestampDisposition.Rejected, positionResult);
|
||||
Assert.True(controller.TryApplyState(events.State, out _));
|
||||
Assert.True(controller.TryGetSnapshot(0x70000001u, out WorldSession.EntitySpawn accepted));
|
||||
Assert.Equal(original, accepted.Position);
|
||||
Assert.Equal(0x448u, accepted.PhysicsState);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PickupRetainsGeneration_AndFreshPositionReturnsToWorld()
|
||||
{
|
||||
var controller = new InboundPhysicsStateController();
|
||||
controller.AcceptCreate(Spawn(
|
||||
0x70000002u, 7, 20, 1, Position(0x0101FFFFu, 10f), 0x408u));
|
||||
|
||||
Assert.True(controller.TryApplyPickup(
|
||||
new PickupEvent.Parsed(0x70000002u, 7, 21), out WorldSession.EntitySpawn pickedUp));
|
||||
Assert.Null(pickedUp.Position);
|
||||
|
||||
var update = new WorldSession.EntityPositionUpdate(
|
||||
0x70000002u,
|
||||
Position(0x0101FFFFu, 30f),
|
||||
null,
|
||||
null,
|
||||
true,
|
||||
7,
|
||||
22,
|
||||
0,
|
||||
0);
|
||||
Assert.True(controller.TryApplyPosition(
|
||||
update, false, null, null, out var disposition, out WorldSession.EntitySpawn returned, out _));
|
||||
Assert.Equal(PositionTimestampDisposition.Apply, disposition);
|
||||
Assert.Equal(30f, returned.Position!.Value.PositionX);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PositionPlacementAbsentAndPresentZeroBothApplyRetailZero()
|
||||
{
|
||||
var controller = new InboundPhysicsStateController();
|
||||
WorldSession.EntitySpawn seed = Spawn(
|
||||
0x70000009u, 7, 20, 1, Position(0x0101FFFFu, 10f), 0x408u);
|
||||
seed = seed with
|
||||
{
|
||||
PlacementId = 7,
|
||||
Physics = seed.Physics!.Value with { AnimationFrame = 7 },
|
||||
};
|
||||
controller.AcceptCreate(seed);
|
||||
|
||||
Assert.True(controller.TryApplyPosition(
|
||||
new WorldSession.EntityPositionUpdate(
|
||||
seed.Guid,
|
||||
Position(0x0101FFFFu, 20f),
|
||||
null,
|
||||
null,
|
||||
true,
|
||||
7,
|
||||
21,
|
||||
0,
|
||||
0),
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
out PositionTimestampDisposition firstDisposition,
|
||||
out WorldSession.EntitySpawn first,
|
||||
out _));
|
||||
Assert.Equal(PositionTimestampDisposition.Apply, firstDisposition);
|
||||
Assert.Equal((uint)0, first.PlacementId);
|
||||
Assert.Equal((uint)0, first.Physics!.Value.AnimationFrame);
|
||||
|
||||
Assert.True(controller.TryApplyPosition(
|
||||
new WorldSession.EntityPositionUpdate(
|
||||
seed.Guid,
|
||||
Position(0x0101FFFFu, 30f),
|
||||
null,
|
||||
0,
|
||||
true,
|
||||
7,
|
||||
22,
|
||||
0,
|
||||
0),
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
out PositionTimestampDisposition secondDisposition,
|
||||
out WorldSession.EntitySpawn second,
|
||||
out _));
|
||||
Assert.Equal(PositionTimestampDisposition.Apply, secondDisposition);
|
||||
Assert.Equal((uint)0, second.PlacementId);
|
||||
Assert.Equal((uint)0, second.Physics!.Value.AnimationFrame);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemotePositionWithoutVelocityAppliesUnpackedZeroVector()
|
||||
{
|
||||
var controller = new InboundPhysicsStateController();
|
||||
WorldSession.EntitySpawn seed = Spawn(
|
||||
0x7000000Au, 7, 20, 1, Position(0x0101FFFFu, 10f), 0x408u);
|
||||
seed = seed with
|
||||
{
|
||||
Physics = seed.Physics!.Value with { Velocity = new Vector3(4f, 5f, 6f) },
|
||||
};
|
||||
controller.AcceptCreate(seed);
|
||||
|
||||
Assert.True(controller.TryApplyPosition(
|
||||
new WorldSession.EntityPositionUpdate(
|
||||
seed.Guid,
|
||||
Position(0x0101FFFFu, 20f),
|
||||
null,
|
||||
null,
|
||||
true,
|
||||
7,
|
||||
21,
|
||||
0,
|
||||
0),
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
out PositionTimestampDisposition disposition,
|
||||
out WorldSession.EntitySpawn accepted,
|
||||
out _));
|
||||
|
||||
Assert.Equal(PositionTimestampDisposition.Apply, disposition);
|
||||
Assert.Equal(Vector3.Zero, accepted.Physics!.Value.Velocity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LocalNormalCorrectionRetainsLiveVelocity_ButFreshTeleportZerosIt()
|
||||
{
|
||||
var controller = new InboundPhysicsStateController();
|
||||
WorldSession.EntitySpawn seed = WithTimestamps(
|
||||
Spawn(0x5000000Au, 7, 20, 1, Position(0x0101FFFFu, 10f), 0x408u),
|
||||
teleport: 10);
|
||||
controller.AcceptCreate(seed);
|
||||
Vector3 liveVelocity = new(4f, 5f, 6f);
|
||||
|
||||
Assert.True(controller.TryApplyPosition(
|
||||
new WorldSession.EntityPositionUpdate(
|
||||
seed.Guid,
|
||||
Position(0x0101FFFFu, 20f),
|
||||
new Vector3(90f, 80f, 70f),
|
||||
null,
|
||||
true,
|
||||
7,
|
||||
21,
|
||||
10,
|
||||
0),
|
||||
true,
|
||||
null,
|
||||
liveVelocity,
|
||||
out PositionTimestampDisposition normalDisposition,
|
||||
out WorldSession.EntitySpawn normal,
|
||||
out _));
|
||||
Assert.Equal(PositionTimestampDisposition.Apply, normalDisposition);
|
||||
Assert.Equal(liveVelocity, normal.Physics!.Value.Velocity);
|
||||
|
||||
Assert.True(controller.TryApplyPosition(
|
||||
new WorldSession.EntityPositionUpdate(
|
||||
seed.Guid,
|
||||
Position(0x0101FFFFu, 30f),
|
||||
new Vector3(9f, 8f, 7f),
|
||||
null,
|
||||
true,
|
||||
7,
|
||||
22,
|
||||
11,
|
||||
0),
|
||||
true,
|
||||
null,
|
||||
liveVelocity,
|
||||
out PositionTimestampDisposition teleportDisposition,
|
||||
out WorldSession.EntitySpawn teleported,
|
||||
out _));
|
||||
Assert.Equal(PositionTimestampDisposition.Apply, teleportDisposition);
|
||||
Assert.Equal(Vector3.Zero, teleported.Physics!.Value.Velocity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParentEventWaitsForParent_ThenConsumesChildPositionOnce()
|
||||
{
|
||||
var controller = new InboundPhysicsStateController();
|
||||
controller.AcceptCreate(Spawn(
|
||||
0x70000003u, 3, 4, 1, Position(0x0101FFFFu, 10f), 0x408u));
|
||||
var update = new ParentEvent.Parsed(
|
||||
0x70000004u, 0x70000003u, 1, 2, 9, 5);
|
||||
|
||||
Assert.False(controller.TryApplyParent(update, out _));
|
||||
controller.AcceptCreate(Spawn(
|
||||
0x70000004u, 9, 1, 1, Position(0x0101FFFFu, 15f), 0x408u));
|
||||
Assert.True(controller.TryApplyParent(update, out WorldSession.EntitySpawn attached));
|
||||
Assert.Equal(0x70000004u, attached.ParentGuid);
|
||||
Assert.Null(attached.Position);
|
||||
Assert.False(controller.TryApplyParent(update, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LocalDeleteIsRejected_AndClearDropsSessionState()
|
||||
{
|
||||
var controller = new InboundPhysicsStateController();
|
||||
controller.AcceptCreate(Spawn(
|
||||
0x50000001u, 4, 1, 1, Position(0x0101FFFFu, 10f), 0x408u));
|
||||
|
||||
Assert.False(controller.TryDelete(new DeleteObject.Parsed(0x50000001u, 4), true));
|
||||
Assert.True(controller.TryGetSnapshot(0x50000001u, out _));
|
||||
|
||||
controller.Clear();
|
||||
Assert.Empty(controller.Snapshots);
|
||||
Assert.False(controller.IsFreshTeleportStart(0x50000001u, 1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeleteForUnknownGenerationIsRejected()
|
||||
{
|
||||
var controller = new InboundPhysicsStateController();
|
||||
|
||||
Assert.False(controller.TryDelete(
|
||||
new DeleteObject.Parsed(0x5000FFFFu, 9), isLocalPlayer: false));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TeleportStartIsComparedWithoutAdvancingTeleportTimestamp()
|
||||
{
|
||||
var controller = new InboundPhysicsStateController();
|
||||
WorldSession.EntitySpawn spawn = Spawn(
|
||||
0x50000002u, 2, 1, 1, Position(0x0101FFFFu, 10f), 0x408u);
|
||||
PhysicsSpawnData physics = spawn.Physics!.Value with
|
||||
{
|
||||
Timestamps = spawn.Physics.Value.Timestamps with { Teleport = 10 },
|
||||
};
|
||||
controller.AcceptCreate(spawn with { Physics = physics });
|
||||
|
||||
Assert.True(controller.IsFreshTeleportStart(0x50000002u, 10));
|
||||
Assert.False(controller.IsFreshTeleportStart(0x50000002u, 9));
|
||||
Assert.True(controller.IsFreshTeleportStart(0x50000002u, 11));
|
||||
Assert.True(controller.IsFreshTeleportStart(0x50000002u, 11));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RejectedServerControlStillMirrorsConsumedMovementTimestamp()
|
||||
{
|
||||
var controller = new InboundPhysicsStateController();
|
||||
WorldSession.EntitySpawn spawn = WithTimestamps(
|
||||
Spawn(0x70000005u, 3, 1, 1, Position(0x0101FFFFu, 10f), 0x408u),
|
||||
movement: 1,
|
||||
serverControl: 5);
|
||||
controller.AcceptCreate(spawn);
|
||||
|
||||
bool applied = controller.TryApplyMotion(
|
||||
new WorldSession.EntityMotionUpdate(
|
||||
spawn.Guid,
|
||||
new CreateObject.ServerMotionState(0x3d, 0x11),
|
||||
3,
|
||||
2,
|
||||
4,
|
||||
false),
|
||||
retainPayload: true,
|
||||
out _,
|
||||
out _);
|
||||
|
||||
Assert.False(applied);
|
||||
Assert.True(controller.TryGetSnapshot(spawn.Guid, out WorldSession.EntitySpawn retained));
|
||||
Assert.Equal((ushort)2, retained.MovementSequence);
|
||||
Assert.Equal((ushort)5, retained.ServerControlSequence);
|
||||
Assert.Equal((ushort)2, retained.Physics!.Value.Timestamps.Movement);
|
||||
Assert.Equal((ushort)5, retained.Physics.Value.Timestamps.ServerControlledMove);
|
||||
Assert.Equal(spawn.MotionState, retained.MotionState);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AutonomousLocalEchoRetainsPayloadButMirrorsAcceptedTimestamps()
|
||||
{
|
||||
var controller = new InboundPhysicsStateController();
|
||||
WorldSession.EntitySpawn spawn = WithTimestamps(
|
||||
Spawn(0x50000006u, 3, 1, 1, Position(0x0101FFFFu, 10f), 0x408u),
|
||||
movement: 1,
|
||||
serverControl: 5) with
|
||||
{
|
||||
MotionState = new CreateObject.ServerMotionState(0x3d, 0x10),
|
||||
};
|
||||
controller.AcceptCreate(spawn);
|
||||
|
||||
Assert.True(controller.TryApplyMotion(
|
||||
new WorldSession.EntityMotionUpdate(
|
||||
spawn.Guid,
|
||||
new CreateObject.ServerMotionState(0x3d, 0x12),
|
||||
3,
|
||||
2,
|
||||
5,
|
||||
true),
|
||||
retainPayload: false,
|
||||
out WorldSession.EntitySpawn retained,
|
||||
out _));
|
||||
|
||||
Assert.Equal(spawn.MotionState, retained.MotionState);
|
||||
Assert.Equal((ushort)2, retained.MovementSequence);
|
||||
Assert.Equal((ushort)5, retained.ServerControlSequence);
|
||||
Assert.Equal((ushort)2, retained.Physics!.Value.Timestamps.Movement);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FreshForceWithOlderTeleportMirrorsForceButRejectsPose()
|
||||
{
|
||||
var controller = new InboundPhysicsStateController();
|
||||
WorldSession.EntitySpawn spawn = WithTimestamps(
|
||||
Spawn(0x50000007u, 3, 10, 1, Position(0x0101FFFFu, 10f), 0x408u),
|
||||
teleport: 10,
|
||||
forcePosition: 0);
|
||||
controller.AcceptCreate(spawn);
|
||||
|
||||
Assert.True(controller.TryApplyPosition(
|
||||
new WorldSession.EntityPositionUpdate(
|
||||
spawn.Guid,
|
||||
Position(0x0101FFFFu, 99f),
|
||||
null,
|
||||
null,
|
||||
true,
|
||||
3,
|
||||
11,
|
||||
9,
|
||||
1),
|
||||
isLocalPlayer: true,
|
||||
forcePositionRotation: Quaternion.Identity,
|
||||
currentLocalVelocity: Vector3.Zero,
|
||||
out PositionTimestampDisposition disposition,
|
||||
out WorldSession.EntitySpawn retained,
|
||||
out _));
|
||||
|
||||
Assert.Equal(PositionTimestampDisposition.Rejected, disposition);
|
||||
Assert.Equal(10f, retained.Position!.Value.PositionX);
|
||||
Assert.Equal((ushort)10, retained.PositionSequence);
|
||||
Assert.Equal((ushort)1, retained.Physics!.Value.Timestamps.ForcePosition);
|
||||
Assert.Equal((ushort)10, retained.Physics.Value.Timestamps.Position);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ForcePositionStoresPreservedLocalHeadingForLaterHydration()
|
||||
{
|
||||
var controller = new InboundPhysicsStateController();
|
||||
WorldSession.EntitySpawn spawn = WithTimestamps(
|
||||
Spawn(0x50000008u, 3, 10, 1, Position(0x0101FFFFu, 10f), 0x408u),
|
||||
teleport: 10,
|
||||
forcePosition: 0);
|
||||
controller.AcceptCreate(spawn);
|
||||
Quaternion heading = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 0.75f);
|
||||
Vector3 liveVelocity = new(1f, 2f, 3f);
|
||||
|
||||
Assert.True(controller.TryApplyPosition(
|
||||
new WorldSession.EntityPositionUpdate(
|
||||
spawn.Guid,
|
||||
Position(0x0101FFFFu, 99f),
|
||||
new Vector3(90f, 80f, 70f),
|
||||
null,
|
||||
true,
|
||||
3,
|
||||
9,
|
||||
10,
|
||||
1),
|
||||
isLocalPlayer: true,
|
||||
forcePositionRotation: heading,
|
||||
currentLocalVelocity: liveVelocity,
|
||||
out PositionTimestampDisposition disposition,
|
||||
out WorldSession.EntitySpawn retained,
|
||||
out _));
|
||||
|
||||
Assert.Equal(PositionTimestampDisposition.ForcePosition, disposition);
|
||||
Assert.Equal(99f, retained.Position!.Value.PositionX);
|
||||
Assert.Equal(heading.W, retained.Position.Value.RotationW, precision: 6);
|
||||
Assert.Equal(heading.X, retained.Position.Value.RotationX, precision: 6);
|
||||
Assert.Equal(heading.Y, retained.Position.Value.RotationY, precision: 6);
|
||||
Assert.Equal(heading.Z, retained.Position.Value.RotationZ, precision: 6);
|
||||
Assert.Equal(retained.Position, retained.Physics!.Value.Position);
|
||||
Assert.Equal(liveVelocity, retained.Physics.Value.Velocity);
|
||||
}
|
||||
|
||||
private static WorldSession.EntitySpawn WithTimestamps(
|
||||
WorldSession.EntitySpawn spawn,
|
||||
ushort? movement = null,
|
||||
ushort? serverControl = null,
|
||||
ushort? teleport = null,
|
||||
ushort? forcePosition = null)
|
||||
{
|
||||
PhysicsSpawnData physics = spawn.Physics!.Value;
|
||||
PhysicsTimestamps timestamps = physics.Timestamps with
|
||||
{
|
||||
Movement = movement ?? physics.Timestamps.Movement,
|
||||
ServerControlledMove = serverControl ?? physics.Timestamps.ServerControlledMove,
|
||||
Teleport = teleport ?? physics.Timestamps.Teleport,
|
||||
ForcePosition = forcePosition ?? physics.Timestamps.ForcePosition,
|
||||
};
|
||||
return spawn with
|
||||
{
|
||||
MovementSequence = timestamps.Movement,
|
||||
ServerControlSequence = timestamps.ServerControlledMove,
|
||||
Physics = physics with { Timestamps = timestamps },
|
||||
};
|
||||
}
|
||||
|
||||
private static WorldSession.EntitySpawn Spawn(
|
||||
uint guid,
|
||||
ushort instance,
|
||||
ushort positionSequence,
|
||||
ushort stateSequence,
|
||||
CreateObject.ServerPosition? position,
|
||||
uint state)
|
||||
{
|
||||
var timestamps = new PhysicsTimestamps(
|
||||
positionSequence,
|
||||
1,
|
||||
stateSequence,
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
instance);
|
||||
var physics = new PhysicsSpawnData(
|
||||
RawState: state,
|
||||
Position: position,
|
||||
Movement: null,
|
||||
AnimationFrame: null,
|
||||
SetupTableId: 0x02000001u,
|
||||
MotionTableId: 0x09000001u,
|
||||
SoundTableId: null,
|
||||
PhysicsScriptTableId: null,
|
||||
Parent: null,
|
||||
Children: null,
|
||||
Scale: null,
|
||||
Friction: null,
|
||||
Elasticity: null,
|
||||
Translucency: null,
|
||||
Velocity: null,
|
||||
Acceleration: null,
|
||||
AngularVelocity: null,
|
||||
DefaultScriptType: null,
|
||||
DefaultScriptIntensity: null,
|
||||
Timestamps: timestamps);
|
||||
return new WorldSession.EntitySpawn(
|
||||
guid,
|
||||
position,
|
||||
0x02000001u,
|
||||
Array.Empty<CreateObject.AnimPartChange>(),
|
||||
Array.Empty<CreateObject.TextureChange>(),
|
||||
Array.Empty<CreateObject.SubPaletteSwap>(),
|
||||
null,
|
||||
null,
|
||||
"fixture",
|
||||
null,
|
||||
null,
|
||||
0x09000001u,
|
||||
PhysicsState: state,
|
||||
InstanceSequence: instance,
|
||||
MovementSequence: 1,
|
||||
ServerControlSequence: 1,
|
||||
PositionSequence: positionSequence,
|
||||
Physics: physics);
|
||||
}
|
||||
|
||||
private static CreateObject.ServerPosition Position(uint cell, float x) =>
|
||||
new(cell, x, 10f, 5f, 1f, 0f, 0f, 0f);
|
||||
}
|
||||
335
tests/AcDream.App.Tests/World/ParentAttachmentStateTests.cs
Normal file
335
tests/AcDream.App.Tests/World/ParentAttachmentStateTests.cs
Normal file
|
|
@ -0,0 +1,335 @@
|
|||
using AcDream.App.Rendering;
|
||||
using AcDream.App.World;
|
||||
using AcDream.Core.Net;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Items;
|
||||
|
||||
namespace AcDream.App.Tests.World;
|
||||
|
||||
public sealed class ParentAttachmentStateTests
|
||||
{
|
||||
[Fact]
|
||||
public void ParentEventBeforeChildCreateResolvesToCanonicalAttachment()
|
||||
{
|
||||
const uint parentGuid = 0x70000100u;
|
||||
const uint childGuid = 0x70000101u;
|
||||
var inbound = new InboundPhysicsStateController();
|
||||
var relations = new ParentAttachmentState();
|
||||
inbound.AcceptCreate(Spawn(parentGuid, instance: 9, positionSequence: 1));
|
||||
relations.Enqueue(new ParentEvent.Parsed(parentGuid, childGuid, 1, 2, 9, 5));
|
||||
|
||||
Resolve(relations, inbound, childGuid);
|
||||
Assert.False(relations.TryGetProjection(childGuid, out _));
|
||||
|
||||
inbound.AcceptCreate(Spawn(childGuid, instance: 3, positionSequence: 4));
|
||||
Resolve(relations, inbound, childGuid);
|
||||
|
||||
Assert.True(relations.TryGetProjection(childGuid, out ParentAttachmentRelation projection));
|
||||
Assert.Equal(parentGuid, projection.ParentGuid);
|
||||
Assert.True(inbound.TryGetSnapshot(childGuid, out WorldSession.EntitySpawn child));
|
||||
Assert.Null(child.Position);
|
||||
Assert.Equal(parentGuid, child.ParentGuid);
|
||||
Assert.Equal((ushort)5, child.PositionSequence);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MultipleQueuedRelationsRemainOrderedAndNewestAcceptedWins()
|
||||
{
|
||||
const uint parentGuid = 0x70000110u;
|
||||
const uint childGuid = 0x70000111u;
|
||||
var gate = new PhysicsTimestampGate();
|
||||
gate.SeedForCreateObject(4, 0, 0, 0, 0, 0, 0, 0, 3);
|
||||
var relations = new ParentAttachmentState();
|
||||
relations.Enqueue(new ParentEvent.Parsed(parentGuid, childGuid, 1, 2, 9, 5));
|
||||
relations.Enqueue(new ParentEvent.Parsed(parentGuid, childGuid, 1, 3, 9, 6));
|
||||
|
||||
relations.Resolve(
|
||||
childGuid,
|
||||
_ => true,
|
||||
guid => guid == parentGuid ? (ushort)9 : null,
|
||||
update => gate.TryAcceptPositionChannelEvent(3, update.ChildPositionSequence));
|
||||
|
||||
Assert.True(relations.TryGetProjection(childGuid, out ParentAttachmentRelation projection));
|
||||
Assert.Equal((ushort)6, projection.ChildPositionSequence);
|
||||
Assert.Equal((uint)3, projection.PlacementId);
|
||||
relations.MarkProjected(childGuid);
|
||||
Assert.True(relations.RestoreLastAccepted(childGuid));
|
||||
Assert.True(relations.TryGetProjection(childGuid, out projection));
|
||||
Assert.Equal((ushort)6, projection.ChildPositionSequence);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RejectedStaleRelationCannotReplaceAcceptedCreateRelation()
|
||||
{
|
||||
const uint childGuid = 0x70000121u;
|
||||
var gate = new PhysicsTimestampGate();
|
||||
gate.SeedForCreateObject(10, 0, 0, 0, 0, 0, 0, 0, 3);
|
||||
var relations = new ParentAttachmentState();
|
||||
relations.AcceptCreateObjectRelation(new ParentAttachmentRelation(
|
||||
0x70000120u, childGuid, 1, 2, 0, 10));
|
||||
relations.Enqueue(new ParentEvent.Parsed(
|
||||
0x70000122u, childGuid, 1, 4, 8, 9));
|
||||
|
||||
relations.Resolve(
|
||||
childGuid,
|
||||
_ => true,
|
||||
guid => guid == 0x70000122u ? (ushort)8 : null,
|
||||
update => gate.TryAcceptPositionChannelEvent(3, update.ChildPositionSequence));
|
||||
|
||||
Assert.True(relations.TryGetProjection(childGuid, out ParentAttachmentRelation projection));
|
||||
Assert.Equal(0x70000120u, projection.ParentGuid);
|
||||
Assert.Equal((ushort)10, projection.ChildPositionSequence);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FutureParentGenerationDoesNotBlockCurrentRelationBehindIt()
|
||||
{
|
||||
const uint childGuid = 0x70000141u;
|
||||
const uint parentGuid = 0x70000140u;
|
||||
var gate = new PhysicsTimestampGate();
|
||||
gate.SeedForCreateObject(4, 0, 0, 0, 0, 0, 0, 0, 3);
|
||||
var relations = new ParentAttachmentState();
|
||||
relations.Enqueue(new ParentEvent.Parsed(
|
||||
parentGuid, childGuid, 1, 2, 10, 6));
|
||||
relations.Enqueue(new ParentEvent.Parsed(
|
||||
parentGuid, childGuid, 1, 3, 9, 5));
|
||||
|
||||
relations.Resolve(
|
||||
childGuid,
|
||||
_ => true,
|
||||
guid => guid == parentGuid ? (ushort)9 : null,
|
||||
update => gate.TryAcceptPositionChannelEvent(3, update.ChildPositionSequence));
|
||||
|
||||
Assert.True(relations.TryGetProjection(childGuid, out ParentAttachmentRelation projection));
|
||||
Assert.Equal((ushort)5, projection.ChildPositionSequence);
|
||||
Assert.Equal((uint)3, projection.PlacementId);
|
||||
Assert.Contains(childGuid, relations.ChildrenWaitingForParent(parentGuid));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FutureRelationSurvivesAtomicParentGenerationReplacement()
|
||||
{
|
||||
const uint parentGuid = 0x70000150u;
|
||||
const uint childGuid = 0x70000151u;
|
||||
var inbound = new InboundPhysicsStateController();
|
||||
var relations = new ParentAttachmentState();
|
||||
var objects = new ClientObjectTable();
|
||||
inbound.AcceptCreate(Spawn(parentGuid, instance: 9, positionSequence: 1));
|
||||
inbound.AcceptCreate(Spawn(childGuid, instance: 3, positionSequence: 4));
|
||||
relations.AcceptCreateObjectRelation(new ParentAttachmentRelation(
|
||||
parentGuid, childGuid, 1, 2, 0, 4));
|
||||
relations.Enqueue(new ParentEvent.Parsed(
|
||||
parentGuid, childGuid, 1, 3, 10, 5));
|
||||
Resolve(relations, inbound, childGuid);
|
||||
objects.Ingest(MinimalWeenie(parentGuid, "generation 9"));
|
||||
objects.ObjectRemovalClassified += removal =>
|
||||
{
|
||||
if (removal.Reason == ClientObjectRemovalReason.GenerationReplacement)
|
||||
relations.EndGeneration(removal.Object.ObjectId, removal.Generation);
|
||||
};
|
||||
|
||||
inbound.AcceptCreate(Spawn(parentGuid, instance: 10, positionSequence: 1));
|
||||
objects.ReplaceGeneration(MinimalWeenie(parentGuid, "generation 10"), 10);
|
||||
Resolve(relations, inbound, childGuid);
|
||||
|
||||
Assert.True(relations.TryGetProjection(childGuid, out ParentAttachmentRelation projection));
|
||||
Assert.Equal(parentGuid, projection.ParentGuid);
|
||||
Assert.Equal((ushort)10, projection.ParentInstanceSequence);
|
||||
Assert.Equal((ushort)5, projection.ChildPositionSequence);
|
||||
Assert.True(inbound.TryGetSnapshot(childGuid, out WorldSession.EntitySpawn child));
|
||||
Assert.Equal(parentGuid, child.ParentGuid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FutureParentRelationSurvivesChildGenerationReplacement()
|
||||
{
|
||||
const uint parentGuid = 0x70000180u;
|
||||
const uint childGuid = 0x70000181u;
|
||||
var relations = new ParentAttachmentState();
|
||||
relations.Enqueue(new ParentEvent.Parsed(
|
||||
parentGuid, childGuid, 1, 3, 10, 5));
|
||||
|
||||
// Parent generation 9 exists, so retail queues this blob against the
|
||||
// future parent rather than against the already-live child.
|
||||
relations.Resolve(
|
||||
childGuid,
|
||||
_ => true,
|
||||
guid => guid == parentGuid ? (ushort)9 : null,
|
||||
_ => throw new InvalidOperationException("future parent must not apply"));
|
||||
relations.EndGeneration(childGuid, replacementGeneration: 4);
|
||||
|
||||
var replacementGate = new PhysicsTimestampGate();
|
||||
replacementGate.SeedForCreateObject(4, 0, 0, 0, 0, 0, 0, 0, 4);
|
||||
relations.Resolve(
|
||||
childGuid,
|
||||
_ => true,
|
||||
guid => guid == parentGuid ? (ushort)10 : null,
|
||||
update => replacementGate.TryAcceptPositionChannelEvent(
|
||||
4, update.ChildPositionSequence));
|
||||
|
||||
Assert.True(relations.TryGetProjection(childGuid, out ParentAttachmentRelation projection));
|
||||
Assert.Equal((ushort)10, projection.ParentInstanceSequence);
|
||||
Assert.Equal((ushort)5, projection.ChildPositionSequence);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemoveAndClearDiscardPendingAndRollbackState()
|
||||
{
|
||||
const uint parentGuid = 0x70000130u;
|
||||
const uint childGuid = 0x70000131u;
|
||||
var relations = new ParentAttachmentState();
|
||||
relations.AcceptCreateObjectRelation(new ParentAttachmentRelation(
|
||||
parentGuid, childGuid, 1, 2, 0, 4));
|
||||
relations.Enqueue(new ParentEvent.Parsed(parentGuid, childGuid, 1, 3, 9, 5));
|
||||
|
||||
relations.RemoveObject(parentGuid);
|
||||
Assert.False(relations.TryGetProjection(childGuid, out _));
|
||||
Assert.False(relations.RestoreLastAccepted(childGuid));
|
||||
Assert.Empty(relations.ChildrenWaitingForParent(parentGuid));
|
||||
|
||||
relations.AcceptCreateObjectRelation(new ParentAttachmentRelation(
|
||||
parentGuid, childGuid, 1, 2, 0, 4));
|
||||
relations.Clear();
|
||||
Assert.False(relations.TryGetProjection(childGuid, out _));
|
||||
Assert.False(relations.RestoreLastAccepted(childGuid));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnparentProjectionRetainsFresherPendingParentEvent()
|
||||
{
|
||||
const uint parentGuid = 0x70000160u;
|
||||
const uint childGuid = 0x70000161u;
|
||||
var gate = new PhysicsTimestampGate();
|
||||
gate.SeedForCreateObject(4, 0, 0, 0, 0, 0, 0, 0, 3);
|
||||
var relations = new ParentAttachmentState();
|
||||
relations.AcceptCreateObjectRelation(new ParentAttachmentRelation(
|
||||
0x70000162u, childGuid, 1, 2, 0, 4));
|
||||
relations.Enqueue(new ParentEvent.Parsed(
|
||||
parentGuid, childGuid, 1, 3, 9, 5));
|
||||
|
||||
relations.EndChildProjection(childGuid);
|
||||
Assert.False(relations.TryGetProjection(childGuid, out _));
|
||||
relations.Resolve(
|
||||
childGuid,
|
||||
_ => true,
|
||||
guid => guid == parentGuid ? (ushort)9 : null,
|
||||
update => gate.TryAcceptPositionChannelEvent(3, update.ChildPositionSequence));
|
||||
|
||||
Assert.True(relations.TryGetProjection(childGuid, out ParentAttachmentRelation projection));
|
||||
Assert.Equal(parentGuid, projection.ParentGuid);
|
||||
Assert.Equal((ushort)5, projection.ChildPositionSequence);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExactParentDeleteRetainsOnlyStrictlyFutureGenerationRelation()
|
||||
{
|
||||
const uint parentGuid = 0x70000170u;
|
||||
const uint childGuid = 0x70000171u;
|
||||
var gate = new PhysicsTimestampGate();
|
||||
gate.SeedForCreateObject(4, 0, 0, 0, 0, 0, 0, 0, 3);
|
||||
var relations = new ParentAttachmentState();
|
||||
relations.Enqueue(new ParentEvent.Parsed(
|
||||
parentGuid, childGuid, 1, 2, 9, 5));
|
||||
relations.Enqueue(new ParentEvent.Parsed(
|
||||
parentGuid, childGuid, 1, 3, 10, 6));
|
||||
|
||||
relations.DeleteGeneration(parentGuid, deletedGeneration: 9);
|
||||
relations.Resolve(
|
||||
childGuid,
|
||||
_ => true,
|
||||
guid => guid == parentGuid ? (ushort)10 : null,
|
||||
update => gate.TryAcceptPositionChannelEvent(3, update.ChildPositionSequence));
|
||||
|
||||
Assert.True(relations.TryGetProjection(childGuid, out ParentAttachmentRelation projection));
|
||||
Assert.Equal((ushort)10, projection.ParentInstanceSequence);
|
||||
Assert.Equal((ushort)6, projection.ChildPositionSequence);
|
||||
}
|
||||
|
||||
private static void Resolve(
|
||||
ParentAttachmentState relations,
|
||||
InboundPhysicsStateController inbound,
|
||||
uint childGuid) =>
|
||||
relations.Resolve(
|
||||
childGuid,
|
||||
guid => inbound.TryGetSnapshot(guid, out _),
|
||||
guid => inbound.TryGetSnapshot(guid, out WorldSession.EntitySpawn spawn)
|
||||
? spawn.InstanceSequence
|
||||
: null,
|
||||
update => inbound.TryApplyParent(update, out _));
|
||||
|
||||
private static WorldSession.EntitySpawn Spawn(
|
||||
uint guid,
|
||||
ushort instance,
|
||||
ushort positionSequence)
|
||||
{
|
||||
var position = new CreateObject.ServerPosition(
|
||||
0x0101FFFFu, 10f, 10f, 5f, 1f, 0f, 0f, 0f);
|
||||
var timestamps = new PhysicsTimestamps(
|
||||
positionSequence, 1, 1, 1, 0, 1, 0, 1, instance);
|
||||
var physics = new PhysicsSpawnData(
|
||||
RawState: 0x408u,
|
||||
Position: position,
|
||||
Movement: null,
|
||||
AnimationFrame: null,
|
||||
SetupTableId: 0x02000001u,
|
||||
MotionTableId: 0x09000001u,
|
||||
SoundTableId: null,
|
||||
PhysicsScriptTableId: null,
|
||||
Parent: null,
|
||||
Children: null,
|
||||
Scale: null,
|
||||
Friction: null,
|
||||
Elasticity: null,
|
||||
Translucency: null,
|
||||
Velocity: null,
|
||||
Acceleration: null,
|
||||
AngularVelocity: null,
|
||||
DefaultScriptType: null,
|
||||
DefaultScriptIntensity: null,
|
||||
Timestamps: timestamps);
|
||||
return new WorldSession.EntitySpawn(
|
||||
guid,
|
||||
position,
|
||||
0x02000001u,
|
||||
Array.Empty<CreateObject.AnimPartChange>(),
|
||||
Array.Empty<CreateObject.TextureChange>(),
|
||||
Array.Empty<CreateObject.SubPaletteSwap>(),
|
||||
null,
|
||||
null,
|
||||
"fixture",
|
||||
null,
|
||||
null,
|
||||
0x09000001u,
|
||||
PhysicsState: 0x408u,
|
||||
InstanceSequence: instance,
|
||||
MovementSequence: 1,
|
||||
ServerControlSequence: 1,
|
||||
PositionSequence: positionSequence,
|
||||
Physics: physics);
|
||||
}
|
||||
|
||||
private static WeenieData MinimalWeenie(uint guid, string name) => new(
|
||||
Guid: guid,
|
||||
Name: name,
|
||||
Type: null,
|
||||
WeenieClassId: 0,
|
||||
IconId: 0,
|
||||
IconOverlayId: 0,
|
||||
IconUnderlayId: 0,
|
||||
Effects: 0,
|
||||
Value: null,
|
||||
StackSize: null,
|
||||
StackSizeMax: null,
|
||||
Burden: null,
|
||||
ContainerId: null,
|
||||
WielderId: null,
|
||||
ValidLocations: null,
|
||||
CurrentWieldedLocation: null,
|
||||
Priority: null,
|
||||
ItemsCapacity: null,
|
||||
ContainersCapacity: null,
|
||||
Structure: null,
|
||||
MaxStructure: null,
|
||||
Workmanship: null);
|
||||
}
|
||||
|
|
@ -564,7 +564,7 @@ public sealed class CreateObjectTests
|
|||
{
|
||||
// L.2g S1 (DEV-6): index 1 of the 9-u16 PhysicsDesc timestamp block
|
||||
// is ObjectMovement (ACE WorldObject_Networking.cs:412) — it seeds
|
||||
// MotionSequenceGate's MOVEMENT_TS so post-spawn UpdateMotion events
|
||||
// PhysicsTimestampGate's MOVEMENT_TS so post-spawn UpdateMotion events
|
||||
// are judged against the entity's live sequence, not zero.
|
||||
byte[] body = BuildMinimalCreateObjectWithWeenieHeader(
|
||||
guid: 0x50000030u, name: "Runner", itemType: 0x10u,
|
||||
|
|
|
|||
|
|
@ -37,41 +37,4 @@ public sealed class DeleteObjectTests
|
|||
Assert.Equal((ushort)0x1234, parsed.Value.InstanceSequence);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Regression guard: TryParse (0xF747 = true destroy) must always produce
|
||||
/// FromPickup = false. PickupEvent (0xF74A) is a different opcode and is
|
||||
/// the ONLY path that sets FromPickup = true (in WorldSession).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TryParse_AlwaysReturnsFromPickupFalse()
|
||||
{
|
||||
Span<byte> body = stackalloc byte[12];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body, DeleteObject.Opcode);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.Slice(4), 0x80000001u);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(body.Slice(8), 0x0001);
|
||||
|
||||
var parsed = DeleteObject.TryParse(body);
|
||||
|
||||
Assert.NotNull(parsed);
|
||||
Assert.False(parsed!.Value.FromPickup,
|
||||
"DeleteObject 0xF747 is a true destroy — FromPickup must be false.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// FromPickup = true can be constructed via the record directly (as
|
||||
/// WorldSession does for PickupEvent 0xF74A). Default is false.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Parsed_DefaultFromPickupIsFalse()
|
||||
{
|
||||
var p = new DeleteObject.Parsed(0x80000001u, 1);
|
||||
Assert.False(p.FromPickup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parsed_FromPickupTrueCanBeSet()
|
||||
{
|
||||
var p = new DeleteObject.Parsed(0x80000001u, 1, FromPickup: true);
|
||||
Assert.True(p.FromPickup);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -61,14 +61,16 @@ public sealed class ObjDescEventTests
|
|||
// 4-byte align after AnimPartChanges (none here, so just align).
|
||||
while (bytes.Count % 4 != 0) bytes.Add(0);
|
||||
|
||||
// Trailing instance + visual-desc sequences (consumed but ignored).
|
||||
AppendU32(bytes, 0x12345678u);
|
||||
AppendU32(bytes, 0x9ABCDEF0u);
|
||||
// Trailing PhysicsTimestampPack: two u16 values.
|
||||
AppendU16(bytes, 0x5678);
|
||||
AppendU16(bytes, 0xDEF0);
|
||||
|
||||
var parsed = ObjDescEvent.TryParse(bytes.ToArray());
|
||||
|
||||
Assert.NotNull(parsed);
|
||||
Assert.Equal(0x50000001u, parsed!.Value.Guid);
|
||||
Assert.Equal((ushort)0x5678, parsed.Value.InstanceSequence);
|
||||
Assert.Equal((ushort)0xDEF0, parsed.Value.ObjDescSequence);
|
||||
|
||||
var md = parsed.Value.ModelData;
|
||||
Assert.Equal(0x0400007Eu, md.BasePaletteId);
|
||||
|
|
@ -87,6 +89,9 @@ public sealed class ObjDescEventTests
|
|||
Assert.Equal(3, md.TextureChanges[3].PartIndex);
|
||||
|
||||
Assert.Empty(md.AnimPartChanges);
|
||||
|
||||
Assert.Null(ObjDescEvent.TryParse(bytes.ToArray()[..^1]));
|
||||
Assert.Null(ObjDescEvent.TryParse([.. bytes, 0]));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -126,6 +131,13 @@ public sealed class ObjDescEventTests
|
|||
dest.AddRange(tmp.ToArray());
|
||||
}
|
||||
|
||||
private static void AppendU16(List<byte> dest, ushort value)
|
||||
{
|
||||
Span<byte> tmp = stackalloc byte[2];
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(tmp, value);
|
||||
dest.AddRange(tmp.ToArray());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mirror of ACE's WritePackedDwordOfKnownType: strip the type prefix
|
||||
/// if it matches <paramref name="knownType"/>, then write as a 16- or
|
||||
|
|
|
|||
|
|
@ -214,6 +214,11 @@ public sealed class ProjectileVfxPacketFixtureTests
|
|||
Assert.Equal(0xF754u, BinaryPrimitives.ReadUInt32LittleEndian(packet));
|
||||
Assert.Equal(0x50000042u, BinaryPrimitives.ReadUInt32LittleEndian(packet[4..]));
|
||||
Assert.Equal(0x33000099u, BinaryPrimitives.ReadUInt32LittleEndian(packet[8..]));
|
||||
|
||||
var parsed = PlayPhysicsScript.TryParse(packet);
|
||||
Assert.Equal(new PlayPhysicsScript(0x50000042u, 0x33000099u), parsed);
|
||||
Assert.Null(PlayPhysicsScript.TryParse(packet[..^1]));
|
||||
Assert.Null(PlayPhysicsScript.TryParse([.. packet.ToArray(), 0]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -227,6 +232,28 @@ public sealed class ProjectileVfxPacketFixtureTests
|
|||
Assert.Equal(0xA5A5A5A5u, BinaryPrimitives.ReadUInt32LittleEndian(packet[8..]));
|
||||
Assert.Equal(0x7FC01234u, BinaryPrimitives.ReadUInt32LittleEndian(packet[12..]));
|
||||
Assert.True(float.IsNaN(BinaryPrimitives.ReadSingleLittleEndian(packet[12..])));
|
||||
|
||||
var parsed = PlayPhysicsScriptType.TryParse(packet);
|
||||
Assert.NotNull(parsed);
|
||||
Assert.Equal(0x50000042u, parsed.Value.Guid);
|
||||
Assert.Equal(0xA5A5A5A5u, parsed.Value.RawScriptType);
|
||||
Assert.Equal(0x7FC01234u, BitConverter.SingleToUInt32Bits(parsed.Value.Intensity));
|
||||
Assert.Null(PlayPhysicsScriptType.TryParse(packet[..^1]));
|
||||
Assert.Null(PlayPhysicsScriptType.TryParse([.. packet.ToArray(), 0]));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0x7F800000u)]
|
||||
[InlineData(0xFF800000u)]
|
||||
public void TypedF755_PreservesInfiniteIntensityBits(uint intensityBits)
|
||||
{
|
||||
byte[] packet = ProjectileVfxPacketFixtures.TypedF755UnknownNaN.ToArray();
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(packet.AsSpan(12), intensityBits);
|
||||
|
||||
var parsed = PlayPhysicsScriptType.TryParse(packet);
|
||||
|
||||
Assert.NotNull(parsed);
|
||||
Assert.Equal(intensityBits, BitConverter.SingleToUInt32Bits(parsed.Value.Intensity));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -248,6 +275,25 @@ public sealed class ProjectileVfxPacketFixtureTests
|
|||
Assert.Equal((ushort)0xFFF8, parsed.Value.PositionSequence);
|
||||
Assert.Equal((ushort)0xFFF9, parsed.Value.MovementSequence);
|
||||
Assert.Equal((ushort)0x0000, parsed.Value.InstanceSequence);
|
||||
|
||||
var physics = Assert.IsType<PhysicsSpawnData>(parsed.Value.Physics);
|
||||
Assert.Equal(0x00000748u, physics.RawState);
|
||||
Assert.True(physics.State.HasFlag(AcDream.Core.Physics.PhysicsStateFlags.Missile));
|
||||
Assert.Equal(0x20000014u, physics.SoundTableId);
|
||||
Assert.Equal(0x34000005u, physics.PhysicsScriptTableId);
|
||||
Assert.Equal(new PhysicsAttachment(0x50000010u, 3u), physics.Parent);
|
||||
Assert.Equal([new PhysicsAttachment(0x50000011u, 4u)],
|
||||
physics.Children!.Value.ToArray());
|
||||
Assert.Equal(0.25f, physics.Translucency);
|
||||
Assert.Equal(new System.Numerics.Vector3(10f, 20f, 30f), physics.Velocity);
|
||||
Assert.Equal(new System.Numerics.Vector3(1f, 2f, 3f), physics.Acceleration);
|
||||
Assert.Equal(new System.Numerics.Vector3(0.1f, 0.2f, 0.3f), physics.AngularVelocity);
|
||||
Assert.Equal(0xA5A5A5A5u, physics.DefaultScriptType);
|
||||
Assert.Equal(0x7FC01234u,
|
||||
BitConverter.SingleToUInt32Bits(physics.DefaultScriptIntensity!.Value));
|
||||
Assert.Equal(new PhysicsTimestamps(
|
||||
0xFFF8, 0xFFF9, 0xFFFA, 0xFFFB, 0xFFFC,
|
||||
0xFFFD, 0xFFFE, 0xFFFF, 0x0000), physics.Timestamps);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -264,6 +310,10 @@ public sealed class ProjectileVfxPacketFixtureTests
|
|||
Assert.Equal(0u, animation.Value.PlacementId);
|
||||
Assert.Equal((ushort)0x1108, movement.Value.InstanceSequence);
|
||||
Assert.Equal((ushort)0x1108, animation.Value.InstanceSequence);
|
||||
Assert.NotNull(movement.Value.Physics!.Value.Movement);
|
||||
Assert.True(movement.Value.Physics.Value.Movement!.Value.RawData.IsEmpty);
|
||||
Assert.Null(movement.Value.Physics.Value.Movement!.Value.IsAutonomous);
|
||||
Assert.Equal(0u, animation.Value.Physics!.Value.AnimationFrame);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -277,7 +327,24 @@ public sealed class ProjectileVfxPacketFixtureTests
|
|||
Assert.Equal(0u, BinaryPrimitives.ReadUInt32LittleEndian(present[20..]));
|
||||
Assert.Equal(0u, BinaryPrimitives.ReadUInt32LittleEndian(absent[12..]));
|
||||
Assert.Equal(present.Length, absent.Length + sizeof(uint));
|
||||
Assert.NotNull(CreateObject.TryParse(present));
|
||||
Assert.NotNull(CreateObject.TryParse(absent));
|
||||
var parsedPresent = CreateObject.TryParse(present);
|
||||
var parsedAbsent = CreateObject.TryParse(absent);
|
||||
Assert.NotNull(parsedPresent);
|
||||
Assert.NotNull(parsedAbsent);
|
||||
Assert.Equal(0u, parsedPresent.Value.Physics!.Value.PhysicsScriptTableId);
|
||||
Assert.Null(parsedAbsent.Value.Physics!.Value.PhysicsScriptTableId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GoldenCreateObject_RejectsEveryTruncationInsidePhysicsDesc()
|
||||
{
|
||||
byte[] packet = ProjectileVfxPacketFixtures.GoldenCreateObjectProjectilePhysicsFields;
|
||||
|
||||
// This fixture's PhysicsDesc ends at byte 168. Every shorter prefix
|
||||
// cuts either a gated field or the mandatory nine-timestamp tail.
|
||||
for (int length = 20; length < 168; length++)
|
||||
Assert.Null(CreateObject.TryParse(packet.AsSpan(0, length)));
|
||||
|
||||
Assert.NotNull(CreateObject.TryParse(packet.AsSpan(0, 168)));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,6 +50,10 @@ public class UpdatePositionTests
|
|||
Assert.Equal(1f, result.Value.Position.RotationW);
|
||||
Assert.Null(result.Value.Velocity);
|
||||
Assert.Null(result.Value.PlacementId);
|
||||
Assert.Equal((ushort)0x1001, result.Value.InstanceSequence);
|
||||
Assert.Equal((ushort)0x2002, result.Value.PositionSequence);
|
||||
Assert.Equal((ushort)0x3003, result.Value.TeleportSequence);
|
||||
Assert.Equal((ushort)0x4004, result.Value.ForcePositionSequence);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -88,6 +92,7 @@ public class UpdatePositionTests
|
|||
bw.Write(5f); bw.Write(6f); bw.Write(7f);
|
||||
bw.Write(1f); bw.Write(0f); bw.Write(0f); bw.Write(0f);
|
||||
bw.Write(100f); bw.Write(200f); bw.Write(300f); // velocity
|
||||
WriteSequences(bw);
|
||||
|
||||
var result = UpdatePosition.TryParse(ms.ToArray());
|
||||
Assert.NotNull(result);
|
||||
|
|
@ -111,6 +116,7 @@ public class UpdatePositionTests
|
|||
bw.Write(0f); bw.Write(0f); bw.Write(0f);
|
||||
bw.Write(1f); bw.Write(0f); bw.Write(0f); bw.Write(0f);
|
||||
bw.Write((uint)0x65); // Placement.Resting
|
||||
WriteSequences(bw);
|
||||
|
||||
var result = UpdatePosition.TryParse(ms.ToArray());
|
||||
Assert.NotNull(result);
|
||||
|
|
@ -131,6 +137,7 @@ public class UpdatePositionTests
|
|||
bw.Write(1f); bw.Write(0f); bw.Write(0f); bw.Write(0f);
|
||||
bw.Write(1f); bw.Write(2f); bw.Write(3f);
|
||||
bw.Write((uint)42u);
|
||||
WriteSequences(bw);
|
||||
|
||||
var result = UpdatePosition.TryParse(ms.ToArray());
|
||||
Assert.NotNull(result);
|
||||
|
|
@ -153,6 +160,7 @@ public class UpdatePositionTests
|
|||
bw.Write(cellId);
|
||||
bw.Write(px); bw.Write(py); bw.Write(pz);
|
||||
bw.Write(rw); bw.Write(rx); bw.Write(ry); bw.Write(rz);
|
||||
WriteSequences(bw);
|
||||
return ms.ToArray();
|
||||
}
|
||||
|
||||
|
|
@ -169,6 +177,15 @@ public class UpdatePositionTests
|
|||
bw.Write(cellId);
|
||||
bw.Write(px); bw.Write(py); bw.Write(pz);
|
||||
foreach (var c in rotationComponents) bw.Write(c);
|
||||
WriteSequences(bw);
|
||||
return ms.ToArray();
|
||||
}
|
||||
|
||||
private static void WriteSequences(System.IO.BinaryWriter writer)
|
||||
{
|
||||
writer.Write((ushort)0x1001); // instance
|
||||
writer.Write((ushort)0x2002); // position
|
||||
writer.Write((ushort)0x3003); // teleport
|
||||
writer.Write((ushort)0x4004); // force-position
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,9 +12,8 @@ namespace AcDream.Core.Net.Tests;
|
|||
/// ctor opens a UDP socket), so the subscription wiring is tested via the guard
|
||||
/// logic extracted as a local handler — this is the same lambda body that
|
||||
/// ObjectTableWiring.Wire wires to session.EntityDeleted. The retail two-table
|
||||
/// semantics are: PickupEvent (0xF74A) removes from the 3-D object_table but
|
||||
/// KEEPS the weenie in ClientObjectTable; DeleteObject (0xF747) evicts the
|
||||
/// weenie from both. FromPickup = true guards the eviction.
|
||||
/// semantics are: PickupEvent (0xF74A) is routed separately and keeps the
|
||||
/// weenie; DeleteObject (0xF747) evicts it from both projections.
|
||||
/// </summary>
|
||||
public sealed class ObjectTableWiringTests
|
||||
{
|
||||
|
|
@ -148,13 +147,6 @@ public sealed class ObjectTableWiringTests
|
|||
Assert.Equal(1, playerChanges);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// The handler that ObjectTableWiring.Wire subscribes to session.EntityDeleted.
|
||||
// Testing it directly avoids needing a live WorldSession (UDP socket).
|
||||
// -------------------------------------------------------------------------
|
||||
private static Action<DeleteObject.Parsed> MakeEntityDeletedHandler(ClientObjectTable table)
|
||||
=> d => { if (!d.FromPickup) table.Remove(d.Guid); };
|
||||
|
||||
private static WeenieData MinimalWeenie(uint guid) => new(
|
||||
Guid: guid,
|
||||
Name: "Test Item",
|
||||
|
|
@ -180,47 +172,90 @@ public sealed class ObjectTableWiringTests
|
|||
Workmanship: null);
|
||||
|
||||
/// <summary>
|
||||
/// PickupEvent path: FromPickup = true → weenie is RETAINED in ClientObjectTable.
|
||||
/// The 3-D render entity is removed (EntityDeleted still fires), but the weenie
|
||||
/// data persists so the follow-up InventoryPutObjInContainer can find it.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void EntityDeleted_FromPickupTrue_RetainsWeenieInTable()
|
||||
{
|
||||
var table = new ClientObjectTable();
|
||||
var handler = MakeEntityDeletedHandler(table);
|
||||
|
||||
const uint guid = 0x80000100u;
|
||||
table.Ingest(MinimalWeenie(guid));
|
||||
Assert.NotNull(table.Get(guid)); // pre-condition: item is in the table
|
||||
|
||||
// Fire EntityDeleted as WorldSession does for PickupEvent (0xF74A).
|
||||
// The handler itself represents the EntityDeleted subscription — firing it IS the event.
|
||||
handler(new DeleteObject.Parsed(guid, 0, FromPickup: true));
|
||||
|
||||
// Post-condition: weenie still in table (it moved into a container, was NOT destroyed).
|
||||
// EntityDeleted still fires (the handler ran), so the 3-D render layer would remove the WorldEntity.
|
||||
Assert.NotNull(table.Get(guid));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DeleteObject path: FromPickup = false → weenie IS evicted from ClientObjectTable.
|
||||
/// DeleteObject is a true destroy and evicts the weenie from ClientObjectTable.
|
||||
/// Regression guard — true destroys (0xF747) must still clean up the table.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void EntityDeleted_FromPickupFalse_EvictsWeenieFromTable()
|
||||
public void EntityDeleted_EvictsWeenieFromTable()
|
||||
{
|
||||
var table = new ClientObjectTable();
|
||||
var handler = MakeEntityDeletedHandler(table);
|
||||
|
||||
const uint guid = 0x80000200u;
|
||||
table.Ingest(MinimalWeenie(guid));
|
||||
Assert.NotNull(table.Get(guid)); // pre-condition: item is in the table
|
||||
|
||||
// Fire EntityDeleted as WorldSession does for DeleteObject (0xF747).
|
||||
handler(new DeleteObject.Parsed(guid, 0, FromPickup: false));
|
||||
ObjectTableWiring.ApplyEntityDelete(
|
||||
table, new DeleteObject.Parsed(guid, 0));
|
||||
|
||||
// Post-condition: weenie evicted (truly destroyed).
|
||||
Assert.Null(table.Get(guid));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NewGenerationCreateObject_ReplacesPriorQualities()
|
||||
{
|
||||
const uint guid = 0x80000300u;
|
||||
var table = new ClientObjectTable();
|
||||
ClientObject old = table.Ingest(MinimalWeenie(guid));
|
||||
old.Properties.Ints[123u] = 456;
|
||||
|
||||
var stale = new WorldSession.EntitySpawn(
|
||||
Guid: guid,
|
||||
Position: null,
|
||||
SetupTableId: null,
|
||||
AnimPartChanges: [],
|
||||
TextureChanges: [],
|
||||
SubPalettes: [],
|
||||
BasePaletteId: null,
|
||||
ObjScale: null,
|
||||
Name: "Stale Name",
|
||||
ItemType: null,
|
||||
MotionState: null,
|
||||
MotionTableId: null);
|
||||
|
||||
int removed = 0;
|
||||
int generationRemoved = 0;
|
||||
table.ObjectRemoved += _ => removed++;
|
||||
table.ObjectRemovalClassified += removal =>
|
||||
{
|
||||
if (removal.Reason == ClientObjectRemovalReason.GenerationReplacement)
|
||||
generationRemoved++;
|
||||
};
|
||||
|
||||
ObjectTableWiring.ApplyEntitySpawn(table, stale, replaceGeneration: true);
|
||||
|
||||
Assert.Equal("Stale Name", table.Get(guid)!.Name);
|
||||
Assert.False(table.Get(guid)!.Properties.Ints.ContainsKey(123u));
|
||||
Assert.Equal(1, removed);
|
||||
Assert.Equal(1, generationRemoved);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SameGenerationCreateObject_MergesPriorQualities()
|
||||
{
|
||||
const uint guid = 0x80000400u;
|
||||
var table = new ClientObjectTable();
|
||||
ClientObject old = table.Ingest(MinimalWeenie(guid));
|
||||
old.Properties.Ints[123u] = 456;
|
||||
|
||||
var refresh = new WorldSession.EntitySpawn(
|
||||
Guid: guid,
|
||||
Position: null,
|
||||
SetupTableId: null,
|
||||
AnimPartChanges: [],
|
||||
TextureChanges: [],
|
||||
SubPalettes: [],
|
||||
BasePaletteId: null,
|
||||
ObjScale: null,
|
||||
Name: "Refreshed Name",
|
||||
ItemType: null,
|
||||
MotionState: null,
|
||||
MotionTableId: null);
|
||||
|
||||
ObjectTableWiring.ApplyEntitySpawn(table, refresh);
|
||||
|
||||
Assert.NotNull(table.Get(guid));
|
||||
Assert.Equal("Refreshed Name", table.Get(guid)!.Name);
|
||||
Assert.Equal(456, table.Get(guid)!.Properties.Ints[123u]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -192,6 +192,53 @@ public class PlayerMovementControllerTests
|
|||
Assert.Equal(snapped, result.RenderPosition);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BlipPosition_ResnapsPoseWithoutStoppingActiveMotion()
|
||||
{
|
||||
var controller = new PlayerMovementController(MakeFlatEngine());
|
||||
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
|
||||
controller.Yaw = 0f;
|
||||
controller.Update(PhysicsBody.MinQuantum, new MovementInput(Forward: true));
|
||||
Vector3 velocity = controller.BodyVelocity;
|
||||
Assert.True(velocity.LengthSquared() > 0f);
|
||||
|
||||
var corrected = new Vector3(100f, 98f, 50f);
|
||||
controller.BlipPosition(corrected, 0x0001, corrected);
|
||||
|
||||
Assert.Equal(corrected, controller.Position);
|
||||
Assert.Equal(corrected, controller.RenderPosition);
|
||||
Assert.Equal(velocity, controller.BodyVelocity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BlipPosition_PublishesCanonicalOutdoorCellAndLocalFrame()
|
||||
{
|
||||
var controller = new PlayerMovementController(MakeFlatEngine());
|
||||
var world = new Vector3(150f, 193f, 50f);
|
||||
var wireLocal = new Vector3(150f, 193f, 50f);
|
||||
|
||||
controller.BlipPosition(world, 0xA9B30038u, wireLocal);
|
||||
|
||||
Assert.Equal(0xA9B40031u, controller.CellId);
|
||||
Assert.Equal(controller.CellId, controller.CellPosition.ObjCellId);
|
||||
Assert.Equal(new Vector3(150f, 1f, 50f), controller.CellPosition.Frame.Origin);
|
||||
Assert.Equal(world, controller.Position);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TeleportPosition_PublishesCanonicalOutdoorCellAndLocalFrame()
|
||||
{
|
||||
var controller = new PlayerMovementController(MakeFlatEngine());
|
||||
var world = new Vector3(12f, 12f, 50f);
|
||||
var wireLocal = new Vector3(12f, 12f, 50f);
|
||||
|
||||
controller.SetPosition(world, 0xA9B40031u, wireLocal);
|
||||
|
||||
Assert.Equal(0xA9B40001u, controller.CellId);
|
||||
Assert.Equal(controller.CellId, controller.CellPosition.ObjCellId);
|
||||
Assert.Equal(wireLocal, controller.CellPosition.Frame.Origin);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_HugeQuantumDiscard_ResnapsRenderInterpolationEndpoints()
|
||||
{
|
||||
|
|
@ -293,6 +340,22 @@ public class PlayerMovementControllerTests
|
|||
Assert.True(controller.VerticalVelocity > 0f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PositionEventGateRequiresContactAndWalkable()
|
||||
{
|
||||
var engine = MakeFlatEngine();
|
||||
var controller = new PlayerMovementController(engine);
|
||||
controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
|
||||
|
||||
Assert.True(controller.CanSendPositionEvent);
|
||||
|
||||
controller.Update(1.0f, new MovementInput(Jump: true));
|
||||
controller.Update(0.016f, new MovementInput(Jump: false));
|
||||
|
||||
Assert.True(controller.IsAirborne);
|
||||
Assert.False(controller.CanSendPositionEvent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JumpChargeSnapshot_TracksHeldChargeAndResetsOnRelease()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ public class DoorBugTrajectoryReplayTests
|
|||
// pos=(132.56,17.11,94.10)
|
||||
private const uint DoorEntityId = 0x000F4246u;
|
||||
private const uint DoorGfxObjId = 0x010044B5u;
|
||||
private const uint DoorClosedState = 0x00010008u; // PERSISTENT_PS | 0x8 (no ETHEREAL)
|
||||
private const uint DoorClosedState = 0x00010008u; // HAS_PHYSICS_BSP | REPORT_COLLISIONS
|
||||
private const uint DoorLandblockId = 0xA9B40000u;
|
||||
|
||||
private static readonly Vector3 BspWorldPos = new(132.57f, 16.99f, 95.36f);
|
||||
|
|
|
|||
|
|
@ -373,7 +373,7 @@ public class DoorCollisionApparatusTests
|
|||
// landblock 0xA9B40000.
|
||||
Vector3 doorWorldPos = new(12f, 12f, 0f);
|
||||
Quaternion doorWorldRot = Quaternion.Identity;
|
||||
const uint doorState = 0x10008u; // PhysicsState.HasPhysicsBSP | HasDefaultScript (typical)
|
||||
const uint doorState = 0x10008u; // PhysicsState.HasPhysicsBSP | ReportCollisions
|
||||
|
||||
engine.ShadowObjects.RegisterMultiPart(
|
||||
entityId: DoorEntityId,
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@ using Xunit;
|
|||
namespace AcDream.Core.Tests.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// Covers <see cref="MotionSequenceGate"/> — the inbound movement-event
|
||||
/// staleness gate ported from retail (L.2g S1, deviation DEV-6):
|
||||
/// Covers <see cref="PhysicsTimestampGate"/> — the inbound physics-event
|
||||
/// staleness gate ported from retail:
|
||||
///
|
||||
/// <list type="bullet">
|
||||
/// <item><c>CPhysicsObj::is_newer</c> (0x00451ad0) — the wraparound u16
|
||||
|
|
@ -22,7 +22,7 @@ namespace AcDream.Core.Tests.Physics;
|
|||
/// newer than the incoming one (equal passes).</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public class MotionSequenceGateTests
|
||||
public class PhysicsTimestampGateTests
|
||||
{
|
||||
// CPhysicsObj::is_newer(old, new): abs(new-old) > 0x7fff ? new < old : old < new.
|
||||
[Theory]
|
||||
|
|
@ -36,14 +36,17 @@ public class MotionSequenceGateTests
|
|||
[InlineData((ushort)2, (ushort)0xFFFE, false)] // older across the seam
|
||||
public void IsNewer_MatchesRetailWraparoundCompare(ushort oldStamp, ushort newStamp, bool expected)
|
||||
{
|
||||
Assert.Equal(expected, MotionSequenceGate.IsNewer(oldStamp, newStamp));
|
||||
Assert.Equal(expected, PhysicsTimestampGate.IsNewer(oldStamp, newStamp));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FirstEvent_WithFreshMovementSequence_IsAccepted()
|
||||
public void EventBeforeCreateObjectSeed_IsRejected()
|
||||
{
|
||||
var gate = new MotionSequenceGate();
|
||||
Assert.True(gate.TryAcceptMovementEvent(instanceSeq: 0, movementSeq: 1, serverControlSeq: 0));
|
||||
var gate = new PhysicsTimestampGate();
|
||||
Assert.False(gate.TryAcceptMovementEvent(instance: 0, movement: 1, serverControlledMove: 0));
|
||||
Assert.False(gate.TryAcceptStateEvent(instance: 0, state: 1));
|
||||
Assert.False(gate.TryAcceptVectorEvent(instance: 0, vector: 1));
|
||||
Assert.False(gate.TryAcceptObjDescEvent(instance: 0, objDesc: 1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -51,7 +54,8 @@ public class MotionSequenceGateTests
|
|||
{
|
||||
// Gate 1 accepts strictly-newer only — an identical movementSeq is a
|
||||
// duplicate delivery, not a new command.
|
||||
var gate = new MotionSequenceGate();
|
||||
var gate = new PhysicsTimestampGate();
|
||||
SeedMovement(gate, instance: 0, movement: 0, serverControl: 0);
|
||||
Assert.True(gate.TryAcceptMovementEvent(0, 5, 0));
|
||||
Assert.False(gate.TryAcceptMovementEvent(0, 5, 0));
|
||||
}
|
||||
|
|
@ -59,7 +63,8 @@ public class MotionSequenceGateTests
|
|||
[Fact]
|
||||
public void StaleMovementSequence_IsDropped_ThenNewerAccepted()
|
||||
{
|
||||
var gate = new MotionSequenceGate();
|
||||
var gate = new PhysicsTimestampGate();
|
||||
SeedMovement(gate, instance: 0, movement: 0, serverControl: 0);
|
||||
Assert.True(gate.TryAcceptMovementEvent(0, 5, 0));
|
||||
Assert.False(gate.TryAcceptMovementEvent(0, 4, 0)); // reordered straggler
|
||||
Assert.True(gate.TryAcceptMovementEvent(0, 6, 0));
|
||||
|
|
@ -71,8 +76,8 @@ public class MotionSequenceGateTests
|
|||
// Stamps live in u16 space and wrap; retail seeds them from the
|
||||
// CreateObject PhysicsDesc timestamp block, so a gate near the seam
|
||||
// must accept the post-wrap values as newer.
|
||||
var gate = new MotionSequenceGate();
|
||||
gate.Seed(instanceSeq: 0, movementSeq: 0xFFFD, serverControlSeq: 0);
|
||||
var gate = new PhysicsTimestampGate();
|
||||
SeedMovement(gate, instance: 0, movement: 0xFFFD, serverControl: 0);
|
||||
Assert.True(gate.TryAcceptMovementEvent(0, 0xFFFE, 0));
|
||||
Assert.True(gate.TryAcceptMovementEvent(0, 2, 0)); // 2 is newer than 0xFFFE by wrap rule
|
||||
Assert.False(gate.TryAcceptMovementEvent(0, 0xFFFE, 0)); // and the old stamp is now stale
|
||||
|
|
@ -87,34 +92,48 @@ public class MotionSequenceGateTests
|
|||
// (ACE WorldObject_Networking.cs:411-420 writes all 9 in enum
|
||||
// order), so the first UM after spawn is judged against the
|
||||
// seeded stamp, not zero.
|
||||
var gate = new MotionSequenceGate();
|
||||
gate.Seed(instanceSeq: 3, movementSeq: 0x9000, serverControlSeq: 2);
|
||||
var gate = new PhysicsTimestampGate();
|
||||
SeedMovement(gate, instance: 3, movement: 0x9000, serverControl: 2);
|
||||
Assert.True(gate.TryAcceptMovementEvent(3, 0x9001, 2));
|
||||
Assert.False(gate.TryAcceptMovementEvent(3, 0x8FFF, 2)); // pre-spawn straggler
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reseed_WithOlderStamps_DoesNotRegress()
|
||||
public void SameGenerationCreateObject_DoesNotConsumeIndividualChannels()
|
||||
{
|
||||
// The #138 rehydrate path replays RETAINED CreateObject spawns
|
||||
// through the normal spawn handler (GameWindow.RehydrateServerEntities
|
||||
// ForLandblock) — re-seeding from that cached spawn must not roll the
|
||||
// live stamps backward, or a post-rehydrate straggler UM would be
|
||||
// accepted as fresh. First Seed adopts wholesale (fresh object);
|
||||
// later Seeds only advance.
|
||||
var gate = new MotionSequenceGate();
|
||||
gate.Seed(instanceSeq: 3, movementSeq: 0x9000, serverControlSeq: 2);
|
||||
// Retail HandleCreateObject keeps an equal-instance object and routes
|
||||
// each field through its ordinary channel handler. Classifying the
|
||||
// CreateObject itself must therefore consume no channel timestamp.
|
||||
var gate = new PhysicsTimestampGate();
|
||||
SeedMovement(gate, instance: 3, movement: 0x9000, serverControl: 2);
|
||||
Assert.True(gate.TryAcceptMovementEvent(3, 0x9001, 2));
|
||||
|
||||
gate.Seed(instanceSeq: 3, movementSeq: 0x8000, serverControlSeq: 2); // stale replay
|
||||
Assert.False(gate.TryAcceptMovementEvent(3, 0x9001, 2)); // still a duplicate
|
||||
var disposition = gate.SeedForCreateObject(
|
||||
0, 0x9002, 0, 0, 0, 2, 0, 0, 3);
|
||||
Assert.Equal(CreateObjectTimestampDisposition.ExistingGeneration, disposition);
|
||||
Assert.True(gate.TryAcceptMovementEvent(3, 0x9002, 2));
|
||||
|
||||
gate.Seed(instanceSeq: 4, movementSeq: 0x9010, serverControlSeq: 2); // genuine re-create
|
||||
SeedMovement(gate, instance: 4, movement: 0x9010, serverControl: 2); // genuine re-create
|
||||
Assert.False(gate.TryAcceptMovementEvent(3, 0x9011, 2)); // old incarnation stale
|
||||
Assert.True(gate.TryAcceptMovementEvent(4, 0x9011, 2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SameGenerationCreateObject_MixedChannelsAreAcceptedIndependently()
|
||||
{
|
||||
var gate = new PhysicsTimestampGate();
|
||||
gate.SeedForCreateObject(10, 10, 10, 10, 20, 5, 0, 10, 3);
|
||||
|
||||
Assert.Equal(CreateObjectTimestampDisposition.ExistingGeneration,
|
||||
gate.SeedForCreateObject(9, 11, 11, 9, 20, 5, 0, 11, 3));
|
||||
|
||||
Assert.False(gate.TryAcceptPositionChannelEvent(3, 9));
|
||||
Assert.True(gate.TryAcceptMovementEvent(3, 11, 5));
|
||||
Assert.True(gate.TryAcceptStateEvent(3, 11));
|
||||
Assert.False(gate.TryAcceptVectorEvent(3, 9));
|
||||
Assert.True(gate.TryAcceptObjDescEvent(3, 11));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StaleServerControl_Drops_ButMovementStampStillAdvances()
|
||||
{
|
||||
|
|
@ -122,7 +141,8 @@ public class MotionSequenceGateTests
|
|||
// (0x00509690: update_times[1] written at 0x005096dd, the SC compare
|
||||
// runs after) — a movement event dropped for stale server-control
|
||||
// still consumes its movement sequence.
|
||||
var gate = new MotionSequenceGate();
|
||||
var gate = new PhysicsTimestampGate();
|
||||
SeedMovement(gate, instance: 0, movement: 0, serverControl: 0);
|
||||
Assert.True(gate.TryAcceptMovementEvent(0, 1, 5));
|
||||
Assert.False(gate.TryAcceptMovementEvent(0, 2, 4)); // sc=4 older than stored 5 → drop
|
||||
Assert.False(gate.TryAcceptMovementEvent(0, 2, 5)); // movementSeq 2 was consumed by the drop
|
||||
|
|
@ -134,7 +154,8 @@ public class MotionSequenceGateTests
|
|||
{
|
||||
// The SC gate drops only when the STORED stamp is strictly newer than
|
||||
// the incoming one; equal means "same server-control era" and applies.
|
||||
var gate = new MotionSequenceGate();
|
||||
var gate = new PhysicsTimestampGate();
|
||||
SeedMovement(gate, instance: 0, movement: 0, serverControl: 0);
|
||||
Assert.True(gate.TryAcceptMovementEvent(0, 1, 7));
|
||||
Assert.True(gate.TryAcceptMovementEvent(0, 2, 7));
|
||||
}
|
||||
|
|
@ -145,31 +166,171 @@ public class MotionSequenceGateTests
|
|||
// The INSTANCE_TS gate runs at dispatch level, BEFORE
|
||||
// CPhysics::SetObjectMovement — a stale-incarnation event must not
|
||||
// touch the movement stamps.
|
||||
var gate = new MotionSequenceGate();
|
||||
var gate = new PhysicsTimestampGate();
|
||||
SeedMovement(gate, instance: 2, movement: 0, serverControl: 0);
|
||||
Assert.True(gate.TryAcceptMovementEvent(2, 1, 0));
|
||||
Assert.False(gate.TryAcceptMovementEvent(1, 2, 0)); // stale incarnation
|
||||
Assert.True(gate.TryAcceptMovementEvent(2, 2, 0)); // seq 2 was NOT consumed by the instance drop
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NewerInstance_IsAdoptedAndApplied()
|
||||
public void NewerInstance_IsDroppedUntilCreateObjectStartsGeneration()
|
||||
{
|
||||
// DIVERGENCE (register row added with this port): retail queues the
|
||||
// blob until the newer incarnation exists (SmartBox::QueueBlobForObject,
|
||||
// dispatch return 4); acdream adopts the newer instance stamp and
|
||||
// applies immediately.
|
||||
var gate = new MotionSequenceGate();
|
||||
// Retail queues this blob; AD-32 records that acdream drops it until
|
||||
// LiveEntityRuntime owns that queue. It must never touch the old body.
|
||||
var gate = new PhysicsTimestampGate();
|
||||
SeedMovement(gate, instance: 1, movement: 0, serverControl: 0);
|
||||
Assert.True(gate.TryAcceptMovementEvent(1, 1, 0));
|
||||
Assert.True(gate.TryAcceptMovementEvent(2, 2, 0)); // newer incarnation adopted
|
||||
Assert.False(gate.TryAcceptMovementEvent(1, 3, 0)); // old incarnation now stale
|
||||
Assert.False(gate.TryAcceptMovementEvent(2, 2, 0));
|
||||
Assert.True(gate.TryAcceptMovementEvent(1, 2, 0)); // future event consumed nothing
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InstanceCompare_WrapsAroundTheU16Seam()
|
||||
{
|
||||
var gate = new MotionSequenceGate();
|
||||
gate.Seed(instanceSeq: 0xFFFE, movementSeq: 0, serverControlSeq: 0);
|
||||
var gate = new PhysicsTimestampGate();
|
||||
SeedMovement(gate, instance: 0xFFFE, movement: 0, serverControl: 0);
|
||||
Assert.True(gate.TryAcceptMovementEvent(0xFFFE, 1, 0));
|
||||
Assert.True(gate.TryAcceptMovementEvent(2, 2, 0)); // instance 2 newer than 0xFFFE by wrap
|
||||
Assert.False(gate.TryAcceptMovementEvent(2, 2, 0)); // future incarnation queues in retail
|
||||
Assert.Equal(CreateObjectTimestampDisposition.NewGeneration,
|
||||
gate.SeedForCreateObject(0, 1, 0, 0, 0, 0, 0, 0, 2));
|
||||
Assert.True(gate.TryAcceptMovementEvent(2, 2, 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StateAndVectorChannelsAdvanceIndependently()
|
||||
{
|
||||
var gate = new PhysicsTimestampGate();
|
||||
gate.SeedForCreateObject(10, 20, 30, 40, 50, 60, 70, 80, 90);
|
||||
|
||||
Assert.True(gate.TryAcceptStateEvent(90, 31));
|
||||
Assert.False(gate.TryAcceptStateEvent(90, 31));
|
||||
Assert.True(gate.TryAcceptVectorEvent(90, 41));
|
||||
Assert.False(gate.TryAcceptVectorEvent(90, 40));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PositionRollsBackWhenNewerTeleportAlreadyExists()
|
||||
{
|
||||
var gate = new PhysicsTimestampGate();
|
||||
gate.SeedForCreateObject(10, 0, 0, 0, 20, 0, 0, 0, 1);
|
||||
|
||||
Assert.Equal(PositionTimestampDisposition.Rejected,
|
||||
gate.TryAcceptPositionEvent(1, 11, 19, 0, isLocalPlayer: false));
|
||||
Assert.Equal(PositionTimestampDisposition.Apply,
|
||||
gate.TryAcceptPositionEvent(1, 11, 20, 0, isLocalPlayer: false));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FreshTeleportAndForcePositionAdvanceTheirOwnChannels()
|
||||
{
|
||||
var gate = new PhysicsTimestampGate();
|
||||
gate.SeedForCreateObject(10, 0, 0, 0, 20, 0, 30, 0, 1);
|
||||
|
||||
Assert.Equal(PositionTimestampDisposition.ForcePosition,
|
||||
gate.TryAcceptPositionEvent(1, 9, 20, 31, isLocalPlayer: true));
|
||||
Assert.Equal((ushort)9, gate.PositionTimestamp);
|
||||
Assert.Equal((ushort)20, gate.TeleportTimestamp);
|
||||
Assert.Equal(PositionTimestampDisposition.Rejected,
|
||||
gate.TryAcceptPositionEvent(1, 9, 20, 31, isLocalPlayer: true));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FreshForceWithNewerTeleport_UsesNormalPositionPath()
|
||||
{
|
||||
var gate = new PhysicsTimestampGate();
|
||||
gate.SeedForCreateObject(10, 0, 0, 0, 20, 0, 30, 0, 1);
|
||||
|
||||
Assert.Equal(PositionTimestampDisposition.Apply,
|
||||
gate.TryAcceptPositionEvent(1, 11, 21, 31, isLocalPlayer: true));
|
||||
Assert.Equal((ushort)11, gate.PositionTimestamp);
|
||||
Assert.Equal((ushort)21, gate.TeleportTimestamp);
|
||||
Assert.Equal((ushort)31, gate.ForcePositionTimestamp);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FreshForceIsConsumedWhenOlderTeleportRejectsPose()
|
||||
{
|
||||
var gate = new PhysicsTimestampGate();
|
||||
gate.SeedForCreateObject(10, 0, 0, 0, 20, 0, 30, 0, 1);
|
||||
|
||||
Assert.Equal(PositionTimestampDisposition.Rejected,
|
||||
gate.TryAcceptPositionEvent(1, 11, 19, 31, isLocalPlayer: true));
|
||||
Assert.Equal((ushort)10, gate.PositionTimestamp);
|
||||
Assert.Equal((ushort)20, gate.TeleportTimestamp);
|
||||
Assert.Equal((ushort)31, gate.ForcePositionTimestamp);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParentPickupAndPositionShareOnePositionChannel()
|
||||
{
|
||||
var gate = new PhysicsTimestampGate();
|
||||
gate.SeedForCreateObject(10, 0, 0, 0, 20, 0, 0, 0, 1);
|
||||
|
||||
Assert.True(gate.TryAcceptPositionChannelEvent(1, 11));
|
||||
Assert.Equal(PositionTimestampDisposition.Rejected,
|
||||
gate.TryAcceptPositionEvent(1, 11, 20, 0, isLocalPlayer: false));
|
||||
Assert.False(gate.TryAcceptPositionChannelEvent(1, 10));
|
||||
Assert.True(gate.TryAcceptPositionChannelEvent(1, 12));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NewCreateObjectGenerationReplacesLowerChannelStampsWholesale()
|
||||
{
|
||||
var gate = new PhysicsTimestampGate();
|
||||
gate.SeedForCreateObject(1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1);
|
||||
|
||||
var disposition = gate.SeedForCreateObject(1, 1, 1, 1, 1, 1, 1, 1, 2);
|
||||
|
||||
Assert.Equal(CreateObjectTimestampDisposition.NewGeneration, disposition);
|
||||
Assert.True(gate.TryAcceptStateEvent(2, 2));
|
||||
Assert.True(gate.TryAcceptVectorEvent(2, 2));
|
||||
Assert.Equal(PositionTimestampDisposition.Apply,
|
||||
gate.TryAcceptPositionEvent(2, 2, 1, 1, isLocalPlayer: false));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StaleCreateObjectAndDeleteCannotAffectCurrentGeneration()
|
||||
{
|
||||
var gate = new PhysicsTimestampGate();
|
||||
gate.SeedForCreateObject(1, 1, 1, 1, 1, 1, 1, 1, 2);
|
||||
|
||||
Assert.Equal(CreateObjectTimestampDisposition.StaleGeneration,
|
||||
gate.SeedForCreateObject(500, 500, 500, 500, 500, 500, 500, 500, 1));
|
||||
Assert.False(gate.TryAcceptDeleteEvent(1));
|
||||
Assert.True(gate.TryAcceptDeleteEvent(2));
|
||||
Assert.False(gate.TryAcceptDeleteEvent(2, isLocalPlayer: true));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ObjDescRequiresCurrentInstanceAndStrictlyNewerSequence()
|
||||
{
|
||||
var gate = new PhysicsTimestampGate();
|
||||
gate.SeedForCreateObject(0, 0, 0, 0, 0, 0, 0, 0xFFFE, 7);
|
||||
|
||||
Assert.False(gate.TryAcceptObjDescEvent(6, 0xFFFF));
|
||||
Assert.True(gate.TryAcceptObjDescEvent(7, 0xFFFF));
|
||||
Assert.True(gate.TryAcceptObjDescEvent(7, 0));
|
||||
Assert.False(gate.TryAcceptObjDescEvent(7, 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlayerTeleportStartChecksButDoesNotAdvanceTeleportChannel()
|
||||
{
|
||||
var gate = new PhysicsTimestampGate();
|
||||
gate.SeedForCreateObject(0, 0, 0, 0, 10, 0, 0, 0, 1);
|
||||
|
||||
Assert.True(gate.IsFreshTeleportStart(10));
|
||||
Assert.False(gate.IsFreshTeleportStart(9));
|
||||
Assert.True(gate.IsFreshTeleportStart(11));
|
||||
Assert.True(gate.IsFreshTeleportStart(11));
|
||||
Assert.Equal((ushort)10, gate.TeleportTimestamp);
|
||||
}
|
||||
|
||||
private static void SeedMovement(
|
||||
PhysicsTimestampGate gate,
|
||||
ushort instance,
|
||||
ushort movement,
|
||||
ushort serverControl) =>
|
||||
gate.SeedForCreateObject(0, movement, 0, 0, 0, serverControl, 0, 0, instance);
|
||||
}
|
||||
|
|
|
|||
34
tests/AcDream.Core.Tests/Physics/PhysicsStateFlagsTests.cs
Normal file
34
tests/AcDream.Core.Tests/Physics/PhysicsStateFlagsTests.cs
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
using AcDream.Core.Physics;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.Physics;
|
||||
|
||||
public sealed class PhysicsStateFlagsTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(PhysicsStateFlags.Static, 0x00000001u)]
|
||||
[InlineData(PhysicsStateFlags.Ethereal, 0x00000004u)]
|
||||
[InlineData(PhysicsStateFlags.ReportCollisions, 0x00000008u)]
|
||||
[InlineData(PhysicsStateFlags.IgnoreCollisions, 0x00000010u)]
|
||||
[InlineData(PhysicsStateFlags.NoDraw, 0x00000020u)]
|
||||
[InlineData(PhysicsStateFlags.Missile, 0x00000040u)]
|
||||
[InlineData(PhysicsStateFlags.Pushable, 0x00000080u)]
|
||||
[InlineData(PhysicsStateFlags.AlignPath, 0x00000100u)]
|
||||
[InlineData(PhysicsStateFlags.PathClipped, 0x00000200u)]
|
||||
[InlineData(PhysicsStateFlags.Gravity, 0x00000400u)]
|
||||
[InlineData(PhysicsStateFlags.Lighting, 0x00000800u)]
|
||||
[InlineData(PhysicsStateFlags.ParticleEmitter, 0x00001000u)]
|
||||
[InlineData(PhysicsStateFlags.Hidden, 0x00004000u)]
|
||||
[InlineData(PhysicsStateFlags.ScriptedCollision, 0x00008000u)]
|
||||
[InlineData(PhysicsStateFlags.HasPhysicsBsp, 0x00010000u)]
|
||||
[InlineData(PhysicsStateFlags.Inelastic, 0x00020000u)]
|
||||
[InlineData(PhysicsStateFlags.HasDefaultAnim, 0x00040000u)]
|
||||
[InlineData(PhysicsStateFlags.HasDefaultScript, 0x00080000u)]
|
||||
[InlineData(PhysicsStateFlags.Cloaked, 0x00100000u)]
|
||||
[InlineData(PhysicsStateFlags.ReportAsEnvironment, 0x00200000u)]
|
||||
[InlineData(PhysicsStateFlags.EdgeSlide, 0x00400000u)]
|
||||
[InlineData(PhysicsStateFlags.Sledding, 0x00800000u)]
|
||||
[InlineData(PhysicsStateFlags.Frozen, 0x01000000u)]
|
||||
public void ValuesMatchRetailPhysicsStateEnum(PhysicsStateFlags flag, uint expected) =>
|
||||
Assert.Equal(expected, (uint)flag);
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
using System.Numerics;
|
||||
using AcDream.Core.Physics;
|
||||
|
||||
namespace AcDream.Core.Tests.Physics;
|
||||
|
||||
public sealed class PositionFrameValidationTests
|
||||
{
|
||||
[Fact]
|
||||
public void ValidPositionRequiresInboundCellAndRetailFrameValidity()
|
||||
{
|
||||
Assert.True(PositionFrameValidation.IsValid(
|
||||
0x0101FFFFu, new Vector3(1f, 2f, 3f), Quaternion.Identity));
|
||||
Assert.False(PositionFrameValidation.IsValid(
|
||||
0u, new Vector3(1f, 2f, 3f), Quaternion.Identity));
|
||||
Assert.False(PositionFrameValidation.IsValid(
|
||||
0x0101FFFFu, new Vector3(float.NaN, 2f, 3f), Quaternion.Identity));
|
||||
Assert.False(PositionFrameValidation.IsValid(
|
||||
0x0101FFFFu, new Vector3(1f, 2f, 3f), new Quaternion(0f, 0f, 0f, 0.9f)));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1.001f, true)]
|
||||
[InlineData(1.0011f, false)]
|
||||
public void QuaternionSquaredNormUsesRetailTolerance(float squaredNorm, bool expected)
|
||||
{
|
||||
var rotation = new Quaternion(0f, 0f, 0f, MathF.Sqrt(squaredNorm));
|
||||
|
||||
Assert.Equal(expected, PositionFrameValidation.IsValid(
|
||||
0x0101FFFFu, Vector3.Zero, rotation));
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,25 @@ namespace AcDream.Core.Tests.Selection;
|
|||
|
||||
public sealed class SelectionStateTests
|
||||
{
|
||||
[Fact]
|
||||
public void ResetClearsCurrentAndSelectionHistoryForNextSession()
|
||||
{
|
||||
var state = new SelectionState();
|
||||
state.Select(0x50000001u, SelectionChangeSource.World);
|
||||
state.Select(0x50000002u, SelectionChangeSource.World);
|
||||
SelectionTransition transition = default;
|
||||
state.Changed += value => transition = value;
|
||||
|
||||
Assert.True(state.Reset());
|
||||
|
||||
Assert.Null(state.SelectedObjectId);
|
||||
Assert.Null(state.PreviousObjectId);
|
||||
Assert.Null(state.PreviousValidObjectId);
|
||||
Assert.Equal(0x50000002u, transition.PreviousObjectId);
|
||||
Assert.Equal(SelectionChangeReason.SessionReset, transition.Reason);
|
||||
Assert.False(state.SelectPrevious());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Select_CommitsOneTransitionAndTracksPreviousLikeRetail()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -216,7 +216,7 @@ public class GpuWorldStateTests
|
|||
{
|
||||
// A normal server object (door) in the pending bucket is NOT persistent;
|
||||
// it must still be dropped (and is recoverable via #138 re-hydrate from
|
||||
// _lastSpawnByGuid, not via the rescue path).
|
||||
// the retained inbound spawn snapshot, not via the rescue path).
|
||||
var state = new GpuWorldState();
|
||||
var door = MakeServerEntity(id: 2, serverGuid: 0x7A9B4001u); // not marked persistent
|
||||
state.AppendLiveEntity(0xA9B40011u, door);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue