diff --git a/docs/architecture/code-structure.md b/docs/architecture/code-structure.md index 66850130..6f788832 100644 --- a/docs/architecture/code-structure.md +++ b/docs/architecture/code-structure.md @@ -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 diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 91c706b9..883864ba 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -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`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. diff --git a/docs/research/2026-05-24-door-bug-apparatus-shipped-findings.md b/docs/research/2026-05-24-door-bug-apparatus-shipped-findings.md index ed74b832..c0b77c32 100644 --- a/docs/research/2026-05-24-door-bug-apparatus-shipped-findings.md +++ b/docs/research/2026-05-24-door-bug-apparatus-shipped-findings.md @@ -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 diff --git a/docs/research/2026-07-13-retail-projectile-vfx-pseudocode.md b/docs/research/2026-07-13-retail-projectile-vfx-pseudocode.md index be1bb3bd..2b1fb107 100644 --- a/docs/research/2026-07-13-retail-projectile-vfx-pseudocode.md +++ b/docs/research/2026-07-13-retail-projectile-vfx-pseudocode.md @@ -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: diff --git a/src/AcDream.App/Input/PlayerMovementController.cs b/src/AcDream.App/Input/PlayerMovementController.cs index 4334690c..582b850c 100644 --- a/src/AcDream.App/Input/PlayerMovementController.cs +++ b/src/AcDream.App/Input/PlayerMovementController.cs @@ -395,6 +395,10 @@ public sealed class PlayerMovementController /// strict subset of the funnel's Contact+OnWalkable gate). internal bool BodyInContact => _body.InContact; + /// Retail SendPositionEvent admission gate: both Contact + /// and OnWalkable must be present on the local physics body. + internal bool CanSendPositionEvent => _body.InContact && _body.OnWalkable; + /// R4-V5: body orientation for the /// manager's position seam (re-derived from every /// Update — heading reads/writes go through Yaw, not this). @@ -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; } + /// + /// Retail SmartBox::BlipPlayer (0x00453940): apply a server + /// FORCE_POSITION correction through CPhysicsObj::SetPositionSimple + /// without the teleport hook. Active motion, velocity, contact state, and + /// PositionManager stick relationships deliberately survive the blip. + /// + 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 diff --git a/src/AcDream.App/Rendering/EquippedChildRenderController.cs b/src/AcDream.App/Rendering/EquippedChildRenderController.cs index a4ca5cbb..b5e7e69e 100644 --- a/src/AcDream.App/Rendering/EquippedChildRenderController.cs +++ b/src/AcDream.App/Rendering/EquippedChildRenderController.cs @@ -27,12 +27,11 @@ public sealed class EquippedChildRenderController : IDisposable private readonly GpuWorldState _worldState; private readonly Func _resolveEntity; private readonly Func _resolveSpawn; + private readonly Func _acceptParent; private readonly Func _nextEntityId; - private readonly Dictionary _pendingByChild = new(); - private readonly Dictionary _lastRelationByChild = new(); + private readonly ParentAttachmentState _relations = new(); private readonly Dictionary _attachedByChild = new(); - private readonly Dictionary _positionSequenceByChild = new(); public IEnumerable AttachedEntityIds { @@ -50,6 +49,7 @@ public sealed class EquippedChildRenderController : IDisposable GpuWorldState worldState, Func resolveEntity, Func resolveSpawn, + Func acceptParent, Func 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; } /// @@ -72,100 +73,106 @@ public sealed class EquippedChildRenderController : IDisposable /// 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 waiting = _relations.ChildrenWaitingForParent(spawn.Guid); + for (int i = 0; i < waiting.Count; i++) + { + ResolveRelations(waiting[i]); TryRealize(waiting[i]); + } } } /// - /// 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. + /// + public void OnWorldEntityRegistered(uint guid) + { + lock (_datLock) + { + IReadOnlyList waiting = _relations.ChildrenWaitingForParent(guid); + for (int i = 0; i < waiting.Count; i++) + { + ResolveRelations(waiting[i]); + TryRealize(waiting[i]); + } + } + } + + /// + /// 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. /// 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); + } + + /// + /// 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. + /// + public void OnChildBecameUnparented(uint childGuid) + { + Remove(childGuid); + _relations.EndChildProjection(childGuid); } /// Recompose every child after the parent's animation tick. @@ -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 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, diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 9f5f0a5d..40fa1908 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -422,7 +422,7 @@ public sealed class GameWindow : IDisposable /// top of , dropped with the entity in /// . /// - private readonly Dictionary _motionSequenceGates = new(); + private readonly AcDream.App.World.InboundPhysicsStateController _inboundPhysics = new(); /// /// Per-remote-entity physics + motion stack — verbatim application of @@ -1028,7 +1028,8 @@ public sealed class GameWindow : IDisposable /// reuses the cached position/setup/motion /// fields when a 0xF625 ObjDescEvent arrives carrying only updated visuals. /// - private readonly Dictionary _lastSpawnByGuid = new(); + private IReadOnlyDictionary 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(); + } + /// /// Step 2 helper: subscribes the live to all /// the parsers / handlers / translators that GameWindow 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 /// /// /// The dungeon collapse (and Near→Far demote) drops a landblock's render - /// entities for FPS but keeps the parsed spawns in + /// entities for FPS but keeps the parsed spawns in /// (our weenie_object_table for world objects). ACE never /// re-broadcasts objects it believes we still know — its per-player /// KnownObjects set is not cleared on a normal teleport (verified @@ -2950,7 +2992,7 @@ public sealed class GameWindow : IDisposable /// 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( - _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 /// 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); + } + + /// + /// 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. + /// + 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}"); + } + } + + /// + /// ParentEvent may precede either CreateObject. The focused child renderer + /// retains it; its realization callback performs the canonical timestamp + /// acceptance immediately before changing the projection. + /// + 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 /// 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 /// 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 /// 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); } + /// + /// Retail's local FORCE_POSITION branch calls SendPositionEvent + /// immediately after BlipPlayer. This acknowledgement uses the accepted + /// timestamps and preserves the player's current heading. + /// + 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 /// 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. /// /// - 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(setupId, out var setup)) return false; @@ -12670,7 +12851,7 @@ public sealed class GameWindow : IDisposable /// 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 /// 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 diff --git a/src/AcDream.App/Rendering/ParentAttachmentState.cs b/src/AcDream.App/Rendering/ParentAttachmentState.cs new file mode 100644 index 00000000..043c0991 --- /dev/null +++ b/src/AcDream.App/Rendering/ParentAttachmentState.cs @@ -0,0 +1,288 @@ +using AcDream.Core.Net.Messages; +using AcDream.Core.Physics; + +namespace AcDream.App.Rendering; + +/// +/// 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. +/// +public sealed class ParentAttachmentState +{ + private readonly Dictionary> _unresolvedByChild = new(); + private readonly Dictionary _projectionByChild = new(); + private readonly Dictionary _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? queue)) + { + queue = new Queue(); + _unresolvedByChild.Add(update.ChildGuid, queue); + } + + queue.Enqueue(new ParentAttachmentRelation( + update.ParentGuid, + update.ChildGuid, + update.ParentLocation, + update.PlacementId, + update.ParentInstanceSequence, + update.ChildPositionSequence)); + } + + /// + /// 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. + /// + public void Resolve( + uint childGuid, + Func isObjectKnown, + Func resolveInstance, + Func accept) + { + if (!_unresolvedByChild.TryGetValue(childGuid, out Queue? 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 ChildrenWaitingForParent(uint parentGuid) + { + var result = new HashSet(); + foreach ((uint childGuid, ParentAttachmentRelation relation) in _projectionByChild) + { + if (relation.ParentGuid == parentGuid) + result.Add(childGuid); + } + + foreach ((uint childGuid, Queue 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 retained = new( + _unresolvedByChild[childGuid].Where(relation => relation.ParentGuid != guid)); + if (retained.Count == 0) + _unresolvedByChild.Remove(childGuid); + else + _unresolvedByChild[childGuid] = retained; + } + } + + /// + /// Ends one logical incarnation without discarding unresolved wire events + /// that may explicitly address the replacement/future parent generation. + /// + 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)); + } + + /// + /// 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. + /// + 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)); + } + + /// + /// Clears an accepted attachment projection after Pickup or a world + /// Position without destroying fresher unresolved ParentEvents. + /// + 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 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 retain) + { + uint[] children = _unresolvedByChild.Keys.ToArray(); + for (int i = 0; i < children.Length; i++) + { + uint childGuid = children[i]; + Queue queue = _unresolvedByChild[childGuid]; + var retained = new Queue( + 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 retain) + { + if (!_unresolvedByChild.TryGetValue( + childGuid, + out Queue? queue)) + { + return; + } + + var retained = new Queue(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, +} diff --git a/src/AcDream.App/Streaming/LandblockEntityRehydrator.cs b/src/AcDream.App/Streaming/LandblockEntityRehydrator.cs index ef473633..66aaff59 100644 --- a/src/AcDream.App/Streaming/LandblockEntityRehydrator.cs +++ b/src/AcDream.App/Streaming/LandblockEntityRehydrator.cs @@ -20,9 +20,9 @@ namespace AcDream.App.Streaming; /// /// /// acdream's dungeon-collapse (and Near→Far demote) drops a landblock's -/// RENDER entities for FPS but retains the parsed spawns -/// (GameWindow._lastSpawnByGuid — the table is only pruned by an -/// explicit server DeleteObject or a spawn de-dup). On reload, ACE will +/// RENDER entities for FPS but retains the accepted immutable spawns in +/// InboundPhysicsStateController.Snapshots (pruned by an explicit server +/// DeleteObject 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 GameWindow can replay them, making diff --git a/src/AcDream.App/UI/Layout/InventoryController.cs b/src/AcDream.App/UI/Layout/InventoryController.cs index f0307a09..ab928a86 100644 --- a/src/AcDream.App/UI/Layout/InventoryController.cs +++ b/src/AcDream.App/UI/Layout/InventoryController.cs @@ -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(); /// True if the object is in (or wielded by) the player — i.e. a rebuild is warranted. 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; diff --git a/src/AcDream.App/UI/Layout/PaperdollController.cs b/src/AcDream.App/UI/Layout/PaperdollController.cs index 514a0766..bab17b87 100644 --- a/src/AcDream.App/UI/Layout/PaperdollController.cs +++ b/src/AcDream.App/UI/Layout/PaperdollController.cs @@ -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(); + } /// 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; diff --git a/src/AcDream.App/UI/Layout/ToolbarController.cs b/src/AcDream.App/UI/Layout/ToolbarController.cs index 378ae05e..3f58c153 100644 --- a/src/AcDream.App/UI/Layout/ToolbarController.cs +++ b/src/AcDream.App/UI/Layout/ToolbarController.cs @@ -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; } } diff --git a/src/AcDream.App/World/InboundPhysicsStateController.cs b/src/AcDream.App/World/InboundPhysicsStateController.cs new file mode 100644 index 00000000..b9a6722f --- /dev/null +++ b/src/AcDream.App/World/InboundPhysicsStateController.cs @@ -0,0 +1,647 @@ +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; +using AcDream.Core.Physics; + +namespace AcDream.App.World; + +/// +/// Update-thread owner of inbound retail physics timestamps and the latest +/// accepted immutable spawn description. This is deliberately narrower than +/// LiveEntityRuntime: it owns no renderer, local entity ID, physics body, +/// animation, effect, or spatial-bucket resource. +/// +public sealed class InboundPhysicsStateController +{ + private readonly Dictionary _gates = new(); + private readonly Dictionary _snapshots = new(); + + public IReadOnlyDictionary 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; + } + + /// + /// 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. + /// + 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.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; + } + + /// + /// 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. + /// + 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; + } + + /// + /// F751 is a notification gate only. Retail compares it to TELEPORT_TS but + /// advances that timestamp later, with the accepted Position packet. + /// + 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); diff --git a/src/AcDream.Core.Net/Messages/CreateObject.cs b/src/AcDream.Core.Net/Messages/CreateObject.cs index 1982645e..83041165 100644 --- a/src/AcDream.Core.Net/Messages/CreateObject.cs +++ b/src/AcDream.Core.Net/Messages/CreateObject.cs @@ -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. /// /// -/// acdream's parser extracts only the fields needed to hand the spawn off -/// to IGameState: +/// The parser preserves the complete PhysicsDesc needed to construct a retail +/// physics object, while retaining legacy convenience fields during the +/// LiveEntityRuntime migration: /// /// /// GUID — always at the start of the body (after the opcode). @@ -25,13 +27,9 @@ namespace AcDream.Core.Net.Messages; /// /// /// -/// 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 Parse 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. /// /// /// @@ -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); /// /// The relevant subset of the server-sent MovementData / @@ -479,8 +481,8 @@ public static class CreateObject /// Parse a reassembled CreateObject body. must /// start with the 4-byte opcode. Returns null if the body is /// malformed (truncated field); returns a populated - /// 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. /// public static Parsed? TryParse(ReadOnlySpan 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? 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 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.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 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; + } } diff --git a/src/AcDream.Core.Net/Messages/DeleteObject.cs b/src/AcDream.Core.Net/Messages/DeleteObject.cs index fc88f28f..63808c12 100644 --- a/src/AcDream.Core.Net/Messages/DeleteObject.cs +++ b/src/AcDream.Core.Net/Messages/DeleteObject.cs @@ -17,23 +17,14 @@ public static class DeleteObject { public const uint Opcode = 0xF747u; - /// - /// when this delete was sourced from a - /// PickupEvent (0xF74A) — the object left the 3-D world view but - /// the weenie record still exists (it is moving into a container). - /// (default) when sourced from DeleteObject - /// (0xF747) — the weenie was destroyed and must be evicted from - /// . - /// Retail two-table model: PickupEvent removes from object_table; - /// DeleteObject removes from weenie_object_table. - /// - public readonly record struct Parsed(uint Guid, ushort InstanceSequence, bool FromPickup = false); + /// A true object-destruction event for one exact incarnation. + public readonly record struct Parsed(uint Guid, ushort InstanceSequence); /// /// Parse a 0xF747 body. must start with the /// 4-byte opcode, matching every other parser in this namespace. - /// The returned is always - /// — this parser handles true destroys only. + /// PickupEvent has a distinct parser and runtime event because it advances + /// POSITION_TS and retains the logical object. /// public static Parsed? TryParse(ReadOnlySpan 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); } } diff --git a/src/AcDream.Core.Net/Messages/ObjDescEvent.cs b/src/AcDream.Core.Net/Messages/ObjDescEvent.cs index b6d966aa..68b51b88 100644 --- a/src/AcDream.Core.Net/Messages/ObjDescEvent.cs +++ b/src/AcDream.Core.Net/Messages/ObjDescEvent.cs @@ -26,8 +26,8 @@ namespace AcDream.Core.Net.Messages; /// u32 opcode (0xF625) /// u32 guid — target object /// ModelData block — see -/// u32 instanceSequence -/// u32 visualDescSequence +/// u16 instanceSequence +/// u16 visualDescSequence (OBJDESC_TS) /// /// public static class ObjDescEvent @@ -35,11 +35,14 @@ public static class ObjDescEvent public const uint Opcode = 0xF625u; /// - /// 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. /// - public readonly record struct Parsed(uint Guid, CreateObject.ModelData ModelData); + public readonly record struct Parsed( + uint Guid, + CreateObject.ModelData ModelData, + ushort InstanceSequence, + ushort ObjDescSequence); /// /// 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 { diff --git a/src/AcDream.Core.Net/Messages/PhysicsSpawnData.cs b/src/AcDream.Core.Net/Messages/PhysicsSpawnData.cs new file mode 100644 index 00000000..2576f98c --- /dev/null +++ b/src/AcDream.Core.Net/Messages/PhysicsSpawnData.cs @@ -0,0 +1,64 @@ +using System.Numerics; +using AcDream.Core.Physics; + +namespace AcDream.Core.Net.Messages; + +/// +/// The nine retail PhysicsTimeStamp channels stored at the tail of a +/// PhysicsDesc. Field order is verbatim from acclient.h:6084. +/// +public readonly record struct PhysicsTimestamps( + ushort Position, + ushort Movement, + ushort State, + ushort Vector, + ushort Teleport, + ushort ServerControlledMove, + ushort ForcePosition, + ushort ObjDesc, + ushort Instance); + +/// A parent or child attachment carried by PhysicsDesc. +public readonly record struct PhysicsAttachment(uint Guid, uint LocationId); + +/// +/// A present Movement field. The wrapper is retained even when +/// is empty so callers can distinguish absent Movement +/// from an explicitly present zero-length movement block. +/// +public readonly record struct PhysicsMovementData( + ReadOnlyMemory RawData, + CreateObject.ServerMotionState? MotionState, + bool? IsAutonomous); + +/// +/// Immutable, lossless projection of the retail PhysicsDesc 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. +/// +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? Children, + float? Scale, + float? Friction, + float? Elasticity, + float? Translucency, + Vector3? Velocity, + Vector3? Acceleration, + Vector3? AngularVelocity, + uint? DefaultScriptType, + float? DefaultScriptIntensity, + PhysicsTimestamps Timestamps) +{ + /// The corrected retail flag view of . + public PhysicsStateFlags State => (PhysicsStateFlags)RawState; +} diff --git a/src/AcDream.Core.Net/Messages/PickupEvent.cs b/src/AcDream.Core.Net/Messages/PickupEvent.cs index 44ff95de..8bdfd057 100644 --- a/src/AcDream.Core.Net/Messages/PickupEvent.cs +++ b/src/AcDream.Core.Net/Messages/PickupEvent.cs @@ -9,9 +9,9 @@ namespace AcDream.Core.Net.Messages; /// ACE emits this from Player_Tracking.RemoveTrackedObject(wo, fromPickup: true) /// when a player picks up a world item — distinguishes the despawn /// from a generic 0xF747 DeleteObject (timeout / death / -/// out-of-LOS). Downstream effect on the client view is the same -/// (remove the entity from the world), so -/// routes both opcodes to the same EntityDeleted event. +/// out-of-LOS). Pickup removes only the object's world projection while +/// retaining its logical weenie and timestamp owner, so +/// publishes it separately through EntityPickedUp. /// /// /// diff --git a/src/AcDream.Core.Net/Messages/PlayPhysicsScript.cs b/src/AcDream.Core.Net/Messages/PlayPhysicsScript.cs new file mode 100644 index 00000000..f85eba22 --- /dev/null +++ b/src/AcDream.Core.Net/Messages/PlayPhysicsScript.cs @@ -0,0 +1,25 @@ +using System.Buffers.Binary; + +namespace AcDream.Core.Net.Messages; + +/// +/// Retail PlayScriptID (0xF754): play the supplied PhysicsScript DID directly +/// on the addressed object. This packet never performs a typed-table lookup. +/// Named-retail handler: SmartBox::HandlePlayScriptID 0x00452020. +/// +public readonly record struct PlayPhysicsScript(uint Guid, uint ScriptDid) +{ + public const uint Opcode = 0xF754u; + public const int WireSize = 12; + + public static PlayPhysicsScript? TryParse(ReadOnlySpan body) + { + if (body.Length != WireSize + || BinaryPrimitives.ReadUInt32LittleEndian(body) != Opcode) + return null; + + return new PlayPhysicsScript( + BinaryPrimitives.ReadUInt32LittleEndian(body[4..]), + BinaryPrimitives.ReadUInt32LittleEndian(body[8..])); + } +} diff --git a/src/AcDream.Core.Net/Messages/PlayPhysicsScriptType.cs b/src/AcDream.Core.Net/Messages/PlayPhysicsScriptType.cs new file mode 100644 index 00000000..29aec017 --- /dev/null +++ b/src/AcDream.Core.Net/Messages/PlayPhysicsScriptType.cs @@ -0,0 +1,31 @@ +using System.Buffers.Binary; + +namespace AcDream.Core.Net.Messages; + +/// +/// 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: SmartBox::HandlePlayScriptType 0x00452070. +/// +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 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..])); + } +} diff --git a/src/AcDream.Core.Net/Messages/UpdateMotion.cs b/src/AcDream.Core.Net/Messages/UpdateMotion.cs index 2049bdbc..353035f0 100644 --- a/src/AcDream.Core.Net/Messages/UpdateMotion.cs +++ b/src/AcDream.Core.Net/Messages/UpdateMotion.cs @@ -73,7 +73,7 @@ public static class UpdateMotion /// /// L.2g S1 (DEV-6): the three staleness stamps + autonomy flag are /// exposed for the retail gate ( - /// MotionSequenceGate) — retail compares them against + /// PhysicsTimestampGate) — retail compares them against /// update_times[INSTANCE_TS/MOVEMENT_TS/SERVER_CONTROLLED_MOVE_TS] /// and stores last_move_was_autonomous /// (CPhysics::SetObjectMovement 0x00509690). diff --git a/src/AcDream.Core.Net/Messages/UpdatePosition.cs b/src/AcDream.Core.Net/Messages/UpdatePosition.cs index e4978cf7..768145b6 100644 --- a/src/AcDream.Core.Net/Messages/UpdatePosition.cs +++ b/src/AcDream.Core.Net/Messages/UpdatePosition.cs @@ -27,8 +27,7 @@ namespace AcDream.Core.Net.Messages; /// Velocity — 3xf32 if HasVelocity set /// PlacementID — u32 if HasPlacementID set /// Four u16 sequence numbers — instance, position, teleport, -/// forcePosition. We don't currently check these for freshness but -/// we must consume them to walk the buffer correctly. +/// forcePosition. Runtime freshness gates consume all four. /// /// 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 { diff --git a/src/AcDream.Core.Net/ObjectTableWiring.cs b/src/AcDream.Core.Net/ObjectTableWiring.cs index 3e24d3a6..be9d577a 100644 --- a/src/AcDream.Core.Net/ObjectTableWiring.cs +++ b/src/AcDream.Core.Net/ObjectTableWiring.cs @@ -4,21 +4,20 @@ using AcDream.Core.Player; namespace AcDream.Core.Net; /// -/// 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). /// public static class ObjectTableWiring { /// - /// Subscribe to the three object-lifecycle events - /// on . Call this BEFORE the render handler subscribes - /// to EntitySpawned so the table is populated before the render path runs. + /// Subscribe to quality, inventory, and property + /// updates whose freshness is independent of the physics timestamp pack. /// 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); } + /// + /// 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. + /// + 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); + } + + /// + /// Applies a true DeleteObject only after the owning runtime accepts the + /// exact object incarnation. Pickup still removes only the 3-D projection. + /// + public static void ApplyEntityDelete( + ClientObjectTable table, + Messages.DeleteObject.Parsed delete) + { + table.RemoveLogicalGeneration(delete.Guid, delete.InstanceSequence); + } + /// Shared application step kept internal for byte-to-state conformance tests. internal static void ApplyPlayerInt64PropertyUpdate( ClientObjectTable table, diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs index 0418b719..695da2d6 100644 --- a/src/AcDream.Core.Net/WorldSession.cs +++ b/src/AcDream.Core.Net/WorldSession.cs @@ -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); /// /// 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); /// Fires when the session finishes parsing a CreateObject. public event Action? EntitySpawned; @@ -204,6 +206,13 @@ public sealed class WorldSession : IDisposable /// public event Action? EntityDeleted; + /// + /// 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. + /// + public event Action? EntityPickedUp; + /// /// Payload for : 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); /// /// Fires when the session parses a 0xF748 UpdatePosition game message. @@ -433,7 +447,10 @@ public sealed class WorldSession : IDisposable /// runtime). See docs/research/2026-04-23-lightning-real.md. /// /// - public event Action? PlayScriptReceived; + public event Action? PlayPhysicsScriptReceived; + + /// Fires for retail typed PhysicsScript playback (0xF755). + public event Action? PlayPhysicsScriptTypeReceived; /// /// Phase 5d — retail's AdminEnvirons packet (opcode @@ -453,7 +470,7 @@ public sealed class WorldSession : IDisposable /// /// /// 0x76..0x7B — Thunder1..Thunder6 sounds. Paired with - /// a separate from the server + /// a separate from the server /// carrying the lightning-flash PhysicsScript. /// /// @@ -505,6 +522,26 @@ public sealed class WorldSession : IDisposable public ushort ServerControlSequence => _serverControlSequence; public ushort TeleportSequence => _teleportSequence; public ushort ForcePositionSequence => _forcePositionSequence; + + /// + /// Publishes the local player's canonical, freshness-accepted physics + /// timestamps for subsequent outbound movement messages. The App runtime + /// calls this only after PhysicsTimestampGate commits the matching + /// CreateObject/Movement/Position event; parsing alone never changes + /// outbound authority. + /// + 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) { diff --git a/src/AcDream.Core/Items/ClientObjectTable.cs b/src/AcDream.Core/Items/ClientObjectTable.cs index f28697bd..8183a3e6 100644 --- a/src/AcDream.Core/Items/ClientObjectTable.cs +++ b/src/AcDream.Core/Items/ClientObjectTable.cs @@ -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); + /// /// The client's table of every server object (retail weenie_object_table / /// CObjectMaint). Resolve by guid via Get. @@ -103,6 +115,13 @@ public sealed class ClientObjectTable /// Fires when an object is removed from the session. public event Action? ObjectRemoved; + /// + /// Generation-aware removal stream for runtime owners. UI consumers keep + /// using ; runtime owners use the reason and + /// generation to preserve only packets addressed to future incarnations. + /// + public event Action? ObjectRemovalClassified; + /// Fires when an object's properties are updated (typically after Appraise). public event Action? ObjectUpdated; @@ -321,15 +340,47 @@ public sealed class ClientObjectTable /// space, stolen, etc). /// public bool Remove(uint itemId) + => RemoveCore(itemId, ClientObjectRemovalReason.Ordinary, 0, notifyObjectRemoved: true); + + /// Removes an exact accepted server object incarnation. + 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; } + /// + /// Atomically replaces the retained qualities for a newer server object + /// incarnation while publishing a generation-specific teardown event. + /// + public ClientObject ReplaceGeneration(WeenieData data, ushort generation) + { + RemoveCore( + data.Guid, + ClientObjectRemovalReason.GenerationReplacement, + generation, + notifyObjectRemoved: true); + + return Ingest(data); + } + /// /// Apply a patch (e.g. from an /// IdentifyObjectResponse) to an existing object. Individual diff --git a/src/AcDream.Core/Physics/MotionSequenceGate.cs b/src/AcDream.Core/Physics/MotionSequenceGate.cs deleted file mode 100644 index 55461768..00000000 --- a/src/AcDream.Core/Physics/MotionSequenceGate.cs +++ /dev/null @@ -1,119 +0,0 @@ -using System; - -namespace AcDream.Core.Physics; - -/// -/// Per-entity staleness gate for inbound movement events (0xF74C), ported -/// from retail (L.2g S1, closes deviation DEV-6 for the UM path). -/// -/// Retail keeps a update_times[NUM_PHYSICS_TS] array of u16 -/// stamps on every CPhysicsObj (acclient.h:6084 — -/// PhysicsTimeStamp) and rejects out-of-order network events with a -/// wraparound-aware compare. Three of those stamps gate movement events: -/// -/// -/// INSTANCE_TS (8) — checked at dispatch -/// (ACSmartBox::DispatchSmartBoxEvent 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 (SmartBox::QueueBlobForObject) until that object -/// version exists; acdream adopts-and-applies instead — register row -/// AD-32 records the divergence. -/// MOVEMENT_TS (1)CPhysics::SetObjectMovement -/// (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. -/// SERVER_CONTROLLED_MOVE_TS (5) — 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. -/// -/// -/// The compare itself is CPhysicsObj::is_newer (0x00451ad0); -/// the Binary Ninja pseudo-C mangles its setcc returns, so the port follows -/// ACE's verbatim PhysicsObj.is_newer (PhysicsObj.cs:2853-2859), -/// cross-checked against the decomp's branch structure. -/// -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; - - /// - /// CPhysicsObj::is_newer (0x00451ad0): true when - /// is newer than - /// under u16 wraparound — when the absolute difference exceeds 0x7fff - /// the numerically SMALLER value is the newer one (the counter wrapped). - /// - public static bool IsNewer(ushort oldStamp, ushort newStamp) - { - if (Math.Abs(newStamp - oldStamp) > short.MaxValue) - return newStamp < oldStamp; - return oldStamp < newStamp; - } - - /// - /// Seed the stamps from the entity's CreateObject PhysicsDesc timestamp - /// block. Retail initializes update_times wholesale from the 9 - /// u16s at the tail of PhysicsDesc (ACE - /// WorldObject_Networking.cs:411-420 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. - /// - /// 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. - /// - 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; - } - - /// - /// 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. - /// - 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; - } -} diff --git a/src/AcDream.Core/Physics/PhysicsBody.cs b/src/AcDream.Core/Physics/PhysicsBody.cs index 9bd67c5c..07efb263 100644 --- a/src/AcDream.Core/Physics/PhysicsBody.cs +++ b/src/AcDream.Core/Physics/PhysicsBody.cs @@ -19,18 +19,27 @@ namespace AcDream.Core.Physics; // ──────────────────────────────────────────────────────────────────────────── /// -/// 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 (PhysicsState), verbatim +/// from the retail PhysicsState enum in acclient.h:2815. /// [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, /// /// 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: PhysicsState.HasPhysicsBSP. /// - HasPhysicsBsp = 0x00010000, // bit 16 — retail HAS_PHYSICS_BSP_PS + HasPhysicsBsp = 0x00010000, /// /// 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. /// - 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, } /// diff --git a/src/AcDream.Core/Physics/PhysicsTimestampGate.cs b/src/AcDream.Core/Physics/PhysicsTimestampGate.cs new file mode 100644 index 00000000..baae0594 --- /dev/null +++ b/src/AcDream.Core/Physics/PhysicsTimestampGate.cs @@ -0,0 +1,216 @@ +using System; + +namespace AcDream.Core.Physics; + +public enum CreateObjectTimestampDisposition +{ + StaleGeneration, + InitialGeneration, + ExistingGeneration, + NewGeneration, +} + +public enum PositionTimestampDisposition +{ + Rejected, + Apply, + ForcePosition, +} + +/// +/// Per-object retail update_times[NUM_PHYSICS_TS] gate. Channel order +/// is PhysicsTimeStamp from acclient.h:6084. The comparator and +/// channel mutation order are ported from CPhysicsObj::is_newer +/// 0x00451AD0, CPhysicsObj::newer_event 0x00451B10, +/// SmartBox::DoSetState 0x004520D0, +/// SmartBox::DoVectorUpdate 0x004521C0, and +/// SmartBox::HandleReceivedPosition 0x00453FD0. +/// +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]; + + /// + /// Retail u16 wrap comparison. At the exact half-range ambiguity + /// (0x8000), the numerically lower stamp is considered newer. + /// + public static bool IsNewer(ushort oldStamp, ushort newStamp) + { + int distance = Math.Abs((int)newStamp - oldStamp); + return distance > 0x7FFF ? newStamp < oldStamp : oldStamp < newStamp; + } + + /// + /// 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. + /// + public CreateObjectTimestampDisposition SeedForCreateObject( + ushort position, + ushort movement, + ushort state, + ushort vector, + ushort teleport, + ushort serverControlledMove, + ushort forcePosition, + ushort objDesc, + ushort instance) + { + Span 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); + + /// + /// ParentEvent and PickupEvent share POSITION_TS with UpdatePosition. + /// Retail exact-gates INSTANCE_TS, then strictly advances POSITION_TS. + /// + public bool TryAcceptPositionChannelEvent(ushort instance, ushort position) => + TryAcceptInstance(instance) && AdvanceStrict(Position, position); + + public bool IsCurrentInstance(ushort instance) => TryAcceptInstance(instance); + + /// + /// 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. + /// + public bool IsFreshTeleportStart(ushort teleport) => + _seeded && !IsNewer(teleport, _timestamps[Teleport]); + + /// + /// Delete/Pickup events only affect the exact live incarnation. Retail + /// drops older deletes and queues newer-incarnation deletes without + /// touching the current object. + /// + public bool TryAcceptDeleteEvent(ushort instance, bool isLocalPlayer = false) => + !isLocalPlayer && _seeded && instance == _timestamps[Instance]; + + /// + /// Ports the timestamp portion of HandleReceivedPosition. 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. + /// + 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]; + } +} diff --git a/src/AcDream.Core/Physics/PositionFrameValidation.cs b/src/AcDream.Core/Physics/PositionFrameValidation.cs new file mode 100644 index 00000000..0028ca8d --- /dev/null +++ b/src/AcDream.Core/Physics/PositionFrameValidation.cs @@ -0,0 +1,30 @@ +using System.Numerics; + +namespace AcDream.Core.Physics; + +/// +/// Retail Position::IsValid 0x005A9480 composed with +/// Frame::IsValid 0x00534ED0. The quaternion test is performed on its +/// squared norm with retail's exact 0.0002 * 5 tolerance. +/// +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; + } +} diff --git a/src/AcDream.Core/Selection/SelectionState.cs b/src/AcDream.Core/Selection/SelectionState.cs index e9581011..bfc28f43 100644 --- a/src/AcDream.Core/Selection/SelectionState.cs +++ b/src/AcDream.Core/Selection/SelectionState.cs @@ -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); + /// + /// 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. + /// + 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); diff --git a/tests/AcDream.App.Tests/UI/AutoWieldGenerationTests.cs b/tests/AcDream.App.Tests/UI/AutoWieldGenerationTests.cs new file mode 100644 index 00000000..4233a3bb --- /dev/null +++ b/tests/AcDream.App.Tests/UI/AutoWieldGenerationTests.cs @@ -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); +} diff --git a/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs index 977c3b82..729b6a78 100644 --- a/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs @@ -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() { diff --git a/tests/AcDream.App.Tests/UI/Layout/PaperdollControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/PaperdollControllerTests.cs index 2c819607..c5afbbbe 100644 --- a/tests/AcDream.App.Tests/UI/Layout/PaperdollControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/PaperdollControllerTests.cs @@ -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() { diff --git a/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs index e4b49b64..089e2c63 100644 --- a/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs @@ -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 + { + 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() { diff --git a/tests/AcDream.App.Tests/World/InboundPhysicsStateControllerTests.cs b/tests/AcDream.App.Tests/World/InboundPhysicsStateControllerTests.cs new file mode 100644 index 00000000..dd998c50 --- /dev/null +++ b/tests/AcDream.App.Tests/World/InboundPhysicsStateControllerTests.cs @@ -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(), + Array.Empty(), + Array.Empty(), + 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); +} diff --git a/tests/AcDream.App.Tests/World/ParentAttachmentStateTests.cs b/tests/AcDream.App.Tests/World/ParentAttachmentStateTests.cs new file mode 100644 index 00000000..b20a63e1 --- /dev/null +++ b/tests/AcDream.App.Tests/World/ParentAttachmentStateTests.cs @@ -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(), + Array.Empty(), + Array.Empty(), + 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); +} diff --git a/tests/AcDream.Core.Net.Tests/Messages/CreateObjectTests.cs b/tests/AcDream.Core.Net.Tests/Messages/CreateObjectTests.cs index 9e057808..9f08c413 100644 --- a/tests/AcDream.Core.Net.Tests/Messages/CreateObjectTests.cs +++ b/tests/AcDream.Core.Net.Tests/Messages/CreateObjectTests.cs @@ -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, diff --git a/tests/AcDream.Core.Net.Tests/Messages/DeleteObjectTests.cs b/tests/AcDream.Core.Net.Tests/Messages/DeleteObjectTests.cs index b1140d94..096202db 100644 --- a/tests/AcDream.Core.Net.Tests/Messages/DeleteObjectTests.cs +++ b/tests/AcDream.Core.Net.Tests/Messages/DeleteObjectTests.cs @@ -37,41 +37,4 @@ public sealed class DeleteObjectTests Assert.Equal((ushort)0x1234, parsed.Value.InstanceSequence); } - /// - /// 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). - /// - [Fact] - public void TryParse_AlwaysReturnsFromPickupFalse() - { - Span 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."); - } - - /// - /// FromPickup = true can be constructed via the record directly (as - /// WorldSession does for PickupEvent 0xF74A). Default is false. - /// - [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); - } } diff --git a/tests/AcDream.Core.Net.Tests/Messages/ObjDescEventTests.cs b/tests/AcDream.Core.Net.Tests/Messages/ObjDescEventTests.cs index 5610dc2f..e038d14b 100644 --- a/tests/AcDream.Core.Net.Tests/Messages/ObjDescEventTests.cs +++ b/tests/AcDream.Core.Net.Tests/Messages/ObjDescEventTests.cs @@ -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])); } /// @@ -126,6 +131,13 @@ public sealed class ObjDescEventTests dest.AddRange(tmp.ToArray()); } + private static void AppendU16(List dest, ushort value) + { + Span tmp = stackalloc byte[2]; + BinaryPrimitives.WriteUInt16LittleEndian(tmp, value); + dest.AddRange(tmp.ToArray()); + } + /// /// Mirror of ACE's WritePackedDwordOfKnownType: strip the type prefix /// if it matches , then write as a 16- or diff --git a/tests/AcDream.Core.Net.Tests/Messages/ProjectileVfxPacketFixtures.cs b/tests/AcDream.Core.Net.Tests/Messages/ProjectileVfxPacketFixtures.cs index dd907285..8eb368fe 100644 --- a/tests/AcDream.Core.Net.Tests/Messages/ProjectileVfxPacketFixtures.cs +++ b/tests/AcDream.Core.Net.Tests/Messages/ProjectileVfxPacketFixtures.cs @@ -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(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))); } } diff --git a/tests/AcDream.Core.Net.Tests/Messages/UpdatePositionTests.cs b/tests/AcDream.Core.Net.Tests/Messages/UpdatePositionTests.cs index ff82a99b..0754f60c 100644 --- a/tests/AcDream.Core.Net.Tests/Messages/UpdatePositionTests.cs +++ b/tests/AcDream.Core.Net.Tests/Messages/UpdatePositionTests.cs @@ -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 + } } diff --git a/tests/AcDream.Core.Net.Tests/ObjectTableWiringTests.cs b/tests/AcDream.Core.Net.Tests/ObjectTableWiringTests.cs index f532935d..56e83ea3 100644 --- a/tests/AcDream.Core.Net.Tests/ObjectTableWiringTests.cs +++ b/tests/AcDream.Core.Net.Tests/ObjectTableWiringTests.cs @@ -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. /// 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 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); /// - /// 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. - /// - [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)); - } - - /// - /// 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. /// [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]); + } } diff --git a/tests/AcDream.Core.Tests/Input/PlayerMovementControllerTests.cs b/tests/AcDream.Core.Tests/Input/PlayerMovementControllerTests.cs index b61ec5ad..fb9f7386 100644 --- a/tests/AcDream.Core.Tests/Input/PlayerMovementControllerTests.cs +++ b/tests/AcDream.Core.Tests/Input/PlayerMovementControllerTests.cs @@ -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() { diff --git a/tests/AcDream.Core.Tests/Physics/DoorBugTrajectoryReplayTests.cs b/tests/AcDream.Core.Tests/Physics/DoorBugTrajectoryReplayTests.cs index ee99ade4..b6522f5f 100644 --- a/tests/AcDream.Core.Tests/Physics/DoorBugTrajectoryReplayTests.cs +++ b/tests/AcDream.Core.Tests/Physics/DoorBugTrajectoryReplayTests.cs @@ -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); diff --git a/tests/AcDream.Core.Tests/Physics/DoorCollisionApparatusTests.cs b/tests/AcDream.Core.Tests/Physics/DoorCollisionApparatusTests.cs index 218aa163..1aadbefb 100644 --- a/tests/AcDream.Core.Tests/Physics/DoorCollisionApparatusTests.cs +++ b/tests/AcDream.Core.Tests/Physics/DoorCollisionApparatusTests.cs @@ -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, diff --git a/tests/AcDream.Core.Tests/Physics/MotionSequenceGateTests.cs b/tests/AcDream.Core.Tests/Physics/MotionSequenceGateTests.cs index 30430faf..8941dcf3 100644 --- a/tests/AcDream.Core.Tests/Physics/MotionSequenceGateTests.cs +++ b/tests/AcDream.Core.Tests/Physics/MotionSequenceGateTests.cs @@ -4,8 +4,8 @@ using Xunit; namespace AcDream.Core.Tests.Physics; /// -/// Covers — the inbound movement-event -/// staleness gate ported from retail (L.2g S1, deviation DEV-6): +/// Covers — the inbound physics-event +/// staleness gate ported from retail: /// /// /// CPhysicsObj::is_newer (0x00451ad0) — the wraparound u16 @@ -22,7 +22,7 @@ namespace AcDream.Core.Tests.Physics; /// newer than the incoming one (equal passes). /// /// -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); } diff --git a/tests/AcDream.Core.Tests/Physics/PhysicsStateFlagsTests.cs b/tests/AcDream.Core.Tests/Physics/PhysicsStateFlagsTests.cs new file mode 100644 index 00000000..0ec06a10 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/PhysicsStateFlagsTests.cs @@ -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); +} diff --git a/tests/AcDream.Core.Tests/Physics/PositionFrameValidationTests.cs b/tests/AcDream.Core.Tests/Physics/PositionFrameValidationTests.cs new file mode 100644 index 00000000..cef97805 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/PositionFrameValidationTests.cs @@ -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)); + } +} diff --git a/tests/AcDream.Core.Tests/Selection/SelectionStateTests.cs b/tests/AcDream.Core.Tests/Selection/SelectionStateTests.cs index ae4f0c4b..be4b1a24 100644 --- a/tests/AcDream.Core.Tests/Selection/SelectionStateTests.cs +++ b/tests/AcDream.Core.Tests/Selection/SelectionStateTests.cs @@ -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() { diff --git a/tests/AcDream.Core.Tests/Streaming/GpuWorldStateTests.cs b/tests/AcDream.Core.Tests/Streaming/GpuWorldStateTests.cs index dfe186e1..0bfe8a30 100644 --- a/tests/AcDream.Core.Tests/Streaming/GpuWorldStateTests.cs +++ b/tests/AcDream.Core.Tests/Streaming/GpuWorldStateTests.cs @@ -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);