diff --git a/AGENTS.md b/AGENTS.md index e061aa2e..82755c3d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -108,17 +108,12 @@ movement queries. ## Current state -**Currently working toward: M2 — Kill a drudge** (equip sword → hit a drudge → damage -in chat → loot → inventory). **M1.5 — Indoor world feels right LANDED 2026-07-10**: -buildings/cellars/multi-floor inns + full dungeon round-trip (enter→navigate→exit) all -user-gated; #138 closed by the round-trip gate; #133/#137/#95/#79/#93/#80 CLOSED. M2 -first ports = `CombatMath.ComputeDamage` (F.3) + inventory panel (F.2) + combat anim -(L.1c) — see the M2 section in the milestones doc + `docs/research/2026-06-04-combat-math-deep-dive.md`. -**Carried post-M1.5, NOT blockers:** #145-residual (far-town teleport-OUT cascade — -capture-harness-first), #116 slide-response. **D.6 retained retail radar/compass** -implemented 2026-07-10 (live-world user visual gate pending; `claude-memory/project_d2b_retail_ui.md`); -**R5 movement-manager arc DONE** (2026-07-05; carried #167, R6/TS-42); **Track MP** perf -side track at MP0. Keep this paragraph ≤6 lines + pointers — detail in the docs below, NOT here. +**Currently working toward: M3 — Cast a spell. M2 — Kill a drudge LANDED 2026-07-15**: +the live melee/missile loop covers weapon switching, target-facing, attack/damage/death, loot, +inventory, and item giving. The M2/M3 projectile + DAT-effect foundation is automated through +Step 9; single-client missiles/protection effects are user-gated. **Next:** reconcile F.4/L.1d's +cast lifecycle against that foundation, then F.5 spellbook + active enchantments. **Carried:** final +two-client portal/observer VFX gate, #145-residual, #116, R6/TS-42, and Track MP0. Detail below. For canonical state, read in this order: - [`docs/plans/2026-05-12-milestones.md`](docs/plans/2026-05-12-milestones.md) — milestone targets + freeze list per milestone diff --git a/CLAUDE.md b/CLAUDE.md index a7cac5e6..815748d9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -106,17 +106,12 @@ movement queries. ## Current state -**Currently working toward: M2 — Kill a drudge** (equip sword → hit a drudge → damage -in chat → loot → inventory). **M1.5 — Indoor world feels right LANDED 2026-07-10**: -buildings/cellars/multi-floor inns + full dungeon round-trip (enter→navigate→exit) all -user-gated; #138 closed by the round-trip gate; #133/#137/#95/#79/#93/#80 CLOSED. M2 -first ports = `CombatMath.ComputeDamage` (F.3) + inventory panel (F.2) + combat anim -(L.1c) — see the M2 section in the milestones doc + `docs/research/2026-06-04-combat-math-deep-dive.md`. -**Carried post-M1.5, NOT blockers:** #145-residual (far-town teleport-OUT cascade — -capture-harness-first), #116 slide-response. **D.6 retained retail radar/compass** -implemented 2026-07-10 (live-world user visual gate pending; `claude-memory/project_d2b_retail_ui.md`); -**R5 movement-manager arc DONE** (2026-07-05; carried #167, R6/TS-42); **Track MP** perf -side track at MP0. Keep this paragraph ≤6 lines + pointers — detail in the docs below, NOT here. +**Currently working toward: M3 — Cast a spell. M2 — Kill a drudge LANDED 2026-07-15**: +the live melee/missile loop covers weapon switching, target-facing, attack/damage/death, loot, +inventory, and item giving. The M2/M3 projectile + DAT-effect foundation is automated through +Step 9; single-client missiles/protection effects are user-gated. **Next:** reconcile F.4/L.1d's +cast lifecycle against that foundation, then F.5 spellbook + active enchantments. **Carried:** final +two-client portal/observer VFX gate, #145-residual, #116, R6/TS-42, and Track MP0. Detail below. For canonical state, read in this order: - [`docs/plans/2026-05-12-milestones.md`](docs/plans/2026-05-12-milestones.md) — milestone targets + freeze list per milestone diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 6738653d..a2e4b71a 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -46,7 +46,7 @@ Copy this block when adding a new issue: ## #217 — Character windows did not receive live 64-bit experience updates -**Status:** IN-PROGRESS — implementation complete; connected visual gate pending +**Status:** DONE — 2026-07-13, connected gate user-confirmed **Severity:** HIGH **Filed:** 2026-07-13 **Component:** retained UI / character sheet / net / player state @@ -79,6 +79,9 @@ the value element `0x10000235` is explicitly right-aligned. right-aligned total and red progress fill; Skills immediately shows the same server-authoritative unassigned XP available for raises. +**Gate:** Passed in the starter-dungeon item-giving session: earned XP, +right-aligned Total Experience/progress, and Unassigned Experience all updated. + --- ## #213 — Retail client commands were sent to ACE as chat text @@ -184,18 +187,19 @@ the combat button and grave key briefly selected Melee before ACE restored NonCombat, so the character could not enter combat. **Root cause:** PlayerDescription recorded equipped entries with their equip -mask but no player ownership/container index. Live WieldObject updates did -index the same item under the player. `ToggleLiveCombatMode` reads the player's -ordered contents for retail `GetObjectAtLocation`, so it missed login equipment, -defaulted to Melee, and sent a mode incompatible with ACE's equipped missile -weapon. ACE correctly returned authoritative NonCombat. +mask but no player ownership/container index. The combat-mode lookup therefore +missed login equipment, defaulted to Melee, and sent a mode incompatible with +ACE's equipped missile weapon. ACE correctly returned authoritative NonCombat. -**Resolution:** PlayerDescription equipment now uses the same -contained-by-wielder projection as live WieldObject while preserving its wire -order, equip mask, and layering priority. The production combat planner now -selects Missile for the login-equipped crossbow. The connected gate completed -NonCombat → Missile → NonCombat → Missile, including a Jump press, without a -server rejection. +**Resolution:** PlayerDescription preserves login equipment ownership and the +production combat planner selects Missile for the login-equipped crossbow. A +2026-07-14 retail-conformance refinement also corrected live WieldObject: its +payload is exactly `(itemGuid, equipLocation)`, and confirmation produces +`ContainerId=0`, `WielderId=player`. Equipment consumers now use +`GetEquippedBy`, which unifies that authoritative state with the temporary +optimistic pre-confirm projection instead of treating equipment as backpack +contents. The connected gate completed NonCombat → Missile → NonCombat → +Missile, including a Jump press, without a server rejection. **Research:** `docs/research/2026-07-11-combat-default-and-parent-event-pseudocode.md` diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index f0603c59..a2c6cea9 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -212,7 +212,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-109 | Character Titles page is inert and live displayed-title/luminance state is absent | `src/AcDream.App/UI/Layout/CharacterStatController.cs`; `CharacterSheetProvider.cs` | Attributes/skills core output is user-accepted | Titles cannot be selected/displayed and level-200 luminance fields are missing | `gmCharacterTitleUI @ 0x0049A610`; `gmStatManagementUI::UpdateExperience @ 0x004F0A70` | | AP-110 | Remaining retained gameplay panels and world HUD are absent: advanced-combat powerbar, spellbook/effects/favorite spell bars, residual social/examine/floating chat, quests/map/vitae/options/smartbox, vendor/trade/salvage/tinkering, and D.6 nameplates/floaters | D.5/D.6 roadmap; retained layout registration set | Basic `gmCombatUI` now covers the active M2 melee/missile loop; Wave 10 lands each remaining surface against authoritative state | Large portions of retail gameplay still have no production UI | Named `gm*UI::PostInit` methods and LayoutDesc catalog | | ~~AP-111~~ | **RETIRED 2026-07-11 (M2 held-object parenting)** — equipped hand items are no longer omitted from the render world. CreateObject now preserves Placement/Parent/position timestamp bootstrap; live `0xF749` ParentEvent is parsed with retail sequence freshness; a focused render controller resolves `Setup.HoldingLocations`, applies the child's placement frame, and recomposes the separate child entity after every parent animation tick. Pickup retains the weenie's visual metadata for a later wield. | `src/AcDream.Core.Net/Messages/{CreateObject,ParentEvent}.cs`; `src/AcDream.Core/Meshing/EquippedChildAttachment.cs`; `src/AcDream.App/Rendering/EquippedChildRenderController.cs` | — | — | `ClientCombatSystem::GetDefaultCombatMode @ 0x0056B310`; `SmartBox::HandleParentEvent @ 0x004535D0`; `CPhysicsObj::set_parent @ 0x00515A90`; `CPhysicsObj::UpdateChild @ 0x00512D50` | -| AP-112 | The basic combat bar ports visibility, height selection, desired-power slider, exact 1.0/0.8-second charge, ready-stance gating, request/release, server-response queueing, and auto-repeat, but omits `StartAttackRequest`'s `FinishJump`/`MaybeStopCompletely` command-interpreter calls and exact trained-Recklessness visibility semantics (IA-20 keeps the dark range as the accepted baseline) | `src/AcDream.App/Combat/CombatAttackController.cs`; `src/AcDream.App/UI/Layout/CombatUiController.cs` | The M2 attack contract and authored basic panel are live; the remaining seams require the jump/movement command owner and a distinct Recklessness treatment rather than UI-local guesses | Starting an attack while charging a jump or deliberately moving may not stop/cancel exactly when retail does; trained/untrained Recklessness presentation is identical | `ClientCombatSystem::StartAttackRequest @ 0x0056C040`; `gmCombatUI::ListenToElementMessage @ 0x004CC430` | +| AP-112 | The basic combat bar ports visibility, height selection, desired-power slider, exact 1.0/0.8-second charge, ready-stance gating, request/release, `MaybeStopCompletely`, server-response queueing, and auto-repeat, but still omits `StartAttackRequest`'s `FinishJump` call and exact trained-Recklessness visibility semantics (IA-20 keeps the dark range as the accepted baseline) | `src/AcDream.App/Combat/CombatAttackController.cs`; `src/AcDream.App/UI/Layout/CombatUiController.cs` | The shared player movement owner now performs retail's server-control-gated full stop and movement report before an attack build; the remaining seams require the jump owner and a distinct Recklessness treatment | Starting an attack while charging a jump may not finish that jump exactly when retail does; trained/untrained Recklessness presentation is identical | `ClientCombatSystem::StartAttackRequest @ 0x0056C040`; `CommandInterpreter::MaybeStopCompletely @ 0x006B3B90`; `gmCombatUI::ListenToElementMessage @ 0x004CC430` | | AP-113 | Invalid lifestone-command arguments display the local text `Usage: /lifestone`; retail definitely emits a local usage/error line but Binary Ninja misidentifies the referenced wide-string address, so its exact wording is not yet recovered | `src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs`; `RetailClientCommandCatalog.cs` | The behavior boundary is exact (handled locally, no chat and no game action); only a low-impact diagnostic sentence differs | `/ls now` can show different wording/color from retail while still refusing the invalid request correctly | `ClientCommunicationSystem::DoLifestone @ 0x0056FC70` | | ~~AP-114~~ | **RETIRED 2026-07-14 (protection-effect corrective gate)** — the particle renderer no longer replaces every authored GfxObj with one bounding-box quad. Retail `Always2D` classification preserves mode-1/no-degrade full meshes through the modern shared mesh buffer and leaves only other degrade modes on the billboard path; stable emitter handles balance mesh ownership. | `src/AcDream.App/Rendering/ParticleRenderer.cs`; `RetailParticleGeometryClassifier.cs`; `particle_mesh.vert/.frag` | — | — | `CPhysicsPart::Draw @ 0x0050D7A0`; `CPhysicsPart::Always2D @ 0x0050D8A0`; `ParticleEmitter::SetInfo @ 0x0051CE90`; `docs/research/2026-07-13-retail-projectile-vfx-pseudocode.md` | @@ -245,7 +245,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | TS-30 | Chat DAT elements `0x10000522`–`0x10000525` render but have no controller semantics; the older claim that they are numbered in-window filter tabs is **unproven** | `src/AcDream.App/UI/Layout/ChatWindowController.cs` | Named retail proves separately filtered main/floaty chat windows, not an in-window numbered-tab model. Wave 5 must live/DAT-confirm these element roles before assigning behavior | The controls may be inert today, but inventing tab switching could be a larger divergence than leaving an unconfirmed role inactive | `gmMainChatUI @ 0x004CCCC0..0x004CE2A0`; correction in `docs/research/2026-07-10-retail-panel-behavior-pseudocode.md` | | TS-31 | **NARROWED 2026-07-13** — `/squelch`, `/unsquelch`, `/filter`, `/unfilter`, and `/messagetypes` send the exact modification events and consume the authoritative retail `SquelchDB`; incoming `ChatLog` lines are not yet filtered through that database, and clickable name-tag social actions remain absent | `src/AcDream.Core/Social/SquelchState.cs`; `src/AcDream.Core.Net/Messages/SocialStateMessages.cs`; `src/AcDream.App/UI/ClientCommandController.cs`; `src/AcDream.Core/Chat/ChatLog.cs` | Command/state transport is complete; enforcement belongs at the shared inbound-chat boundary so both backends remain identical | A squelch appears in the list and persists server-side but matching incoming lines can still render; contextual name actions remain unavailable | `SquelchDB::UnPack @ 0x006B1900`; `ChatFilter::IsSquelched`; retail right-click player name → Squelch menu | | TS-32 | `ClientObjectTable` has no pre-queue for a child `CreateObject` that arrives before its parent (out-of-order PARENTED create); such objects are ingested as root objects and their `ContainerId` links a not-yet-known container. Retail's `null_object_table` + `null_weenie_object_table` hold unresolvable objects until the parent arrives | `src/AcDream.Core/Items/ClientObjectTable.cs` (`Ingest`) | PD↔`CreateObject` ordering is handled (upsert semantics); out-of-order PARENTED creates are observed only at high packet loss or in vendor/corpse multi-object bursts on non-loopback links; deferred to D.5.5+ | A container's child object arriving before the container is ingested as a root item — it won't appear in `GetContents` until the next `RecordMembership` or a move event corrects the parent link | `CObjectMaint::null_object_table` / `null_weenie_object_table` (acclient.h / named-retail pc) | -| TS-33 | Outbound position-send tracker over-stamped after MoveToState: `NotePositionSent` writes last-sent position + cell + contact-plane after BOTH the MTS and AP sends, but retail's `SendMovementEvent` (0x006b4680) stamps ONLY `last_sent_position_time` after an MTS — only `SendPositionEvent` (0x006b4770, the AP path) stamps all three. Broader: acdream gates APs on a plain interval (`HeartbeatDue`) where retail uses `ShouldSendPositionEvent` (0x006b45e0 — interval OR cell-change OR contact-plane-change), AND acdream's frame-changed diff compares POSITION only (`ApproxPositionEqual`) where retail's `Frame::is_equal` compares the full frame incl. ORIENTATION — a stationary heading change (R4-V5: the MoveToManager's `HandleTurnToHeading` arrival snap, `set_heading(send:true)`) never triggers an AP, so the server keeps the stale facing until the player next moves. Masked against ACE (ACE rotates server-side on its own mt-8/9 / close-range-use paths and broadcasts the result) | `src/AcDream.App/Rendering/GameWindow.cs:8331` (MTS `NotePositionSent`); `src/AcDream.App/Input/PlayerMovementController.cs` (`ApproxPositionEqual` + the heartbeat diff); the player MoveToManager `setHeading` seam in `EnterPlayerModeNow` (the `send` flag's would-be consumer) | D5 audit-only in the L.2b wire-parity slice; the full cadence port (`ShouldSendPositionEvent` gate + split MTS/AP stamping + full-frame `Frame::is_equal` diff) is a dedicated follow-up slice (R7 outbound) | AP heartbeat cadence diverges from retail — acdream may suppress or reorder autonomous-position sends differently after movement-state sends, and a stationary server-commanded turn leaves observers with stale facing until the next movement; on a real network this shifts the position-correction rhythm | `CommandInterpreter::SendMovementEvent` 0x006b4680, `SendPositionEvent` 0x006b4770, `ShouldSendPositionEvent` 0x006b45e0, `Frame::is_equal` (pc:700263) | +| TS-33 | **NARROWED 2026-07-15** — full AP tracker semantics are ported: MTS stamps time only; AP stamps complete cell-local Position + contact plane + time; `ShouldSendPositionEvent` compares cell/contact inside the interval and the complete Frame including orientation afterward. Residual: acdream's single update path snapshots the AP predicate, emits a same-update MTS first when input changed, then AP. Retail proves `UseTime` performs Should→AP, but MTS originates in separate input callbacks; their relative same-tick callback/wire order is not yet traced | `src/AcDream.App/Rendering/GameWindow.cs` (outbound MTS/AP blocks); `src/AcDream.App/Input/PlayerMovementController.cs` (ported tracker) | Preserve the pre-existing acdream wire order until a focused retail packet/breakpoint trace establishes input callback versus `UseTime`; do not infer it from `UseTime` alone | In the rare update where both packets are due, ACE may observe their position timestamps/action sequences in the opposite order from retail, shifting only that correction tick; stationary target-facing is live-gated because full-frame orientation now publishes | `CommandInterpreter::UseTime` 0x006B3BF0; `SendMovementEvent` 0x006B4680; `SendPositionEvent` 0x006B4770; `ShouldSendPositionEvent` 0x006B45E0; `Frame::is_equal` 0x00424C30 | | TS-40 | Retail's `physics_obj->cell` ("placed in the world") is proxied by the explicit `PhysicsBody.InWorld` flag — set by `SnapToCell` (local player placement) and `RemoteMotion` construction (remotes exist only for world entities); consumed by `CMotionInterp`'s detached-object link-strip guards (`if (cell == 0) RemoveLinkAnimations`, raw @305627). Replaces the UNREGISTERED `CellPosition.ObjCellId == 0` proxy, which only the local player ever seeded (#145 `SnapToCell`), so every REMOTE body read "detached" and every dispatched transition link (door swings, remote walk↔run links) was stripped the same tick it was appended — the 2026-07-03 door-snap bug | `src/AcDream.Core/Physics/PhysicsBody.cs` (`InWorld`); `src/AcDream.Core/Physics/MotionInterpreter.cs` (3 guard sites) | acdream has no per-body CObjCell pointer; a boolean placement flag carries exactly the guard's retail meaning until cell-pointer plumbing exists | A body used without either placement path (a future entity class constructing bodies directly) reads detached and loses transition links until its creation site sets the flag | `CMotionInterp::DoInterpretedMotion` 0x00528360 tail @305627; `CPhysicsObj::RemoveLinkAnimations` | | TS-35 | `PhysicsBody.IsFullyConstrained` is a stub property (default `false`, never set by any physics code), read by `jump_is_allowed`'s verbatim `IsFullyConstrained` gate (raw 305524-305525) | `src/AcDream.Core/Physics/PhysicsBody.cs` (`IsFullyConstrained`) | R3-W3 needed the read site to port `jump_is_allowed`'s full chain. **R5-V1 CORRECTED the mechanism** (the earlier "per-cell contact-plane / doorway-jamming" guess was WRONG): the write side is the **ConstraintManager server-position rubber-band leash** — armed by `SmartBox::HandleReceivedPosition` on every inbound server position, `IsFullyConstrained` = `max*0.9 < offset`. R5-V1 ported `ConstraintManager` (`src/AcDream.Core/Physics/Motion/ConstraintManager.cs`) but does NOT arm it (no acdream `SmartBox` + two x87 distance constants BN elided) — so this read stays false. Arming = issue #167 | A body retail would consider fully constrained (still rubber-banding toward a server position inside the tight leash) never refuses the jump (0x47) — a jump succeeds mid-rubber-band where retail blocks it. Low practical risk (the leash band is tight + short-lived) | `CPhysicsObj::IsFullyConstrained` 0x0050ec60 → `ConstraintManager::IsFullyConstrained` 0x005560d0; `jump_is_allowed` 0x005282b0; arming `SmartBox::HandleReceivedPosition` 0x00453fd0 (issue #167) | | TS-37 | RETIRED misattribution note (not a live divergence — kept here as the historical record R3-W3 closes): the S2a port had `contact_allows_move` (0x00528240) arm `StandingLongJump` as a side effect, explicitly flagged "PRE-EXISTING acdream side effect (not part of 0x00528240)". R3-W3 deletes that side effect; `ChargeJump` (0x005281c0) is now the ONLY arming site, matching retail exactly. No further action — recorded per the register's retire-in-same-commit rule | `src/AcDream.Core/Physics/MotionInterpreter.cs` (`contact_allows_move`, `ChargeJump`) | N/A — retired | N/A — retired | `CMotionInterp::charge_jump` 0x005281c0 @305448 | diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index cc13f126..860151cb 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -1,11 +1,34 @@ # acdream — strategic roadmap -**Status:** Living document. Updated 2026-05-19. **Between phases.** **Since the last header update:** Indoor walkable-plane BSP port FOUNDATION shipped (6 commits, `ff548b9` → `f845b22`) but visual verification failed — cellar descent, 2nd-floor walking, single-floor cottage regressions all confirm the shipped fix doesn't address the user-reported indoor bugs. Root cause now diagnosed as deeper than originally thought: `TryFindIndoorWalkablePlane` exists as a Phase 2 stop-gap that retail doesn't have an analog for. Retail retains ContactPlane state across frames; we re-synthesize per frame. Foundation work (BSP walker + probe + tests) remains useful; next phase needs to port retail's ContactPlane retention mechanism and likely eliminate `TryFindIndoorWalkablePlane` entirely. Handoff: [`docs/research/2026-05-19-indoor-walkable-plane-bsp-port-shipped-handoff.md`](../research/2026-05-19-indoor-walkable-plane-bsp-port-shipped-handoff.md). ISSUES #83 remains OPEN with deeper diagnosis. **Earlier:** Indoor cell rendering Phase 1 (diagnostics) + Phase 2 (fix) shipped — root cause was a one-line WB bug at `ObjectMeshManager.cs:1223` (blind `TryGet` on GfxObj-prefixed stab ids threw `ArgumentOutOfRangeException` which WB's outer catch silently swallowed, causing 26/123 Holtburg cells to fail upload). Identified via diagnostic chain (5 `[indoor-*]` probes + a `ContinueWith` exception surfacer + a `ConsoleErrorLogger` injected into WB), fixed with a Setup-prefix guard. User visually confirmed floors render. Surfaced 9 pre-existing indoor bugs filed in `docs/ISSUES.md`. **Earlier:** C.1.5b shipped (issue #56 per-part transforms for multi-emitter PES + `EntityScriptActivator` extended to dat-hydrated EnvCell statics & exterior stabs — portal swirl, inn fireplace flames, cottage chimney smoke, spell-cast particles all match retail). post-A.5 polish completed (#52 lifestone, #54 JobKind, #53 Tier 1 cache); N.6 slice 1 shipped (gpu_us fix + radius=12 perf baseline, conclusion CPU dominates GPU 30–50×); C.1.5a shipped (portal PES wiring; surfaced #56 → resolved in C.1.5b). +**Status:** Living document. Updated 2026-07-15. **M3 active.** M2's connected demo loop is complete: melee and missile weapon switching, target-facing, combat animation and damage/death presentation, loot/inventory, stack operations, and giving an item to a world target have all passed user gates. The projectile/PhysicsScript/particle/portal foundation is implemented through its Step 9 automated hardening gate; single-client missile and protection visuals pass, while the final two-client portal-out/materialization observer gate remains open. The immediate work order is an F.4/L.1d retail-conformance audit against that shipped foundation, implementation of the remaining cast lifecycle, then F.5 spellbook and active-enchantment UI. **Purpose:** One source of truth for where the project is and where it's going. Every observed defect or missing feature has a named phase that owns it; when something looks wrong in-game, look here to find the phase that'll address it. Implementation details live in per-phase specs under `docs/superpowers/specs/`, not in this file. --- -## Current program: Phase W — Unified Cell Graph (UCG) +## Current program: M3 — Cast a spell + +**Outcome:** Cast a projectile spell at a hostile target, see the complete +turn/windup/release/projectile/impact lifecycle, self-cast a buff and see its +active enchantment, then recall through the DAT-driven portal presentation. + +**Next concrete work, in order:** + +1. Reconcile current E.5 wire, M2/M3 projectile/VFX, motion, and enchantment + state against named-retail F.4/L.1d; record only the proven remaining gaps. +2. Port and conformance-test those cast-state and animation lifecycle gaps, + including facing, release, interruption/fizzle, recoil, and authoritative + completion. +3. Ship F.5's retained spellbook and active-enchantment surfaces over the + existing shared state/command seam. +4. Run the pending two-client portal-out/materialization observer visual gate + before M3 closes. + +Opportunistic UI/physics fixes are no longer the work-order driver. They enter +the active slice only when they block this demo or represent a severe regression. + +--- + +## Historical program: Phase W — Unified Cell Graph (UCG) **[SUPERSEDED — G.3 dungeon work shipped; M1.5 next step = #138 round-trip gate + #145-residual. See the shipped table + `docs/plans/2026-05-12-milestones.md` @@ -497,16 +520,16 @@ behavior. Estimated 17–26 days focused work, 3–5 weeks calendar. - **✓ SHIPPED — Wave 4.4c exact selected-health policy.** Core ports `ObjectIsAttackable @ 0x0056A600`; CreateObject preserves second-header PetOwner; toolbar player/pet composition is exact. Self, pets, and Free-PK creatures now query health while friendly NPCs and attackable non-creatures remain name-only. AP-46 retired. The world/radar policy matrix and exact DAT paperdoll body-map + upper-item-priority route passed live 2026-07-11. - **✓ SHIPPED — Wave 4.4d toolbar launchers + Use/Examine.** All seven authored launchers are discovered through their DAT panel-id attribute; mounted Inventory/Character panels toggle through the registry and the other five buttons are ghosted. Typed window-visibility events own Highlight/Normal state. Use and Examine act on selection or enter their one-shot retail target modes, with exact Appraise wire dispatch and retail cursors. Warning-free App build and 4,747-pass / 5-skip Release suite are green; the connected live gate passed 2026-07-11. AP-101 is narrowed to the remaining ammo-number display. - **Wave 4.4e implemented — exact missile ammo number (live gate pending).** Pure Core reproduces retail's thrown-weapon-vs-separate-ammo resolution over the ordered player equipment list and its zero-to-one count normalization. Relevant equipment/stack events update authored missile indicator `0x10000194` with the DAT font. Warning-free Release build and 4,754-pass / 5-skip suite are green; AP-101 is retired. -- **Wave 4.5 implemented — retail primary-weapon switching (corrective live gate pending).** Inventory double-click now routes through a focused `AutoWieldController`: the occupied `0x03500000` weapon-ready group is returned to the player container, followed when necessary by an incompatible shield or mismatched ammo; each blocker waits for authoritative `InventoryPutObjInContainer` before the next step, then `GetAndWieldItem` equips the requested bow/sword/caster/two-hander. The transaction remains inventory-busy through the exact authoritative `WieldObject` acknowledgement, independent of the object table's rollback counter, matching retail's waiting state and preventing rapid switches from overlapping or remaining stuck. `CreateObject` retains exact `AmmoType` for compatibility. The same transaction runs in peace and war; ACE owns the latter's old-stance → peace → new-stance motion chain. Player PropertyInt 40 updates `CombatState`, keeping toolbar/combat UI synchronized with ACE's selected Melee/Missile/Magic stance. Research: `docs/research/2026-07-12-retail-weapon-switch-pseudocode.md`. -- **Wave 4 inventory drag visuals implemented — corrective live visual gate pending.** `IconComposer` now preserves retail's separate underlay-free `m_pDragIcon` for cursor tracking across inventory, paperdoll, and toolbar bindings. Physical source cells keep their full icon in place and reveal authored ghost mesh `0x0600109A` from drag begin through every release/cancel path, with the persistent selection square above the mesh; shortcut aliases remain unghosted. `ItemList_DragOver` destination states are split exactly: contents-grid placement uses the green accept circle `0x060011F9`, while side-bag/main-pack container drop-in uses the green arrow `0x060011F7`. AP-47 retired. Research: `docs/research/2026-07-13-retail-item-drag-visuals-pseudocode.md`. -- **Wave 4.6 item giving implemented 2026-07-13; starter-dungeon live gate pending.** SmartBox drag release now preserves the exact release coordinates, world-picks the target, and runs the existing retail `ItemHolder::AttemptPlaceIn3D` policy. NPC/creature targets send exact `GiveObjectRequest 0x00CD(target,item,amount)` bytes, selected partial stacks use the shared split quantity, and the object table waits for ACE's authoritative remove/stack-size response. `PlayerDescription.Options1` supplies the real `DragItemOnPlayerOpensSecureTrade` option. Issue #216; research: `docs/research/2026-07-13-retail-give-item-pseudocode.md`. +- **✓ Wave 4.5 — retail primary-weapon switching (live-gated 2026-07-15).** Inventory double-click now routes through a focused `AutoWieldController`: the occupied `0x03500000` weapon-ready group is returned to the player container, followed when necessary by an incompatible shield or mismatched ammo; each blocker waits for authoritative `InventoryPutObjInContainer` before the next step, then `GetAndWieldItem` equips the requested bow/sword/caster/two-hander. The transaction remains inventory-busy through the exact authoritative `WieldObject` acknowledgement, independent of the object table's rollback counter, matching retail's waiting state and preventing rapid switches from overlapping or remaining stuck. `CreateObject` retains exact `AmmoType` for compatibility. The same transaction runs in peace and war; ACE owns the latter's old-stance → peace → new-stance motion chain. Player PropertyInt 40 updates `CombatState`, keeping toolbar/combat UI synchronized with ACE's selected Melee/Missile/Magic stance. Research: `docs/research/2026-07-12-retail-weapon-switch-pseudocode.md`. +- **✓ Wave 4 inventory drag visuals (live-gated 2026-07-15).** `IconComposer` now preserves retail's separate underlay-free `m_pDragIcon` for cursor tracking across inventory, paperdoll, and toolbar bindings. Physical source cells keep their full icon in place and reveal authored ghost mesh `0x0600109A` from drag begin through every release/cancel path, with the persistent selection square above the mesh; shortcut aliases remain unghosted. `ItemList_DragOver` destination states are split exactly: contents-grid placement uses the green accept circle `0x060011F9`, while side-bag/main-pack container drop-in uses the green arrow `0x060011F7`. AP-47 retired. Research: `docs/research/2026-07-13-retail-item-drag-visuals-pseudocode.md`. +- **✓ Wave 4.6 item giving (starter-dungeon live gate passed 2026-07-13).** SmartBox drag release now preserves the exact release coordinates, world-picks the target, and runs the existing retail `ItemHolder::AttemptPlaceIn3D` policy. NPC/creature targets send exact `GiveObjectRequest 0x00CD(target,item,amount)` bytes, selected partial stacks use the shared split quantity, and the object table waits for ACE's authoritative remove/stack-size response. `PlayerDescription.Options1` supplies the real `DragItemOnPlayerOpensSecureTrade` option. Issue #216; research: `docs/research/2026-07-13-retail-give-item-pseudocode.md`. - **M2 held-object parenting shipped and live-gated 2026-07-11.** The combat toggle now ports `GetDefaultCombatMode` over ordered equipped contents, so a bow requests Missile instead of the old hardcoded Melee. CreateObject preserves parent/placement/timestamp fields, `0xF749` ParentEvent is handled, and `EquippedChildRenderController` renders the weapon as a separate child composed from the animated hand part + holding frame + child placement frame. Live gate passed: bow selected missile stance, rendered in-hand, followed animation, unequipped cleanly, and melee remained correct. App Release builds with zero warnings; the full 4,765-pass / 5-skip suite is green. AP-111 is retired; research: `docs/research/2026-07-11-combat-default-and-parent-event-pseudocode.md`. - **Missile/portal VFX campaign Steps 0–5 implemented and independently reviewed 2026-07-14.** The retail oracle and packet fixtures are pinned, complete `PhysicsDesc` plus F754/F755 parsing and nine-channel gates are shipped, and App now has one canonical `LiveEntityRuntime` record per server-object incarnation. Logical register/unregister is separate from spatial rebucketing, so landblock churn and attached equipment retain identity without replaying renderer/script creation or reconstructing from stale spawn data. Canonical materialized and visible target/radar views are distinct; pickup/parent leave-world preserves owners while pausing root simulation; spawn publication is once per incarnation. `GpuWorldState` is spatial-only for live objects. Production PhysicsScript and Animation loading shares a narrow `DatCollection`-backed compatibility reader for retail's inherited blocking-particle payload, including mesh-side preloading; the proven concurrent-safe DatReaderWriter 2.1.7 read path avoids blocking update-thread effects behind streaming. Typed-table selection preserves DAT order and retail's first `intensity <= Mod` boundary, and live `PhysicsDesc` effect defaults replace rather than fall back to Setup. The Step 4 scheduler is now one serial FIFO per owner with duplicate stacking, catch-up dispatch, deterministic delayed `CallPES`, complete hook-router fan-out, update-frame clock publication, retail cell/Frozen eligibility, and structural rejection of malformed zero-time recursive DAT chains without rejecting valid timed weather loops. `EntityEffectController` retains pre-create F754/F755 in one mixed per-GUID FIFO, resolves local identity only through `LiveEntityRuntime`, replays once only after the canonical owner is fully ready and in-world, drops later plays while that existing owner is cell-less, routes attached-child updates through the eligible parent, and replaces/clears the live SoundTable on every PhysicsDesc application. Step 5 publishes current rigid root/indexed-part poses after animation and recursively composed held-child transforms, keeping render-only Setup scale out of particle and holding-location anchors; it then drains animation hooks, owner PhysicsScripts, moving emitters/lights, and particle simulation in fixed order. Normal/blocking/anonymous logical emitter identity is exact, including Stop retaining a blocking ID until final-particle retirement; moving and held Setup lights follow their current roots and share retail's PhysicsState Lighting/SetLight transition state; world-released versus parent-local particle behavior is preserved; missing emitter DAT produces an actionable diagnostic and no invented fallback. Drawable meshes no longer collapse stable Setup-part indices; nested attachments update parent-before-child, recursively withdraw on ancestor pose loss, and recover the complete descendant chain only on real publication edges. Loaded↔pending projection edges skip particle update/draw while retaining original absolute creation times and logical identity, and withdraw light presentation; re-entry evaluates elapsed state once without backlog emission or recreating an effect owner. Scripts, particles, lights, translucency, audio, and teardown all share canonical `WorldEntity.Id`; static allocators fail fast before namespace wrap. IA-7, AD-14, TS-10, TS-11, TS-12, TS-13, and AP-67 are retired; AD-32 now covers only the remaining non-effect, non-Parent pre-create packet families, and AD-43 registers the corrupt-DAT zero-time-cycle safety boundary. -- **M2 local attack receive funnel implemented 2026-07-11; live gate pending.** Retail `ExecuteAttack` only sends the request; ACE chooses the concrete melee/missile action and returns it in a non-autonomous mt-0 `UpdateMotion`. The local branch now runs that state through the same constructor-defaulted `MoveToInterpretedState` funnel and 15-bit action-stamp gate as remotes, then applies sticky/long-jump tails. The old local-only direct `Commands[]` replay is deleted. Shared conversion lives in `InboundInterpretedMotionFactory`; research: `docs/research/2026-07-11-local-combat-motion-pseudocode.md`. -- **M2 basic retail combat bar implemented 2026-07-11; corrective live visual gate pending.** Production mounts authored `gmCombatUI` LayoutDesc `0x21000073`, shows it only for Melee/Missile, and routes mouse plus keyboard through one `CombatAttackController`. Corrections include the authored wider centered dark-red child range (accepted IA-20), full-width left-to-right bright live attack-charge feedback, exact Speed-left/Power-right justification, silent control-only `AttackDone(ActionCancelled)`, target-frame Keep in View with manual orbit, and persistent corpse motion: the AP-80 velocity-only NPC adaptation can now replace only Ready/Walk/Run, never authoritative Dead/actions. `CombatTargetController` ports the selection-cleared AutoTarget consumer, so a selected creature's authoritative Dead motion clears it and selects the nearest eligible creature when enabled. The 2026-07-12 replacement-corpse correction unifies both multi-frame and static/reactive spawns behind retail's CreateObject lifecycle: apply the wire's Dead state while detached, then `MotionTableManager::HandleEnterWorld` strips Ready→Dead links before the first in-world tick; multiple corpses remaining fallen passed the live user gate that day. Follow-up #205 makes the toolbar read the final canonical selection after a reentrant Auto Target notice and restricts automatic candidates to hostile non-player monsters (intentional PK-edge divergence IA-19); that live gate also passed 2026-07-12. Research: `docs/research/2026-07-11-retail-combat-bar-pseudocode.md`, `docs/research/2026-07-11-combat-target-camera-pseudocode.md`, `docs/research/2026-07-12-death-and-auto-target-pseudocode.md`; AP-24/AP-95 retired, AP-80 narrowed, AP-110 narrowed, AP-112 records the remaining attack-start and exact trained-Recklessness seams. +- **✓ M2 local attack receive funnel (live-gated 2026-07-15).** Retail `ExecuteAttack` only sends the request; ACE chooses the concrete melee/missile action and returns it in a non-autonomous mt-0 `UpdateMotion`. The local branch now runs that state through the same constructor-defaulted `MoveToInterpretedState` funnel and 15-bit action-stamp gate as remotes, then applies sticky/long-jump tails. The old local-only direct `Commands[]` replay is deleted. Shared conversion lives in `InboundInterpretedMotionFactory`; research: `docs/research/2026-07-11-local-combat-motion-pseudocode.md`. +- **✓ M2 basic retail combat bar (live-gated 2026-07-15).** Production mounts authored `gmCombatUI` LayoutDesc `0x21000073`, shows it only for Melee/Missile, and routes mouse plus keyboard through one `CombatAttackController`. Corrections include the authored wider centered dark-red child range (accepted IA-20), full-width left-to-right bright live attack-charge feedback, exact Speed-left/Power-right justification, silent control-only `AttackDone(ActionCancelled)`, target-frame Keep in View with manual orbit, and persistent corpse motion: the AP-80 velocity-only NPC adaptation can now replace only Ready/Walk/Run, never authoritative Dead/actions. `CombatTargetController` ports the selection-cleared AutoTarget consumer, so a selected creature's authoritative Dead motion clears it and selects the nearest eligible creature when enabled. The 2026-07-12 replacement-corpse correction unifies both multi-frame and static/reactive spawns behind retail's CreateObject lifecycle: apply the wire's Dead state while detached, then `MotionTableManager::HandleEnterWorld` strips Ready→Dead links before the first in-world tick; multiple corpses remaining fallen passed the live user gate that day. Follow-up #205 makes the toolbar read the final canonical selection after a reentrant Auto Target notice and restricts automatic candidates to hostile non-player monsters (intentional PK-edge divergence IA-19); that live gate also passed 2026-07-12. Research: `docs/research/2026-07-11-retail-combat-bar-pseudocode.md`, `docs/research/2026-07-11-combat-target-camera-pseudocode.md`, `docs/research/2026-07-12-death-and-auto-target-pseudocode.md`; AP-24/AP-95 retired, AP-80 narrowed, AP-110 narrowed, AP-112 records the remaining attack-start and exact trained-Recklessness seams. - **Retail client command families implemented 2026-07-13; corrective live gate pending.** One shared typed catalog now separates retail client actions from ACE administrator commands for both chat backends. Named-decomp ports cover recall/house/PK travel; age/birth; framerate, lock, version, location, corpse, and die confirmation; clear plus named/automatic UI layouts; AFK/consent; emotes; friends; squelch/filter/message types; and fill-components. App owns execution, Core owns authoritative friends/squelch state, Core.Net owns exact UIQueue/ControlQueue packets. Confirmation reuse now ports retail `DialogFactory` contexts, queue groups/priority, fresh DAT roots, property results, callbacks/close notices, and server abort handling; `/die`, gameplay request tuples, and guarded item-use prompts share its type-1 LayoutDesc `0x2100003C` presenter. The first live gate passed the family except for raw suicide-success code `0x004A` and the title-bar-only FPS presentation; the correction maps retail's text and mounts SmartBox element `0x10000047` with live two-decimal `FPS`/`DEG`. #L.6 is closed; TS-31/TS-47 are narrowed. Research: `docs/research/2026-07-13-retail-client-command-families-pseudocode.md`, `docs/research/2026-07-13-retail-dialog-factory-pseudocode.md`. - **✓ SHIPPED — Character window** (`LayoutDesc 0x2100002E`, `CharacterStatController`, 2026-06-26, same branch). **Visually user-confirmed 2026-06-26 — Attributes tab reads as retail.** Three tabs, header (name/heritage/PK), large-gold level number (dat font, `largeDatFont` 18px), "Total Experience (XP):" + "XP for next level:" captions, 9-row attribute list (icons + right-aligned values + Health/Stamina/Mana vitals), click-to-select (top/bottom selection bars + footer State-B "{Attr}: {value}" / "Experience To Raise: Infinity!" + affordability-gated raise triangles), centered footer. User noted "still needs some polish for later" — deferred to Issue #158. -- **✓ CORRECTIVE PORT — live character experience qualities** (2026-07-13, #217; connected gate pending). Added retail `PrivateUpdatePropertyInt64 (0x02CF)` so Total Experience, the level-progress meter, and Skills' Unassigned Experience refresh from the same authoritative server update; Total XP value alignment now matches the authored right-justified layout. +- **✓ CORRECTIVE PORT — live character experience qualities** (2026-07-13, #217; connected gate passed). Added retail `PrivateUpdatePropertyInt64 (0x02CF)` so Total Experience, the level-progress meter, and Skills' Unassigned Experience refresh from the same authoritative server update; Total XP value alignment now matches the authored right-justified layout. - **✓ SHIPPED — D.5.4 — Client object/item data model (foundation).** Shipped 2026-06-18 (`b506f53`..`a33e897`, 11 commits). Renamed `ItemRepository`→`ClientObjectTable` / `ItemInstance`→`ClientObject`; broadened the table to hold EVERY server object (retail `weenie_object_table` shape). `CreateObject` is now the canonical merge-upsert (`ClientObjectTable.Ingest`, retail `SetWeenieDesc` semantics) via a new Core.Net `ObjectTableWiring` (off GameWindow); `DeleteObject` evicts; `PlayerDescription` is a membership manifest (`RecordMembership`); live container-membership index (`GetContents`, retail `object_inventory_table`). `_liveEntityInfoByGuid` retired (selection/describe resolve from the one table). Root fix: the old enrich-existing-only `EnrichItem` dropped `CreateObject`s for items with no `PlayerDescription` stub — live-Coldeve 4/6 hotbar slots blank; items are now created, not dropped. **Crux resolved:** retail is TWO tables (`object_table` + `weenie_object_table`), NOT one — acdream's `WorldEntity` (3D system) + `ClientObjectTable` (data/UI) split was already architecturally faithful; the fix was the ingestion path, not a table unification. 2671 tests green. - **Roadmap correction (2026-07-10):** the completion order is now the architecture-first campaign in `docs/superpowers/plans/2026-07-10-retail-ui-fidelity-completion.md`. Retail `gmToolbarUI` is object-only: preserve `ShortCutData.index_`, `objectID_`, and `spellID_`, but do not invent spell glyphs on this bar. `PlayerModule::favorite_spells_[8]` feeds separate spell bars. - **✓ D.5.3 — Toolbar selected-object display (issue #141) SHIPPED.** Exact health/player/pet/Free-PK policy, selected-item mana (`0x100001A2`), stack entry (`0x100001A3`), stack slider (`0x100001A4`), formatted count/name, shared split quantity, click-to-use, peace/war, health/name, and paperdoll self/upper-item selection all passed live by 2026-07-11. Exact raw `ShortCutData` preservation is already shipped in Wave 4.2; the eight `favorite_spells_` lists belong to the separate spellbook/spell-bar phase. diff --git a/docs/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md index d14083ac..1e53cce6 100644 --- a/docs/plans/2026-05-12-milestones.md +++ b/docs/plans/2026-05-12-milestones.md @@ -2,16 +2,13 @@ **Status:** Living document. Created 2026-05-12. **Sits above:** [`docs/plans/2026-04-11-roadmap.md`](2026-04-11-roadmap.md) (the strategic phase index). -**Currently working toward:** **M2 — "Kill a drudge."** **M1.5 — Indoor -world feels right LANDED 2026-07-10** (pinned writeup in the M1.5 section -below): the building/cellar demo (prior sessions) AND the full dungeon -round-trip (enter via portal → navigate → exit to the outdoor world) are -both user-gated. Dungeon support (full Phase G.3) shipped — render / stream -/ teleport-in / collide / light / doors, each individually gated -(#133/#137/#95/#79/#93/#80 CLOSED; #138 closed by the round-trip gate). -Carried forward as post-M1.5 follow-ups (NOT M1.5 blockers): the far-town -teleport-OUT arrival cascade (**#145-residual, REOPENED** — -capture-harness-first) and #116 slide-response feel-debt. +**Currently working toward:** **M3 — "Cast a spell." M2 — "Kill a drudge" +LANDED 2026-07-15.** The complete connected melee/missile, death/loot, +inventory, and item-giving demo is user-gated. The shared projectile/effect +foundation is hardened through Step 9 and its single-client visual gate passes. +Next is the F.4/L.1d retail cast-lifecycle gap audit, then F.5 spellbook and +active enchantments. Carried: the two-client portal/observer VFX gate, +#145-residual, #116 slide-response, R6/TS-42, and Track MP0. --- @@ -426,13 +423,21 @@ unblocks that). --- -### M2 — "Kill a drudge" — 🔵 ACTIVE (M1.5 landed 2026-07-10) +### M2 — "Kill a drudge" — ✅ LANDED 2026-07-15 **Demo scenario:** Equip a sword. Walk to a drudge. Swing. See "You hit Drudge for 12 slashing damage (87%)" in chat. Watch the swing animation play. Drudge dies, drops loot. Pick up the loot. Open the inventory panel and see it. +**Landed:** The connected Release client now completes that entire loop. Both +melee and missile weapons switch through server-authoritative inventory +transactions; attacks face the selected hostile target before release; combat +animation, damage text, persistent death, auto-target, loot, inventory/stack +operations, paperdoll/toolbar interaction, and world-item giving have passed +user gates. Work discovered outside this demo remains explicitly carried below +rather than keeping the milestone open indefinitely. + **First port target when M2 starts (per the M2 combat-math research memo, `docs/research/2026-06-04-combat-math-deep-dive.md`):** `CombatMath.ComputeDamage` — damage-calc + armor-resists are port-ready @@ -454,7 +459,7 @@ include dungeons. - **L.1c held-object foundation (implemented and live-gated 2026-07-11)** — exact equipment-aware default combat mode (bow→Missile, caster→Magic) plus retail CreateObject/ParentEvent child attachment and per-frame hand-follow rendering. -- **L.1c inventory weapon switching (implemented 2026-07-12; live gate pending)** — +- **L.1c inventory weapon switching (implemented and live-gated 2026-07-15)** — inventory double-click ports retail's two-stage `AutoWield`: return the occupied primary weapon to the main pack, then any incompatible shield/ammo, waiting for every server move confirmation before wielding the requested @@ -463,7 +468,7 @@ include dungeons. acknowledgement independently of rollback bookkeeping, and prevents rapid switches from overlapping or remaining stuck; ACE owns stance-specific motions and its PropertyInt 40 update now drives the client `CombatState`. -- **M2 item giving (implemented 2026-07-13; starter-dungeon live gate pending)** — +- **M2 item giving (implemented and starter-dungeon live-gated 2026-07-13)** — a physical inventory drag released over a 3-D creature now ports `ItemHolder::AttemptPlaceIn3D` through the exact `GiveObjectRequest 0x00CD` packet, including selected partial-stack quantities and authoritative-only @@ -565,11 +570,11 @@ include dungeons. modes retain the billboard path. This restores the four-piece apex of Armor/protection shield effects. The final two-client visual gate is still pending. -- **L.1c local attack receive path (implemented 2026-07-11; live gate pending)** — +- **L.1c local attack receive path (implemented and live-gated 2026-07-15)** — local non-autonomous mt-0 UpdateMotion now uses retail's wholesale interpreted funnel and action-stamp gate, so ACE's server-selected melee/missile action drives the player's motion table; direct command-list replay is removed. -- **M2 basic combat bar (implemented 2026-07-11; live gate pending)** — authored +- **M2 basic combat bar (implemented and live-gated 2026-07-15)** — authored `gmCombatUI` LayoutDesc `0x21000073` now pops for Melee/Missile and shares one press/hold/release attack-request owner across DAT buttons and keybindings. Retail x86 resolves full charge to 1.0 s (0.8 s dual wield), also retiring the @@ -614,13 +619,21 @@ a game. --- -### M3 — "Cast a spell" — 🔵 (~3–4 weeks after M2) +### M3 — "Cast a spell" — 🔵 ACTIVE (started 2026-07-15) **Demo scenario:** Cast Flame Bolt at a drudge. Watch the cast animation, the projectile, the impact. Self-cast a buff (Strength Self). See the enchantment in a buff list. Recall to lifestone — full recall animation, correct teleport, correct re-spawn. +**Starting position:** E.5 already supplies the cast wire and authoritative +spell/enchantment state, while the M2/M3 campaign supplies traveling physical +and spell missiles, DAT PhysicsScripts, live part-attached particles/lights, +Hidden/UnHide, and recall motion/effects. Representative projectiles and +protection effects pass the single-client visual gate. M3 begins with a focused +named-retail gap audit of F.4/L.1d—not a rewrite of this foundation—then fills +the spellbook/active-enchantment UI and completes the two-client portal gate. + **Phases to ship:** - **F.4** — Spell cast state machine (buffs + recalls first, projectile spells second). diff --git a/docs/research/2026-07-14-wield-ownership-and-target-facing-pseudocode.md b/docs/research/2026-07-14-wield-ownership-and-target-facing-pseudocode.md new file mode 100644 index 00000000..8e7b0799 --- /dev/null +++ b/docs/research/2026-07-14-wield-ownership-and-target-facing-pseudocode.md @@ -0,0 +1,377 @@ +# Wield ownership and targeted-action facing pseudocode + +Date: 2026-07-14 + +## Sources + +- Named retail `ItemHolder::DetermineUseResult @ 0x00588460` +- Named retail `ACCWeenieObject::UIAttemptWield @ 0x0058D590` +- Named retail `ACCWeenieObject::ServerSaysMoveItem @ 0x0058DBB0` +- Named retail `ACCWeenieObject::SetPlayerWieldLocation @ 0x0058D8C0` +- Named retail `ACCObjectMaint::ViewObjectContents @ 0x00558A70` +- Named retail `gmPaperDollUI::ServerSaysMoveItem @ 0x004A64A0` +- Named retail `gmToolbarUI::RecvNotice_ServerSaysMoveItem @ 0x004BF2B0` +- Named retail WieldObject dispatch `0x0055B27C..0x0055B2B5` +- Named retail `ClientCombatSystem::ExecuteAttack @ 0x0056BB70` +- Named retail `ClientCombatSystem::StartAttackRequest @ 0x0056C040` +- Named retail `CameraSet::ToggleMouseLook @ 0x00457490` +- Named retail `CameraSet::MouseLookHandler @ 0x00458D80` +- Named retail `CameraSet::Rotate @ 0x00458310` +- Named retail `CommandInterpreter::HandleMouseMovementCommand @ 0x006B3870` +- Named retail `CommandInterpreter::StopListHeadMovement @ 0x006B35A0` +- Named retail `CommandInterpreter::MaybeStopCompletely @ 0x006B3B90` +- Named retail `MovementManager::unpack_movement @ 0x00524440` +- Named retail `MoveToManager::TurnToObject @ 0x005297D0` +- Named retail `CommandInterpreter::SendMovementEvent @ 0x006B4680` +- Named retail `CommandInterpreter::SendPositionEvent @ 0x006B4770` +- Named retail `CommandInterpreter::ShouldSendPositionEvent @ 0x006B45E0` +- Named retail `CommandInterpreter::UseTime @ 0x006B3BF0` +- Named retail `Frame::is_equal @ 0x00424C30` +- Named retail `Frame::is_quaternion_equal @ 0x00424C70` +- Named retail `Position::Pack @ 0x005A9640` +- ACE `Player_Missile.HandleActionTargetedMissileAttack` and + `Creature.Rotate/TurnToObject` (interpretation aid; retail remains the oracle) + +## Wield request and authoritative ownership + +`UIAttemptWield` does not rewrite the item's public weenie fields. It sends the +request and records the one outstanding inventory request: + +```text +UIAttemptWield(item, location): + if player is not ready for an inventory request: + return + if item is a split stack and a partial split is selected: + send StackableSplitToWield(item.id, location, splitSize) + return + send GetAndWieldItem(item.id, location) + prevRequestObjectID = item.id + prevRequest = Wield + prevRequestTime = now +``` + +`DetermineUseResult` uses the authoritative `wielderID`, not container +membership, to decide whether a player-owned weapon needs to be wielded: + +```text +if item is owned by the player + and (item.combatUse != None or item is a caster or item has WieldOnUse) + and item.wielderID != player.id: + return WieldLeft when WieldLeft is set, otherwise WieldRight +``` + +The server move callback updates container, wielder, and location as one +authoritative state transition: + +```text +ServerSaysMoveItem(item, newContainer, placement, newWielder, newLocation): + oldContainer = item.containerID + oldWielder = item.wielderID + oldLocation = item.location + + remove item from oldContainer.contents, when present + item.containerID = newContainer + add item to newContainer.contents at placement, when present + + if newWielder == player.id: + SetPlayerWieldLocation(item, newLocation) + item.wielderID = player.id + else if oldWielder == player.id: + SetPlayerWieldLocation(item, None) + item.wielderID = 0 + + item.location = newLocation + clear the matching outstanding inventory request + notify inventory UI with old/new container, slot, wielder, and location +``` + +Paperdoll and toolbar consumers test the complete old and new placement. A +post-mutation item plus old/new container alone is insufficient: confirmed +equipment has `ContainerId=0`, so an unwield into a side bag would otherwise +lose the old player wielder/location and fail to clear the visible slot. + +Retail ViewContents is a different operation: + +```text +ViewObjectContents(container, contentProfile): + clear container.itemsList and container.containersList + rebuild those ordered lists from ContentProfile + do not mutate any child containerID, wielderID, or location +``` + +PlayerDescription therefore initializes login inventory ownership explicitly; +later ViewContents packets replace only the viewed list projection. A later +authoritative move or delete evicts the GUID from every stale viewed-list +projection before applying its new canonical membership. Only move, wield, and +put-in-3D events publish placement transitions. + +PlayerDescription assigns its complete packed InventoryPlacement list directly, +preserving wire order. Later live `SetPlayerWieldLocation` calls insert the new +placement at the head, and `GetObjectAtLocation` scans head-to-tail. Combat mode +and ammo selection must preserve this mixed lifecycle rather than sorting +confirmed equipment by GUID. + +The retail 0x22 and 0x23 dispatchers publish `ServerSaysMoveItem_s` even when +the item GUID has not resolved to a weenie yet. The notice carries the GUID and +zero old placement plus the raw new placement; it must not be suppressed or +manufacture a retained stub. This lets UI transaction owners observe the exact +server boundary despite CreateObject/message reordering. + +The WieldObject event itself contains exactly two words. Its dispatch supplies +the local player identity and an empty container to `ServerSaysMoveItem`: + +```text +WieldObject(itemGuid, equipLocation): + item = FindObject(itemGuid) + ServerSaysMoveItem( + item, + newContainer = 0, + placement = 0, + newWielder = GetPlayerID(), + newLocation = equipLocation, + isServerMove = true) +``` + +Conformance consequence: a bow moved from the paper doll into a backpack must +have `WielderId == 0` after the authoritative PutObjectInContainer event. If it +retains the player's ID, the next double click is classified as already wielded +and no GetAndWieldItem request is sent. + +## Targeted missile facing ownership + +Retail's client combat UI does not invent a local pre-attack rotation: + +```text +ClientCombatSystem::ExecuteAttack(target, height, power): + validate target and player readiness + send Event_TargetedMissileAttack(target.id, height, power) +``` + +The authoritative movement response is consumed through the ordinary movement +funnel: + +```text +MovementManager::unpack_movement(server movement): + interrupt current movement + apply the wire stance when it changed + case TurnToObject: + unpack object id, heading, and turn parameters + physicsObject.TurnToObject(object id, parameters) +``` + +ACE independently confirms the expected server sequence for the local test +environment: + +```text +HandleActionTargetedMissileAttack(target): + rotateTime = Rotate(target) + wait rotateTime + LaunchMissile(target) + +Rotate(target): + broadcast server-controlled TurnToObject(target) + calculate the retail motion-table turn duration +``` + +Therefore missile facing belongs in the shared inbound TurnToObject execution +path. Delaying the outbound missile attack request or synthesizing a bow-specific +turn would be a retail divergence. Targeted spell facing needs its own retail +casting-path proof before making the same claim. + +The inbound turn also depends on the server knowing the player's current +heading. ACE calculates its turn duration from the server-side `Location` +orientation. If a stationary client turn never publishes its quaternion, ACE +can believe the player already faces the target, schedule a zero-duration turn, +and launch before the ordinary inbound TurnToObject visibly completes. + +Retail keeps that authority synchronized through the full position-event +cadence: + +```text +CommandInterpreter.UseTime(): + if ShouldSendPositionEvent(): + SendPositionEvent() + +ShouldSendPositionEvent(): + if lastSentTime + 1 second >= now: + return cell changed OR contact plane changed + + return cell changed OR NOT Frame.is_equal(lastFrame, player.frame) + +Frame.is_equal(a, b): + return origin components differ by <= 0.0002 world units + AND quaternion W/X/Y/Z components differ by < 0.0002 + +SendMovementEvent(): + send MoveToState(player.m_position) + lastSentTime = now + // do not replace last frame or contact plane + +SendPositionEvent(): + send AutonomousPosition(player.m_position) + lastSentTime = now + lastSentPosition = player.m_position // cell + origin + orientation + lastSentContactPlane = player.contact_plane +``` + +Quaternion comparison is component-wise; retail does not canonicalize the +equivalent stored `q` and `-q` representations during this comparison. Plane +comparison uses `<= 0.0002` for the three normal components but `< 0.0002` for +the distance component. `UseTime` proves the internal Should→AP order, while +MTS originates in separate input handlers; their relative same-tick callback +and wire order is not established by these functions and remains the narrow +TS-33 trace item. This is the shared correction for melee, missile, use, and +spell targeting; there is no weapon-specific pre-turn or artificial attack +delay. + +## Instant mouse-look is player movement, not a render-only yaw + +Retail's middle-mouse instant look does not mutate the visible character frame +behind the movement system. The camera owner translates the mouse into ordinary +turn motions and the command interpreter publishes those motions to the server: + +```text +CameraSet.ToggleMouseLook(active): + mouseLookActive = active + commandInterpreter.SetMouseLookActive(active) + commandInterpreter.MovePlayer(CameraInstantMouseLook, active, 1, mouse=true, holdRun=true) + if !active: + commandInterpreter.ApplyCurrentMovement() + commandInterpreter.SendMovementEvent() + // Toggle does not update CameraSet.m_LastServerMessage. + +CInputManager_WIN32.GetInput(): + accumulate all raw device records into the frame's cursor displacement + if the cursor position changed: + lastInputEvent = now + CallMouseLookHandler(totalDx, totalDy) once + else if mouseLookActive && now > lastInputEvent + 0.2 seconds: + CallMouseLookHandler(0, 0) + +CameraSet.MouseLookHandler(mouseX, mouseY): + if raw mouseX == 0 && raw mouseY == 0: + StopDrift() + if now > m_LastServerMessage + 0.5 seconds: + m_LastServerMessage = now + SendMovementEvent() + low-pass filter the one accumulated raw sample + multiply by configured input sensitivity and 0.0666666701 + on horizontal zero: reset mouselook_x_extent + on horizontal nonzero: increment mouselook_x_extent + only after five consecutive nonzero samples: + CameraSet.Rotate(filtered horizontal input) + Rotate publishes only when its own invocation crosses the same strict + m_LastServerMessage + 0.5-second boundary + +CameraSet.Rotate(horizontal input): + direction = horizontal input selects TurnLeft or TurnRight + speed = 2 * filtered camera adjustment for this frame + speed = min(speed, 1.5) + commandInterpreter.MovePlayer(direction, start=true, speed, mouse=true, holdRun=true) + if last mouse movement event + 0.5 seconds < now: + commandInterpreter.SendMovementEvent() + +CommandInterpreter.HandleMouseMovementCommand(command): + TakeControlFromServer() + remember command as mouse-origin movement + MovePlayer(command.motion, start=true, command.speed, mouse=true, holdRun=true) + SendMovementEvent() + +CommandInterpreter.StopListHeadMovement(): + if the active command came from the mouse: + MovePlayer(command.motion, start=false, command.speed, mouse=true, holdRun=true) + +CommandInterpreter.TakeControlFromServer(): + if controlled_by_server and autonomy_level != 0 and player is alive: + controlled_by_server = false + player.last_move_was_autonomous = true + player.StopCompletely() + StopInterpolating() + SetHoldRun() + ApplyCurrentMovement() +``` + +`ApplyCurrentMovement` means the complete device snapshot: global run state, +forward/back, sidestep, and turn. `CameraInstantMouseLook` remaps a held +keyboard turn into the sidestep channel with that axis's `HoldKey.Run`, even +when the global movement mode is walk. Both the synchronous toggle packet and +later movement-edge packets must serialize the canonical remapped raw channel; +reconstructing sidestep from only the physical strafe keys loses A/D after MMB +is already active. `RawMotionState.CurrentHoldKey` is serialized independently +of active axes, so an idle run/walk change cannot be inferred from “is moving.” + +Attack entry also clears client movement before the request: + +```text +ClientCombatSystem.StartAttackRequest(...): + attack_request_in_progress = true + requested_attack_power = 1 + FinishJump() + commandInterpreter.MaybeStopCompletely() + current_build_is_automatic = false + build and send the attack request + +CommandInterpreter.MaybeStopCompletely(): + if player movement is not currently controlled by the server: + StopCompletely() +``` + +The architectural invariant is therefore one heading with one owner: the +character seen on screen, the local physics orientation, the raw outbound turn +state, and ACE's authoritative player orientation all advance through the same +movement command. A mouse callback that writes `PlayerMovementController.Yaw` +directly violates that invariant. It creates the exact failure mode observed in +the bow test: the client can appear 180 degrees away while ACE retains the old +heading, computes a zero-duration `Rotate(target)`, and launches immediately. + +ACE 1.76.4751 commit `7233b0e57db9a8566954d8dfbc2128c84e64c4ff` +cross-checks the consequence: `HandleActionTargetedMissileAttack` calls +`Rotate(target)` and delays by the returned turn time; `Creature.Rotate` +computes that delay from the server-side heading and broadcasts +`TurnToObject`. ACE is an interpretation aid here; the client mechanism and +ordering above come from named retail. + +## Outbound position frame + +Retail never reconstructs protocol coordinates from a renderer origin: + +```text +SendMovementEvent(): + MoveToStatePack(rawMotionState, &player.m_position, ...) + SendMoveToStateEvent(pack) + +SendPositionEvent(): + if Position.IsValid(player.m_position): + AutonomousPositionPack(&player.m_position, ...) + SendAutonomousPositionEvent(pack) + +Position.Pack(): + write objcell_id + Frame.Pack(frame) // landblock-local origin + orientation +``` + +`CPhysicsObj::m_position` is the canonical carried pair `(cell id, local +Frame.Origin)`. In acdream the equivalent is `PhysicsBody.CellPosition`. +`_liveCenterX/Y` is a render/streaming translation and must not participate in +outbound messages. + +The captured regression was exact: the player crossed from A9B1 to A9B2 and +the logical entity's projection was recovered. That recovery re-ran the login +origin initialization, moving `_liveCenterY` by one block while the existing +world position remained in the A9B1 render frame. Reconstructing local Y then +sent `214.56` instead of the carried `22.56`, a 192 m displacement. The fix has +two invariants: + +```text +first accepted canonical player Position in CreateObject or Position update: + initialize render/streaming origin once, before world translation + +non-player projection before that Position: + retain canonical live record, defer render projection + +spatial projection recovery, rebucketing, or appearance mutation afterward: + never initialize or move the session origin + +every outbound movement/position/jump packet: + serialize PlayerMovementController.CellPosition directly +``` diff --git a/src/AcDream.App/Combat/CombatAttackController.cs b/src/AcDream.App/Combat/CombatAttackController.cs index 6e5893fa..88680892 100644 --- a/src/AcDream.App/Combat/CombatAttackController.cs +++ b/src/AcDream.App/Combat/CombatAttackController.cs @@ -27,6 +27,7 @@ public sealed class CombatAttackController : IDisposable private readonly CombatState _combat; private readonly Func _canStartAttack; + private readonly Action _prepareAttackRequest; private readonly Func _sendAttack; private readonly Action _sendCancelAttack; private readonly Func _isDualWield; @@ -50,6 +51,7 @@ public sealed class CombatAttackController : IDisposable CombatState combat, Func canStartAttack, Func sendAttack, + Action? prepareAttackRequest = null, Action? sendCancelAttack = null, Func? isDualWield = null, Func? playerReadyForAttack = null, @@ -58,6 +60,7 @@ public sealed class CombatAttackController : IDisposable { _combat = combat ?? throw new ArgumentNullException(nameof(combat)); _canStartAttack = canStartAttack ?? throw new ArgumentNullException(nameof(canStartAttack)); + _prepareAttackRequest = prepareAttackRequest ?? (() => { }); _sendAttack = sendAttack ?? throw new ArgumentNullException(nameof(sendAttack)); _sendCancelAttack = sendCancelAttack ?? (() => { }); _isDualWield = isDualWield ?? (() => false); @@ -73,6 +76,7 @@ public sealed class CombatAttackController : IDisposable public AttackHeight RequestedHeight { get; private set; } = AttackHeight.Medium; public float DesiredPower { get; private set; } = InitialDesiredPower; public bool AttackRequestInProgress => _attackRequestInProgress; + public float RequestedAttackPower => _requestedAttackPower; public bool BuildInProgress => _buildInProgress; /// The level retail publishes to the embedded combat meter. @@ -257,8 +261,13 @@ public sealed class CombatAttackController : IDisposable || !_canStartAttack()) return; + // Retail StartAttackRequest (0x0056C040) stores request-in-progress + // and requested power first, then FinishJump and + // CommandInterpreter::MaybeStopCompletely. The host callback owns the + // player movement object and must run before any later attack send. _attackRequestInProgress = true; _requestedAttackPower = 1f; + _prepareAttackRequest(); _currentBuildIsAutomatic = false; AttemptStartBuildingAttack(); } diff --git a/src/AcDream.App/Input/PlayerMovementController.cs b/src/AcDream.App/Input/PlayerMovementController.cs index 61010ab6..ddace4ec 100644 --- a/src/AcDream.App/Input/PlayerMovementController.cs +++ b/src/AcDream.App/Input/PlayerMovementController.cs @@ -67,7 +67,21 @@ public readonly record struct MovementResult( // = runRate (where ForwardSpeed is 1.0 / null); everything else = 1.0. bool JustLanded = false, // true on the single frame we transitioned airborne → grounded float? JumpExtent = null, // non-null when a jump was triggered this frame - Vector3? JumpVelocity = null); // BODY-LOCAL launch velocity (forward/right/up relative to facing) — see PlayerMovementController jump path for the inverse-yaw conversion. Server rotates body→world on broadcast. + Vector3? JumpVelocity = null, // BODY-LOCAL launch velocity (forward/right/up relative to facing) — see PlayerMovementController jump path for the inverse-yaw conversion. Server rotates body→world on broadcast. + // Retail updates mouse-origin turn motions continuously but sends the + // resulting movement state only on ToggleMouseLook start/stop and the + // CameraSet half-second cadence. Keep packet ownership separate from the + // local motion-state change so mouse sampling cannot flood the server. + bool ShouldSendMovementEvent = false, + // CameraSet::Rotate always calls MovePlayer with holdRun=true for the turn + // axis, independently of the player's ordinary walk/run toggle. + bool TurnUsesRunHold = false, + // CameraInstantMouseLook remaps keyboard turn to sidestep with the same + // per-axis Run hold, independently of the global walk/run toggle. + bool SidestepUsesRunHold = false, + // The host acknowledges this clock/queue only after the MoveToState has + // actually been put on the wire. + bool IsMouseLookMovementEvent = false); /// /// Portal-space state for the player movement controller. @@ -99,8 +113,6 @@ public sealed class PlayerMovementController private readonly MotionInterpreter _motion; private readonly PlayerWeenie _weenie; - public float MouseTurnSensitivity { get; set; } = 0.003f; - /// /// Maximum Z increase per movement step before the move is rejected. /// Retail's step_up_height for human characters is ~0.4 m (hip- @@ -138,6 +150,25 @@ public sealed class PlayerMovementController public uint CellId { get; private set; } public AcDream.Core.Physics.Position CellPosition => _body.CellPosition; + /// + /// Returns retail's canonical outbound Position: the physics body's + /// carried cell id plus its landblock-local frame origin. Retail + /// CommandInterpreter::SendMovementEvent @ 0x006B4680 and + /// SendPositionEvent @ 0x006B4770 serialize + /// CPhysicsObj::m_position directly; render/streaming origins are not + /// part of the protocol frame. + /// + internal bool TryGetOutboundPosition( + Quaternion rotation, + out uint cellId, + out Vector3 localOrigin) + { + AcDream.Core.Physics.Position canonical = _body.CellPosition; + cellId = canonical.ObjCellId; + localOrigin = canonical.Frame.Origin; + return PositionFrameValidation.IsValid(cellId, localOrigin, rotation); + } + /// /// Local-player entity id used to skip self-collision in the /// airborne sweep. GameWindow updates this whenever the local @@ -223,18 +254,44 @@ public sealed class PlayerMovementController private bool _prevTurnLeftHeld; private bool _prevTurnRightHeld; private bool _prevRunHeld; + private bool _hasInputSnapshot; - // Heartbeat timer. - // Cadence is 1.0 sec to match holtburger's - // AUTONOMOUS_POSITION_HEARTBEAT_INTERVAL and the retail trace - // (2026-05-01 motion-trace findings.md): retail sends ~1 Hz at rest, - // not the 5 Hz our pre-fix code used. Sending at 5 Hz was harmless - // but wasteful and probably looked like jitter to observers. + // Retail CameraSet::ToggleMouseLook / Rotate (0x00457490 / 0x00458310). + // Mouse look owns a transient turn command in the SAME MotionInterpreter + // as keyboard and server turns; it never writes Yaw directly. The latest + // filtered sample replaces the preceding sample, matching CameraSet's + // per-mouse-message MovePlayer call. + private bool _mouseLookActive; + private bool _mouseTurnSamplePending; + private float _mouseTurnAdjustment; + private uint? _activeInputTurnCommand; + private float _activeInputTurnSpeed; + private bool _activeInputTurnFromMouse; + private uint? _activeInputSidestepCommand; + private bool _activeInputSidestepUsesRunHold; + private bool _mouseMovementEventCandidate; + private bool _mouseMovementEventPending; + private float _lastMouseMovementEventTime; + private bool _controlledByServer = true; + + /// + /// Retail's mouse-look movement report interval from + /// CameraSet::Rotate and MouseLookHandler. + /// + public const float MouseMovementEventInterval = 0.5f; + + private const float MouseTurnDeadZone = 0.02f; + private const float MouseTurnSpeedScale = 2.0f; + private const float MouseTurnMaximumSpeed = 1.5f; + + // Position-event comparison interval. Retail does not send an idle packet + // every second: after the interval elapses it compares the complete frame + // and sends only when the cell, origin, or orientation changed. /// /// 2026-05-16 — retail-faithful AP cadence. Matches retail's /// CommandInterpreter::ShouldSendPositionEvent (acclient_2013_pseudo_c.txt - /// at address 0x006b45e0) which gates on either (a) position-or-cell - /// change since the last send, or (b) at-rest 1 sec heartbeat elapsed. + /// at address 0x006b45e0). Inside the interval it reacts to cell/contact + /// changes; after the interval it compares cell plus the complete frame. /// `time_between_position_events` constant at 0x006b3efb = 1.0 sec. /// /// Old model: a 1 Hz idle / 10 Hz active flat accumulator. That @@ -245,13 +302,11 @@ public sealed class PlayerMovementController /// public const float HeartbeatInterval = 1.0f; // retail 0x006b3efb - private System.Numerics.Vector3 _lastSentPos; - private uint _lastSentCellId; + private AcDream.Core.Physics.Position _lastSentPosition; private System.Numerics.Plane _lastSentContactPlane; private float _lastSentTime; private bool _lastSentInitialized; private float _simTimeSeconds; - public bool HeartbeatDue { get; private set; } /// Sim-time accumulator (advanced by dt at the top of Update). /// Exposed for the network outbound layer to stamp NotePositionSent. @@ -386,6 +441,307 @@ public sealed class PlayerMovementController _body.LastMoveWasAutonomous = true; } + /// + /// Begins retail instant mouse-look. CameraSet::ToggleMouseLook + /// (0x00457490) sends a movement event on both transitions, even before a + /// horizontal turn sample arrives. + /// + public bool BeginMouseLook(MovementInput input) + { + if (_mouseLookActive || State != PlayerState.InWorld) + return false; + + _mouseLookActive = true; + // ToggleMouseLook stores the new mode before MovePlayer enters + // TakeControlFromServer, so its ApplyCurrentMovement observes the + // remapped turn→sidestep channels immediately. + TakeControlFromServer(input); + _mouseTurnSamplePending = false; + _mouseTurnAdjustment = 0f; + ApplyMouseLookToggleMovement(input, entering: true); + return true; + } + + /// + /// Supplies the latest signed, filtered horizontal mouse adjustment. + /// Negative means TurnRight in acdream's yaw convention; positive means + /// TurnLeft. Retail CameraSet::Rotate doubles the magnitude, applies + /// a 0.02 dead zone, and caps the raw turn speed at 1.5. + /// + public void SubmitMouseTurnAdjustment( + float signedAdjustment, + MovementInput input) + { + if (!_mouseLookActive || !float.IsFinite(signedAdjustment)) + return; + + TakeControlFromServer(input); + _mouseTurnAdjustment = signedAdjustment; + _mouseTurnSamplePending = true; + _mouseMovementEventCandidate = true; + } + + /// + /// Retail's delayed zero-input MouseLookHandler calls + /// CommandInterpreter::StopDrift @ 0x006B4510 before filtering the + /// zero sample. It stops both turn directions and participates in the + /// same half-second movement-report cadence. + /// + public void StopMouseDrift(MovementInput input) + { + if (!_mouseLookActive) + return; + + TakeControlFromServer(input); + + var p = MouseAxisParameters(_activeInputTurnSpeed == 0f + ? 1f + : _activeInputTurnSpeed); + StopMotionAtPhysicsObjectBoundary(MotionCommand.TurnRight, p); + StopMotionAtPhysicsObjectBoundary(MotionCommand.TurnLeft, p); + _activeInputTurnCommand = null; + _activeInputTurnSpeed = 0f; + _activeInputTurnFromMouse = false; + _mouseTurnSamplePending = false; + _mouseTurnAdjustment = 0f; + _mouseMovementEventCandidate = true; + } + + /// + /// Ends retail instant mouse-look. The next update restores any held + /// keyboard turn (or stops the mouse-origin turn) and publishes the final + /// heading through MoveToState. + /// + public bool EndMouseLook(MovementInput input) + { + if (!_mouseLookActive && !_activeInputTurnFromMouse) + return false; + + _mouseLookActive = false; + // Retail clears the mode before TakeControlFromServer/ApplyCurrentMovement + // so a held sidestep-remapped turn is restored directly as turn. + TakeControlFromServer(input); + _mouseTurnSamplePending = false; + _mouseTurnAdjustment = 0f; + + ApplyMouseLookToggleMovement(input, entering: false); + return true; + } + + private void ApplyMouseLookToggleMovement(MovementInput input, bool entering) + { + // CameraSet::ToggleMouseLook @ 0x00457490 routes the pseudo-command + // CameraInstantMouseLook through CommandInterpreter::MovePlayer + // @ 0x006B3F40. While entering, held keyboard turns move to the + // sidestep channel. On exit, MovePlayer reverses that mapping and + // ApplyCurrentMovement replays every currently held channel before the + // synchronous movement packet is captured. + (uint? sidestep, bool useRunHold) = DesiredInputSidestep(input, entering); + ApplyInputSidestep(sidestep, useRunHold, reapply: !entering); + + uint? turn = entering + ? null + : input.TurnRight + ? MotionCommand.TurnRight + : input.TurnLeft + ? MotionCommand.TurnLeft + : null; + ApplyInputTurn(turn, speed: 1f, fromMouse: false, reapply: !entering); + + // TakeControlFromServer::ApplyCurrentMovement and both sides of + // ToggleMouseLook replay the complete current input, not merely the + // turn/sidestep channels affected by CameraInstantMouseLook. + bool runHeld = _motion.RawState.CurrentHoldKey == HoldKey.Run; + if (runHeld != input.Run) + _motion.set_hold_run(input.Run, interrupt: true); + + uint? desiredForward = input.Forward + ? MotionCommand.WalkForward + : input.Backward + ? MotionCommand.WalkBackward + : null; + + uint rawForward = _motion.RawState.ForwardCommand; + uint? activeForward = rawForward == RawMotionState.Default.ForwardCommand + ? null + : rawForward; + if (activeForward is { } active && active != desiredForward) + { + StopMotionAtPhysicsObjectBoundary( + active, + new AcDream.Core.Physics.Motion.MovementParameters()); + } + + if (desiredForward is { } command + && (activeForward != desiredForward || !entering)) + DoMotionAtPhysicsObjectBoundary( + command, + new AcDream.Core.Physics.Motion.MovementParameters()); + + _hasInputSnapshot = true; + _prevForwardHeld = input.Forward; + _prevBackwardHeld = input.Backward; + _prevStrafeLeftHeld = input.StrafeLeft; + _prevStrafeRightHeld = input.StrafeRight; + _prevTurnLeftHeld = input.TurnLeft; + _prevTurnRightHeld = input.TurnRight; + _prevRunHeld = input.Run; + _prevRunHold = input.Run; + _body.LastMoveWasAutonomous = true; + } + + private static (uint? Command, bool UseRunHold) DesiredInputSidestep( + MovementInput input, + bool mouseLookActive) + { + if (input.StrafeRight) + return (MotionCommand.SideStepRight, false); + if (input.StrafeLeft) + return (MotionCommand.SideStepLeft, false); + if (mouseLookActive && input.TurnRight) + return (MotionCommand.SideStepRight, true); + if (mouseLookActive && input.TurnLeft) + return (MotionCommand.SideStepLeft, true); + return (null, false); + } + + private bool ApplyInputSidestep( + uint? desired, + bool useRunHold, + bool reapply = false) + { + bool changed = desired != _activeInputSidestepCommand; + if (_activeInputSidestepCommand is { } active + && (changed || reapply)) + { + StopMotionAtPhysicsObjectBoundary( + active, + _activeInputSidestepUsesRunHold + ? MouseAxisParameters(1f) + : new()); + } + + if (desired is { } command && (changed || reapply)) + { + DoMotionAtPhysicsObjectBoundary( + command, + useRunHold ? MouseAxisParameters(1f) : new()); + } + + _activeInputSidestepCommand = desired; + _activeInputSidestepUsesRunHold = desired.HasValue && useRunHold; + return changed || (reapply && desired.HasValue); + } + + private bool ApplyInputTurn( + uint? desired, + float speed, + bool fromMouse, + bool reapply = false) + { + bool commandChanged = desired != _activeInputTurnCommand; + bool bothPresent = desired.HasValue && _activeInputTurnCommand.HasValue; + bool speedChanged = bothPresent + && MathF.Abs(speed - _activeInputTurnSpeed) >= 0.0001f; + bool ownerChanged = bothPresent && fromMouse != _activeInputTurnFromMouse; + bool apply = commandChanged || speedChanged || ownerChanged + || (reapply && desired.HasValue); + + if (_activeInputTurnCommand is { } active && apply) + { + StopMotionAtPhysicsObjectBoundary( + active, + _activeInputTurnFromMouse + ? MouseAxisParameters(_activeInputTurnSpeed) + : new()); + } + + if (desired is { } command && apply) + { + DoMotionAtPhysicsObjectBoundary( + command, + fromMouse ? MouseAxisParameters(speed) : new()); + } + + _activeInputTurnCommand = desired; + _activeInputTurnSpeed = desired.HasValue ? speed : 0f; + _activeInputTurnFromMouse = desired.HasValue && fromMouse; + return apply; + } + + private static AcDream.Core.Physics.Motion.MovementParameters MouseAxisParameters( + float speed) => new() + { + Speed = speed, + SetHoldKey = false, + HoldKeyToApply = HoldKey.Run, + }; + + /// + /// Retail CommandInterpreter::TakeControlFromServer + /// (0x006B32D0). A genuine user movement first terminates the server-owned + /// movement state, marks subsequent movement autonomous, and lets the next + /// input update re-apply every key that is still physically held. + /// + private void TakeControlFromServer(MovementInput? currentInput = null) + { + if (!_controlledByServer) + return; + + _controlledByServer = false; + _body.LastMoveWasAutonomous = true; + StopCompletelyAtPhysicsObjectBoundary(); + _activeInputTurnCommand = null; + _activeInputTurnSpeed = 0f; + _activeInputTurnFromMouse = false; + _activeInputSidestepCommand = null; + _activeInputSidestepUsesRunHold = false; + + // Retail follows StopCompletely with ApplyCurrentMovement. Our key + // snapshot is supplied to Update, so clearing the edge history makes + // that same held input re-enter through the normal DoMotion boundary. + _prevForwardHeld = false; + _prevBackwardHeld = false; + _prevStrafeLeftHeld = false; + _prevStrafeRightHeld = false; + _prevTurnLeftHeld = false; + _prevTurnRightHeld = false; + _prevRunHeld = _motion.RawState.CurrentHoldKey == HoldKey.Run; + + // MouseLookHandler runs before the normal per-frame input edge pass. + // When it is the action that takes control, replay the device levels + // synchronously just as retail ApplyCurrentMovement does; otherwise a + // StopCompletely would erase held movement while the edge history is + // simultaneously advanced past it. + if (currentInput is { } input) + ApplyMouseLookToggleMovement(input, entering: _mouseLookActive); + } + + /// + /// Retail ClientCombatSystem::StartAttackRequest (0x0056C040) + /// invokes CommandInterpreter::MaybeStopCompletely before building + /// the attack. This removes a mouse-origin drift immediately and schedules + /// the final authoritative heading ahead of the eventual attack send. + /// + public bool PrepareForAttackRequest() + { + // CommandInterpreter::MaybeStopCompletely @ 0x006B3B90 is a no-op + // while an authoritative server movement owns the player. + if (_controlledByServer) + return false; + + StopCompletelyAtPhysicsObjectBoundary(); + _activeInputTurnCommand = null; + _activeInputTurnSpeed = 0f; + _activeInputTurnFromMouse = false; + _activeInputSidestepCommand = null; + _activeInputSidestepUsesRunHold = false; + _mouseTurnSamplePending = false; + _mouseTurnAdjustment = 0f; + _body.LastMoveWasAutonomous = true; + return true; + } + public void SetCharacterSkills(int runSkill, int jumpSkill) { _weenie.SetSkills(runSkill, jumpSkill); @@ -400,6 +756,59 @@ public sealed class PlayerMovementController /// internal MotionInterpreter Motion => _motion; + /// + /// Retail CPhysicsObj::StopCompletely (0x00510180) enters through + /// MovementManager::PerformMovement (0x005240D0), not directly + /// through CMotionInterp::StopCompletely. The facade's type-5 path + /// performs the required zero-duration animation completion sweep after + /// the interpreter has queued its matching pending-motion node. + /// + internal WeenieError StopCompletelyAtPhysicsObjectBoundary() => + Movement.PerformMovement(new MovementStruct + { + Type = MovementType.StopCompletely, + }); + + /// + /// Retail CPhysicsObj::DoMotion (0x00510020) constructs a type-1 + /// MovementStruct carrying the original parameters pointer and + /// routes it through MovementManager::PerformMovement. That outer + /// boundary is observable: CMotionInterp::PerformMovement + /// (0x00528E80) performs the synchronous completed-animation sweep after + /// the motion dispatch. + /// + private WeenieError DoMotionAtPhysicsObjectBoundary( + uint motion, + AcDream.Core.Physics.Motion.MovementParameters parameters) + { + _body.LastMoveWasAutonomous = true; + return Movement.PerformMovement(new MovementStruct + { + Type = MovementType.RawCommand, + Motion = motion, + Params = parameters, + }); + } + + /// + /// Retail CPhysicsObj::StopMotion (0x005100D0), the type-3 peer of + /// . Player input must use + /// this facade; MoveToManager's private _DoMotion/_StopMotion path stays + /// directly on CMotionInterp, matching retail. + /// + private WeenieError StopMotionAtPhysicsObjectBoundary( + uint motion, + AcDream.Core.Physics.Motion.MovementParameters parameters) + { + _body.LastMoveWasAutonomous = true; + return Movement.PerformMovement(new MovementStruct + { + Type = MovementType.StopRawCommand, + Motion = motion, + Params = parameters, + }); + } + /// R4-V5: CONTACT transient-state bit (retail /// transient_state & 1) — the manager's /// UseTime tick gate reads exactly this bit (decomp §6a @307781; a @@ -426,7 +835,10 @@ public sealed class PlayerMovementController /// during a server moveto so apply_raw can't clobber the manager's /// dispatched motions with the idle raw state. internal void SetLastMoveWasAutonomous(bool autonomous) - => _body.LastMoveWasAutonomous = autonomous; + { + _body.LastMoveWasAutonomous = autonomous; + _controlledByServer = !autonomous; + } /// /// Wire the player's AnimationSequencer current cycle velocity into @@ -456,28 +868,95 @@ public sealed class PlayerMovementController } /// - /// 2026-05-16. Called by the network outbound layer after every - /// AutonomousPosition or MoveToState that carries the player's - /// position. Resets the diff-driven heartbeat clock so the next - /// `HeartbeatDue` evaluation requires either a fresh state change - /// (cell, contact-plane, or frame) OR another full HeartbeatInterval. - /// Mirrors retail's SendPositionEvent at - /// acclient_2013_pseudo_c.txt:700345-700348 which updates - /// `last_sent_position`, `last_sent_position_time`, AND - /// `last_sent_contact_plane` after every send. + /// Retail CommandInterpreter::SendMovementEvent (0x006B4680) + /// stamps only last_sent_position_time. The carried frame and + /// contact plane remain those from the last AutonomousPosition packet. /// - public void NotePositionSent(System.Numerics.Vector3 worldPos, - uint cellId, - System.Numerics.Plane contactPlane, - float nowSeconds) + public void NoteMovementSent(float nowSeconds, bool mouseLookEvent = false) { - _lastSentPos = worldPos; - _lastSentCellId = cellId; + _lastSentTime = nowSeconds; + if (mouseLookEvent) + { + _mouseMovementEventPending = false; + _lastMouseMovementEventTime = nowSeconds; + } + } + + /// + /// Captures the current raw movement axes for an input-boundary + /// SendMovementEvent. ToggleMouseLook sends synchronously, so the host + /// cannot wait for the next physics update to reconstruct this state. + /// + public MovementResult CaptureMovementResult(bool mouseLookEvent) + { + var raw = _motion.RawState; + uint? forward = raw.ForwardCommand == RawMotionState.Default.ForwardCommand + ? null : raw.ForwardCommand; + uint? sidestep = raw.SidestepCommand == RawMotionState.Default.SidestepCommand + ? null : raw.SidestepCommand; + uint? turn = raw.TurnCommand == RawMotionState.Default.TurnCommand + ? null : raw.TurnCommand; + return new MovementResult( + Position: Position, + RenderPosition: RenderPosition, + CellId: CellId, + IsOnGround: CanSendPositionEvent, + MotionStateChanged: true, + ForwardCommand: forward, + SidestepCommand: sidestep, + TurnCommand: turn, + ForwardSpeed: forward.HasValue ? raw.ForwardSpeed : null, + SidestepSpeed: sidestep.HasValue ? raw.SidestepSpeed : null, + TurnSpeed: turn.HasValue ? raw.TurnSpeed : null, + IsRunning: raw.CurrentHoldKey == HoldKey.Run, + ShouldSendMovementEvent: true, + TurnUsesRunHold: turn.HasValue && raw.TurnHoldKey == HoldKey.Run, + SidestepUsesRunHold: sidestep.HasValue + && raw.SidestepHoldKey == HoldKey.Run, + IsMouseLookMovementEvent: mouseLookEvent); + } + + /// + /// Retail CommandInterpreter::SendPositionEvent (0x006B4770) + /// stamps the send time, complete cell-local frame (origin and + /// orientation), and contact plane after an AutonomousPosition packet. + /// + public void NotePositionSent(AcDream.Core.Physics.Position position, + System.Numerics.Plane contactPlane, + float nowSeconds) + { + _lastSentPosition = position; _lastSentContactPlane = contactPlane; _lastSentTime = nowSeconds; _lastSentInitialized = true; } + /// + /// Faithful port of retail + /// CommandInterpreter::ShouldSendPositionEvent (0x006B45E0). + /// Before the one-second interval expires, only a cell or contact-plane + /// change sends. Once it expires, the complete frame is compared, including + /// orientation; this is what publishes a stationary heading change. + /// + internal bool ShouldSendPositionEvent( + AcDream.Core.Physics.Position currentPosition, + System.Numerics.Plane currentContactPlane, + float nowSeconds) + { + if (!_lastSentInitialized) + return true; + + bool cellChanged = _lastSentPosition.ObjCellId != currentPosition.ObjCellId; + if ((_lastSentTime + HeartbeatInterval) >= nowSeconds) + { + return cellChanged + || !ApproxPlaneEqual(_lastSentContactPlane, currentContactPlane); + } + + return cellChanged + || !ApproxFrameEqual(_lastSentPosition.Frame, currentPosition.Frame); + } + // L.2a slice 1 (2026-05-12): centralized CellId mutation so the // [cell-transit] probe fires from a single chokepoint. Both the // server-snap path (SetPosition) and the per-frame resolver path @@ -538,7 +1017,17 @@ public sealed class PlayerMovementController // R3-W6: retail's teleport idle is a FULL stop (StopCompletely // 0x00527e40: resets fwd/sidestep/turn COMMANDS, zeroes velocity, // enqueues the A9 jump-snapshot node) — not a bare DoMotion(Ready). - _motion.StopCompletely(); + StopCompletelyAtPhysicsObjectBoundary(); + _activeInputTurnCommand = null; + _activeInputTurnSpeed = 0f; + _activeInputTurnFromMouse = false; + _activeInputSidestepCommand = null; + _activeInputSidestepUsesRunHold = false; + _mouseLookActive = false; + _mouseTurnSamplePending = false; + _mouseTurnAdjustment = 0f; + _mouseMovementEventCandidate = false; + _mouseMovementEventPending = false; // R5-V3 (#171): retail teleport_hook (0x00514ed0) — PositionManager:: // UnStick (@0x00514eee) right after the moveto cancel: a teleport // tears down any active stick. (StopInterpolating/UnConstrain have no @@ -556,6 +1045,7 @@ public sealed class PlayerMovementController _prevTurnLeftHeld = false; _prevTurnRightHeld = false; _prevRunHeld = false; + _hasInputSnapshot = false; // Reset physics clock so any subsequent update_object calls start fresh. _body.LastUpdateTime = 0.0; @@ -732,8 +1222,31 @@ public sealed class PlayerMovementController // InterruptCurrentMovement → MoveTo.CancelMoveTo(ActionCancelled), // the retail user-input cancel chain (TS-36 retired; set_hold_run's // interrupt:true is the same chain for the Shift edge). - bool motionEdgeFired = false; + if (!_hasInputSnapshot) { + // GameWindow supplies the configured default walk/run mode as a + // level, not a physical key edge. Seed that first snapshot without + // claiming movement control; a simultaneous directional edge below + // still takes control normally. + _hasInputSnapshot = true; + _prevRunHeld = input.Run; + _prevRunHold = input.Run; + _motion.set_hold_run(input.Run, interrupt: false); + } + + bool motionEdgeFired = false; + bool movementEventRequested = false; + { + bool userInputEdge = input.Run != _prevRunHeld + || input.Forward != _prevForwardHeld + || input.Backward != _prevBackwardHeld + || input.StrafeLeft != _prevStrafeLeftHeld + || input.StrafeRight != _prevStrafeRightHeld + || input.TurnLeft != _prevTurnLeftHeld + || input.TurnRight != _prevTurnRightHeld; + if (userInputEdge) + TakeControlFromServer(); + var p = new AcDream.Core.Physics.Motion.MovementParameters(); // Shift/run edge FIRST — retail's set_hold_run re-applies @@ -752,37 +1265,84 @@ public sealed class PlayerMovementController // the old level-triggered build, which always reflected the // currently-held set). if (input.Forward && !_prevForwardHeld) - { _motion.DoMotion(MotionCommand.WalkForward, p); motionEdgeFired = true; } + { DoMotionAtPhysicsObjectBoundary(MotionCommand.WalkForward, p); motionEdgeFired = true; } else if (input.Backward && !_prevBackwardHeld && !input.Forward) - { _motion.DoMotion(MotionCommand.WalkBackward, p); motionEdgeFired = true; } + { DoMotionAtPhysicsObjectBoundary(MotionCommand.WalkBackward, p); motionEdgeFired = true; } if (!input.Forward && _prevForwardHeld) { if (input.Backward) - _motion.DoMotion(MotionCommand.WalkBackward, p); + DoMotionAtPhysicsObjectBoundary(MotionCommand.WalkBackward, p); else - _motion.StopMotion(MotionCommand.WalkForward, p); + StopMotionAtPhysicsObjectBoundary(MotionCommand.WalkForward, p); motionEdgeFired = true; } else if (!input.Backward && _prevBackwardHeld && !input.Forward) - { _motion.StopMotion(MotionCommand.WalkBackward, p); motionEdgeFired = true; } + { StopMotionAtPhysicsObjectBoundary(MotionCommand.WalkBackward, p); motionEdgeFired = true; } - // Sidestep channel. - if (input.StrafeRight && !_prevStrafeRightHeld) - { _motion.DoMotion(MotionCommand.SideStepRight, p); motionEdgeFired = true; } - else if (input.StrafeLeft && !_prevStrafeLeftHeld) - { _motion.DoMotion(MotionCommand.SideStepLeft, p); motionEdgeFired = true; } - else if (!input.StrafeRight && !input.StrafeLeft - && (_prevStrafeRightHeld || _prevStrafeLeftHeld)) - { _motion.StopMotion(MotionCommand.SideStepRight, p); motionEdgeFired = true; } + // Sidestep channel. CameraInstantMouseLook remaps held keyboard + // TurnLeft/TurnRight into this channel while MMB is active. + (uint? desiredSidestep, bool sidestepUsesRunHold) = + DesiredInputSidestep(input, _mouseLookActive); + if (ApplyInputSidestep(desiredSidestep, sidestepUsesRunHold)) + motionEdgeFired = true; - // Turn channel. - if (input.TurnRight && !_prevTurnRightHeld) - { _motion.DoMotion(MotionCommand.TurnRight, p); motionEdgeFired = true; } - else if (input.TurnLeft && !_prevTurnLeftHeld) - { _motion.DoMotion(MotionCommand.TurnLeft, p); motionEdgeFired = true; } - else if (!input.TurnRight && !input.TurnLeft - && (_prevTurnRightHeld || _prevTurnLeftHeld)) - { _motion.StopMotion(MotionCommand.TurnRight, p); motionEdgeFired = true; } + // Everything above is an ordinary input edge and therefore owns + // an immediate SendMovementEvent. Mouse-origin turn updates below + // deliberately do not: CameraSet reports them on its separate + // half-second cadence. + movementEventRequested = motionEdgeFired; + + // Turn channel. Retail CameraSet::Rotate (0x00458310) feeds mouse + // input through CommandInterpreter::MovePlayer as TurnLeft / + // TurnRight, so character yaw, physics orientation, raw wire state, + // and ACE authority all share this one MotionInterpreter owner. + bool keyboardTurnEdge = input.TurnRight != _prevTurnRightHeld + || input.TurnLeft != _prevTurnLeftHeld; + if (keyboardTurnEdge) + movementEventRequested = true; + + uint? desiredTurnCommand = _mouseLookActive + ? null + : input.TurnRight + ? MotionCommand.TurnRight + : input.TurnLeft + ? MotionCommand.TurnLeft + : null; + float desiredTurnSpeed = 1f; + bool desiredTurnFromMouse = false; + + if (_mouseLookActive && _mouseTurnSamplePending) + { + float adjustment = _mouseTurnAdjustment; + _mouseTurnSamplePending = false; + _mouseTurnAdjustment = 0f; + + if (MathF.Abs(adjustment) >= MouseTurnDeadZone) + { + desiredTurnCommand = adjustment < 0f + ? MotionCommand.TurnRight + : MotionCommand.TurnLeft; + desiredTurnSpeed = MathF.Min( + MathF.Abs(adjustment) * MouseTurnSpeedScale, + MouseTurnMaximumSpeed); + desiredTurnFromMouse = true; + } + } + else if (_mouseLookActive && _activeInputTurnFromMouse) + { + // Eventless render frames do not synthesize a zero. Preserve + // the last Rotate motion until the delayed retail idle handler + // explicitly calls StopMouseDrift. + desiredTurnCommand = _activeInputTurnCommand; + desiredTurnSpeed = _activeInputTurnSpeed; + desiredTurnFromMouse = true; + } + + if (ApplyInputTurn( + desiredTurnCommand, + desiredTurnSpeed, + desiredTurnFromMouse)) + motionEdgeFired = true; // Retail stores last_move_was_autonomous = 1 at the CPhysicsObj // INPUT boundary — CPhysicsObj::DoMotion @00510030 / @@ -796,7 +1356,10 @@ public sealed class PlayerMovementController // routes the per-tick pump's A3 dual dispatch (raw for // input-driven motion, interpreted for server/manager-driven). if (motionEdgeFired) + { _body.LastMoveWasAutonomous = true; + _controlledByServer = false; + } } _prevForwardHeld = input.Forward; @@ -828,14 +1391,14 @@ public sealed class PlayerMovementController // and TurnToHeading nodes dispatch TurnRight/TurnLeft through // _DoMotion into the SAME interpreted turn state, so this one // integrator rotates the player for both input and moveto turns. - // Mouse turn stays a direct Yaw delta (deliberately generates no - // turn command — avoids MoveToState spam from mouse jitter). + // Mouse-origin turns now enter the same interpreted turn state above. + // Packet cadence is carried separately by ShouldSendMovementEvent, so + // local mouse sampling cannot flood MoveToState. if (_motion.InterpretedState.TurnCommand == MotionCommand.TurnRight) { Yaw -= (MathF.PI / 2.0f) // BaseTurnRateRadPerSec (AP-9), ex-RemoteMoveToDriver * _motion.InterpretedState.TurnSpeed * dt; } - Yaw -= input.MouseDeltaX * MouseTurnSensitivity; // Wrap yaw to [-PI, PI] so it doesn't grow unbounded. while (Yaw > MathF.PI) Yaw -= 2f * MathF.PI; while (Yaw < -MathF.PI) Yaw += 2f * MathF.PI; @@ -1192,35 +1755,24 @@ public sealed class PlayerMovementController outForwardSpeed = 1.0f; } - // Strafe: retail uses speed=1.0 for SideStep (see holtburger - // common.rs::locomotion_command_for_state). 0.5 was our earlier guess - // and made strafing feel lethargic; the retail feel is full-speed - // sidestep matching the walk forward pace. - if (input.StrafeRight) + // Source the outbound axis from the canonical input-owned raw channel. + // CameraInstantMouseLook maps keyboard TurnLeft/TurnRight into this + // same channel, so reconstructing it from only physical strafe keys + // would send ACE an idle sidestep while the local body moved. + if (_activeInputSidestepCommand is { } activeInputSidestep) { - outSidestepCmd = MotionCommand.SideStepRight; - outSidestepSpeed = 1.0f; - } - else if (input.StrafeLeft) - { - outSidestepCmd = MotionCommand.SideStepLeft; - outSidestepSpeed = 1.0f; + outSidestepCmd = activeInputSidestep; + outSidestepSpeed = _motion.RawState.SidestepSpeed; } - // Turn commands from KEYBOARD only (A/D). Mouse turning is applied - // directly to Yaw above and doesn't generate a turn command — if it - // did, mouse jitter would flip turnCmd between TurnRight/TurnLeft - // every frame, causing stateChanged=True on every frame and flooding - // the server with MoveToState spam. - if (input.TurnRight) + // Turn commands come from the current user-owned turn channel. This is + // keyboard A/D or CameraSet's transient mouse-origin turn, never a + // server-owned MoveTo turn. The raw speed is the exact value passed to + // MotionInterpreter before hold-run adjustment. + if (_activeInputTurnCommand is { } activeInputTurn) { - outTurnCmd = MotionCommand.TurnRight; - outTurnSpeed = 1.0f; - } - else if (input.TurnLeft) - { - outTurnCmd = MotionCommand.TurnLeft; - outTurnSpeed = 1.0f; + outTurnCmd = activeInputTurn; + outTurnSpeed = _activeInputTurnSpeed; } // ── 7. Detect motion state change ───────────────────────────────────── @@ -1245,6 +1797,15 @@ public sealed class PlayerMovementController || !FloatsEqual(outForwardSpeed, _prevForwardSpeed) || runHold != _prevRunHold || motionEdgeFired; + + bool mouseMovementEventDue = _mouseMovementEventPending + || (_mouseMovementEventCandidate + && _simTimeSeconds > _lastMouseMovementEventTime + MouseMovementEventInterval); + _mouseMovementEventCandidate = false; + if (mouseMovementEventDue) + _mouseMovementEventPending = true; + bool shouldSendMovementEvent = movementEventRequested || mouseMovementEventDue; + _prevForwardCmd = outForwardCmd; _prevSidestepCmd = outSidestepCmd; _prevTurnCmd = outTurnCmd; @@ -1258,63 +1819,11 @@ public sealed class PlayerMovementController return System.Math.Abs(a.Value - b.Value) < 1e-4f; } - // ── 8. Heartbeat timer (always while in-world, not just while moving) ─ - // 2026-05-16 (closes #74) — retail-faithful AP cadence per - // CommandInterpreter::ShouldSendPositionEvent at - // acclient_2013_pseudo_c.txt:700233-700285. Two-branch: - // - // Branch 1 — interval NOT yet elapsed (< 1 sec since last - // send): send only if cell changed OR contact-plane changed - // (mid-walk events that matter — stair / hill / cell cross). - // - // Branch 2 — interval HAS elapsed (>= 1 sec): send only if - // cell OR position frame changed. Truly idle = no send - // (retail's `last_sent.frame == player.frame` check at - // acclient_2013_pseudo_c.txt:700248-700265). - // - // SendPositionEvent (line 700327) gates the actual send on - // (state & 1) != 0 && (state & 2) != 0 — Contact AND - // OnWalkable both set. We mirror that gate so airborne and - // wall-contact-without-walkable suppress AP entirely; - // MoveToState carries jump/fall snapshots while airborne. - // - // Effective rates: - // Truly idle (grounded, no movement) : 0 Hz - // Smooth movement (no cell/plane changes) : ~1 Hz (interval) - // Cell crossings + stair/hill steps : per-event - // Airborne : 0 Hz - // - // Bootstrap: when NotePositionSent has never been called - // (_lastSentInitialized=false), every state-changed branch is - // forced true so the first AP gets a chance to fire. - - bool intervalElapsed = !_lastSentInitialized - || (_simTimeSeconds - _lastSentTime) >= HeartbeatInterval; - - bool cellChanged = !_lastSentInitialized - || _lastSentCellId != CellId; - bool planeChanged = !_lastSentInitialized - || !ApproxPlaneEqual(_lastSentContactPlane, _body.ContactPlane); - bool frameChanged = !_lastSentInitialized - || !ApproxPositionEqual(_lastSentPos, _body.Position); - - bool sendThisFrame = intervalElapsed - ? (cellChanged || frameChanged) - : (cellChanged || planeChanged); - - // 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. - HeartbeatDue = CanSendPositionEvent && sendThisFrame; - // R3-W6: the K-fix5 LocalAnimationSpeed synthesis is DELETED — the // run pacing now comes from the ported machinery itself // (apply_run_to_command scales the interpreted speed by my_run_rate; // the DefaultSink dispatch plays the cycle at that speed — the same // source remotes use). - bool anyDirectional = input.Forward || input.Backward - || input.StrafeLeft || input.StrafeRight; - // R4-V5: the #69 auto-walk turn-cycle edge synthesizer is DELETED — // the MoveToManager's own aux-turn steering and TurnToHeading nodes // dispatch TurnRight/TurnLeft through _DoMotion (the retail @@ -1333,34 +1842,36 @@ public sealed class PlayerMovementController ForwardSpeed: outForwardSpeed, SidestepSpeed: outSidestepSpeed, TurnSpeed: outTurnSpeed, - // Run hold-key applies to ANY active directional axis, not just - // forward (per holtburger's build_motion_state_raw_motion_state: - // "uses the same value for every active per-axis hold key"). The - // pre-fix condition `input.Run && input.Forward` made strafe-run - // and backward-run incorrectly broadcast as walk to observers, - // who then animated walk + dead-reckoned at walk speed while the - // server position moved at run speed — visible as observer lag. - IsRunning: input.Run && anyDirectional, + // CurrentHoldKey is a level independent of active axes; every + // active ordinary axis inherits it during wire projection. This + // preserves idle run/walk toggles as well as strafe/backward run. + IsRunning: input.Run, JustLanded: justLanded, JumpExtent: outJumpExtent, - JumpVelocity: outJumpVelocity); + JumpVelocity: outJumpVelocity, + ShouldSendMovementEvent: shouldSendMovementEvent, + TurnUsesRunHold: _activeInputTurnFromMouse && outTurnCmd.HasValue, + SidestepUsesRunHold: _activeInputSidestepUsesRunHold + && outSidestepCmd.HasValue, + IsMouseLookMovementEvent: mouseMovementEventDue); } /// - /// 2026-05-16. Position-equality test for diff-driven AP cadence. - /// Retail uses Frame::is_equal at acclient_2013_pseudo_c.txt:700263 - /// which is essentially exact float comparison after a memcmp of - /// the frame struct. For floating-point safety we use a tiny epsilon - /// — sub-millimeter — that's well below any movement we'd want to - /// suppress sending for. + /// Retail Frame::is_equal (0x00424C30) compares both the origin + /// and all four quaternion components with a 0.0002-unit epsilon. It does + /// not treat the mathematically equivalent q/-q pair as the + /// same stored frame. /// - private static bool ApproxPositionEqual( - System.Numerics.Vector3 a, System.Numerics.Vector3 b) + private static bool ApproxFrameEqual(CellFrame a, CellFrame b) { - const float Epsilon = 0.001f; // 1 mm - return MathF.Abs(a.X - b.X) < Epsilon - && MathF.Abs(a.Y - b.Y) < Epsilon - && MathF.Abs(a.Z - b.Z) < Epsilon; + const float Epsilon = 0.000199999995f; + return MathF.Abs(a.Origin.X - b.Origin.X) <= Epsilon + && MathF.Abs(a.Origin.Y - b.Origin.Y) <= Epsilon + && MathF.Abs(a.Origin.Z - b.Origin.Z) <= Epsilon + && MathF.Abs(a.Orientation.W - b.Orientation.W) < Epsilon + && MathF.Abs(a.Orientation.X - b.Orientation.X) < Epsilon + && MathF.Abs(a.Orientation.Y - b.Orientation.Y) < Epsilon + && MathF.Abs(a.Orientation.Z - b.Orientation.Z) < Epsilon; } /// @@ -1375,11 +1886,10 @@ public sealed class PlayerMovementController private static bool ApproxPlaneEqual( System.Numerics.Plane a, System.Numerics.Plane b) { - const float NormalEpsilon = 1e-4f; - const float DistanceEpsilon = 0.001f; - return MathF.Abs(a.Normal.X - b.Normal.X) < NormalEpsilon - && MathF.Abs(a.Normal.Y - b.Normal.Y) < NormalEpsilon - && MathF.Abs(a.Normal.Z - b.Normal.Z) < NormalEpsilon - && MathF.Abs(a.D - b.D) < DistanceEpsilon; + const float Epsilon = 0.000199999995f; + return MathF.Abs(a.Normal.X - b.Normal.X) <= Epsilon + && MathF.Abs(a.Normal.Y - b.Normal.Y) <= Epsilon + && MathF.Abs(a.Normal.Z - b.Normal.Z) <= Epsilon + && MathF.Abs(a.D - b.D) < Epsilon; } } diff --git a/src/AcDream.App/Rendering/EquippedChildRenderController.cs b/src/AcDream.App/Rendering/EquippedChildRenderController.cs index 39668e45..093966d4 100644 --- a/src/AcDream.App/Rendering/EquippedChildRenderController.cs +++ b/src/AcDream.App/Rendering/EquippedChildRenderController.cs @@ -570,10 +570,10 @@ public sealed class EquippedChildRenderController : IDisposable return new PaletteOverride(spawn.BasePaletteId ?? 0, ranges); } - private void OnObjectMoved(ClientObject item, uint _, uint __) + private void OnObjectMoved(ClientObjectMove move) { - if (item.CurrentlyEquippedLocation == EquipMask.None) - Remove(item.ObjectId); + if (move.Current.EquipLocation == EquipMask.None) + Remove(move.ItemId); } private void OnMoveRolledBack(ClientObject item) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 7b1f15cb..bf7ef098 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -1054,9 +1054,11 @@ public sealed class GameWindow : IDisposable private AcDream.Core.Net.WorldSession? _liveSession; private int _liveCenterX; private int _liveCenterY; - // #192 (2026-07-09): true once the player's OWN spawn CreateObject has been - // received and processed — i.e. _liveCenterX/Y reflects a real, - // server-confirmed position, not the Holtburg startup placeholder. Gates + // #192 (2026-07-09): true once the player's first canonical Position has + // been accepted — i.e. _liveCenterX/Y reflects a real, server-confirmed + // position, not the Holtburg startup placeholder. Renderability is not a + // prerequisite: a partial CreateObject may carry Position before Setup. + // Gates // streaming via StreamingReadinessGate.ShouldStream so no landblock is ever // built around a still-guessed center in live mode. See // AcDream.Core.World.StreamingReadinessGate for the full mechanism this @@ -1244,6 +1246,7 @@ public sealed class GameWindow : IDisposable _window.Update += OnUpdate; _window.Render += OnRender; _window.Closing += OnClosing; + _window.FocusChanged += OnFocusChanged; // L.0 Display tab: keep the GL viewport + camera aspect in sync // with the window framebuffer. Without this handler, resizing // the window (or applying a Display-tab Resolution change at @@ -1287,15 +1290,17 @@ public sealed class GameWindow : IDisposable _kbSource, _mouseSource, _keyBindings); _inputDispatcher.Fired += OnInputAction; - // Phase K.2 — MMB-hold instant mouse-look. The yaw mutator - // drives _playerController.Yaw (the chase camera reads this - // automatically via ChaseCamera.Update). Active only while - // _playerController and _chaseCamera are live; the lambda - // safely no-ops outside player mode. + // Retail CameraSet::ToggleMouseLook / Rotate (0x00457490 / + // 0x00458310): the callback submits a filtered horizontal + // adjustment to the player's MotionInterpreter. It must never + // mutate visible Yaw directly; doing so leaves ACE's authoritative + // heading stale and makes targeted attacks skip their server turn. _mouseLook = new AcDream.UI.Abstractions.Input.MouseLookState( - applyYawDelta: dYaw => + applyHorizontalAdjustment: adjustment => { - if (_playerController is not null) _playerController.Yaw += dYaw; + _playerController?.SubmitMouseTurnAdjustment( + adjustment, + CaptureMovementInput()); }); // Phase K.2 — auto-enter player mode after EnterWorld @@ -1379,25 +1384,10 @@ public sealed class GameWindow : IDisposable float sens = _sensChase; if (_mouseLook is not null && _mouseLook.Active) { - // Phase K.2 — MMB instant mouse-look. dx drives - // character yaw via the MouseLookState callback - // (which mutates _playerController.Yaw); the chase - // camera tracks the character automatically because - // ChaseCamera.Update reads the player yaw. So mouse-X - // here goes ONLY through ApplyDelta — no separate - // YawOffset write. dy still pitches the camera only. - _mouseLook.ApplyDelta(dx, sens); - if (AcDream.Core.Rendering.CameraDiagnostics.UseRetailChaseCamera && _retailChaseCamera is not null) - { - float nowSec = (float)(Environment.TickCount64 / 1000.0); - var (_, filteredDy) = _retailChaseCamera.FilterMouseDelta( - rawX: 0f, rawY: dy, weight: 0.5f, nowSec: nowSec); - _retailChaseCamera.AdjustPitch(filteredDy * 0.003f * sens); - } - else - { - _chaseCamera.AdjustPitch(dy * 0.003f * sens); - } + // DirectInput may deliver several device records before + // one retail GetInput pass. Accumulate raw motion here; + // the update loop filters the aggregate exactly once. + _mouseLook.QueueDelta(dx, dy); } else if (_rmbHeld) { @@ -2077,6 +2067,7 @@ public sealed class GameWindow : IDisposable Combat, CanStartLiveCombatAttack, SendLiveCombatAttack, + prepareAttackRequest: PreparePlayerForAttackRequest, sendCancelAttack: () => _liveSession?.SendCancelAttack(), isDualWield: () => _playerController?.Motion.InterpretedState.CurrentStyle == AcDream.Core.Combat.CombatInputPlanner.DualWieldCombatStyle, @@ -2761,6 +2752,13 @@ public sealed class GameWindow : IDisposable private void ClearInboundEntityState() { + EndMouseLookAndRestoreCursor(); + + // Session-scoped origin readiness. A reconnect's first logical player + // CreateObject must initialize the render/streaming center anew; + // projection recovery inside one session never crosses this boundary. + _liveCenterKnown = false; + // Attachment projections own GL-backed world registrations, so tear // them down before dropping the canonical GUID/timestamp snapshots. _equippedChildRenderer?.Clear(); @@ -3183,6 +3181,13 @@ public sealed class GameWindow : IDisposable /// mesh refs and register it in IGameState. Called from WorldSession events /// on the main thread (Tick runs in the Silk.NET Update callback). /// + private enum LiveProjectionPurpose + { + LogicalRegistration, + SpatialRecovery, + AppearanceMutation, + } + private void OnLiveEntitySpawned(AcDream.Core.Net.WorldSession.EntitySpawn spawn) { // Phase A.1 hotfix: live CreateObject handler reads dats extensively @@ -3220,7 +3225,9 @@ public sealed class GameWindow : IDisposable return; } - OnLiveEntitySpawnedLocked(result.Snapshot); + OnLiveEntitySpawnedLocked( + result.Snapshot, + LiveProjectionPurpose.LogicalRegistration); } catch (Exception applyFailure) when (registration.PriorGenerationCleanupFailure is not null) { @@ -3293,7 +3300,9 @@ public sealed class GameWindow : IDisposable } else { - OnLiveEntitySpawnedLocked(record.Snapshot); + OnLiveEntitySpawnedLocked( + record.Snapshot, + LiveProjectionPurpose.SpatialRecovery); if (record.WorldEntity is not null) projected++; } @@ -3318,8 +3327,45 @@ public sealed class GameWindow : IDisposable /// private static bool IsDoorName(string? name) => name == "Door"; + private void TryInitializeLiveCenter( + AcDream.Core.Net.WorldSession.EntitySpawn spawn) + { + if (_liveCenterKnown + || spawn.Guid != _playerServerGuid + || spawn.Position is not { } position) + return; + + int lbX = (int)((position.LandblockId >> 24) & 0xFFu); + int lbY = (int)((position.LandblockId >> 16) & 0xFFu); + _liveCenterKnown = true; + if (lbX != _liveCenterX || lbY != _liveCenterY) + { + Console.WriteLine( + $"live: first player position — recentering streaming from ({_liveCenterX},{_liveCenterY}) " + + $"to ({lbX},{lbY}) @0x{position.LandblockId:X8}"); + _liveCenterX = lbX; + _liveCenterY = lbY; + } + + if (_streamingController is not null && _dats is not null) + { + _streamingController.InitializeKnownLoginCenter( + lbX, + lbY, + isSealedDungeon: IsSealedDungeonCell(position.LandblockId)); + } + + // Streaming is normally gated until this point, so the next loaded- + // landblock callback will materialize deferred records. Also cover an + // already-resident block (tests, reconnect teardown overlap) without + // rebuilding any still-live identity. + foreach (uint loaded in _worldState.LoadedLandblockIds.ToArray()) + RehydrateServerEntitiesForLandblock(loaded); + } + private void OnLiveEntitySpawnedLocked( AcDream.Core.Net.WorldSession.EntitySpawn spawn, + LiveProjectionPurpose purpose, AppearanceUpdateState? appearanceUpdate = null) { _liveSpawnReceived++; @@ -3343,6 +3389,12 @@ public sealed class GameWindow : IDisposable if (_liveEntities!.TryGetSnapshot(spawn.Guid, out var canonicalSpawn)) spawn = canonicalSpawn; + // Streaming readiness belongs to the first accepted canonical player + // Position, not to mesh projection. A partial CreateObject with no + // Setup can establish the real world origin before a later mutation or + // spatial recovery makes it renderable. + TryInitializeLiveCenter(spawn); + // 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 @@ -3416,6 +3468,16 @@ public sealed class GameWindow : IDisposable } if (_dats is null) return; + + // The retained live record and its canonical packet state are valid + // before the player's first Position, but render-world coordinates are + // not. Defer every projection until the authoritative origin exists; + // StreamingController's loaded callback (or the already-resident pass + // in TryInitializeLiveCenter) materializes it once with the correct + // landblock translation. + if (!_liveCenterKnown) + return; + if (spawn.Position is null || spawn.SetupTableId is null) { // Can't place a mesh without both. Most of these are inventory @@ -3434,66 +3496,6 @@ public sealed class GameWindow : IDisposable int lbX = (int)((p.LandblockId >> 24) & 0xFFu); int lbY = (int)((p.LandblockId >> 16) & 0xFFu); - // G.3 (#133): recenter streaming onto the player's spawn landblock at - // login. The streaming center (_liveCenterX/_liveCenterY) is pinned to - // the startup default (Holtburg, 0xA9B4) and is otherwise only moved by - // the teleport-arrival path (OnLivePositionUpdated, ~line 4901). A - // character saved INSIDE a far dungeon spawns with that dungeon's - // landblock id, but the center never followed it, so the dungeon (tens - // of km away in world space) never streamed and the #107 auto-entry - // gate's SampleTerrainZ(pe.Position) waited forever — the player hung - // frozen at login. Mirror the teleport-arrival recenter HERE, for the - // PLAYER's spawn only, BEFORE the world-space translation below: when - // the spawn landblock differs from the current center, move the center - // onto it so the spawn maps to (PositionX, PositionY, PositionZ) in the - // new center frame (identical to the teleport path's - // `newWorldPos = new Vector3(p.PositionX, p.PositionY, p.PositionZ)`), - // and the next StreamingController.Tick observes the new center and - // streams the spawn landblock. - // - // No-op for a normal Holtburg login: the saved spawn landblock equals - // the default center, so the guard is false and origin/worldPos are - // byte-identical to the pre-fix path. Gated on the player guid so NPC / - // object spawns never move the center. Idempotent + thrash-free: a - // re-sent CreateObject for the same spawn landblock leaves the center - // already-equal, so the guard is false on every repeat. - // - // #192: the player's own spawn is the first point we can trust - // _liveCenterX/Y as a REAL position rather than the Holtburg startup - // placeholder — set this unconditionally (even when no recenter is - // needed, e.g. a genuine Holtburg login) so StreamingReadinessGate can - // stop gating streaming on session state alone. - if (spawn.Guid == _playerServerGuid) - _liveCenterKnown = true; - if (spawn.Guid == _playerServerGuid - && (lbX != _liveCenterX || lbY != _liveCenterY)) - { - Console.WriteLine( - $"live: login spawn — recentering streaming from ({_liveCenterX},{_liveCenterY}) " + - $"to ({lbX},{lbY}) for player spawn @0x{p.LandblockId:X8}"); - _liveCenterX = lbX; - _liveCenterY = lbY; - } - - // #135: the instant we know the player spawned into a SEALED dungeon, - // pre-collapse streaming to that single landblock — BEFORE the first - // StreamingController.Tick bootstraps the 25×25 ocean-grid window. The - // player isn't placed yet (physics CurrCell is null), so the per-frame - // insideDungeon gate stays false for the entire hydration window and - // NormalTick would otherwise load ~24 neighbor dungeons then unload them - // (the login FPS ramp the user reported — 10 fps slowly climbing). Sealed- - // dungeon only: a cottage/inn interior (SeenOutside) keeps its outdoor - // surround. We hold _datLock here, and IsSealedDungeonCell re-takes it - // (reentrant); the controller call is render-thread-safe (Channel writes). - if (spawn.Guid == _playerServerGuid - && _streamingController is not null) - { - _streamingController.InitializeKnownLoginCenter( - lbX, - lbY, - isSealedDungeon: IsSealedDungeonCell(p.LandblockId)); - } - var origin = new System.Numerics.Vector3( (lbX - _liveCenterX) * 192f, (lbY - _liveCenterY) * 192f, @@ -4172,6 +4174,14 @@ public sealed class GameWindow : IDisposable } } + // MotionDone ownership is established with the logical animation + // runtime, before EnterPlayerModeNow can dispatch SetPosition -> + // StopCompletely. That call may synchronously finish a zero-tick + // Ready entry; dropping its callback leaves CMotionInterp with an + // unmatched node that permanently starves later MoveTo/TurnTo work. + if (_animatedEntities.TryGetValue(entity.Id, out var registeredAnimation)) + EnsureMotionDoneBinding(spawn.Guid, registeredAnimation); + // Renderer, script owner, optional animation owner, collision body, // and effect profile are now all installed. Only at this boundary may // the mixed pre-Create F754/F755 FIFO replay against the local ID. @@ -4270,7 +4280,10 @@ public sealed class GameWindow : IDisposable lock (_datLock) { AppearanceUpdateState? appearanceState = CaptureLiveAppearanceState(update.Guid); - OnLiveEntitySpawnedLocked(newSpawn, appearanceState); + OnLiveEntitySpawnedLocked( + newSpawn, + LiveProjectionPurpose.AppearanceMutation, + appearanceState); } // Slice 2: flag the paperdoll doll to re-clone the player's newly @@ -4822,7 +4835,11 @@ public sealed class GameWindow : IDisposable { var mtm = new AcDream.Core.Physics.Motion.MoveToManager( rm.Motion, - stopCompletely: () => rmT.Motion.StopCompletely(), + stopCompletely: () => rmT.Movement.PerformMovement( + new AcDream.Core.Physics.MovementStruct + { + Type = AcDream.Core.Physics.MovementType.StopCompletely, + }), getPosition: () => new AcDream.Core.Physics.Position( rmT.CellId, mtBody.Position, mtBody.Orientation), getHeading: () => AcDream.Core.Physics.Motion.MoveToMath.GetHeading( @@ -4928,7 +4945,17 @@ public sealed class GameWindow : IDisposable // used to establish new target/voyeur relationships. Existing // subscriptions are cleared by LiveEntityPresentationController. if (!_visibleEntitiesByServerGuid.ContainsKey(id)) + { + if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled) + { + Console.WriteLine( + $"[autowalk-host-miss] object=0x{id:X8} " + + $"materialized={_entitiesByServerGuid.ContainsKey(id)} " + + $"registered={_physicsHosts.ContainsKey(id)} " + + $"hidden={_liveEntities?.IsHidden(id) == true}"); + } return null; + } if (_physicsHosts.TryGetValue(id, out var existing)) return existing; @@ -5281,6 +5308,24 @@ public sealed class GameWindow : IDisposable } } movement.PerformMovement(ms); + if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled) + { + string target = turnPath.TargetGuid is { } targetGuid + ? $"0x{targetGuid:X8}" : "null"; + bool targetVisible = turnPath.TargetGuid is { } visibleGuid + && _visibleEntitiesByServerGuid.ContainsKey(visibleGuid); + bool targetHost = turnPath.TargetGuid is { } hostGuid + && _physicsHosts.ContainsKey(hostGuid); + var moveTo = movement.MoveTo; + Console.WriteLine( + $"[autowalk-turn-route] wire=0x{update.MotionState.MovementType:X2} " + + $"routed={ms.Type} target={target} visible={targetVisible} " + + $"host={targetHost} stop={mp.StopCompletelyFlag} " + + $"initialized={moveTo?.Initialized ?? false} " + + $"nodes={moveTo?.PendingActions.Count() ?? 0} " + + $"command=0x{moveTo?.CurrentCommand ?? 0u:X8} " + + $"pendingMotions={movement.Minterp.MotionsPending()}"); + } return true; } @@ -6041,8 +6086,7 @@ public sealed class GameWindow : IDisposable lastContact: 1); _liveSession.SendGameAction(body); _playerController.NotePositionSent( - _playerController.Position, - cellId, + new AcDream.Core.Physics.Position(cellId, position, rotation), _playerController.ContactPlane, _playerController.SimTimeSeconds); } @@ -6081,6 +6125,14 @@ public sealed class GameWindow : IDisposable if (timestampDisposition is AcDream.Core.Physics.PositionTimestampDisposition.Rejected) return; + // A PlayerDescription/CreateObject may establish the live record + // without either Position or enough render data. Bind streaming + // readiness directly to this first accepted canonical Position before + // translating it through the current world origin; projection recovery + // below is a separate concern and may never be needed for UI-only state. + lock (_datLock) + TryInitializeLiveCenter(acceptedSpawn); + var p = update.Position; int lbX = (int)((p.LandblockId >> 24) & 0xFFu); int lbY = (int)((p.LandblockId >> 16) & 0xFFu); @@ -6105,7 +6157,9 @@ public sealed class GameWindow : IDisposable { _equippedChildRenderer?.OnChildBecameUnparented(update.Guid); lock (_datLock) - OnLiveEntitySpawnedLocked(acceptedSpawn); + OnLiveEntitySpawnedLocked( + acceptedSpawn, + LiveProjectionPurpose.SpatialRecovery); } if (!_entitiesByServerGuid.TryGetValue(update.Guid, out var entity)) return; @@ -6961,6 +7015,7 @@ public sealed class GameWindow : IDisposable if (!_liveEntities!.IsFreshTeleportStart(_playerServerGuid, (ushort)sequence)) return; + EndMouseLookAndRestoreCursor(); if (_playerController is not null) _playerController.State = AcDream.App.Input.PlayerState.PortalSpace; _teleportInProgress = true; @@ -8860,14 +8915,39 @@ public sealed class GameWindow : IDisposable // it doesn't get stuck hidden under a panel. if (_mouseLook is not null) { - bool wcm = DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureMouse; + bool wcm = IsUiCapturingMouse(); if (wcm != _lastWantCaptureMouse) { - bool wasActive = _mouseLook.Active; + if (wcm && _mouseLook.Active) + EndMouseLookAndRestoreCursor(); _mouseLook.OnWantCaptureMouseChanged(wcm); - if (wasActive && !_mouseLook.Active) RestoreCursorAfterMouseLook(); _lastWantCaptureMouse = wcm; } + + float nowSeconds = (float)(Environment.TickCount64 / 1000.0); + if (_mouseLook.TryTakeRawSample(nowSeconds, out float rawX, out float rawY)) + { + // GetInput synthesizes (0,0) only after >0.2 s idle. + // MouseLookHandler stops drift before filtering that sample; + // the filter tail may then start a smaller Rotate again. + if (rawX == 0f && rawY == 0f) + _playerController?.StopMouseDrift(CaptureMovementInput()); + + (float filteredX, float filteredY) = + AcDream.Core.Rendering.CameraDiagnostics.UseRetailChaseCamera + && _retailChaseCamera is not null + ? _retailChaseCamera.FilterMouseDelta( + rawX, + rawY, + weight: 0.5f, + nowSec: nowSeconds) + : (rawX, rawY); + _mouseLook.ApplyDelta(filteredX, _sensChase); + if (_retailChaseCamera is not null) + _retailChaseCamera.AdjustPitch(filteredY * 0.003f * _sensChase); + else + _chaseCamera?.AdjustPitch(filteredY * 0.003f * _sensChase); + } } // Phase K.2 — auto-enter player mode at login. The guard @@ -8948,20 +9028,7 @@ public sealed class GameWindow : IDisposable // until Q is pressed again. Handled in OnInputAction; here // we just OR _autoRunActive into the Forward flag. // * Mouse never drives character yaw (K.1b regression-prevention). - bool walking = _inputDispatcher.IsActionHeld( - AcDream.UI.Abstractions.Input.InputAction.MovementWalkMode); - bool wHeld = _inputDispatcher.IsActionHeld( - AcDream.UI.Abstractions.Input.InputAction.MovementForward); - var input = new AcDream.App.Input.MovementInput( - Forward: wHeld || _autoRunActive, - Backward: _inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.MovementBackup), - StrafeLeft: _inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.MovementStrafeLeft), - StrafeRight: _inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.MovementStrafeRight), - TurnLeft: _inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.MovementTurnLeft), - TurnRight: _inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.MovementTurnRight), - Run: !walking, // default = run; Shift held = walk - MouseDeltaX: 0f, - Jump: _inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.MovementJump)); + var input = CaptureMovementInput(); // Fix #42 (2026-05-05): keep PlayerMovementController's // LocalEntityId in sync with the live local player entity so @@ -9069,131 +9136,46 @@ public sealed class GameWindow : IDisposable trackedTargetPoint: trackedCombatTarget); // Send outbound movement messages to the live server. - if (_liveSession is not null && !localPlayerHidden) + if (_liveSession is not null + && !localPlayerHidden + && _playerController.TryGetOutboundPosition( + YawToAcQuaternion(_playerController.Yaw), + out uint wireCellId, + out System.Numerics.Vector3 wirePos)) { - // Convert world position back to AC wire coordinates. - // World origin is _liveCenterX/_liveCenterY; each landblock is 192 units. - // - // #107 (2026-06-10): the wire (cell, position) pair must be - // SELF-CONSISTENT — derive the landblock frame from the - // resolver's FULL cell id (always prefixed post-#106) instead - // of recomputing it from the position. The old composition - // welded a position-derived landblock onto result.CellId's low - // word; any tick where the two disagreed (landblock crossing, - // indoor cells near a boundary) sent ACE an id naming a - // DIFFERENT landblock's cell — and ACE persists the pair - // verbatim into the character save, which is the poisoned - // (cell-from-one-building, position-in-another) restore shape - // behind the #107 login wedge. - uint wireCellId = result.CellId; - int lbX = (int)(wireCellId >> 24); - int lbY = (int)((wireCellId >> 16) & 0xFFu); - if ((wireCellId & 0xFFFF0000u) == 0u) - { - // Defensive: bare low-word id (should not occur post-#106) — - // fall back to the position-derived landblock. - lbX = _liveCenterX + (int)MathF.Floor(result.Position.X / 192f); - lbY = _liveCenterY + (int)MathF.Floor(result.Position.Y / 192f); - wireCellId = ((uint)lbX << 24) | ((uint)lbY << 16) | (wireCellId & 0xFFFFu); - } - float localX = result.Position.X - (lbX - _liveCenterX) * 192f; - float localY = result.Position.Y - (lbY - _liveCenterY) * 192f; - var wirePos = new System.Numerics.Vector3(localX, localY, result.Position.Z); + // Retail serializes CPhysicsObj::m_position directly. The + // controller owns this canonical (cell, landblock-local origin) + // pair; the render/streaming origin never enters wire coordinates. var wireRot = YawToAcQuaternion(_playerController.Yaw); byte contactByte = result.IsOnGround ? (byte)1 : (byte)0; + var wirePosition = new AcDream.Core.Physics.Position( + wireCellId, + wirePos, + wireRot); + + // Snapshot the AP predicate before this path mutates its send + // tracker. Retail's UseTime calls ShouldSendPositionEvent then + // SendPositionEvent; MTS originates in separate input handlers, + // so their relative same-tick callback order remains TS-33. + bool positionEventDue = _playerController.ShouldSendPositionEvent( + wirePosition, + _playerController.ContactPlane, + _playerController.SimTimeSeconds); // 2026-05-16 (issue #75) / R4-V5: user-MoveToState packets // are ONLY for user-initiated motion intent — retail's // architectural split between user-input motion and // server-driven motion. Post-V5 the split holds BY - // CONSTRUCTION: MotionStateChanged derives exclusively from - // input edges (the MoveToManager's dispatches never touch - // the controller's edge detector), so a manager-driven - // moveto produces no outbound MoveToState; the moment the - // user presses a key, the edge's CancelMoveTo chain kills - // the moveto and THAT state change legitimately goes on - // the wire ("user took control" — which is now true). The - // former !IsServerAutoWalking guard is redundant and gone - // with B.6. - if (result.MotionStateChanged) - { - // HoldKey axis values — retail enum (acclient.h enum - // HoldKey): Invalid = 0, None = 1, Run = 2. - // Retail always sends CURRENT_HOLD_KEY (and uses the same - // value for every active per-axis hold key — see - // holtburger's build_motion_state_raw_motion_state). - // When the player is running forward, Run; otherwise None. - var axisHoldKey = result.IsRunning - ? AcDream.Core.Physics.HoldKey.Run - : AcDream.Core.Physics.HoldKey.None; + // By construction, ShouldSendMovementEvent derives from user + // input edges plus CameraSet's mouse-look start/stop/0.5-second + // cadence. MoveToManager dispatches never touch either source, + // so server-driven movement cannot echo a MoveToState. A user + // edge cancels the moveto and legitimately takes control. + if (result.ShouldSendMovementEvent) + TrySendPlayerMovementEvent(result); - // D1/L.2b (2026-06-30): build the COMPLETE RawMotionState - // snapshot (matching retail's CPhysicsObj::InqRawMotionState()) - // and let RawMotionStatePacker's default-difference comparison - // decide what actually goes on the wire — see - // RawMotionStatePacker (ports RawMotionState::Pack, 0x0051ed10). - // This slice changes ONLY the packing; the state values - // below are exactly what the pre-slice code passed in. - var rawMotionState = new AcDream.Core.Physics.RawMotionState - { - CurrentHoldKey = axisHoldKey, - // CurrentStyle: not tracked by acdream today — leave at - // the retail default (0x8000003D) so the packer omits it. - ForwardCommand = result.ForwardCommand ?? AcDream.Core.Physics.RawMotionState.Default.ForwardCommand, - ForwardHoldKey = result.ForwardCommand.HasValue ? axisHoldKey : AcDream.Core.Physics.HoldKey.Invalid, - // D6.2b: the wire carries the RAW forward_speed (1.0 for run/walk; - // the controller now sets outForwardSpeed=1.0). ACE recomputes the - // broadcast run speed from run skill (echo-confirmed 2026-07-01), so - // this is retail-faithful; 1.0 is omitted by default-difference packing. - ForwardSpeed = result.ForwardSpeed ?? AcDream.Core.Physics.RawMotionState.Default.ForwardSpeed, - SidestepCommand = result.SidestepCommand ?? AcDream.Core.Physics.RawMotionState.Default.SidestepCommand, - SidestepHoldKey = result.SidestepCommand.HasValue ? axisHoldKey : AcDream.Core.Physics.HoldKey.Invalid, - SidestepSpeed = result.SidestepSpeed ?? AcDream.Core.Physics.RawMotionState.Default.SidestepSpeed, - TurnCommand = result.TurnCommand ?? AcDream.Core.Physics.RawMotionState.Default.TurnCommand, - TurnHoldKey = result.TurnCommand.HasValue ? axisHoldKey : AcDream.Core.Physics.HoldKey.Invalid, - TurnSpeed = result.TurnSpeed ?? AcDream.Core.Physics.RawMotionState.Default.TurnSpeed, - // Actions: acdream does not yet emit discrete motion - // events on this path (D2, deferred to Phase 2+). - }; - - var seq = _liveSession.NextGameActionSequence(); - var body = AcDream.Core.Net.Messages.MoveToState.Build( - gameActionSequence: seq, - rawMotionState: rawMotionState, - cellId: wireCellId, - position: wirePos, - rotation: wireRot, - instanceSequence: _liveSession.InstanceSequence, - serverControlSequence: _liveSession.ServerControlSequence, - teleportSequence: _liveSession.TeleportSequence, - forcePositionSequence: _liveSession.ForcePositionSequence, - contact: contactByte != 0, - // Standing longjump is not implemented yet — honest - // current value, not a guess (D3). - standingLongjump: false); - DumpMovementTruthOutbound( - "MTS", seq, result, wirePos, wireCellId, contactByte); - _liveSession.SendGameAction(body); - // D5 audit (2026-06-30, this slice): retail's - // SendMovementEvent (acclient_2013_pseudo_c.txt, 0x006b4680) - // stamps ONLY last_sent_position_time after an MTS send — it - // does NOT update last_sent_position or - // last_sent_contact_plane (confirmed via Ghidra - // decompile-by-address during this slice). Only - // SendPositionEvent (the AP path, below) stamps all three. - // acdream's NotePositionSent call here stamps all three on - // BOTH paths — this is a real, audit-confirmed divergence - // from retail. Per this slice's scope (D5 = audit only, - // change only with explicit sign-off), left UNCHANGED; - // reported to the lead engineer instead of fixed here. - _playerController.NotePositionSent( - worldPos: _playerController.Position, - cellId: _playerController.CellId, - contactPlane: _playerController.ContactPlane, - nowSeconds: _playerController.SimTimeSeconds); - } - - if (_playerController.HeartbeatDue) + // SendPositionEvent itself admits only Contact+OnWalkable. + if (positionEventDue && _playerController.CanSendPositionEvent) { var seq = _liveSession.NextGameActionSequence(); var body = AcDream.Core.Net.Messages.AutonomousPosition.Build( @@ -9209,18 +9191,8 @@ public sealed class GameWindow : IDisposable DumpMovementTruthOutbound( "AP", seq, result, wirePos, wireCellId, contactByte); _liveSession.SendGameAction(body); - // B.6/B.7 (2026-05-16): stamp the diff-driven heartbeat clock so - // HeartbeatDue resets its interval from THIS send — mirrors retail's - // SendPositionEvent (acclient_2013_pseudo_c.txt:700345-700348, - // decomp address 0x006b4770, re-confirmed via the Ghidra - // decompile-by-address bridge during the L.2b slice 2026-06-30) - // writing last_sent_position_time + last_sent_position + - // last_sent_contact_plane after each AP. Unlike the MTS path - // above (SendMovementEvent, 0x006b4680), which retail stamps - // ONLY last_sent_position_time, this AP path is correct. _playerController.NotePositionSent( - worldPos: _playerController.Position, - cellId: _playerController.CellId, + position: wirePosition, contactPlane: _playerController.ContactPlane, nowSeconds: _playerController.SimTimeSeconds); } @@ -9380,6 +9352,12 @@ public sealed class GameWindow : IDisposable private void OnCameraModeChanged(bool _modeBool) { + if (_mouseLook?.Active == true + && _cameraController?.IsChaseMode != true) + { + EndMouseLookAndRestoreCursor(); + } + if (_input is null) return; var mouse = _input.Mice.FirstOrDefault(); if (mouse is null) return; @@ -10656,6 +10634,11 @@ public sealed class GameWindow : IDisposable // exactly matching the old scan's miss case. uint serverGuid = ae.Entity.ServerGuid; + // Defensive for a runtime retained across visual rehydration. + // This is deliberately before every early-out and before + // Sequencer.Advance: no completion may precede its consumer. + EnsureMotionDoneBinding(serverGuid, ae); + // Retail CPhysicsObj::UpdateObjectInternal skips root movement, // MovementManager use, and PartArray animation while cell == 0. // Pickup/parent leave-world preserves the same owners (including @@ -10760,40 +10743,6 @@ public sealed class GameWindow : IDisposable } seqFrames = ae.Sequencer.Advance(dt); - // R3-W2: bind the MotionDone seam once per sequencer to the - // entity's REAL consumer — the MotionInterpreter's - // pending_motions pop (retail CPhysicsObj::MotionDone - // 0x0050fdb0 → MovementManager → CMotionInterp::MotionDone - // 0x00527ec0; r3-port-plan.md §4). Remotes bind their - // RemoteMotion interp; the local player binds the - // controller's; interp-less entities (doors, statics) keep - // the diagnostic recorder only. - if (ae.Sequencer.MotionDoneTarget is null) - { - uint mdGuid = serverGuid; - // Resolve the interp AT FIRE TIME, not bind time: a - // remote's RemoteMotion is created on its first UM/UP, - // which can arrive AFTER the first anim tick — an - // eagerly-captured null would silently drop completions - // forever. MotionDone fires per completed motion (rare), - // so the dictionary lookup is negligible. - ae.Sequencer.MotionDoneTarget = (m, ok) => - { - AcDream.Core.Physics.MotionInterpreter? interp = null; - if (mdGuid != 0 && mdGuid == _playerServerGuid) - interp = _playerController?.Motion; - else if (mdGuid != 0 - && _remoteDeadReckon.TryGetValue(mdGuid, out var rmFire)) - interp = rmFire.Motion; - - interp?.MotionDone(m, ok); - if (System.Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1") - System.Console.WriteLine( - $"[MOTIONDONE] guid={mdGuid:X8} motion=0x{m:X8} success={ok} " - + $"pending={(interp?.MotionsPending() ?? false)}"); - }; - } - // Capture hooks now, but deliver them only after every final // part transform and equipped-child root has been published. // This matches CPhysicsObj::UpdateObjectInternal followed by @@ -10978,6 +10927,42 @@ public sealed class GameWindow : IDisposable } } + /// + /// R3-W2: binds the animation queue's completion seam to the entity's + /// real CMotionInterp::MotionDone consumer + /// (CPhysicsObj::MotionDone 0x0050FDB0). Retail owns both queues + /// under one CPhysicsObj/CPartArray lifetime; acdream's split owners must + /// establish the relationship before the first animation can finish. + /// + private void EnsureMotionDoneBinding(uint serverGuid, AnimatedEntity ae) + { + if (ae.Sequencer is not { MotionDoneTarget: null } sequencer) + return; + + uint ownerGuid = serverGuid; + // Resolve at callback time: a remote MotionInterpreter can be created + // after its animation runtime, and the player controller is created + // during EnterPlayerModeNow. Eagerly capturing either would preserve + // a legitimate null forever. + sequencer.MotionDoneTarget = (motion, success) => + { + AcDream.Core.Physics.MotionInterpreter? interpreter = null; + if (ownerGuid != 0 && ownerGuid == _playerServerGuid) + interpreter = _playerController?.Motion; + else if (ownerGuid != 0 + && _remoteDeadReckon.TryGetValue(ownerGuid, out var remote)) + interpreter = remote.Motion; + + interpreter?.MotionDone(motion, success); + if (System.Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1") + { + System.Console.WriteLine( + $"[MOTIONDONE] guid={ownerGuid:X8} motion=0x{motion:X8} " + + $"success={success} pending={(interpreter?.MotionsPending() ?? false)}"); + } + }; + } + // IsEntityCurrentlyMoving REMOVED (2026-07-09): it powered a cache-bypass // narrowing that dropped settled-open doors / faded-out walls back onto the // stale Tier-1 rest-pose cache entry (the door/fade "flip-back"). See the @@ -12469,15 +12454,38 @@ public sealed class GameWindow : IDisposable if (_mouseLook is null) return; if (activation == AcDream.UI.Abstractions.Input.ActivationType.Press) { - bool wcm = DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureMouse; - _mouseLook.Press(_lastMouseX, _lastMouseY, wcm); - if (_mouseLook.Active) HideCursorForMouseLook(); + if (!_playerMode + || _cameraController?.IsChaseMode != true + || _playerController is not { State: AcDream.App.Input.PlayerState.InWorld }) + return; + + bool wcm = IsUiCapturingMouse(); + float nowSeconds = (float)(Environment.TickCount64 / 1000.0); + _mouseLook.Press(_lastMouseX, _lastMouseY, wcm, nowSeconds); + if (_mouseLook.Active) + { + if (_playerController is not { } controller + || !controller.BeginMouseLook(CaptureMovementInput())) + { + // Keep capture ownership atomic with the movement + // transition. A portal/state change between the outer + // gate and this call must not leave a hidden cursor + // with no active movement owner. + _mouseLook.Release(); + return; + } + + // ToggleMouseLook calls SendMovementEvent at the input + // boundary. This also preserves down+up events that occur + // before the next physics update. + TrySendPlayerMovementEvent( + controller.CaptureMovementResult(mouseLookEvent: false)); + HideCursorForMouseLook(); + } } else if (activation == AcDream.UI.Abstractions.Input.ActivationType.Release) { - bool wasActive = _mouseLook.Active; - _mouseLook.Release(); - if (wasActive) RestoreCursorAfterMouseLook(); + EndMouseLookAndRestoreCursor(); } return; } @@ -12653,6 +12661,7 @@ public sealed class GameWindow : IDisposable _cameraController.ToggleFly(); // exit fly, release cursor else if (_playerMode) { + EndMouseLookAndRestoreCursor(); _playerMode = false; _cameraController?.ExitChaseMode(); _playerController = null; @@ -12671,13 +12680,8 @@ public sealed class GameWindow : IDisposable || _liveSession.CurrentState != AcDream.Core.Net.WorldSession.State.InWorld) return; - var orderedEquipmentIds = Objects.GetContents(_playerServerGuid); - var orderedEquipment = new List(orderedEquipmentIds.Count); - for (int i = 0; i < orderedEquipmentIds.Count; i++) - { - if (Objects.Get(orderedEquipmentIds[i]) is { } item) - orderedEquipment.Add(item); - } + IReadOnlyList orderedEquipment = + Objects.GetEquippedBy(_playerServerGuid); var defaultMode = AcDream.Core.Combat.CombatInputPlanner .GetDefaultCombatMode(orderedEquipment); var nextMode = AcDream.Core.Combat.CombatInputPlanner.ToggleMode( @@ -12747,6 +12751,98 @@ public sealed class GameWindow : IDisposable return true; } + /// + /// Retail ClientCombatSystem::StartAttackRequest (0x0056C040) + /// calls MaybeStopCompletely, whose successful path sends the + /// stopped movement state synchronously. Doing this in the host preserves + /// wire ordering: ACE sees the final player heading before the later + /// targeted attack request. + /// + private void PreparePlayerForAttackRequest() + { + if (_playerController is not { } controller + || !controller.PrepareForAttackRequest()) + return; + + TrySendPlayerMovementEvent( + controller.CaptureMovementResult(mouseLookEvent: false)); + } + + internal static AcDream.Core.Physics.RawMotionState BuildOutboundRawMotionState( + AcDream.App.Input.MovementResult result) + { + var axisHoldKey = result.IsRunning + ? AcDream.Core.Physics.HoldKey.Run + : AcDream.Core.Physics.HoldKey.None; + return new AcDream.Core.Physics.RawMotionState + { + CurrentHoldKey = axisHoldKey, + ForwardCommand = result.ForwardCommand + ?? AcDream.Core.Physics.RawMotionState.Default.ForwardCommand, + ForwardHoldKey = result.ForwardCommand.HasValue + ? axisHoldKey : AcDream.Core.Physics.HoldKey.Invalid, + ForwardSpeed = result.ForwardSpeed + ?? AcDream.Core.Physics.RawMotionState.Default.ForwardSpeed, + SidestepCommand = result.SidestepCommand + ?? AcDream.Core.Physics.RawMotionState.Default.SidestepCommand, + SidestepHoldKey = result.SidestepCommand.HasValue + ? result.SidestepUsesRunHold + ? AcDream.Core.Physics.HoldKey.Run + : axisHoldKey + : AcDream.Core.Physics.HoldKey.Invalid, + SidestepSpeed = result.SidestepSpeed + ?? AcDream.Core.Physics.RawMotionState.Default.SidestepSpeed, + TurnCommand = result.TurnCommand + ?? AcDream.Core.Physics.RawMotionState.Default.TurnCommand, + TurnHoldKey = result.TurnCommand.HasValue + ? result.TurnUsesRunHold + ? AcDream.Core.Physics.HoldKey.Run + : axisHoldKey + : AcDream.Core.Physics.HoldKey.Invalid, + TurnSpeed = result.TurnSpeed + ?? AcDream.Core.Physics.RawMotionState.Default.TurnSpeed, + }; + } + + private bool TrySendPlayerMovementEvent(AcDream.App.Input.MovementResult result) + { + if (_liveSession is not { } session + || _playerController is not { } controller) + return false; + + var wireRot = YawToAcQuaternion(controller.Yaw); + if (!controller.TryGetOutboundPosition( + wireRot, + out uint wireCellId, + out System.Numerics.Vector3 wirePos)) + return false; + + byte contactByte = result.IsOnGround ? (byte)1 : (byte)0; + AcDream.Core.Physics.RawMotionState rawMotionState = + BuildOutboundRawMotionState(result); + + uint seq = session.NextGameActionSequence(); + byte[] body = AcDream.Core.Net.Messages.MoveToState.Build( + gameActionSequence: seq, + rawMotionState: rawMotionState, + cellId: wireCellId, + position: wirePos, + rotation: wireRot, + instanceSequence: session.InstanceSequence, + serverControlSequence: session.ServerControlSequence, + teleportSequence: session.TeleportSequence, + forcePositionSequence: session.ForcePositionSequence, + contact: contactByte != 0, + standingLongjump: false); + DumpMovementTruthOutbound( + "MTS", seq, result, wirePos, wireCellId, contactByte); + session.SendGameAction(body); + controller.NoteMovementSent( + controller.SimTimeSeconds, + result.IsMouseLookMovementEvent); + return true; + } + private uint? GetSelectedOrClosestCombatTarget() { if (_selection.SelectedObjectId is { } selected && IsLiveHostileMonsterTarget(selected)) @@ -13669,6 +13765,7 @@ public sealed class GameWindow : IDisposable } else { + EndMouseLookAndRestoreCursor(); _cameraController?.ExitChaseMode(); _playerController = null; _chaseCamera = null; @@ -13830,13 +13927,11 @@ public sealed class GameWindow : IDisposable // the local player has no InterpolationManager. Own radius/height // are the real setup cylsphere values (R5-V3 — lazy reads because // this method caches the player Setup a few paragraphs further - // down). setHeading's - // `send` flag is currently UNCONSUMED (register TS-33): the AP - // heartbeat diffs position/plane/cell but not orientation (retail's - // Frame::is_equal compares the full frame), so a stationary heading - // snap doesn't reach the wire — masked against ACE, which rotates - // server-side on its own turn paths; the full-frame diff lands with - // the R7 outbound-cadence port. + // down). The setHeading `send` argument remains unused because retail + // CPhysicsObj::set_heading (0x00514160) also ignores that parameter. + // Stationary heading changes reach the wire through the shared + // ShouldSendPositionEvent full-Frame comparison (0x006B45E0), including + // orientation, rather than through a MoveTo-specific send side channel. var pcMoveTo = _playerController; // R5-V2: forward-declared so the player MoveToManager's target seams // route into the player's TargetManager (retail CPhysicsObj::set_target). @@ -13849,7 +13944,7 @@ public sealed class GameWindow : IDisposable { var playerMoveTo = new AcDream.Core.Physics.Motion.MoveToManager( pcMoveTo.Motion, - stopCompletely: () => pcMoveTo.Motion.StopCompletely(), + stopCompletely: () => pcMoveTo.StopCompletelyAtPhysicsObjectBoundary(), getPosition: () => new AcDream.Core.Physics.Position( pcMoveTo.CellId, pcMoveTo.Position, pcMoveTo.BodyOrientation), getHeading: () => AcDream.Core.Physics.Motion.MoveToMath.HeadingFromYaw(pcMoveTo.Yaw), @@ -13916,7 +14011,19 @@ public sealed class GameWindow : IDisposable // Retail CPhysicsObj::HandleUpdateTarget (0x00512bc0) fan head: // MovementManager::HandleUpdateTarget (@0x00512bf0 — the facade // relay); the host chains the PositionManager leg after it. - handleUpdateTarget: info => _playerController?.Movement.HandleUpdateTarget(info), + handleUpdateTarget: info => + { + if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled) + { + Console.WriteLine( + $"[autowalk-target] object=0x{info.ObjectId:X8} " + + $"status={info.Status} context={info.ContextId} " + + $"target=({info.TargetPosition.Frame.Origin.X:F2}," + + $"{info.TargetPosition.Frame.Origin.Y:F2}," + + $"{info.TargetPosition.Frame.Origin.Z:F2})"); + } + _playerController?.Movement.HandleUpdateTarget(info); + }, interruptCurrentMovement: () => _playerController?.Movement.CancelMoveTo( AcDream.Core.Physics.WeenieError.ActionCancelled)); _playerHost = playerHost; @@ -14137,6 +14244,62 @@ public sealed class GameWindow : IDisposable return true; } + /// + /// Both presentation stacks participate in the same gameplay-input gate. + /// A retained retail panel capturing the pointer must suspend instant + /// mouse-look just as an ImGui developer panel does. + /// + private bool IsUiCapturingMouse() + => (DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureMouse) + || (_uiHost?.Root.WantsMouse ?? false); + + private AcDream.App.Input.MovementInput CaptureMovementInput() + { + if (_inputDispatcher is null) + return default; + + bool walking = _inputDispatcher.IsActionHeld( + AcDream.UI.Abstractions.Input.InputAction.MovementWalkMode); + bool forward = _inputDispatcher.IsActionHeld( + AcDream.UI.Abstractions.Input.InputAction.MovementForward); + return new AcDream.App.Input.MovementInput( + Forward: forward || _autoRunActive, + Backward: _inputDispatcher.IsActionHeld( + AcDream.UI.Abstractions.Input.InputAction.MovementBackup), + StrafeLeft: _inputDispatcher.IsActionHeld( + AcDream.UI.Abstractions.Input.InputAction.MovementStrafeLeft), + StrafeRight: _inputDispatcher.IsActionHeld( + AcDream.UI.Abstractions.Input.InputAction.MovementStrafeRight), + TurnLeft: _inputDispatcher.IsActionHeld( + AcDream.UI.Abstractions.Input.InputAction.MovementTurnLeft), + TurnRight: _inputDispatcher.IsActionHeld( + AcDream.UI.Abstractions.Input.InputAction.MovementTurnRight), + Run: !walking, + Jump: _inputDispatcher.IsActionHeld( + AcDream.UI.Abstractions.Input.InputAction.MovementJump)); + } + + /// + /// Single idempotent teardown for every mouse-look exit path. Retail sends + /// the stopped movement state synchronously; doing so before nulling the + /// player controller preserves ACE's final authoritative heading. + /// + private void EndMouseLookAndRestoreCursor() + { + bool stateWasActive = _mouseLook?.Active == true; + _mouseLook?.Release(); + + if (_playerController is { } controller + && controller.EndMouseLook(CaptureMovementInput())) + { + TrySendPlayerMovementEvent( + controller.CaptureMovementResult(mouseLookEvent: false)); + } + + if (stateWasActive || _mouseLookSavedCursorMode.HasValue) + RestoreCursorAfterMouseLook(); + } + /// /// Phase K.2: hide the system cursor while MMB instant mouse-look is /// held. Saves the previous CursorMode so @@ -14407,6 +14570,8 @@ public sealed class GameWindow : IDisposable private void OnClosing() { + EndMouseLookAndRestoreCursor(); + // Retained controllers subscribe to session-owned object/selection/combat sources. // Tear their window-manager ownership down first, while every source and GL-backed // UI resource is still alive. UiHost disposes controllers before its renderer. @@ -14471,6 +14636,12 @@ public sealed class GameWindow : IDisposable _gl?.Dispose(); } + private void OnFocusChanged(bool focused) + { + if (!focused) + EndMouseLookAndRestoreCursor(); + } + public void Dispose() => _window?.Dispose(); // ── Phase I.6 — TurbineChat outbound helpers ────────────────── diff --git a/src/AcDream.App/UI/AutoWieldController.cs b/src/AcDream.App/UI/AutoWieldController.cs index fc267e70..eb725958 100644 --- a/src/AcDream.App/UI/AutoWieldController.cs +++ b/src/AcDream.App/UI/AutoWieldController.cs @@ -222,13 +222,13 @@ internal sealed class AutoWieldController : IDisposable return true; } - private void OnObjectMoved(ClientObject item, uint _, uint newContainerId) + private void OnObjectMoved(ClientObjectMove move) { if (_pendingSwitch is not { } pending || pending.BlockingItemId == 0 - || item.ObjectId != pending.BlockingItemId - || newContainerId != _playerGuid() - || item.CurrentlyEquippedLocation != EquipMask.None) + || move.ItemId != pending.BlockingItemId + || move.Current.ContainerId != _playerGuid() + || move.Current.EquipLocation != EquipMask.None) return; // Clear before re-entry: the second AutoWield pass must see the newly @@ -238,10 +238,10 @@ internal sealed class AutoWieldController : IDisposable TryWield(requested, pending.RequestedMask); } - private void OnWieldConfirmed(ClientObject item) + private void OnWieldConfirmed(uint itemId) { if (_pendingSwitch is { BlockingItemId: 0 } pending - && item.ObjectId == pending.RequestedItemId) + && itemId == pending.RequestedItemId) _pendingSwitch = null; } diff --git a/src/AcDream.App/UI/Layout/InventoryController.cs b/src/AcDream.App/UI/Layout/InventoryController.cs index ab928a86..20cc80f3 100644 --- a/src/AcDream.App/UI/Layout/InventoryController.cs +++ b/src/AcDream.App/UI/Layout/InventoryController.cs @@ -170,6 +170,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo // Rebuild on any change to the player's possessions. _objects.ObjectAdded += OnObjectChanged; _objects.ObjectMoved += OnObjectMoved; + _objects.ContainerContentsReplaced += OnContainerContentsReplaced; _objects.ObjectRemoved += OnObjectRemoved; _objects.ObjectUpdated += OnObjectChanged; _objects.Cleared += OnObjectsCleared; @@ -239,8 +240,21 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo } if (Concerns(o)) Populate(); } - private void OnObjectMoved(ClientObject o, uint from, uint to) - { if (Concerns(o) || from == _playerGuid() || to == _playerGuid()) Populate(); } + private void OnObjectMoved(ClientObjectMove move) + { + uint player = _playerGuid(); + if ((move.Item is { } item && Concerns(item)) + || move.Previous.ContainerId == player + || move.Current.ContainerId == player + || move.Previous.WielderId == player + || move.Current.WielderId == player) + Populate(); + } + private void OnContainerContentsReplaced(uint containerId) + { + if (containerId == EffectiveOpen() || containerId == _playerGuid()) + Populate(); + } private void OnInteractionStateChanged() => ApplyIndicators(); private void OnSelectionChanged(SelectionTransition _) => ApplyIndicators(); private void OnObjectsCleared() => Populate(); @@ -702,6 +716,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo _disposed = true; _objects.ObjectAdded -= OnObjectChanged; _objects.ObjectMoved -= OnObjectMoved; + _objects.ContainerContentsReplaced -= OnContainerContentsReplaced; _objects.ObjectRemoved -= OnObjectRemoved; _objects.ObjectUpdated -= OnObjectChanged; _objects.Cleared -= OnObjectsCleared; diff --git a/src/AcDream.App/UI/Layout/PaperdollController.cs b/src/AcDream.App/UI/Layout/PaperdollController.cs index 83f2daa1..c68b2ddc 100644 --- a/src/AcDream.App/UI/Layout/PaperdollController.cs +++ b/src/AcDream.App/UI/Layout/PaperdollController.cs @@ -211,8 +211,16 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo else if (Concerns(o)) Populate(); } - private void OnObjectMoved(ClientObject o, uint from, uint to) - { if (Concerns(o) || from == _playerGuid() || to == _playerGuid()) Populate(); } + private void OnObjectMoved(ClientObjectMove move) + { + uint player = _playerGuid(); + if ((move.Item is { } item && Concerns(item)) + || move.Previous.ContainerId == player + || move.Current.ContainerId == player + || move.Previous.WielderId == player + || move.Current.WielderId == player) + Populate(); + } private void OnSelectionChanged(SelectionTransition _) => ApplySelectionIndicators(); private void OnObjectsCleared() { @@ -224,8 +232,8 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo /// add/remove/repaint a doll slot. Player-scoped: an NPC's or vendor's wielded item (which also carries /// CurrentlyEquippedLocation from the wire) must NOT trigger a repaint. A player-equipped item always /// has WielderId==p (login, from CreateObject) or ContainerId==p (live/optimistic wield, set by - /// WieldItemOptimistic), so the equip-location need not be tested here; OnObjectMoved adds the from/to - /// player backstop for moves that transiently satisfy neither (e.g. unwield into a side bag). + /// WieldItemOptimistic), so the equip-location need not be tested here; OnObjectMoved carries the + /// complete old/new retail placement for transitions that satisfy neither after mutation. private bool Concerns(ClientObject o) { uint p = _playerGuid(); diff --git a/src/AcDream.App/UI/Layout/ToolbarController.cs b/src/AcDream.App/UI/Layout/ToolbarController.cs index 3f58c153..4b7969e2 100644 --- a/src/AcDream.App/UI/Layout/ToolbarController.cs +++ b/src/AcDream.App/UI/Layout/ToolbarController.cs @@ -216,13 +216,17 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont Populate(); } - private void OnRepositoryObjectMoved(ClientObject obj, uint oldContainer, uint newContainer) + private void OnRepositoryObjectMoved(ClientObjectMove move) { uint player = _playerGuid?.Invoke() ?? 0u; - if (obj.ObjectId == _ammoObjectId - || (player != 0 && (oldContainer == player || newContainer == player))) + if (move.ItemId == _ammoObjectId + || (player != 0 + && (move.Previous.ContainerId == player + || move.Current.ContainerId == player + || move.Previous.WielderId == player + || move.Current.WielderId == player))) RefreshAmmo(); - if (IsShortcutGuid(obj.ObjectId)) + if (IsShortcutGuid(move.ItemId)) Populate(); } @@ -238,7 +242,7 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont return obj.ObjectId == _ammoObjectId || (player != 0 && obj.ObjectId == player) || (player != 0 - && obj.ContainerId == player + && (obj.WielderId == player || obj.ContainerId == player) && (obj.CurrentlyEquippedLocation & (EquipMask.MissileWeapon | EquipMask.MissileAmmo)) != 0); } @@ -246,13 +250,9 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont private void RefreshAmmo() { uint player = _playerGuid?.Invoke() ?? 0u; - var placements = new List(); - if (player != 0) - { - foreach (uint objectId in _repo.GetContents(player)) - if (_repo.Get(objectId) is { } item) - placements.Add(item); - } + IReadOnlyList placements = player != 0 + ? _repo.GetEquippedBy(player) + : Array.Empty(); ToolbarAmmoPolicy.Result result = ToolbarAmmoPolicy.Resolve(placements); _ammoObjectId = result.ObjectId; diff --git a/src/AcDream.Core.Net/GameEventWiring.cs b/src/AcDream.Core.Net/GameEventWiring.cs index 08759825..8b93233d 100644 --- a/src/AcDream.Core.Net/GameEventWiring.cs +++ b/src/AcDream.Core.Net/GameEventWiring.cs @@ -305,33 +305,29 @@ public static class GameEventWiring var p = GameEvents.ParseWieldObject(e.Payload.Span); if (p is null) return; - uint wielderGuid = p.Value.WielderGuid != 0u - ? p.Value.WielderGuid - : (playerGuid?.Invoke() ?? 0u); - items.MoveItem( + uint wielderGuid = playerGuid?.Invoke() ?? 0u; + items.ApplyConfirmedServerWield( p.Value.ItemGuid, - newContainerId: wielderGuid, - newEquipLocation: (AcDream.Core.Items.EquipMask)p.Value.EquipLoc); - items.ConfirmWield(p.Value.ItemGuid); // Exact IR_WIELD completion + optimistic reconciliation. + wielderGuid, + (AcDream.Core.Items.EquipMask)p.Value.EquipLoc); }); dispatcher.Register(GameEventType.InventoryPutObjInContainer, e => { var p = GameEvents.ParsePutObjInContainer(e.Payload.Span); if (p is null) return; - items.MoveItem( + items.ApplyConfirmedServerMove( p.Value.ItemGuid, p.Value.ContainerGuid, + newWielderId: 0u, newSlot: (int)p.Value.Placement, containerTypeHint: p.Value.ContainerType); - items.ConfirmMove(p.Value.ItemGuid); // B-Drag: the server confirmed our optimistic move }); // ViewContents (0x0196) — the server's AUTHORITATIVE full contents list for a container you - // opened (Use 0x0036). Treat it as a full REPLACE: flush the container's prior membership and - // record the new entries in order (ContainerSlot = index — ACE writes entries - // OrderBy(PlacementPosition) with no slot field). The container-open UI (InventoryController) - // repaints the grid off the resulting ObjectAdded/Moved events. Retail: ClientUISystem::OnViewContents. + // opened (Use 0x0036). Treat it as a full projection-only REPLACE: update membership without + // inventing ContainerSlot values, then publish one ContainerContentsReplaced notification so + // every UI consumer repaints from the same snapshot. Retail: ClientUISystem::OnViewContents. dispatcher.Register(GameEventType.ViewContents, e => { var p = GameEvents.ParseViewContents(e.Payload.Span); @@ -352,8 +348,10 @@ public static class GameEventWiring var guid = GameEvents.ParsePutObjectIn3D(e.Payload.Span); if (guid is not null) { - items.MoveItem(guid.Value, newContainerId: 0u); - items.ConfirmMove(guid.Value); + items.ApplyConfirmedServerMove( + guid.Value, + newContainerId: 0u, + newWielderId: 0u); } }); @@ -596,20 +594,35 @@ public static class GameEventWiring entries[i] = new ContainerContentEntry( p.Value.Inventory[i].Guid, p.Value.Inventory[i].ContainerType); - items.ReplaceContents(ownerGuid, entries); + items.InitializeInventoryManifest(ownerGuid, entries); } else { foreach (var inv in p.Value.Inventory) items.RecordMembership(inv.Guid, containerTypeHint: inv.ContainerType); } - foreach (var eq in p.Value.Equipped) + if (ownerGuid != 0u) { - items.RecordMembership( - eq.Guid, - containerId: ownerGuid, - equip: (EquipMask)eq.EquipLocation, - priority: eq.Priority); + var equipment = new EquipmentManifestEntry[p.Value.Equipped.Count]; + for (int i = 0; i < equipment.Length; i++) + { + var eq = p.Value.Equipped[i]; + equipment[i] = new EquipmentManifestEntry( + eq.Guid, + (EquipMask)eq.EquipLocation, + eq.Priority); + } + items.InitializeEquipmentManifest(ownerGuid, equipment); + } + else + { + foreach (var eq in p.Value.Equipped) + { + items.RecordMembership( + eq.Guid, + equip: (EquipMask)eq.EquipLocation, + priority: eq.Priority); + } } // D.5.1 Task 4: forward shortcut bar entries to the caller so the diff --git a/src/AcDream.Core.Net/Messages/GameEvents.cs b/src/AcDream.Core.Net/Messages/GameEvents.cs index e92890c4..ab831cbe 100644 --- a/src/AcDream.Core.Net/Messages/GameEvents.cs +++ b/src/AcDream.Core.Net/Messages/GameEvents.cs @@ -350,19 +350,14 @@ public static class GameEvents /// 0x0023 WieldObject: server-driven equip. public readonly record struct WieldObject( uint ItemGuid, - uint EquipLoc, - uint WielderGuid); + uint EquipLoc); public static WieldObject? ParseWieldObject(ReadOnlySpan payload) { if (payload.Length < 8) return null; - uint wielderGuid = payload.Length >= 12 - ? BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(8)) - : 0u; return new WieldObject( BinaryPrimitives.ReadUInt32LittleEndian(payload), - BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)), - wielderGuid); + BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4))); } /// 0x0022 InventoryPutObjInContainer: server puts item into container slot. diff --git a/src/AcDream.Core/Chat/WeenieErrorMessages.cs b/src/AcDream.Core/Chat/WeenieErrorMessages.cs index 5178a091..c6bf7342 100644 --- a/src/AcDream.Core/Chat/WeenieErrorMessages.cs +++ b/src/AcDream.Core/Chat/WeenieErrorMessages.cs @@ -100,6 +100,7 @@ public static class WeenieErrorMessages [0x0498] = "You have moved too far!", // YouHaveMovedTooFar [0x0499] = "That is not a valid destination!", // TeleToInvalidPosition [0x0532] = "You must wait 30 days after purchasing a house before you may purchase another with any character on the same account.", + [0x0550] = "Out of Range!", // MissileOutOfRange // Fellowship [0x0528] = "The fellowship is locked; you cannot open locked fellowships.", diff --git a/src/AcDream.Core/Items/ClientObjectTable.cs b/src/AcDream.Core/Items/ClientObjectTable.cs index 8183a3e6..b24f3c9f 100644 --- a/src/AcDream.Core/Items/ClientObjectTable.cs +++ b/src/AcDream.Core/Items/ClientObjectTable.cs @@ -10,6 +10,12 @@ namespace AcDream.Core.Items; /// public readonly record struct ContainerContentEntry(uint Guid, uint ContainerType); +/// One login-time PlayerDescription equipment placement. +public readonly record struct EquipmentManifestEntry( + uint Guid, + EquipMask EquipLocation, + uint Priority); + /// /// Server rejection of an inventory move request. /// is true when the table restored a locally optimistic move; false for @@ -21,6 +27,35 @@ public readonly record struct MoveRequestFailure( uint WeenieError, bool RolledBack); +/// +/// One side of retail's ServerSaysMoveItem_s placement transition. +/// Container, slot, wielder, and equip location are captured before listeners +/// run so they never have to infer the old state from the already-mutated item. +/// +public readonly record struct ClientObjectPlacement( + uint ContainerId, + int ContainerSlot, + uint WielderId, + EquipMask EquipLocation) +{ + public static ClientObjectPlacement From(ClientObject item) => new( + item.ContainerId, + item.ContainerSlot, + item.WielderId, + item.CurrentlyEquippedLocation); +} + +/// +/// Retail-shaped inventory placement notice. Named retail +/// ACCWeenieObject::ServerSaysMoveItem @ 0x0058DBB0 snapshots and +/// publishes both complete placements after applying the new state. +/// +public readonly record struct ClientObjectMove( + uint ItemId, + ClientObject? Item, + ClientObjectPlacement Previous, + ClientObjectPlacement Current); + public enum ClientObjectRemovalReason { Ordinary, @@ -72,21 +107,29 @@ public sealed class ClientObjectTable private readonly ConcurrentDictionary _objects = new(); private readonly ConcurrentDictionary _containers = new(); private readonly Dictionary> _containerIndex = new(); + private readonly Dictionary> _equipmentIndex = new(); // B-Drag: pre-move snapshots for optimistic inventory moves. itemId → (container, slot, equip) BEFORE // the optimistic MoveItem; restored by RollbackMove on InventoryServerSaveFailed (0x00A0), // cleared by ConfirmMove on the InventoryPutObjInContainer (0x0022) echo. - private readonly Dictionary _pendingMoves = new(); + private readonly Dictionary _pendingMoves = new(); /// Fires when an object is first added to the session. public event Action? ObjectAdded; /// - /// Fires when an object's container / slot changes (moved between - /// packs, equipped, unequipped, dropped on ground). Old and new - /// container ids are 0 if origin or destination is "world" / "nowhere". + /// Fires after a complete placement notice (moved between packs, + /// equipped, unequipped, dropped on ground). + /// is null when retail publishes the notice before CreateObject resolves + /// the GUID; the old/new placement remains authoritative notice data. /// - public event Action? ObjectMoved; + public event Action? ObjectMoved; + + /// + /// Fires after retail ViewContents replaces a container's ordered member + /// lists. This is a list-projection change, not an item ownership move. + /// + public event Action? ContainerContentsReplaced; /// /// Fires after an optimistic inventory/equipment move is rejected and @@ -103,7 +146,7 @@ public sealed class ClientObjectTable /// rollback bookkeeping: retail clears IR_WIELD on the matching server /// response even when separate reconciliation state exists for the item. /// - public event Action? WieldConfirmed; + public event Action? WieldConfirmed; /// /// Fires for every InventoryServerSaveFailed response, including requests @@ -164,8 +207,12 @@ public sealed class ClientObjectTable public void AddOrUpdate(ClientObject item) { ArgumentNullException.ThrowIfNull(item); - bool existed = _objects.ContainsKey(item.ObjectId); + bool existed = _objects.TryGetValue(item.ObjectId, out ClientObject? prior); + ClientObjectPlacement previous = prior is null + ? default + : ClientObjectPlacement.From(prior); _objects[item.ObjectId] = item; + UpdateEquipmentIndex(item.ObjectId, previous, ClientObjectPlacement.From(item)); if (!existed) ObjectAdded?.Invoke(item); else ObjectUpdated?.Invoke(item); } @@ -180,25 +227,156 @@ public sealed class ClientObjectTable } /// - /// Handle a server-driven move — called from - /// InventoryPutObjInContainer (0x0022) and WieldObject (0x0023) - /// handlers. Updates ContainerId / ContainerSlot / CurrentlyEquippedLocation - /// and fires ObjectMoved. Does NOT touch WielderId — neither the wield nor the - /// unwield path manages it (a wielded item is modeled as contained-by-the-wielder), - /// so RollbackMove restores full pre-move state through this method alone. + /// Move the local projection of an item without changing authoritative + /// WielderId. Optimistic inventory requests use this path because retail + /// UIAttemptWield sends the request without rewriting public weenie fields. /// public bool MoveItem(uint itemId, uint newContainerId, int newSlot = -1, EquipMask newEquipLocation = EquipMask.None, uint? containerTypeHint = null) { if (!_objects.TryGetValue(itemId, out var item)) return false; + return ApplyPlacement( + item, + new ClientObjectPlacement( + newContainerId, + newSlot, + item.WielderId, + newEquipLocation), + containerTypeHint); + } - uint oldContainer = item.ContainerId; - item.ContainerId = newContainerId; - item.ContainerSlot = newSlot; - item.CurrentlyEquippedLocation = newEquipLocation; - if (containerTypeHint is { } hint) item.ContainerTypeHint = hint; - Reindex(item, oldContainer); - ObjectMoved?.Invoke(item, oldContainer, newContainerId); + /// + /// Apply an authoritative server move, including the item's wielder + /// ownership. Retail ACCWeenieObject::ServerSaysMoveItem @ + /// 0x0058DBB0 updates _containerID, _wielderID, and + /// _location in one callback. Keeping the old wielder after an + /// unwield makes DetermineUseResult @ 0x00588460 classify a bow in + /// the backpack as already wielded, so a later double click sends no + /// request. + /// + public bool ApplyServerMove( + uint itemId, + uint newContainerId, + uint newWielderId, + int newSlot = -1, + EquipMask newEquipLocation = EquipMask.None, + uint? containerTypeHint = null) + { + if (!_objects.TryGetValue(itemId, out var item)) return false; + return ApplyPlacement( + item, + new ClientObjectPlacement( + newContainerId, + newSlot, + newWielderId, + newEquipLocation), + containerTypeHint); + } + + private bool ApplyPlacement( + ClientObject item, + ClientObjectPlacement current, + uint? containerTypeHint = null) + { + ClientObjectPlacement previous = ClientObjectPlacement.From(item); + List? changedContainers = RemoveFromOtherContainerIndexes( + item.ObjectId, + current.ContainerId); + item.ContainerId = current.ContainerId; + item.ContainerSlot = current.ContainerSlot; + item.WielderId = current.WielderId; + item.CurrentlyEquippedLocation = current.EquipLocation; + if (containerTypeHint is { } hint) + item.ContainerTypeHint = hint; + Reindex(item, previous.ContainerId); + PublishPlacementChange(item, previous); + PublishContainerContentsChanges(changedContainers); + return true; + } + + private void PublishPlacementChange( + ClientObject item, + ClientObjectPlacement previous) + { + ClientObjectPlacement current = ClientObjectPlacement.From(item); + UpdateEquipmentIndex(item.ObjectId, previous, current); + ObjectMoved?.Invoke(new ClientObjectMove(item.ObjectId, item, previous, current)); + } + + /// + /// Applies a server-confirmed container/world move. The matching pending + /// request is reconciled before is published, + /// matching retail ServerSaysMoveItem: a reentrant listener may start + /// a new request without the old confirmation consuming it afterward. + /// + public bool ApplyConfirmedServerMove( + uint itemId, + uint newContainerId, + uint newWielderId, + int newSlot = -1, + EquipMask newEquipLocation = EquipMask.None, + uint? containerTypeHint = null) + { + ConfirmMove(itemId); + if (ApplyServerMove( + itemId, + newContainerId, + newWielderId, + newSlot, + newEquipLocation, + containerTypeHint)) + { + return true; + } + + ObjectMoved?.Invoke(new ClientObjectMove( + itemId, + Item: null, + Previous: default, + Current: new ClientObjectPlacement( + newContainerId, + newSlot, + newWielderId, + newEquipLocation))); + return false; + } + + /// + /// Applies retail WieldObject (0x0023). The packet has only item and + /// location; the local player is the wielder, while ContainerId is zero. + /// Pending reconciliation precedes notifications as in + /// ACCWeenieObject::ServerSaysMoveItem @ 0x0058DBB0. + /// + public bool ApplyConfirmedServerWield( + uint itemId, + uint wielderId, + EquipMask equipLocation) + { + ConfirmMove(itemId); + var placement = new ClientObjectPlacement( + ContainerId: 0u, + ContainerSlot: 0, + WielderId: wielderId, + EquipLocation: equipLocation); + if (!_objects.TryGetValue(itemId, out ClientObject? item)) + { + ObjectMoved?.Invoke(new ClientObjectMove( + itemId, + Item: null, + Previous: default, + Current: placement)); + WieldConfirmed?.Invoke(itemId); + return false; + } + + if (!ApplyPlacement( + item, + placement)) + { + return false; + } + + WieldConfirmed?.Invoke(itemId); return true; } @@ -209,10 +387,9 @@ public sealed class ClientObjectTable private void RecordPending(uint itemId, ClientObject item) { if (_pendingMoves.TryGetValue(itemId, out var p)) - _pendingMoves[itemId] = (p.container, p.slot, p.equip, p.outstanding + 1); + _pendingMoves[itemId] = (p.placement, p.outstanding + 1); else - _pendingMoves[itemId] = (item.ContainerId, item.ContainerSlot, - item.CurrentlyEquippedLocation, 1); + _pendingMoves[itemId] = (ClientObjectPlacement.From(item), 1); } /// @@ -234,9 +411,13 @@ public sealed class ClientObjectTable // containers' slots 0..N-1. A raw MoveItem sets ONLY this item's slot, which then collides with // the item already at that slot; the sort-by-slot tie reshuffles the whole grid on every repaint // (the "items change position when I move one" bug). Retail's ItemList_InsertItem shifts the list. - uint oldContainer = item.ContainerId; + ClientObjectPlacement previous = ClientObjectPlacement.From(item); + uint oldContainer = previous.ContainerId; item.ContainerId = newContainerId; item.CurrentlyEquippedLocation = EquipMask.None; + List? changedContainers = RemoveFromOtherContainerIndexes( + itemId, + newContainerId); if (oldContainer != 0 && oldContainer != newContainerId && _containerIndex.TryGetValue(oldContainer, out var srcList)) @@ -253,15 +434,16 @@ public sealed class ClientObjectTable } else item.ContainerSlot = newSlot; - ObjectMoved?.Invoke(item, oldContainer, newContainerId); + PublishPlacementChange(item, previous); + PublishContainerContentsChanges(changedContainers); return true; } /// Optimistic (instant) wield: snapshot the pre-wield (container, slot, equip), then set - /// ContainerId = wielderGuid and CurrentlyEquippedLocation = equipMask — EXACTLY what the server's - /// WieldObject 0x0023 confirm does via (acdream models a wielded item as - /// contained-by-the-wielder). Neither path writes WielderId, so the optimistic state equals the - /// confirmed state and via MoveItem fully restores pre-move state. Fires + /// ContainerId = wielderGuid and CurrentlyEquippedLocation = equipMask. WielderId remains unchanged + /// until receives the authoritative WieldObject, matching retail's + /// UIAttemptWield request/confirmation split. The confirmation converts this temporary projection to + /// ContainerId zero + authoritative WielderId. restores the local projection. Fires /// ObjectMoved for an immediate repaint. The caller sends GetAndWieldItem; ConfirmMove (on the 0x0023 /// echo) / RollbackMove (on 0x00A0) reconcile. public bool WieldItemOptimistic(uint itemId, uint wielderGuid, EquipMask equipMask) @@ -291,24 +473,10 @@ public sealed class ClientObjectTable } else { - _pendingMoves[itemId] = (p.container, p.slot, p.equip, p.outstanding - 1); + _pendingMoves[itemId] = (p.placement, p.outstanding - 1); } } - /// - /// Reconcile an authoritative WieldObject response and publish the exact - /// retail inventory-request completion boundary. The notification is not - /// conditional on the optimistic rollback counter reaching zero. - /// Retail: ACCWeenieObject::ServerSaysMoveItem @ 0x0058DBB0 clears the - /// matching prevRequestObjectID directly. - /// - public void ConfirmWield(uint itemId) - { - ConfirmMove(itemId); - if (_objects.TryGetValue(itemId, out ClientObject? item)) - WieldConfirmed?.Invoke(item); - } - /// The server rejected a move (InventoryServerSaveFailed 0x00A0) — restore the item to its /// pre-move (container, slot) and drop the snapshot entirely (the server's next snapshot reconciles /// any still-outstanding moves). False if nothing was pending. @@ -316,7 +484,13 @@ public sealed class ClientObjectTable { if (!_pendingMoves.TryGetValue(itemId, out var pre)) return false; _pendingMoves.Remove(itemId); - if (!MoveItem(itemId, pre.container, pre.slot, pre.equip)) + ClientObjectPlacement placement = pre.placement; + if (!ApplyServerMove( + itemId, + placement.ContainerId, + placement.WielderId, + placement.ContainerSlot, + placement.EquipLocation)) return false; MoveRolledBack?.Invoke(_objects[itemId]); return true; @@ -357,12 +531,18 @@ public sealed class ClientObjectTable 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); + List? changedContainers = RemoveFromOtherContainerIndexes( + itemId, + exceptContainerId: 0u); + UpdateEquipmentIndex( + itemId, + ClientObjectPlacement.From(item), + default); _pendingMoves.Remove(itemId); // a destroyed item must not leave a snapshot that mis-rolls-back a recycled guid if (notifyObjectRemoved) ObjectRemoved?.Invoke(item); ObjectRemovalClassified?.Invoke(new ClientObjectRemoval(item, reason, generation)); + PublishContainerContentsChanges(changedContainers); return true; } @@ -437,10 +617,13 @@ public sealed class ClientObjectTable public bool UpdateIntProperty(uint itemId, uint propertyId, int value) { if (!_objects.TryGetValue(itemId, out var item)) return false; + ClientObjectPlacement previous = ClientObjectPlacement.From(item); item.Properties.Ints[propertyId] = value; if (propertyId == UiEffectsPropertyId) item.Effects = (uint)value; if (propertyId == CurrentWieldedLocationPropertyId) item.CurrentlyEquippedLocation = (EquipMask)(uint)value; + if (propertyId == CurrentWieldedLocationPropertyId) + UpdateEquipmentIndex(itemId, previous, ClientObjectPlacement.From(item)); ObjectUpdated?.Invoke(item); return true; } @@ -489,6 +672,7 @@ public sealed class ClientObjectTable _objects[d.Guid] = obj; } uint oldContainer = obj.ContainerId; + ClientObjectPlacement previous = ClientObjectPlacement.From(obj); if (!string.IsNullOrEmpty(d.Name)) obj.Name = d.Name!; if (!string.IsNullOrEmpty(d.PluralName)) obj.PluralName = d.PluralName!; @@ -524,17 +708,23 @@ public sealed class ClientObjectTable if (d.MaxStructure is { } ms) obj.MaxStructure = ms; if (d.Workmanship is { } wm) obj.Workmanship = wm; + List? changedContainers = RemoveFromOtherContainerIndexes( + obj.ObjectId, + obj.ContainerId); Reindex(obj, oldContainer); + UpdateEquipmentIndex(obj.ObjectId, previous, ClientObjectPlacement.From(obj)); if (!existed) ObjectAdded?.Invoke(obj); else ObjectUpdated?.Invoke(obj); + PublishContainerContentsChanges(changedContainers); return obj; } /// /// PlayerDescription manifest: record that this guid is the player's /// (in inventory or equipped at ), creating an - /// empty entry if CreateObject hasn't arrived yet. Equipped entries may - /// specify the player as so login and live - /// WieldObject updates share the same contained-by-wielder projection. + /// empty entry if CreateObject hasn't arrived yet. PlayerDescription may + /// use the player as for its login-time + /// placement projection; live WieldObject confirmation later establishes + /// canonical ContainerId zero plus WielderId player. /// Never touches icon/name/type/effects — that data comes from CreateObject. /// public ClientObject RecordMembership(uint guid, uint containerId = 0, @@ -548,6 +738,7 @@ public sealed class ClientObjectTable _objects[guid] = obj; } uint oldContainer = obj.ContainerId; + ClientObjectPlacement previous = ClientObjectPlacement.From(obj); if (containerId != 0) obj.ContainerId = containerId; obj.CurrentlyEquippedLocation = equip; @@ -555,11 +746,130 @@ public sealed class ClientObjectTable obj.ContainerSlot = -1; if (containerTypeHint is { } hint) obj.ContainerTypeHint = hint; if (priority is { } p) obj.Priority = p; + List? changedContainers = RemoveFromOtherContainerIndexes( + obj.ObjectId, + obj.ContainerId); Reindex(obj, oldContainer); + UpdateEquipmentIndex(obj.ObjectId, previous, ClientObjectPlacement.From(obj)); if (!existed) ObjectAdded?.Invoke(obj); else ObjectUpdated?.Invoke(obj); + PublishContainerContentsChanges(changedContainers); return obj; } + /// + /// Initialize PlayerDescription equipment in canonical retail form while + /// preserving the packed InventoryPlacement list order exactly. Retail + /// UpdateObjectInventory @ 0x00559550 assigns the complete list; it + /// does not replay live head-insertion for each login entry. + /// + public void InitializeEquipmentManifest( + uint wielderId, + IReadOnlyList entries) + { + ArgumentNullException.ThrowIfNull(entries); + if (wielderId == 0u) return; + + var ordered = new List(entries.Count); + var notifications = new List<(ClientObject Item, bool Existed)>(entries.Count); + var changedContainers = new List(); + var incoming = new HashSet(); + for (int i = 0; i < entries.Count; i++) + incoming.Add(entries[i].Guid); + + // UpdateObjectInventory assigns a complete authoritative list. Clear + // canonical placement on members omitted by a replay/replacement + // manifest before installing the new order; replacing only the index + // leaves ghost equipment visible to AutoWield and burden queries. + if (_equipmentIndex.TryGetValue(wielderId, out List? priorEquipment)) + { + foreach (uint priorId in priorEquipment.ToArray()) + { + if (incoming.Contains(priorId) + || !_objects.TryGetValue(priorId, out ClientObject? prior) + || prior is null + || (prior.WielderId != wielderId + && !(prior.ContainerId == wielderId + && prior.CurrentlyEquippedLocation != EquipMask.None))) + continue; + + prior.ContainerId = 0u; + prior.ContainerSlot = -1; + prior.WielderId = 0u; + prior.CurrentlyEquippedLocation = EquipMask.None; + prior.Priority = 0u; + if (RemoveFromOtherContainerIndexes(priorId, exceptContainerId: 0u) + is { } removedFrom) + { + changedContainers.AddRange(removedFrom); + } + notifications.Add((prior, true)); + } + } + + for (int i = 0; i < entries.Count; i++) + { + EquipmentManifestEntry entry = entries[i]; + ordered.Add(entry.Guid); + bool existed = _objects.TryGetValue(entry.Guid, out ClientObject? obj); + if (!existed || obj is null) + { + obj = new ClientObject { ObjectId = entry.Guid }; + _objects[entry.Guid] = obj; + } + + ClientObjectPlacement previous = ClientObjectPlacement.From(obj); + obj.ContainerId = 0u; + obj.ContainerSlot = -1; + obj.WielderId = wielderId; + obj.CurrentlyEquippedLocation = entry.EquipLocation; + obj.Priority = entry.Priority; + if (RemoveFromOtherContainerIndexes(entry.Guid, exceptContainerId: 0u) + is { } changed) + { + changedContainers.AddRange(changed); + } + Reindex(obj, previous.ContainerId); + RemoveEquipmentIndexMembership(entry.Guid); + notifications.Add((obj, existed)); + } + + _equipmentIndex[wielderId] = ordered; + foreach ((ClientObject item, bool existed) in notifications) + { + if (!existed) ObjectAdded?.Invoke(item); + else ObjectUpdated?.Invoke(item); + } + PublishContainerContentsChanges(changedContainers); + } + + private List? RemoveFromOtherContainerIndexes( + uint itemId, + uint exceptContainerId) + { + List? changed = null; + foreach ((uint containerId, List members) in _containerIndex) + { + if (containerId == exceptContainerId || !members.Remove(itemId)) + continue; + (changed ??= new List()).Add(containerId); + } + + return changed; + } + + private void PublishContainerContentsChanges(List? changed) + { + if (changed is null || changed.Count == 0) + return; + var published = new HashSet(); + foreach (uint containerId in changed) + { + if (!published.Add(containerId)) + continue; + ContainerContentsReplaced?.Invoke(containerId); + } + } + private void Reindex(ClientObject obj, uint oldContainerId) { if (oldContainerId != obj.ContainerId && oldContainerId != 0 @@ -582,6 +892,62 @@ public sealed class ClientObjectTable } } + private static uint EquipmentOwner(ClientObjectPlacement placement) + { + if (placement.EquipLocation == EquipMask.None) + return 0u; + return placement.WielderId != 0u + ? placement.WielderId + : placement.ContainerId; + } + + private void RemoveEquipmentIndexMembership(uint itemId) + { + List? emptyOwners = null; + foreach ((uint ownerId, List placements) in _equipmentIndex) + { + placements.Remove(itemId); + if (placements.Count == 0) + (emptyOwners ??= new List()).Add(ownerId); + } + + if (emptyOwners is null) + return; + foreach (uint ownerId in emptyOwners) + _equipmentIndex.Remove(ownerId); + } + + /// + /// Maintain retail's _invPlacement order. Named retail + /// SetPlayerWieldLocation @ 0x0058D8C0 inserts a placement at the + /// head; GetObjectAtLocation @ 0x0058CE00 scans head-to-tail. + /// + private void UpdateEquipmentIndex( + uint itemId, + ClientObjectPlacement previous, + ClientObjectPlacement current) + { + if (previous == current) + return; + + uint previousOwner = EquipmentOwner(previous); + if (previousOwner != 0u + && _equipmentIndex.TryGetValue(previousOwner, out List? oldList)) + { + oldList.Remove(itemId); + if (oldList.Count == 0) + _equipmentIndex.Remove(previousOwner); + } + + uint currentOwner = EquipmentOwner(current); + if (currentOwner == 0u) + return; + if (!_equipmentIndex.TryGetValue(currentOwner, out List? currentList)) + _equipmentIndex[currentOwner] = currentList = new List(); + currentList.Remove(itemId); + currentList.Insert(0, itemId); + } + private int SlotOf(uint guid) => _objects.TryGetValue(guid, out var o) && o.ContainerSlot >= 0 ? o.ContainerSlot @@ -596,13 +962,25 @@ public sealed class ClientObjectTable ? l.ToArray() : System.Array.Empty(); /// - /// Replace a container's entire membership with (in order) — the - /// authoritative full snapshot the server sends in ViewContents (0x0196) when you open a - /// container. Members no longer present are detached (ContainerId → 0); new members are - /// recorded with ContainerSlot = list index. ACE writes ViewContents entries - /// OrderBy(PlacementPosition) with NO explicit slot field (GameEventViewContents.cs), so the - /// list order IS the slot order. Fires ObjectAdded/ObjectMoved so bound UI repaints. - /// Retail consumer: ClientUISystem::OnViewContents. + /// Snapshot of items equipped by . Confirmed + /// retail equipment has ContainerId zero and WielderId set; the + /// ContainerId arm keeps the optimistic pre-confirm projection visible. + /// + public IReadOnlyList GetEquippedBy(uint wielderId) => + _equipmentIndex.TryGetValue(wielderId, out List? placements) + ? placements + .Select(Get) + .Where(item => item is not null + && item.CurrentlyEquippedLocation != EquipMask.None + && (item.WielderId == wielderId || item.ContainerId == wielderId)) + .Cast() + .ToArray() + : Array.Empty(); + + /// + /// Replace only a viewed container's ordered membership projection. + /// Retail ACCObjectMaint::ViewObjectContents @ 0x00558A70 rebuilds + /// the container lists without mutating child ownership or equip state. /// public void ReplaceContents(uint containerId, IReadOnlyList guids) { @@ -616,51 +994,110 @@ public sealed class ClientObjectTable } /// - /// Replace a container's entire membership with in retail order. - /// PlayerDescription and ViewContents both carry ContentProfile entries; the order is the - /// dense list order retail feeds to ACCObjectMaint::ViewObjectContents, while ContainerType - /// chooses the loose-item list versus the side-pack/container list. + /// Replace only a viewed container's ordered membership projection. + /// ContentProfile order is retained in while the + /// child weenie's canonical placement remains owned by move/wield events. /// public void ReplaceContents(uint containerId, IReadOnlyList entries) { ArgumentNullException.ThrowIfNull(entries); if (containerId == 0) return; - var keep = new HashSet(); - foreach (var entry in entries) - keep.Add(entry.Guid); - - // Detach prior members no longer present (they left the container server-side). GetContents - // returns a snapshot, so mutating the index inside the loop is safe. - foreach (var old in GetContents(containerId)) - { - if (keep.Contains(old)) continue; - if (!_objects.TryGetValue(old, out var o) || o.ContainerId != containerId) continue; - o.ContainerId = 0; - o.CurrentlyEquippedLocation = EquipMask.None; - Reindex(o, containerId); - ObjectMoved?.Invoke(o, containerId, 0); - } - - // Record new members in order; ContainerSlot = index reconstructs the server PlacementPosition. + var ordered = new List(entries.Count); + var added = new List(); for (int i = 0; i < entries.Count; i++) { var entry = entries[i]; + ordered.Add(entry.Guid); bool existed = _objects.TryGetValue(entry.Guid, out var obj); if (!existed || obj is null) { obj = new ClientObject { ObjectId = entry.Guid }; _objects[entry.Guid] = obj; } - uint oldContainer = obj.ContainerId; - obj.ContainerId = containerId; + obj.ContainerTypeHint = entry.ContainerType; + if (!existed) added.Add(obj); + } + + _containerIndex[containerId] = ordered; + foreach (ClientObject item in added) + ObjectAdded?.Invoke(item); + ContainerContentsReplaced?.Invoke(containerId); + } + + /// + /// Initialize canonical inventory ownership from PlayerDescription. This + /// login manifest is object-state initialization, not a move transition. + /// + public void InitializeInventoryManifest( + uint ownerId, + IReadOnlyList entries) + { + ArgumentNullException.ThrowIfNull(entries); + if (ownerId == 0u) return; + + var ordered = new List(entries.Count); + var changedContainers = new List(); + var notifications = new List<(ClientObject Item, bool Existed)>(entries.Count); + var incoming = new HashSet(); + for (int i = 0; i < entries.Count; i++) + incoming.Add(entries[i].Guid); + + if (_containerIndex.TryGetValue(ownerId, out List? priorInventory)) + { + foreach (uint priorId in priorInventory.ToArray()) + { + if (incoming.Contains(priorId) + || !_objects.TryGetValue(priorId, out ClientObject? prior) + || prior is null + || prior.ContainerId != ownerId) + continue; + + ClientObjectPlacement previous = ClientObjectPlacement.From(prior); + prior.ContainerId = 0u; + prior.ContainerSlot = -1; + prior.WielderId = 0u; + prior.CurrentlyEquippedLocation = EquipMask.None; + prior.Priority = 0u; + UpdateEquipmentIndex( + prior.ObjectId, + previous, + ClientObjectPlacement.From(prior)); + notifications.Add((prior, true)); + } + } + + for (int i = 0; i < entries.Count; i++) + { + ContainerContentEntry entry = entries[i]; + ordered.Add(entry.Guid); + bool existed = _objects.TryGetValue(entry.Guid, out ClientObject? obj); + if (!existed || obj is null) + { + obj = new ClientObject { ObjectId = entry.Guid }; + _objects[entry.Guid] = obj; + } + + ClientObjectPlacement previous = ClientObjectPlacement.From(obj); + obj.ContainerId = ownerId; obj.ContainerSlot = i; + obj.WielderId = 0u; obj.CurrentlyEquippedLocation = EquipMask.None; obj.ContainerTypeHint = entry.ContainerType; - Reindex(obj, oldContainer); - if (!existed) ObjectAdded?.Invoke(obj); - else ObjectMoved?.Invoke(obj, oldContainer, containerId); + if (RemoveFromOtherContainerIndexes(entry.Guid, ownerId) is { } changed) + changedContainers.AddRange(changed); + UpdateEquipmentIndex(entry.Guid, previous, ClientObjectPlacement.From(obj)); + notifications.Add((obj, existed)); } + + _containerIndex[ownerId] = ordered; + foreach ((ClientObject item, bool existed) in notifications) + { + if (!existed) ObjectAdded?.Invoke(item); + else ObjectUpdated?.Invoke(item); + } + ContainerContentsReplaced?.Invoke(ownerId); + PublishContainerContentsChanges(changedContainers); } /// @@ -680,10 +1117,11 @@ public sealed class ClientObjectTable return total; } - // NOTE: WielderId is populated from wire data only (CreateObject → Ingest). An + // NOTE: WielderId is populated from authoritative wire data only (CreateObject → + // Ingest or ApplyServerMove). An // optimistically-wielded item (WieldItemOptimistic, before the server confirm) has // WielderId == 0 and is detected via the ContainerId walk below (ContainerId == wielder). - // A future "is this wielded by me?" check must therefore use ContainerId, NOT WielderId alone. + // Equipment queries must therefore accept authoritative WielderId OR the optimistic ContainerId. private bool IsCarriedBy(ClientObject o, uint ownerGuid) { if (o.WielderId == ownerGuid) return true; @@ -705,6 +1143,7 @@ public sealed class ClientObjectTable _objects.Clear(); _containers.Clear(); _containerIndex.Clear(); + _equipmentIndex.Clear(); _pendingMoves.Clear(); // B-Drag: drop in-flight optimistic snapshots (a recycled guid must not mis-rollback) Cleared?.Invoke(); } diff --git a/src/AcDream.Core/Physics/MotionInterpreter.cs b/src/AcDream.Core/Physics/MotionInterpreter.cs index d5e1dd73..9f52557f 100644 --- a/src/AcDream.Core/Physics/MotionInterpreter.cs +++ b/src/AcDream.Core/Physics/MotionInterpreter.cs @@ -952,9 +952,17 @@ public sealed class MotionInterpreter : IMotionDoneSink /// public WeenieError PerformMovement(MovementStruct mvs) { - var p = new MovementParameters + // Retail CMotionInterp::PerformMovement (0x00528E80) forwards the + // MovementStruct's params pointer unchanged for types 1-4. Preserve + // that exact parameter object when the CPhysicsObj boundary supplied + // one: rebuilding only speed/modify bits loses CancelMoveTo, + // SetHoldKey, Autonomous, and the remaining retail bitfield. The + // legacy scalar fields remain as a compatibility path for older + // callers that construct a MovementStruct without Params. + var p = mvs.Params ?? new MovementParameters { Speed = mvs.Speed, + Autonomous = mvs.Autonomous, ModifyInterpretedState = mvs.ModifyInterpretedState, ModifyRawState = mvs.ModifyRawState, }; diff --git a/src/AcDream.UI.Abstractions/Input/MouseLookState.cs b/src/AcDream.UI.Abstractions/Input/MouseLookState.cs index 09c13854..a2e66947 100644 --- a/src/AcDream.UI.Abstractions/Input/MouseLookState.cs +++ b/src/AcDream.UI.Abstractions/Input/MouseLookState.cs @@ -5,11 +5,10 @@ namespace AcDream.UI.Abstractions.Input; /// /// Phase K.2 — state machine for MMB-hold "instant mouse-look" mode /// (retail's CameraInstantMouseLook). While active, mouse-X -/// delta drives the character's heading AND the chase camera yaw -/// together (combined drive — the camera "instantly" follows the -/// character because mouse-X moves the character, and the chase -/// camera always tracks the character). Mouse-Y is left to the -/// caller — typically pitches the chase camera only. +/// delta produces retail's signed horizontal camera adjustment. The host +/// routes that adjustment through the player's TurnLeft/TurnRight movement +/// commands; the chase camera follows the resulting character heading. +/// Mouse-Y is left to the caller — typically pitches the chase camera only. /// /// /// The class owns three transitions: @@ -27,17 +26,28 @@ namespace AcDream.UI.Abstractions.Input; /// /// is the per-frame mouse-move hook: when /// active, scales by sensitivity and feeds the -/// caller-supplied yaw mutator. The mutator is the only side-channel — -/// the class doesn't know about PlayerMovementController or -/// ChaseCamera. +/// caller-supplied movement-adjustment sink. The sink is the only +/// side-channel — the class doesn't know about +/// PlayerMovementController or ChaseCamera. /// /// public sealed class MouseLookState { - private readonly Action _applyYawDelta; + private readonly Action _applyHorizontalAdjustment; + private int _horizontalExtent; + private float _queuedDeltaX; + private float _queuedDeltaY; + private float _lastMouseInputTime; + + /// + /// Retail CInputManager_WIN32::GetInput @ 0x006897A0 does not + /// synthesize an idle sample until the cursor has been still for strictly + /// more than 0.2 seconds. + /// + public const float IdleZeroDelaySeconds = 0.2f; /// True while MMB is held AND ImGui isn't capturing the - /// mouse. Mouse-X deltas drive yaw only when this is true. + /// mouse. Mouse-X deltas drive turn adjustment only when this is true. public bool Active { get; private set; } /// Cursor X at the moment activated. @@ -48,17 +58,19 @@ public sealed class MouseLookState public float CapturedCursorY { get; private set; } /// - /// Per-radian yaw multiplier applied to mouse-X delta. The same + /// Per-pixel adjustment multiplier applied to mouse-X delta. The same /// chase-camera sensitivity factor used elsewhere in GameWindow is /// folded in by the caller; this class only owns the constant - /// scale that converts pixels to radians. Default 0.004 matches - /// the K.1b RMB-orbit factor. + /// scale used before CameraSet's ×2 speed and 1.5 cap. Retail + /// MouseLookHandler uses exactly 0.0666666701 after the configured + /// input sensitivity. /// - public float SensitivityRadiansPerPixel { get; set; } = 0.004f; + public float SensitivityPerPixel { get; set; } = 0.0666666701f; - public MouseLookState(Action applyYawDelta) + public MouseLookState(Action applyHorizontalAdjustment) { - _applyYawDelta = applyYawDelta ?? throw new ArgumentNullException(nameof(applyYawDelta)); + _applyHorizontalAdjustment = applyHorizontalAdjustment + ?? throw new ArgumentNullException(nameof(applyHorizontalAdjustment)); } /// @@ -71,13 +83,21 @@ public sealed class MouseLookState /// Mirror of /// ImGui.GetIO().WantCaptureMouse. When true, the press is /// ignored so a hover-over-panel MMB doesn't grab the cursor. - public void Press(float cursorX, float cursorY, bool wantCaptureMouse) + public void Press( + float cursorX, + float cursorY, + bool wantCaptureMouse, + float nowSeconds = 0f) { if (wantCaptureMouse) return; if (Active) return; Active = true; CapturedCursorX = cursorX; CapturedCursorY = cursorY; + _horizontalExtent = 0; + _queuedDeltaX = 0f; + _queuedDeltaY = 0f; + _lastMouseInputTime = nowSeconds; } /// MMB release transition. Always deactivates if active. @@ -85,6 +105,9 @@ public sealed class MouseLookState { if (!Active) return; Active = false; + _horizontalExtent = 0; + _queuedDeltaX = 0f; + _queuedDeltaY = 0f; } /// @@ -94,24 +117,83 @@ public sealed class MouseLookState /// public void OnWantCaptureMouseChanged(bool wantCaptureMouse) { - if (Active && wantCaptureMouse) Active = false; + if (Active && wantCaptureMouse) + { + Active = false; + _horizontalExtent = 0; + _queuedDeltaX = 0f; + _queuedDeltaY = 0f; + } + } + + /// + /// Accumulates event-driven Silk mouse motion for the next update sample. + /// Retail's extent gate is clocked by samples, not by the number of native + /// mouse messages delivered during one frame. + /// + public void QueueDelta(float dx, float dy = 0f) + { + if (Active && float.IsFinite(dx) && float.IsFinite(dy)) + { + _queuedDeltaX += dx; + _queuedDeltaY += dy; + } + } + + /// + /// Consumes the frame's accumulated raw device motion once. When there is + /// no net movement, returns a synthetic zero only after the strict retail + /// 0.2-second idle boundary. The caller filters this one aggregate and then + /// passes its horizontal component to . + /// + public bool TryTakeRawSample( + float nowSeconds, + out float dx, + out float dy) + { + dx = 0f; + dy = 0f; + if (!Active) + { + _queuedDeltaX = 0f; + _queuedDeltaY = 0f; + return false; + } + + dx = _queuedDeltaX; + dy = _queuedDeltaY; + _queuedDeltaX = 0f; + _queuedDeltaY = 0f; + + if (dx != 0f || dy != 0f) + { + _lastMouseInputTime = nowSeconds; + return true; + } + + return nowSeconds > _lastMouseInputTime + IdleZeroDelaySeconds; } /// /// Apply a per-frame mouse-X delta. When active, scales by - /// times the + /// times the /// caller-supplied (typically - /// the chase-camera sens) and feeds it to the yaw mutator. Sign + /// the chase-camera sens) and feeds it to the movement sink. Sign /// matches retail: dragging the mouse RIGHT yaws the character to - /// the right (positive yaw delta). + /// the right (a negative adjustment in acdream's turn convention). /// /// Pixels of horizontal mouse motion since the /// last frame. /// Multiplier (e.g. chase-camera - /// sensitivity) applied on top of the radians-per-pixel scale. + /// sensitivity) applied on top of the per-pixel scale. public void ApplyDelta(float dx, float extraSensitivity) { if (!Active) return; + ProcessDelta(dx, extraSensitivity); + } + + private void ProcessDelta(float dx, float extraSensitivity) + { // Sign: dragging the mouse RIGHT (dx > 0) should yaw the // character to the right. With the acdream Yaw convention // (where Yaw 0 = +X, increasing to +Y), positive yaw is @@ -119,6 +201,20 @@ public sealed class MouseLookState // yaw goes DOWN (more clockwise). The dispatcher convention // for chase YawOffset is `YawOffset -= dx * factor`; we keep // the same sign here so character + camera rotate identically. - _applyYawDelta(-dx * SensitivityRadiansPerPixel * extraSensitivity); + float adjustment = -dx * SensitivityPerPixel * extraSensitivity; + if (adjustment == 0f) + { + _horizontalExtent = 0; + return; + } + + // MouseLookHandler @ 0x00458D80 increments mouselook_x_extent and + // calls Rotate only after five consecutive non-zero samples. A zero + // sample resets the extent. + _horizontalExtent++; + if (_horizontalExtent <= 5) + return; + + _applyHorizontalAdjustment(adjustment); } } diff --git a/tests/AcDream.App.Tests/Combat/CombatAttackControllerTests.cs b/tests/AcDream.App.Tests/Combat/CombatAttackControllerTests.cs index 0a798048..8e02c90c 100644 --- a/tests/AcDream.App.Tests/Combat/CombatAttackControllerTests.cs +++ b/tests/AcDream.App.Tests/Combat/CombatAttackControllerTests.cs @@ -179,6 +179,42 @@ public sealed class CombatAttackControllerTests Assert.Equal(0, cancels); } + [Fact] + public void StartAttackRequest_PreparesPlayerMovementBeforePowerBuild() + { + var events = new List(); + var combat = new CombatState(); + CombatAttackController? controller = null; + controller = new CombatAttackController( + combat, + canStartAttack: () => true, + sendAttack: (_, _) => + { + events.Add("send"); + return true; + }, + prepareAttackRequest: () => + { + Assert.True(controller!.AttackRequestInProgress); + Assert.Equal(1f, controller.RequestedAttackPower); + events.Add("prepare"); + }); + using (controller) + { + combat.SetCombatMode(CombatMode.Missile); + controller.SetDesiredPower(0f); + + controller.PressAttack(AttackHeight.Medium); + + Assert.Equal(new[] { "prepare" }, events); + Assert.True(controller.AttackRequestInProgress); + Assert.True(controller.BuildInProgress); + + controller.ReleaseAttack(); + Assert.Equal(new[] { "prepare", "send" }, events); + } + } + private static CombatAttackController Create( CombatState combat, Func now, diff --git a/tests/AcDream.App.Tests/Input/PlayerMouseLookMovementTests.cs b/tests/AcDream.App.Tests/Input/PlayerMouseLookMovementTests.cs new file mode 100644 index 00000000..eea533eb --- /dev/null +++ b/tests/AcDream.App.Tests/Input/PlayerMouseLookMovementTests.cs @@ -0,0 +1,452 @@ +using System.Numerics; +using AcDream.App.Input; +using AcDream.App.Rendering; +using AcDream.Core.Physics; +using AcDream.Core.Physics.Motion; +using Xunit; + +namespace AcDream.App.Tests.Input; + +public sealed class PlayerMouseLookMovementTests +{ + [Fact] + public void KeyboardMotionEdges_StillRequestImmediateMovementEvents() + { + var controller = CreateController(); + + MovementResult pressed = controller.Update( + 1f / 60f, + new MovementInput(Forward: true)); + MovementResult held = controller.Update( + 1f / 60f, + new MovementInput(Forward: true)); + MovementResult released = controller.Update( + 1f / 60f, + new MovementInput()); + + Assert.True(pressed.ShouldSendMovementEvent); + Assert.False(held.ShouldSendMovementEvent); + Assert.True(released.ShouldSendMovementEvent); + } + + [Fact] + public void MouseAdjustment_UsesTurnMotionInsteadOfDirectYawMutation() + { + var controller = CreateController(); + Assert.True(controller.BeginMouseLook(new MovementInput())); + controller.NoteMovementSent(controller.SimTimeSeconds, mouseLookEvent: false); + + float yawBefore = controller.Yaw; + controller.SubmitMouseTurnAdjustment(-0.4f, new MovementInput()); + MovementResult turning = controller.Update(1f / 60f, new MovementInput()); + + Assert.Equal(MotionCommand.TurnRight, turning.TurnCommand); + Assert.Equal(0.8f, turning.TurnSpeed); + Assert.True(controller.Yaw < yawBefore); + Assert.False(turning.ShouldSendMovementEvent); + Assert.True(turning.TurnUsesRunHold); + + MovementResult held = controller.Update(1f / 60f, new MovementInput()); + Assert.Equal(MotionCommand.TurnRight, held.TurnCommand); + + controller.StopMouseDrift(new MovementInput()); + MovementResult stopped = controller.Update(1f / 60f, new MovementInput()); + Assert.Null(stopped.TurnCommand); + Assert.False(stopped.ShouldSendMovementEvent); + } + + [Fact] + public void MouseLook_ReportsStartCadenceAndStopWithoutPerSampleFlooding() + { + var controller = CreateController(); + Assert.True(controller.BeginMouseLook(new MovementInput())); + MovementResult begin = controller.CaptureMovementResult(mouseLookEvent: false); + Assert.True(begin.ShouldSendMovementEvent); + Assert.False(begin.IsMouseLookMovementEvent); + controller.NoteMovementSent(controller.SimTimeSeconds, mouseLookEvent: false); + + controller.SubmitMouseTurnAdjustment(0.5f, new MovementInput()); + MovementResult sample = controller.Update(0.01f, new MovementInput()); + Assert.Equal(MotionCommand.TurnLeft, sample.TurnCommand); + Assert.False(sample.ShouldSendMovementEvent); + + controller.Update( + PlayerMovementController.MouseMovementEventInterval, + new MovementInput()); + controller.SubmitMouseTurnAdjustment(0.5f, new MovementInput()); + MovementResult cadence = controller.Update( + 0.001f, + new MovementInput()); + Assert.True(cadence.ShouldSendMovementEvent); + Assert.True(cadence.IsMouseLookMovementEvent); + controller.NoteMovementSent( + controller.SimTimeSeconds, + mouseLookEvent: true); + + Assert.True(controller.EndMouseLook(new MovementInput())); + MovementResult ended = controller.CaptureMovementResult(mouseLookEvent: false); + Assert.True(ended.ShouldSendMovementEvent); + Assert.False(ended.IsMouseLookMovementEvent); + Assert.Null(ended.TurnCommand); + } + + [Fact] + public void MouseMovementEvent_IsRetriedUntilSuccessfulSendIsAcknowledged() + { + var controller = CreateController(); + controller.BeginMouseLook(new MovementInput()); + controller.Update( + PlayerMovementController.MouseMovementEventInterval + 0.001f, + new MovementInput()); + controller.SubmitMouseTurnAdjustment(0.4f, new MovementInput()); + + MovementResult first = controller.Update(0.01f, new MovementInput(Run: true)); + MovementResult retry = controller.Update(0.01f, new MovementInput(Run: true)); + + Assert.True(first.ShouldSendMovementEvent); + Assert.True(first.IsMouseLookMovementEvent); + Assert.True(retry.ShouldSendMovementEvent); + + controller.NoteMovementSent( + controller.SimTimeSeconds, + mouseLookEvent: true); + MovementResult acknowledged = controller.Update( + 0.01f, + new MovementInput(Run: true)); + Assert.False(acknowledged.ShouldSendMovementEvent); + } + + [Fact] + public void MouseLook_RapidPressReleaseCanBeCapturedSynchronously() + { + var controller = CreateController(); + var input = new MovementInput(Run: true); + + Assert.True(controller.BeginMouseLook(input)); + MovementResult press = controller.CaptureMovementResult(mouseLookEvent: false); + Assert.True(controller.EndMouseLook(input)); + MovementResult release = controller.CaptureMovementResult(mouseLookEvent: false); + + Assert.True(press.ShouldSendMovementEvent); + Assert.False(press.IsMouseLookMovementEvent); + Assert.True(release.ShouldSendMovementEvent); + Assert.False(release.IsMouseLookMovementEvent); + } + + [Fact] + public void MouseLook_CadenceUsesStrictHalfSecondBoundary() + { + var controller = CreateController(); + controller.BeginMouseLook(new MovementInput()); + controller.NoteMovementSent(nowSeconds: 0f, mouseLookEvent: true); + + controller.SubmitMouseTurnAdjustment(0.4f, new MovementInput()); + MovementResult boundary = controller.Update( + PlayerMovementController.MouseMovementEventInterval, + new MovementInput()); + Assert.False(boundary.ShouldSendMovementEvent); + + controller.SubmitMouseTurnAdjustment(0.4f, new MovementInput()); + MovementResult justOver = controller.Update( + 0.0001f, + new MovementInput()); + Assert.True(justOver.ShouldSendMovementEvent); + Assert.True(justOver.IsMouseLookMovementEvent); + } + + [Fact] + public void MouseTurn_UsesPerAxisRunHoldEvenInWalkMode() + { + var controller = CreateController(); + controller.BeginMouseLook(new MovementInput()); + controller.NoteMovementSent(nowSeconds: 0f, mouseLookEvent: true); + controller.SubmitMouseTurnAdjustment(-0.4f, new MovementInput()); + + MovementResult turning = controller.Update( + 0.01f, + new MovementInput(Run: false)); + + Assert.Equal(MotionCommand.TurnRight, turning.TurnCommand); + Assert.False(turning.IsRunning); + Assert.True(turning.TurnUsesRunHold); + Assert.Equal(HoldKey.Run, controller.Motion.RawState.TurnHoldKey); + } + + [Fact] + public void MouseLookRelease_SynchronouslyRestoresHeldKeyboardTurn() + { + var controller = CreateController(); + controller.Update( + 0.01f, + new MovementInput(TurnLeft: true, Run: true)); + var held = new MovementInput(TurnLeft: true, Run: true); + controller.BeginMouseLook(held); + controller.NoteMovementSent(controller.SimTimeSeconds, mouseLookEvent: false); + controller.SubmitMouseTurnAdjustment(-0.4f, held); + controller.Update(0.01f, held); + + controller.EndMouseLook(held); + MovementResult release = controller.CaptureMovementResult(mouseLookEvent: false); + + Assert.Equal(MotionCommand.TurnLeft, release.TurnCommand); + Assert.Equal(1f, release.TurnSpeed); + Assert.False(release.TurnUsesRunHold); + } + + [Fact] + public void MouseLook_RemapMovesHeldKeyboardTurnToSidestepAndBack() + { + var controller = CreateController(); + var held = new MovementInput(TurnLeft: true, Run: true); + controller.Update(0.01f, held); + + Assert.True(controller.BeginMouseLook(held)); + MovementResult press = controller.CaptureMovementResult(mouseLookEvent: false); + + Assert.Null(press.TurnCommand); + Assert.Equal(MotionCommand.SideStepLeft, press.SidestepCommand); + Assert.Equal(HoldKey.Run, controller.Motion.RawState.SidestepHoldKey); + + Assert.True(controller.EndMouseLook(held)); + MovementResult release = controller.CaptureMovementResult(mouseLookEvent: false); + + Assert.Null(release.SidestepCommand); + Assert.Equal(MotionCommand.TurnLeft, release.TurnCommand); + Assert.False(release.TurnUsesRunHold); + } + + [Fact] + public void MouseLookEntry_PreservesHeldForwardAndRunAfterTakingControl() + { + var controller = CreateController(); + var held = new MovementInput(Forward: true, Run: true); + + Assert.True(controller.BeginMouseLook(held)); + MovementResult press = controller.CaptureMovementResult(mouseLookEvent: false); + + Assert.Equal(MotionCommand.WalkForward, press.ForwardCommand); + Assert.True(press.IsRunning); + Assert.Equal(HoldKey.Run, controller.Motion.RawState.CurrentHoldKey); + } + + [Fact] + public void MouseLookEntry_AlreadyAutonomousSameFrameForwardEdgeIsNotLost() + { + var controller = CreateController(); + controller.Update(0.01f, new MovementInput(Forward: true)); + controller.Update(0.01f, new MovementInput()); + var justPressed = new MovementInput(Forward: true, Run: true); + + controller.BeginMouseLook(justPressed); + MovementResult press = controller.CaptureMovementResult(mouseLookEvent: false); + + Assert.Equal(MotionCommand.WalkForward, press.ForwardCommand); + Assert.True(press.IsRunning); + Assert.Equal(HoldKey.Run, controller.Motion.RawState.CurrentHoldKey); + } + + [Fact] + public void MouseLookEntry_AlreadyAutonomousSameFrameRunChangeIsNotLost() + { + var controller = CreateController(); + controller.Update(0.01f, new MovementInput(Forward: true)); + controller.Update(0.01f, new MovementInput()); + + controller.BeginMouseLook(new MovementInput(Run: true)); + MovementResult press = controller.CaptureMovementResult(mouseLookEvent: false); + RawMotionState wire = GameWindow.BuildOutboundRawMotionState(press); + + Assert.Equal(HoldKey.Run, controller.Motion.RawState.CurrentHoldKey); + Assert.True(press.IsRunning); + Assert.Equal(HoldKey.Run, wire.CurrentHoldKey); + Assert.Equal(HoldKey.Invalid, wire.ForwardHoldKey); + Assert.Equal(HoldKey.Invalid, wire.SidestepHoldKey); + Assert.Equal(HoldKey.Invalid, wire.TurnHoldKey); + } + + [Fact] + public void MouseLookEntry_ServerControlledHeldTurnNeverQueuesWrongAxis() + { + var controller = CreateController(); + + controller.BeginMouseLook(new MovementInput(TurnLeft: true)); + + Assert.DoesNotContain( + controller.Motion.PendingMotions, + node => node.Motion == MotionCommand.TurnLeft); + Assert.Equal(MotionCommand.SideStepLeft, + controller.Motion.RawState.SidestepCommand); + } + + [Fact] + public void MouseLookExit_ServerControlledHeldTurnNeverQueuesRemappedAxis() + { + var controller = CreateController(); + var held = new MovementInput(TurnLeft: true); + controller.BeginMouseLook(held); + DrainPendingMotions(controller); + controller.SetLastMoveWasAutonomous(false); + + controller.EndMouseLook(held); + + Assert.DoesNotContain( + controller.Motion.PendingMotions, + node => node.Motion == MotionCommand.SideStepLeft); + Assert.Equal(MotionCommand.TurnLeft, controller.Motion.RawState.TurnCommand); + } + + [Fact] + public void MouseLook_RemapPressedAfterEntryAppearsInOutboundResult() + { + var controller = CreateController(); + controller.BeginMouseLook(new MovementInput()); + + MovementResult result = controller.Update( + 0.01f, + new MovementInput(TurnLeft: true)); + + Assert.Equal(MotionCommand.SideStepLeft, result.SidestepCommand); + Assert.Null(result.TurnCommand); + Assert.True(result.SidestepUsesRunHold); + Assert.False(result.IsRunning); + Assert.True(result.ShouldSendMovementEvent); + } + + [Fact] + public void MouseLook_WalkModeRemapCarriesPerAxisRunHold() + { + var controller = CreateController(); + var held = new MovementInput(TurnRight: true, Run: false); + + controller.BeginMouseLook(held); + MovementResult press = controller.CaptureMovementResult(mouseLookEvent: false); + RawMotionState wire = GameWindow.BuildOutboundRawMotionState(press); + + Assert.Equal(MotionCommand.SideStepRight, press.SidestepCommand); + Assert.True(press.SidestepUsesRunHold); + Assert.False(press.IsRunning); + Assert.Equal(HoldKey.Run, controller.Motion.RawState.SidestepHoldKey); + Assert.Equal(HoldKey.None, wire.CurrentHoldKey); + Assert.Equal(HoldKey.Run, wire.SidestepHoldKey); + } + + [Fact] + public void MouseLook_MouseHandlerRetakesServerControlWithoutDroppingHeldInput() + { + var controller = CreateController(); + var held = new MovementInput(Forward: true, TurnLeft: true, Run: true); + controller.BeginMouseLook(held); + controller.SetLastMoveWasAutonomous(false); + + controller.SubmitMouseTurnAdjustment(-0.4f, held); + MovementResult result = controller.Update(0.01f, held); + + Assert.Equal(MotionCommand.WalkForward, result.ForwardCommand); + Assert.Equal(MotionCommand.SideStepLeft, result.SidestepCommand); + Assert.Equal(MotionCommand.TurnRight, result.TurnCommand); + Assert.True(result.IsRunning); + Assert.True(result.SidestepUsesRunHold); + Assert.True(result.TurnUsesRunHold); + } + + [Fact] + public void MouseLookRelease_UsesCurrentInputSnapshotInsteadOfPreviousFrame() + { + var controller = CreateController(); + var held = new MovementInput(Forward: true, TurnLeft: true, Run: true); + controller.Update(0.01f, held); + controller.BeginMouseLook(held); + + Assert.True(controller.EndMouseLook(new MovementInput())); + MovementResult release = controller.CaptureMovementResult(mouseLookEvent: false); + + Assert.Null(release.ForwardCommand); + Assert.Null(release.SidestepCommand); + Assert.Null(release.TurnCommand); + Assert.False(release.IsRunning); + } + + [Fact] + public void MouseLook_CannotBeginInPortalSpace() + { + var controller = CreateController(); + controller.State = PlayerState.PortalSpace; + + Assert.False(controller.BeginMouseLook(new MovementInput(Forward: true))); + + MovementResult result = controller.CaptureMovementResult(mouseLookEvent: false); + Assert.Null(result.ForwardCommand); + Assert.Null(result.SidestepCommand); + Assert.Null(result.TurnCommand); + } + + [Fact] + public void AttackPreparation_StopsMouseTurnAndPublishesFinalHeading() + { + var controller = CreateController(); + controller.BeginMouseLook(new MovementInput()); + controller.Update(0.01f, new MovementInput()); + controller.SubmitMouseTurnAdjustment(-0.5f, new MovementInput()); + MovementResult turning = controller.Update(1f / 60f, new MovementInput()); + Assert.Equal(MotionCommand.TurnRight, turning.TurnCommand); + + Assert.True(controller.PrepareForAttackRequest()); + float finalYaw = controller.Yaw; + MovementResult stopped = controller.CaptureMovementResult(mouseLookEvent: false); + + Assert.Equal(finalYaw, controller.Yaw); + Assert.Null(stopped.TurnCommand); + Assert.True(stopped.ShouldSendMovementEvent); + } + + [Fact] + public void AttackPreparation_DoesNotInterruptServerControlledMovement() + { + var controller = CreateController(); + controller.SetLastMoveWasAutonomous(false); + + Assert.False(controller.PrepareForAttackRequest()); + MovementResult result = controller.Update( + 0.01f, + new MovementInput(Run: true)); + + Assert.False(result.ShouldSendMovementEvent); + } + + [Fact] + public void AttackPreparation_DefaultServerControlledStateIsNoOp() + { + var controller = CreateController(); + + Assert.False(controller.PrepareForAttackRequest()); + } + + private static PlayerMovementController CreateController() + { + var engine = new PhysicsEngine(); + var heights = new byte[81]; + Array.Fill(heights, (byte)50); + var heightTable = new float[256]; + for (int i = 0; i < heightTable.Length; i++) + heightTable[i] = i; + + engine.AddLandblock( + 0xA9B4FFFFu, + new TerrainSurface(heights, heightTable), + Array.Empty(), + Array.Empty(), + worldOffsetX: 0f, + worldOffsetY: 0f); + + var controller = new PlayerMovementController(engine); + controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001u); + return controller; + } + + private static void DrainPendingMotions(PlayerMovementController controller) + { + int remaining = controller.Motion.PendingMotions.Count(); + for (int i = 0; i < remaining; i++) + controller.Motion.MotionDone(0u, success: true); + } +} diff --git a/tests/AcDream.App.Tests/Physics/PlayerOutboundPositionTests.cs b/tests/AcDream.App.Tests/Physics/PlayerOutboundPositionTests.cs new file mode 100644 index 00000000..b33873b3 --- /dev/null +++ b/tests/AcDream.App.Tests/Physics/PlayerOutboundPositionTests.cs @@ -0,0 +1,196 @@ +using System.Numerics; +using AcDream.App.Input; +using AcDream.Core.Physics; +using Xunit; + +namespace AcDream.App.Tests.Physics; + +public sealed class PlayerOutboundPositionTests +{ + private static readonly Plane GroundPlane = + new(Vector3.UnitZ, 0f); + + [Fact] + public void CrossLandblockWorldFrame_SendsCarriedCellLocalPosition() + { + var controller = new PlayerMovementController(new PhysicsEngine()); + + // The render origin remains A9B1, so a point 22.56 m into A9B2 is + // represented at world Y=214.56. The wire frame must remain the + // carried A9B2-local 22.56, never the render-space 214.56. + controller.SetPosition( + new Vector3(116.07f, 214.56f, 83.76f), + 0xA9B20021u, + new Vector3(116.07f, 22.56f, 83.76f)); + + Assert.True(controller.TryGetOutboundPosition( + Quaternion.Identity, + out uint cellId, + out Vector3 localOrigin)); + Assert.Equal(0xA9B20021u, cellId); + Assert.Equal(new Vector3(116.07f, 22.56f, 83.76f), localOrigin); + Assert.NotEqual(controller.Position, localOrigin); + } + + [Fact] + public void UnplacedBody_DoesNotProduceWirePosition() + { + var controller = new PlayerMovementController(new PhysicsEngine()); + + Assert.False(controller.TryGetOutboundPosition( + Quaternion.Identity, + out _, + out _)); + } + + [Fact] + public void PositionEvent_FirstFrameIsDue() + { + var controller = new PlayerMovementController(new PhysicsEngine()); + var current = PositionAt(Quaternion.Identity); + + Assert.True(controller.ShouldSendPositionEvent(current, GroundPlane, 0f)); + } + + [Fact] + public void PositionEvent_StationaryHeadingChangeIsDueAfterRetailInterval() + { + var controller = new PlayerMovementController(new PhysicsEngine()); + var sent = PositionAt(Quaternion.Identity); + var turned = PositionAt(Quaternion.CreateFromAxisAngle( + Vector3.UnitZ, + MathF.PI / 2f)); + controller.NotePositionSent(sent, GroundPlane, nowSeconds: 0f); + + Assert.False(controller.ShouldSendPositionEvent( + turned, + GroundPlane, + PlayerMovementController.HeartbeatInterval)); + Assert.True(controller.ShouldSendPositionEvent( + turned, + GroundPlane, + PlayerMovementController.HeartbeatInterval + 0.01f)); + } + + [Fact] + public void PositionEvent_UnchangedCompleteFrameIsNotIdleHeartbeat() + { + var controller = new PlayerMovementController(new PhysicsEngine()); + var sent = PositionAt(Quaternion.Identity); + controller.NotePositionSent(sent, GroundPlane, nowSeconds: 0f); + + Assert.False(controller.ShouldSendPositionEvent( + sent, + GroundPlane, + PlayerMovementController.HeartbeatInterval + 0.01f)); + } + + [Fact] + public void MovementEvent_StampsOnlyTime_NotTheRememberedFrame() + { + var controller = new PlayerMovementController(new PhysicsEngine()); + var sent = PositionAt(Quaternion.Identity); + var turned = PositionAt(Quaternion.CreateFromAxisAngle( + Vector3.UnitZ, + MathF.PI / 2f)); + controller.NotePositionSent(sent, GroundPlane, nowSeconds: 0f); + controller.NoteMovementSent(nowSeconds: 0.9f); + + Assert.False(controller.ShouldSendPositionEvent( + turned, + GroundPlane, + nowSeconds: 1.1f)); + Assert.True(controller.ShouldSendPositionEvent( + turned, + GroundPlane, + nowSeconds: 1.91f)); + } + + [Fact] + public void PositionEvent_CellOrContactPlaneChangeIsDueInsideInterval() + { + var controller = new PlayerMovementController(new PhysicsEngine()); + var sent = PositionAt(Quaternion.Identity); + controller.NotePositionSent(sent, GroundPlane, nowSeconds: 0f); + + var otherCell = sent with { ObjCellId = 0xA9B20022u }; + var otherPlane = new Plane(Vector3.Normalize(new Vector3(0.01f, 0f, 1f)), 0f); + + Assert.True(controller.ShouldSendPositionEvent(otherCell, GroundPlane, 0.1f)); + Assert.True(controller.ShouldSendPositionEvent(sent, otherPlane, 0.1f)); + } + + [Fact] + public void PositionEvent_QuaternionSignIsStoredFrameDataLikeRetail() + { + var controller = new PlayerMovementController(new PhysicsEngine()); + var sent = PositionAt(Quaternion.Identity); + var oppositeStoredQuaternion = PositionAt(new Quaternion(0f, 0f, 0f, -1f)); + controller.NotePositionSent(sent, GroundPlane, nowSeconds: 0f); + + Assert.True(controller.ShouldSendPositionEvent( + oppositeStoredQuaternion, + GroundPlane, + PlayerMovementController.HeartbeatInterval + 0.01f)); + } + + [Fact] + public void PositionEvent_RetailEpsilonUsesDifferentOriginAndQuaternionBoundaries() + { + const float epsilon = 0.000199999995f; + var controller = new PlayerMovementController(new PhysicsEngine()); + var sent = new Position(0xA9B20021u, Vector3.Zero, Quaternion.Identity); + controller.NotePositionSent(sent, GroundPlane, nowSeconds: 0f); + + var exactOriginBoundary = sent with + { + Frame = sent.Frame with { Origin = new Vector3(epsilon, 0f, 0f) }, + }; + var exactQuaternionBoundary = sent with + { + Frame = sent.Frame with + { + Orientation = new Quaternion(epsilon, 0f, 0f, 1f), + }, + }; + float elapsed = PlayerMovementController.HeartbeatInterval + 0.01f; + + Assert.False(controller.ShouldSendPositionEvent( + exactOriginBoundary, + GroundPlane, + elapsed)); + Assert.True(controller.ShouldSendPositionEvent( + exactQuaternionBoundary, + GroundPlane, + elapsed)); + } + + [Fact] + public void PositionEvent_RetailPlaneDistanceBoundaryIsStrict() + { + const float epsilon = 0.000199999995f; + var controller = new PlayerMovementController(new PhysicsEngine()); + var sent = PositionAt(Quaternion.Identity); + controller.NotePositionSent(sent, GroundPlane, nowSeconds: 0f); + + var exactNormalBoundary = new Plane( + new Vector3(epsilon, 0f, 1f), + 0f); + var exactDistanceBoundary = new Plane(Vector3.UnitZ, epsilon); + + Assert.False(controller.ShouldSendPositionEvent( + sent, + exactNormalBoundary, + nowSeconds: 0.1f)); + Assert.True(controller.ShouldSendPositionEvent( + sent, + exactDistanceBoundary, + nowSeconds: 0.1f)); + } + + private static Position PositionAt(Quaternion orientation) + => new( + 0xA9B20021u, + new Vector3(116.07f, 22.56f, 83.76f), + orientation); +} diff --git a/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs b/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs index ebaf9b35..b212e789 100644 --- a/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs @@ -468,6 +468,37 @@ public sealed class ItemInteractionControllerTests Assert.Equal(Player, h.Objects.Get(bow)!.ContainerId); } + [Fact] + public void PreviouslyWieldedBow_AfterAuthoritativeUnwield_DoubleClickWieldsAgain() + { + var h = new Harness(); + const uint bow = 0x50000B03u; + h.Objects.AddOrUpdate(new ClientObject + { + ObjectId = bow, + Name = "Bow", + Type = ItemType.MissileWeapon, + CombatUse = 1, + ValidLocations = EquipMask.MissileWeapon, + ContainerId = Player, + WielderId = Player, + CurrentlyEquippedLocation = EquipMask.MissileWeapon, + }); + + // Retail ServerSaysMoveItem clears _wielderID when this bow leaves + // the paper doll. This is the login-existing-bow regression: leaving + // the old player id here makes DetermineUseResult say "already wielded." + Assert.True(h.Objects.ApplyServerMove( + bow, + Pack, + newWielderId: 0u, + newSlot: 0)); + + Assert.True(h.Controller.ActivateItem(bow)); + + Assert.Equal(new[] { (bow, (uint)EquipMask.MissileWeapon) }, h.Wields); + } + [Fact] public void WeaponReplacement_doesNotOverlapRequestsWhileAwaitingServer() { @@ -534,10 +565,12 @@ public sealed class ItemInteractionControllerTests Assert.True(h.Objects.WieldItemOptimistic( bow, Player, EquipMask.MissileWeapon)); - // Authoritative WieldObject + ConfirmWield completes AutoWield even + // Authoritative WieldObject completes AutoWield even // though the object table still has another rollback record. - h.Objects.MoveItem(bow, Player, -1, EquipMask.MissileWeapon); - h.Objects.ConfirmWield(bow); + Assert.True(h.Objects.ApplyConfirmedServerWield( + bow, + Player, + EquipMask.MissileWeapon)); h.Now += 200; Assert.True(h.Controller.ActivateItem(sword)); Assert.Equal( diff --git a/tests/AcDream.App.Tests/UI/Layout/PaperdollControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/PaperdollControllerTests.cs index d17f1f8d..f45ddae2 100644 --- a/tests/AcDream.App.Tests/UI/Layout/PaperdollControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/PaperdollControllerTests.cs @@ -440,6 +440,29 @@ public class PaperdollControllerTests Assert.Equal(0xF01u, lists[HeadSlot].Cell.ItemId); // the head slot repainted with the helm } + [Fact] + public void Confirmed_unwield_to_side_bag_clears_slot_from_old_wielder_state() + { + var (layout, lists) = BuildLayout(); + var objects = new ClientObjectTable(); + const uint helm = 0xF03u, sideBag = 0x50000020u; + Bind(layout, objects); + objects.AddOrUpdate(new ClientObject { ObjectId = helm }); + Assert.True(objects.ApplyServerMove( + helm, + newContainerId: 0u, + newWielderId: Player, + newEquipLocation: EquipMask.HeadWear)); + Assert.Equal(helm, lists[HeadSlot].Cell.ItemId); + + Assert.True(objects.ApplyServerMove( + helm, + newContainerId: sideBag, + newWielderId: 0u)); + + Assert.Equal(0u, lists[HeadSlot].Cell.ItemId); + } + [Fact] public void Live_npc_equip_does_not_appear_on_the_doll() // player-scoped: a remote creature's wielded item never leaks { diff --git a/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs index 089e2c63..b0521cb4 100644 --- a/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs @@ -477,9 +477,17 @@ public class ToolbarControllerTests repo.AddOrUpdate(new ClientObject { ObjectId = player, Type = ItemType.Creature }); repo.AddOrUpdate(new ClientObject { ObjectId = pack, Type = ItemType.Container }); repo.AddOrUpdate(new ClientObject { ObjectId = bow, StackSize = 1, StackSizeMax = 1 }); - repo.MoveItem(bow, player, newEquipLocation: EquipMask.MissileWeapon); + repo.ApplyServerMove( + bow, + newContainerId: 0u, + newWielderId: player, + newEquipLocation: EquipMask.MissileWeapon); repo.AddOrUpdate(new ClientObject { ObjectId = arrows, StackSize = 42, StackSizeMax = 100 }); - repo.MoveItem(arrows, player, newEquipLocation: EquipMask.MissileAmmo); + repo.ApplyServerMove( + arrows, + newContainerId: 0u, + newWielderId: player, + newEquipLocation: EquipMask.MissileAmmo); ToolbarController.Bind( layout, @@ -492,7 +500,7 @@ public class ToolbarControllerTests Assert.Equal("42", ammoButton.Label); - Assert.True(repo.MoveItem(arrows, pack)); + Assert.True(repo.ApplyServerMove(arrows, pack, newWielderId: 0u)); Assert.Null(ammoButton.Label); } diff --git a/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs b/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs index 205f16a4..25b39dcc 100644 --- a/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs +++ b/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs @@ -104,13 +104,13 @@ public sealed class GameEventWiringTests [Fact] public void WireAll_WieldObject_RoutesToClientObjectTable() { - var (d, items, _, _, _) = MakeAll(); + const uint player = 0x2000u; + var (d, items, _, _, _) = MakeAll(() => player); items.AddOrUpdate(new ClientObject { ObjectId = 0x1000, WeenieClassId = 1 }); - byte[] payload = new byte[12]; + byte[] payload = new byte[8]; BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x1000); BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), (uint)EquipMask.MeleeWeapon); - BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(8), 0x2000); var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.WieldObject, payload)); d.Dispatch(env!.Value); @@ -118,7 +118,8 @@ public sealed class GameEventWiringTests var item = items.Get(0x1000); Assert.NotNull(item); Assert.Equal(EquipMask.MeleeWeapon, item!.CurrentlyEquippedLocation); - Assert.Equal(0x2000u, item.ContainerId); + Assert.Equal(0u, item.ContainerId); + Assert.Equal(player, item.WielderId); } [Fact] @@ -130,7 +131,7 @@ public sealed class GameEventWiringTests items.MoveItem(0x1500, 0x9999u, newSlot: 0); // start in a pack items.WieldItemOptimistic(0x1500, player, EquipMask.MeleeWeapon); // optimistic wield → snapshot pending var wieldConfirmed = new List(); - items.WieldConfirmed += item => wieldConfirmed.Add(item.ObjectId); + items.WieldConfirmed += itemId => wieldConfirmed.Add(itemId); byte[] payload = new byte[8]; BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x1500); @@ -139,7 +140,8 @@ public sealed class GameEventWiringTests d.Dispatch(env!.Value); // server confirms the wield Assert.False(items.RollbackMove(0x1500)); // pending snapshot was cleared by ConfirmMove - Assert.Equal(player, items.Get(0x1500)!.ContainerId); + Assert.Equal(0u, items.Get(0x1500)!.ContainerId); + Assert.Equal(player, items.Get(0x1500)!.WielderId); Assert.Equal(new[] { 0x1500u }, wieldConfirmed); } @@ -567,6 +569,7 @@ public sealed class GameEventWiringTests const uint playerGuid = 0x50000001u; const uint packItemGuid = 0x50000A01u; const uint crossbowGuid = 0x50000B01u; + const uint ammoGuid = 0x50000B02u; var dispatcher = new GameEventDispatcher(); var items = new ClientObjectTable(); GameEventWiring.WireAll( @@ -591,10 +594,13 @@ public sealed class GameEventWiringTests w.Write(0u); // spellbook_filters w.Write(1u); // inventory count w.Write(packItemGuid); w.Write(0u); - w.Write(1u); // equipped count + w.Write(2u); // equipped count w.Write(crossbowGuid); w.Write((uint)EquipMask.MissileWeapon); w.Write(7u); // layering priority + w.Write(ammoGuid); + w.Write((uint)EquipMask.MissileAmmo); + w.Write(8u); var envelope = GameEventEnvelope.TryParse( WrapEnvelope(GameEventType.PlayerDescription, sb.ToArray())); @@ -603,17 +609,19 @@ public sealed class GameEventWiringTests ClientObject crossbow = items.Get(crossbowGuid)!; crossbow.Type = ItemType.MissileWeapon; crossbow.CombatUse = 2; - var orderedPlayerContents = items.GetContents(playerGuid) - .Select(guid => items.Get(guid)!) - .ToList(); + IReadOnlyList orderedEquipment = items.GetEquippedBy(playerGuid); - Assert.Equal(new[] { packItemGuid, crossbowGuid }, items.GetContents(playerGuid)); - Assert.Equal(playerGuid, crossbow.ContainerId); + Assert.Equal(new[] { packItemGuid }, items.GetContents(playerGuid)); + Assert.Equal( + new[] { crossbowGuid, ammoGuid }, + orderedEquipment.Select(item => item.ObjectId)); + Assert.Equal(0u, crossbow.ContainerId); + Assert.Equal(playerGuid, crossbow.WielderId); Assert.Equal(EquipMask.MissileWeapon, crossbow.CurrentlyEquippedLocation); Assert.Equal(7u, crossbow.Priority); Assert.Equal( CombatMode.Missile, - CombatInputPlanner.GetDefaultCombatMode(orderedPlayerContents)); + CombatInputPlanner.GetDefaultCombatMode(orderedEquipment)); } [Fact] @@ -879,8 +887,8 @@ public sealed class GameEventWiringTests var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.ViewContents, payload)); d.Dispatch(env!.Value); - Assert.Equal(0x500000C9u, items.Get(0x50000A01u)!.ContainerId); - Assert.Equal(0x500000C9u, items.Get(0x50000A02u)!.ContainerId); + Assert.Equal(0u, items.Get(0x50000A01u)!.ContainerId); + Assert.Equal(0u, items.Get(0x50000A02u)!.ContainerId); Assert.Equal(new[] { 0x50000A01u, 0x50000A02u }, items.GetContents(0x500000C9u)); Assert.Equal(0u, items.Get(0x50000A01u)!.ContainerTypeHint); Assert.Equal(1u, items.Get(0x50000A02u)!.ContainerTypeHint); @@ -959,6 +967,37 @@ public sealed class GameEventWiringTests Assert.Equal(1u, items.Get(0x50000B02u)!.ContainerTypeHint); } + [Fact] + public void WireAll_InventoryPutObjInContainer_ClearsPriorWielder() + { + const uint player = 0x50000001u; + const uint pack = 0x500000C1u; + const uint bow = 0x50000B03u; + var (d, items, _, _, _) = MakeAll(() => player); + items.AddOrUpdate(new ClientObject + { + ObjectId = bow, + ContainerId = player, + WielderId = player, + CurrentlyEquippedLocation = EquipMask.MissileWeapon, + }); + + byte[] payload = new byte[16]; + BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(0), bow); + BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), pack); + BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(8), 0u); + BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(12), 1u); + var env = GameEventEnvelope.TryParse(WrapEnvelope( + GameEventType.InventoryPutObjInContainer, + payload)); + + d.Dispatch(env!.Value); + + Assert.Equal(pack, items.Get(bow)!.ContainerId); + Assert.Equal(0u, items.Get(bow)!.WielderId); + Assert.Equal(EquipMask.None, items.Get(bow)!.CurrentlyEquippedLocation); + } + [Fact] public void WireAll_InventoryPutObjectIn3D_UnparentsFromContainer() { diff --git a/tests/AcDream.Core.Net.Tests/Messages/AppraiseTests.cs b/tests/AcDream.Core.Net.Tests/Messages/AppraiseTests.cs index 9977a71f..75b8483a 100644 --- a/tests/AcDream.Core.Net.Tests/Messages/AppraiseTests.cs +++ b/tests/AcDream.Core.Net.Tests/Messages/AppraiseTests.cs @@ -41,16 +41,14 @@ public sealed class AppraiseTests [Fact] public void ParseWieldObject_RoundTrip() { - byte[] payload = new byte[12]; + byte[] payload = new byte[8]; BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x1000u); BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), 0x00400000u); // MeleeWeapon slot - BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(8), 0x2000u); // wielder var parsed = GameEvents.ParseWieldObject(payload); Assert.NotNull(parsed); Assert.Equal(0x1000u, parsed!.Value.ItemGuid); Assert.Equal(0x00400000u, parsed.Value.EquipLoc); - Assert.Equal(0x2000u, parsed.Value.WielderGuid); } [Fact] diff --git a/tests/AcDream.Core.Net.Tests/Messages/GameEventDispatcherTests.cs b/tests/AcDream.Core.Net.Tests/Messages/GameEventDispatcherTests.cs index 63001be7..6b3f17ae 100644 --- a/tests/AcDream.Core.Net.Tests/Messages/GameEventDispatcherTests.cs +++ b/tests/AcDream.Core.Net.Tests/Messages/GameEventDispatcherTests.cs @@ -207,7 +207,6 @@ public sealed class GameEventDispatcherTests Assert.NotNull(parsed); Assert.Equal(0x50000A01u, parsed!.Value.ItemGuid); Assert.Equal((uint)EquipMask.ChestArmor, parsed.Value.EquipLoc); - Assert.Equal(0u, parsed.Value.WielderGuid); } [Fact] diff --git a/tests/AcDream.Core.Tests/Chat/WeenieErrorMessagesTests.cs b/tests/AcDream.Core.Tests/Chat/WeenieErrorMessagesTests.cs index 6124eb4a..3e929a20 100644 --- a/tests/AcDream.Core.Tests/Chat/WeenieErrorMessagesTests.cs +++ b/tests/AcDream.Core.Tests/Chat/WeenieErrorMessagesTests.cs @@ -100,6 +100,7 @@ public sealed class WeenieErrorMessagesTests [InlineData(0x0036u, "Action cancelled!")] [InlineData(0x003Du, "You charged too far!")] [InlineData(0x004Au, "Ack! You killed yourself!")] + [InlineData(0x0550u, "Out of Range!")] public void Format_CombatMovementErrors(uint code, string expected) => Assert.Equal(expected, WeenieErrorMessages.Format(code, null)); diff --git a/tests/AcDream.Core.Tests/Input/PlayerMoveToCutoverTests.cs b/tests/AcDream.Core.Tests/Input/PlayerMoveToCutoverTests.cs index 1a32b33d..0202c92b 100644 --- a/tests/AcDream.Core.Tests/Input/PlayerMoveToCutoverTests.cs +++ b/tests/AcDream.Core.Tests/Input/PlayerMoveToCutoverTests.cs @@ -1,6 +1,7 @@ using System; using System.Numerics; using AcDream.App.Input; +using AcDream.App.Rendering; using AcDream.Core.Physics; using AcDream.Core.Physics.Motion; using DatReaderWriter.DBObjs; @@ -27,7 +28,9 @@ namespace AcDream.Core.Tests.Input; public class PlayerMoveToCutoverTests { private const uint NC = 0x8000003Du; + private const uint MissileCombat = 0x8000003Fu; private const uint Ready = 0x41000003u; + private const uint Reload = 0x40000016u; private const uint Walk = 0x45000005u; private const uint Run = 0x44000007u; private const uint TurnRight = 0x6500000Du; @@ -73,7 +76,10 @@ public class PlayerMoveToCutoverTests var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NC }; mt.StyleDefaults[(DRWMotionCommand)NC] = (DRWMotionCommand)Ready; + mt.StyleDefaults[(DRWMotionCommand)MissileCombat] = (DRWMotionCommand)Ready; mt.Cycles[(int)((NC << 16) | (Ready & 0xFFFFFFu))] = MakeMd(0x300u); + mt.Cycles[(int)((MissileCombat << 16) | (Ready & 0xFFFFFFu))] = MakeMd(0x300u); + mt.Cycles[(int)((MissileCombat << 16) | (Reload & 0xFFFFFFu))] = MakeMd(0x301u); mt.Cycles[(int)((NC << 16) | (Walk & 0xFFFFFFu))] = MakeMd(0x301u); mt.Cycles[(int)((NC << 16) | (Run & 0xFFFFFFu))] = MakeMd(0x302u); // The MoveToManager's aux-turn steering / TurnToHeading nodes @@ -82,6 +88,7 @@ public class PlayerMoveToCutoverTests // _DoMotion error and the manager cancels the moveto (retail // dispatch-failure semantics), so the fixture needs one. mt.Cycles[(int)((NC << 16) | (TurnRight & 0xFFFFFFu))] = MakeMd(0x303u); + mt.Cycles[(int)((MissileCombat << 16) | (TurnRight & 0xFFFFFFu))] = MakeMd(0x303u); return new AnimationSequencer(setup, mt, loader); } @@ -102,8 +109,27 @@ public class PlayerMoveToCutoverTests { public required PlayerMovementController Controller; public required MoveToManager MoveTo; + public required EntityPhysicsHost PlayerHost; + public required Dictionary Hosts; public int MoveToCompleteCount; public WeenieError LastCompleteError; + + public void AddStaticTarget(uint guid, Position position) + { + var target = new EntityPhysicsHost( + guid, + getPosition: () => position, + getVelocity: () => Vector3.Zero, + getRadius: () => 0.5f, + inContact: () => true, + minterpMaxSpeed: () => null, + curTime: () => Controller.SimTimeSeconds, + physicsTimerTime: () => Controller.SimTimeSeconds, + getObjectA: id => Hosts.TryGetValue(id, out var host) ? host : null, + handleUpdateTarget: _ => { }, + interruptCurrentMovement: () => { }); + Hosts[guid] = target; + } } /// The EnterPlayerModeNow bind set, verbatim shape: real sink, @@ -116,6 +142,12 @@ public class PlayerMoveToCutoverTests var controller = new PlayerMovementController(MakeFlatEngine()); var seq = MakeSequencer(); seqOut = seq; + // Production AnimatedEntity wiring installs the completion callback + // before EnterPlayerModeNow binds the sink and calls SetPosition. + // A synchronous zero-tick completion at the CPhysicsObj movement + // boundary must therefore reach MotionInterpreter even during the + // initial placement stop. + seq.MotionDoneTarget = (m, ok) => controller.Motion.MotionDone(m, ok); // Production (EnterPlayerModeNow) order: the sink binds BEFORE the // initial SetPosition — SetPosition → StopCompletely needs the sink // for its type-5 motion-table dispatch, the completable partner of @@ -130,10 +162,19 @@ public class PlayerMoveToCutoverTests controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001); controller.Yaw = 0f; // heading 90 = facing +X - var rig = new Rig { Controller = controller, MoveTo = null! }; + const uint selfGuid = 0x5000000Au; + var hosts = new Dictionary(); + EntityPhysicsHost playerHost = null!; + var rig = new Rig + { + Controller = controller, + MoveTo = null!, + PlayerHost = null!, + Hosts = hosts, + }; var moveTo = new MoveToManager( controller.Motion, - stopCompletely: () => controller.Motion.StopCompletely(), + stopCompletely: () => controller.StopCompletelyAtPhysicsObjectBoundary(), getPosition: () => new Position( controller.CellId, controller.Position, controller.BodyOrientation), getHeading: () => MoveToMath.HeadingFromYaw(controller.Yaw), @@ -143,12 +184,28 @@ public class PlayerMoveToCutoverTests contact: () => controller.BodyInContact, isInterpolating: () => false, getVelocity: () => controller.BodyVelocity, - getSelfId: () => 0x5000000Au, - setTarget: (_, _, _, _) => { }, - clearTarget: () => { }, - getTargetQuantum: () => 0.0, - setTargetQuantum: _ => { }, + getSelfId: () => selfGuid, + setTarget: (ctx, id, radius, quantum) => + playerHost.SetTarget(ctx, id, radius, quantum), + clearTarget: () => playerHost.ClearTarget(), + getTargetQuantum: () => playerHost.TargetManager.GetTargetQuantum(), + setTargetQuantum: quantum => playerHost.TargetManager.SetTargetQuantum(quantum), curTime: () => controller.SimTimeSeconds); + playerHost = new EntityPhysicsHost( + selfGuid, + getPosition: () => new Position( + controller.CellId, controller.Position, controller.BodyOrientation), + getVelocity: () => controller.BodyVelocity, + getRadius: () => 0.5f, + inContact: () => controller.BodyInContact, + minterpMaxSpeed: () => controller.Motion.GetMaxSpeed(), + curTime: () => controller.SimTimeSeconds, + physicsTimerTime: () => controller.SimTimeSeconds, + getObjectA: id => hosts.TryGetValue(id, out var host) ? host : null, + handleUpdateTarget: info => moveTo.HandleUpdateTarget(info), + interruptCurrentMovement: () => + moveTo.CancelMoveTo(WeenieError.ActionCancelled)); + hosts[selfGuid] = playerHost; moveTo.MoveToComplete = err => { rig.MoveToCompleteCount++; @@ -158,6 +215,7 @@ public class PlayerMoveToCutoverTests controller.Motion.InterruptCurrentMovement = () => moveTo.CancelMoveTo(WeenieError.ActionCancelled); rig.MoveTo = moveTo; + rig.PlayerHost = playerHost; return rig; } @@ -290,8 +348,6 @@ public class PlayerMoveToCutoverTests // under the production completion feed. var rig = MakeRig(out var seq); var c = rig.Controller; - seq.MotionDoneTarget = (m, ok) => c.Motion.MotionDone(m, ok); - for (int f = 0; f < 300 && c.Motion.MotionsPending(); f++) { c.Update(1f / 60f, new MovementInput()); @@ -317,7 +373,6 @@ public class PlayerMoveToCutoverTests // pops; remotes 0/40). var rig = MakeRig(out var seq); var c = rig.Controller; - seq.MotionDoneTarget = (m, ok) => c.Motion.MotionDone(m, ok); var dest = new Vector3(104f, 96f, 50f); // 8 m dead ahead (heading 90) // A few idle frames first — production always has render ticks @@ -355,6 +410,130 @@ public class PlayerMoveToCutoverTests Assert.Equal(1, rig.MoveToCompleteCount); } + [Fact] + public void ServerTurnToObject_AfterMissileReload_ProductionFeed_RotatesAndCompletes() + { + // Live bow repro (2026-07-15): ACE broadcasts MissileCombat/Reload, + // then a type-8 TurnToObject carrying StopCompletely before it + // launches the arrow. After target resolution, retail's object turn + // is exactly one TurnToHeading node, so this pins the animation-queue + // boundary without replacing TargetManager with a test-only poll. + var rig = MakeRig(out var seq); + var c = rig.Controller; + for (int f = 0; f < 120; f++) + { + c.Update(1f / 60f, new MovementInput { Run = true }); + seq.Advance(1f / 60f); + } + + Assert.Equal(WeenieError.None, c.Motion.DoMotion( + MissileCombat, new MovementParameters())); + Assert.Equal(WeenieError.None, c.Motion.DoInterpretedMotion( + Reload, new MovementParameters { Speed = 2f })); + + const uint targetGuid = 0x80001234u; + rig.AddStaticTarget(targetGuid, new Position( + c.CellId, new Vector3(88f, 96f, 50f), Quaternion.Identity)); + + rig.MoveTo.PerformMovement(new MovementStruct + { + Type = MovementType.TurnToObject, + ObjectId = targetGuid, + TopLevelId = targetGuid, + Params = new MovementParameters + { + StopCompletelyFlag = true, + Speed = 1f, + }, + }); + + float initialYaw = c.Yaw; + for (int f = 0; f < 600 && rig.MoveTo.IsMovingTo(); f++) + { + c.Update(1f / 60f, new MovementInput { Run = true }); + seq.Advance(1f / 60f); + } + + string interpQueue = string.Join(", ", + c.Motion.PendingMotions.Select(n => $"0x{n.Motion:X8}")); + string animationQueue = string.Join(", ", + seq.Manager.PendingAnimations.Select(n => $"0x{n.Motion:X8}/{n.NumAnims}")); + Assert.True(initialYaw != c.Yaw, + $"turn never changed yaw; moving={rig.MoveTo.IsMovingTo()} " + + $"complete={rig.MoveToCompleteCount} error={rig.LastCompleteError} " + + $"interp=[{interpQueue}] animation=[{animationQueue}]"); + Assert.False(rig.MoveTo.IsMovingTo()); + Assert.Equal(1, rig.MoveToCompleteCount); + Assert.InRange(MoveToMath.HeadingFromYaw(c.Yaw), 269.99f, 270.01f); + Assert.False(c.Motion.MotionsPending()); + } + + [Fact] + public void KeyboardTurnRelease_ThenAttackStop_DrainsBeforeServerTurnToObject() + { + // Live bow repro (2026-07-15): turn the character away with a player + // input edge, release the key, begin an attack (MaybeStopCompletely), + // then receive ACE's type-8 TurnToObject. Retail routes the input + // DoMotion/StopMotion calls through CPhysicsObj -> MovementManager, + // so every edge runs the zero-tick completion sweep and leaves no + // orphan that can starve BeginTurnToHeading. + var rig = MakeRig(out var seq); + var c = rig.Controller; + + for (int f = 0; f < 90; f++) + { + c.Update(1f / 60f, new MovementInput { Run = true, TurnRight = true }); + seq.Advance(1f / 60f); + } + + c.Update(1f / 60f, new MovementInput { Run = true }); + seq.Advance(1f / 60f); + for (int f = 0; f < 120 && c.Motion.MotionsPending(); f++) + { + c.Update(1f / 60f, new MovementInput { Run = true }); + seq.Advance(1f / 60f); + } + + Assert.False(c.Motion.MotionsPending(), + "keyboard turn release must leave no interpreter queue orphan"); + Assert.Empty(seq.Manager.PendingAnimations); + + Assert.True(c.PrepareForAttackRequest()); + Assert.False(c.Motion.MotionsPending(), + "attack-entry StopCompletely must synchronously drain zero-tick Ready"); + Assert.Empty(seq.Manager.PendingAnimations); + + const uint targetGuid = 0x80004321u; + rig.AddStaticTarget(targetGuid, new Position( + c.CellId, c.Position + new Vector3(12f, 0f, 0f), Quaternion.Identity)); + float initialYaw = c.Yaw; + rig.Controller.SetLastMoveWasAutonomous(false); + rig.MoveTo.PerformMovement(new MovementStruct + { + Type = MovementType.TurnToObject, + ObjectId = targetGuid, + TopLevelId = targetGuid, + Params = new MovementParameters + { + StopCompletelyFlag = true, + Speed = 1f, + }, + }); + + for (int f = 0; f < 600 && rig.MoveTo.IsMovingTo(); f++) + { + c.Update(1f / 60f, new MovementInput { Run = true }); + seq.Advance(1f / 60f); + } + + Assert.NotEqual(initialYaw, c.Yaw); + Assert.False(rig.MoveTo.IsMovingTo()); + Assert.Equal(1, rig.MoveToCompleteCount); + Assert.InRange(MoveToMath.HeadingFromYaw(c.Yaw), 89.99f, 90.01f); + Assert.False(c.Motion.MotionsPending()); + Assert.Empty(seq.Manager.PendingAnimations); + } + [Fact] public void JumpRelease_CancelsMoveTo() { diff --git a/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs b/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs index 0afafaf7..2d167fbf 100644 --- a/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs +++ b/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs @@ -54,7 +54,11 @@ public sealed class ClientObjectTableTests repo.AddOrUpdate(item); uint seenOld = 999, seenNew = 999; - repo.ObjectMoved += (it, oldC, newC) => { seenOld = oldC; seenNew = newC; }; + repo.ObjectMoved += move => + { + seenOld = move.Previous.ContainerId; + seenNew = move.Current.ContainerId; + }; repo.MoveItem(100, 42, newSlot: 3); @@ -71,6 +75,74 @@ public sealed class ClientObjectTableTests Assert.False(repo.MoveItem(999, 42)); } + [Fact] + public void ApplyServerMove_PublishesCompleteOldAndNewRetailPlacements() + { + var repo = new ClientObjectTable(); + const uint player = 0x50000001u, pack = 0x40000005u, itemId = 101u; + repo.AddOrUpdate(new ClientObject + { + ObjectId = itemId, + WielderId = player, + CurrentlyEquippedLocation = EquipMask.MissileWeapon, + }); + ClientObjectMove? seen = null; + repo.ObjectMoved += move => seen = move; + + Assert.True(repo.ApplyServerMove(itemId, pack, 0u, 3)); + + Assert.Equal( + new ClientObjectPlacement(0u, -1, player, EquipMask.MissileWeapon), + seen!.Value.Previous); + Assert.Equal( + new ClientObjectPlacement(pack, 3, 0u, EquipMask.None), + seen.Value.Current); + } + + [Fact] + public void ConfirmedUnknownMove_PublishesGuidAndRawPlacement() + { + var repo = new ClientObjectTable(); + ClientObjectMove? seen = null; + repo.ObjectMoved += move => seen = move; + + Assert.False(repo.ApplyConfirmedServerMove( + 0xDEADu, + 0x40000001u, + newWielderId: 0u, + newSlot: 7)); + + Assert.Equal(0xDEADu, seen!.Value.ItemId); + Assert.Null(seen.Value.Item); + Assert.Equal(default, seen.Value.Previous); + Assert.Equal( + new ClientObjectPlacement(0x40000001u, 7, 0u, EquipMask.None), + seen.Value.Current); + } + + [Fact] + public void ConfirmedUnknownWield_PublishesGuidPlacementAndCompletion() + { + var repo = new ClientObjectTable(); + const uint itemId = 0xBEEFu, player = 0x50000001u; + ClientObjectMove? seen = null; + uint completed = 0u; + repo.ObjectMoved += move => seen = move; + repo.WieldConfirmed += id => completed = id; + + Assert.False(repo.ApplyConfirmedServerWield( + itemId, + player, + EquipMask.MissileWeapon)); + + Assert.Equal(itemId, seen!.Value.ItemId); + Assert.Null(seen.Value.Item); + Assert.Equal( + new ClientObjectPlacement(0u, 0, player, EquipMask.MissileWeapon), + seen.Value.Current); + Assert.Equal(itemId, completed); + } + [Fact] public void Remove_FiresEventAndRemoves() { @@ -471,53 +543,156 @@ public sealed class ClientObjectTableTests var table = new ClientObjectTable(); table.ReplaceContents(0xC9u, new uint[] { 0xA01u, 0xA02u, 0xA03u }); Assert.Equal(new[] { 0xA01u, 0xA02u, 0xA03u }, table.GetContents(0xC9u)); - Assert.Equal(0xC9u, table.Get(0xA01u)!.ContainerId); + Assert.Equal(0u, table.Get(0xA01u)!.ContainerId); } [Fact] - public void ReplaceContents_SecondSmallerList_DetachesDropped() // the full-REPLACE semantics (vs the old additive merge) + public void ReplaceContents_PublishesListReplacementWithoutSyntheticMove() + { + var table = new ClientObjectTable(); + int moves = 0; + uint replaced = 0u; + table.ObjectMoved += _ => moves++; + table.ContainerContentsReplaced += container => replaced = container; + + table.ReplaceContents(0xC9u, new uint[] { 0xA01u }); + + Assert.Equal(0, moves); + Assert.Equal(0xC9u, replaced); + } + + [Fact] + public void ProjectionOnlyMember_AuthoritativeMoveEvictsStaleViewedList() + { + var table = new ClientObjectTable(); + const uint chest = 0x40000001u, pack = 0x50000001u, item = 0xA20u; + table.ReplaceContents(chest, new uint[] { item }); + + Assert.True(table.ApplyServerMove(item, pack, newWielderId: 0u, newSlot: 0)); + + Assert.Empty(table.GetContents(chest)); + Assert.Equal(new[] { item }, table.GetContents(pack)); + } + + [Fact] + public void ProjectionEvictionNotification_ReentrantMoveSeesCommittedState() + { + var table = new ClientObjectTable(); + const uint chest = 0x40000001u, packA = 0x50000001u; + const uint packB = 0x50000002u, item = 0xA22u; + table.ReplaceContents(chest, new uint[] { item }); + table.ContainerContentsReplaced += containerId => + { + if (containerId == chest) + Assert.True(table.ApplyServerMove(item, packB, newWielderId: 0u, newSlot: 0)); + }; + + Assert.True(table.ApplyServerMove(item, packA, newWielderId: 0u, newSlot: 0)); + + Assert.Empty(table.GetContents(chest)); + Assert.Empty(table.GetContents(packA)); + Assert.Equal(new[] { item }, table.GetContents(packB)); + Assert.Equal(packB, table.Get(item)!.ContainerId); + } + + [Fact] + public void ProjectionOnlyMember_DeleteEvictsStaleViewedListAndNotifies() + { + var table = new ClientObjectTable(); + const uint chest = 0x40000001u, item = 0xA21u; + table.ReplaceContents(chest, new uint[] { item }); + int replacements = 0; + table.ContainerContentsReplaced += id => + { + if (id == chest) replacements++; + }; + + Assert.True(table.Remove(item)); + + Assert.Empty(table.GetContents(chest)); + Assert.Equal(1, replacements); + } + + [Fact] + public void DeleteProjectionNotification_GuidReuseSurvivesCompletedTeardown() + { + var table = new ClientObjectTable(); + const uint chest = 0x40000001u, item = 0xA23u, player = 0x50000001u; + table.ReplaceContents(chest, new uint[] { item }); + table.ContainerContentsReplaced += containerId => + { + if (containerId != chest) return; + table.AddOrUpdate(new ClientObject + { + ObjectId = item, + WielderId = player, + CurrentlyEquippedLocation = EquipMask.MissileWeapon, + }); + }; + + Assert.True(table.Remove(item)); + + Assert.NotNull(table.Get(item)); + Assert.Equal( + new[] { item }, + table.GetEquippedBy(player).Select(equipped => equipped.ObjectId)); + } + + [Fact] + public void ReplaceContents_SecondSmallerList_ReplacesProjectionOnly() { var table = new ClientObjectTable(); table.ReplaceContents(0xC9u, new uint[] { 0xA01u, 0xA02u, 0xA03u }); table.ReplaceContents(0xC9u, new uint[] { 0xA02u }); // A01 + A03 removed server-side Assert.Equal(new[] { 0xA02u }, table.GetContents(0xC9u)); - Assert.Equal(0u, table.Get(0xA01u)!.ContainerId); // detached, not lingering + Assert.Equal(0u, table.Get(0xA01u)!.ContainerId); Assert.Equal(0u, table.Get(0xA03u)!.ContainerId); - Assert.Equal(0xC9u, table.Get(0xA02u)!.ContainerId); + Assert.Equal(0u, table.Get(0xA02u)!.ContainerId); } [Fact] - public void ReplaceContents_listedMemberClearsStaleEquipLocation() + public void ReplaceContents_listedMemberPreservesCanonicalPlacement() { var table = new ClientObjectTable(); table.AddOrUpdate(new ClientObject { ObjectId = 0xA10u, - ContainerId = 0x50000001u, + ContainerId = 0u, + WielderId = 0x50000001u, CurrentlyEquippedLocation = EquipMask.ChestWear | EquipMask.UpperArmWear, }); table.ReplaceContents(0x50000001u, new uint[] { 0xA10u }); var item = table.Get(0xA10u)!; - Assert.Equal(0x50000001u, item.ContainerId); - Assert.Equal(0, item.ContainerSlot); - Assert.Equal(EquipMask.None, item.CurrentlyEquippedLocation); + Assert.Equal(0u, item.ContainerId); + Assert.Equal(0x50000001u, item.WielderId); + Assert.Equal(-1, item.ContainerSlot); + Assert.Equal( + EquipMask.ChestWear | EquipMask.UpperArmWear, + item.CurrentlyEquippedLocation); } [Fact] - public void ReplaceContents_detachedMemberClearsStaleEquipLocation() + public void ReplaceContents_omittedMemberPreservesCanonicalPlacement() { var table = new ClientObjectTable(); - table.AddOrUpdate(new ClientObject { ObjectId = 0xA11u }); + table.AddOrUpdate(new ClientObject + { + ObjectId = 0xA11u, + WielderId = 0x50000001u, + }); table.MoveItem(0xA11u, 0x50000001u, newSlot: 0, newEquipLocation: EquipMask.ChestWear | EquipMask.UpperArmWear); table.ReplaceContents(0x50000001u, Array.Empty()); var item = table.Get(0xA11u)!; - Assert.Equal(0u, item.ContainerId); - Assert.Equal(EquipMask.None, item.CurrentlyEquippedLocation); + Assert.Equal(0x50000001u, item.ContainerId); + Assert.Equal(0x50000001u, item.WielderId); + Assert.Equal( + EquipMask.ChestWear | EquipMask.UpperArmWear, + item.CurrentlyEquippedLocation); } [Fact] @@ -559,11 +734,130 @@ public sealed class ClientObjectTableTests table.Ingest(FullWeenie(0xA01u, container: player)); Assert.Equal(new[] { 0xA01u, 0xA02u }, table.GetContents(player)); - Assert.Equal(0, table.Get(0xA01u)!.ContainerSlot); - Assert.Equal(1, table.Get(0xA02u)!.ContainerSlot); + Assert.Equal(-1, table.Get(0xA01u)!.ContainerSlot); + Assert.Equal(-1, table.Get(0xA02u)!.ContainerSlot); Assert.Equal(1u, table.Get(0xA02u)!.ContainerTypeHint); } + [Fact] + public void InitializeInventoryManifest_SeedsCanonicalOwnershipAndOrder() + { + var table = new ClientObjectTable(); + const uint player = 0x50000001u; + + table.InitializeInventoryManifest(player, new[] + { + new ContainerContentEntry(0xA01u, 0u), + new ContainerContentEntry(0xA02u, 1u), + }); + + Assert.Equal(new[] { 0xA01u, 0xA02u }, table.GetContents(player)); + Assert.Equal(player, table.Get(0xA01u)!.ContainerId); + Assert.Equal(0, table.Get(0xA01u)!.ContainerSlot); + Assert.Equal(player, table.Get(0xA02u)!.ContainerId); + Assert.Equal(1, table.Get(0xA02u)!.ContainerSlot); + } + + [Fact] + public void InitializeEquipmentManifest_PreservesPackedOrderAndCanonicalOwnership() + { + var table = new ClientObjectTable(); + const uint player = 0x50000001u; + + table.InitializeEquipmentManifest(player, new[] + { + new EquipmentManifestEntry(0xA11u, EquipMask.MeleeWeapon, 1u), + new EquipmentManifestEntry(0xA12u, EquipMask.MissileAmmo, 2u), + }); + + Assert.Equal( + new[] { 0xA11u, 0xA12u }, + table.GetEquippedBy(player).Select(item => item.ObjectId)); + Assert.Equal(0u, table.Get(0xA11u)!.ContainerId); + Assert.Equal(player, table.Get(0xA11u)!.WielderId); + Assert.Empty(table.GetContents(player)); + } + + [Fact] + public void InitializeEquipmentManifest_ReplacementClearsOmittedCanonicalEquipment() + { + var table = new ClientObjectTable(); + const uint player = 0x50000001u; + const uint weapon = 0xA11u; + + table.InitializeEquipmentManifest(player, new[] + { + new EquipmentManifestEntry(weapon, EquipMask.MeleeWeapon, 1u), + }); + table.Get(weapon)!.Burden = 10; + + table.InitializeEquipmentManifest(player, Array.Empty()); + + ClientObject item = table.Get(weapon)!; + Assert.Equal(0u, item.WielderId); + Assert.Equal(0u, item.ContainerId); + Assert.Equal(-1, item.ContainerSlot); + Assert.Equal(EquipMask.None, item.CurrentlyEquippedLocation); + Assert.Empty(table.GetEquippedBy(player)); + Assert.Equal(0, table.SumCarriedBurden(player)); + } + + [Fact] + public void InitializeInventoryManifest_ReplacementClearsOmittedCanonicalOwnership() + { + var table = new ClientObjectTable(); + const uint player = 0x50000001u; + const uint itemId = 0xA01u; + + table.InitializeInventoryManifest(player, new[] + { + new ContainerContentEntry(itemId, 0u), + }); + table.Get(itemId)!.Burden = 10; + + table.InitializeInventoryManifest(player, Array.Empty()); + + ClientObject item = table.Get(itemId)!; + Assert.Equal(0u, item.ContainerId); + Assert.Equal(-1, item.ContainerSlot); + Assert.Equal(0u, item.WielderId); + Assert.Equal(EquipMask.None, item.CurrentlyEquippedLocation); + Assert.Empty(table.GetContents(player)); + Assert.Equal(0, table.SumCarriedBurden(player)); + } + + [Fact] + public void PlayerDescriptionManifests_MoveItemBetweenInventoryAndEquipment() + { + var table = new ClientObjectTable(); + const uint player = 0x50000001u; + const uint weapon = 0xA11u; + + table.InitializeInventoryManifest(player, new[] + { + new ContainerContentEntry(weapon, 0u), + }); + table.InitializeEquipmentManifest(player, new[] + { + new EquipmentManifestEntry(weapon, EquipMask.MeleeWeapon, 1u), + }); + + Assert.Empty(table.GetContents(player)); + Assert.Equal(player, table.Get(weapon)!.WielderId); + Assert.Equal(EquipMask.MeleeWeapon, table.Get(weapon)!.CurrentlyEquippedLocation); + + table.InitializeEquipmentManifest(player, Array.Empty()); + table.InitializeInventoryManifest(player, new[] + { + new ContainerContentEntry(weapon, 0u), + }); + + Assert.Empty(table.GetEquippedBy(player)); + Assert.Equal(new[] { weapon }, table.GetContents(player)); + Assert.Equal(player, table.Get(weapon)!.ContainerId); + Assert.Equal(0u, table.Get(weapon)!.WielderId); + } + [Fact] public void Reindex_UnknownSlotAppendsAfterKnownSnapshotSlots() { @@ -692,8 +986,8 @@ public sealed class ClientObjectTableTests table.WieldItemOptimistic(0x940u, player, EquipMask.HeadWear); var o = table.Get(0x940u)!; Assert.Equal(EquipMask.HeadWear, o.CurrentlyEquippedLocation); // equipped now (instant) - Assert.Equal(player, o.ContainerId); // contained-by-wielder (matches the WieldObject 0x0023 confirm) - Assert.Equal(0u, o.WielderId); // optimistic wield does NOT write WielderId (ContainerId-based model) + Assert.Equal(player, o.ContainerId); // temporary pre-confirm placement projection + Assert.Equal(0u, o.WielderId); // authoritative WieldObject later sets WielderId + clears ContainerId } [Fact] @@ -731,6 +1025,89 @@ public sealed class ClientObjectTableTests public void WieldItemOptimistic_unknownItem_false() => Assert.False(new ClientObjectTable().WieldItemOptimistic(0xDEADu, 0x1u, EquipMask.HeadWear)); + [Fact] + public void ApplyServerMove_UpdatesAuthoritativeWielderAcrossWieldAndUnwield() + { + var table = new ClientObjectTable(); + const uint item = 0x9430u, player = 0x50000001u, pack = 0x40000005u; + table.AddOrUpdate(new ClientObject + { + ObjectId = item, + ContainerId = player, + WielderId = player, + CurrentlyEquippedLocation = EquipMask.MissileWeapon, + }); + + Assert.True(table.ApplyServerMove(item, pack, newWielderId: 0u, newSlot: 2)); + Assert.Equal(pack, table.Get(item)!.ContainerId); + Assert.Equal(0u, table.Get(item)!.WielderId); + Assert.Equal(EquipMask.None, table.Get(item)!.CurrentlyEquippedLocation); + + Assert.True(table.ApplyServerMove( + item, + newContainerId: 0u, + newWielderId: player, + newEquipLocation: EquipMask.MissileWeapon)); + Assert.Equal(0u, table.Get(item)!.ContainerId); + Assert.Equal(player, table.Get(item)!.WielderId); + Assert.Equal(EquipMask.MissileWeapon, table.Get(item)!.CurrentlyEquippedLocation); + } + + [Fact] + public void ApplyConfirmedServerWield_ReentrantMoveKeepsNewPendingRequest() + { + var table = new ClientObjectTable(); + const uint item = 0x9431u, player = 0x50000001u, pack = 0x40000005u; + table.AddOrUpdate(new ClientObject { ObjectId = item, ContainerId = pack, ContainerSlot = 0 }); + Assert.True(table.WieldItemOptimistic(item, player, EquipMask.MissileWeapon)); + + bool queuedFromNotification = false; + table.ObjectMoved += move => + { + if (queuedFromNotification || move.Current.WielderId != player) return; + queuedFromNotification = true; + Assert.True(table.MoveItemOptimistic(item, pack, newSlot: 0)); + }; + + Assert.True(table.ApplyConfirmedServerWield(item, player, EquipMask.MissileWeapon)); + Assert.True(queuedFromNotification); + Assert.True(table.RollbackMove(item)); + Assert.Equal(0u, table.Get(item)!.ContainerId); + Assert.Equal(player, table.Get(item)!.WielderId); + Assert.Equal(EquipMask.MissileWeapon, table.Get(item)!.CurrentlyEquippedLocation); + } + + [Fact] + public void GetEquippedBy_PreservesRetailHeadInsertionOrder() + { + var table = new ClientObjectTable(); + const uint player = 0x50000001u; + table.AddOrUpdate(new ClientObject + { + ObjectId = 0x9432u, + ContainerId = 0u, + WielderId = player, + CurrentlyEquippedLocation = EquipMask.MissileWeapon, + }); + table.AddOrUpdate(new ClientObject + { + ObjectId = 0x9433u, + ContainerId = player, + WielderId = 0u, + CurrentlyEquippedLocation = EquipMask.MissileAmmo, + }); + table.AddOrUpdate(new ClientObject + { + ObjectId = 0x9434u, + ContainerId = player, + CurrentlyEquippedLocation = EquipMask.None, + }); + + Assert.Equal( + new[] { 0x9433u, 0x9432u }, + table.GetEquippedBy(player).Select(item => item.ObjectId)); + } + [Fact] public void RejectMove_withoutOptimisticMutation_stillPublishesServerFailure() { @@ -766,7 +1143,7 @@ public sealed class ClientObjectTableTests } [Fact] - public void ConfirmWield_publishesIndependentlyOfRollbackOutstandingCount() + public void ApplyConfirmedServerWield_publishesIndependentlyOfRollbackOutstandingCount() { var table = new ClientObjectTable(); const uint item = 0x944u, player = 0x50000001u, pack = 0x40000005u; @@ -775,12 +1152,20 @@ public sealed class ClientObjectTableTests table.WieldItemOptimistic(item, player, EquipMask.MeleeWeapon); table.MoveItemOptimistic(item, pack, 0); var confirmed = new List(); - table.WieldConfirmed += moved => confirmed.Add(moved.ObjectId); + table.WieldConfirmed += itemId => confirmed.Add(itemId); - table.ConfirmWield(item); + Assert.True(table.ApplyConfirmedServerWield( + item, + player, + EquipMask.MeleeWeapon)); Assert.Equal(new[] { item }, confirmed); + Assert.Equal(0u, table.Get(item)!.ContainerId); + Assert.Equal(player, table.Get(item)!.WielderId); Assert.True(table.RollbackMove(item)); + Assert.Equal(pack, table.Get(item)!.ContainerId); + Assert.Equal(0u, table.Get(item)!.WielderId); + Assert.Equal(EquipMask.None, table.Get(item)!.CurrentlyEquippedLocation); } [Fact] diff --git a/tests/AcDream.Core.Tests/Physics/Motion/MovementManagerTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/MovementManagerTests.cs index da8e627d..02ee10ea 100644 --- a/tests/AcDream.Core.Tests/Physics/Motion/MovementManagerTests.cs +++ b/tests/AcDream.Core.Tests/Physics/Motion/MovementManagerTests.cs @@ -92,6 +92,44 @@ public sealed class MovementManagerTests Assert.Null(mm.MoveTo); } + [Fact] + public void PerformMovement_InterpTypes_ForwardOriginalParametersObject() + { + // CMotionInterp::PerformMovement @ 0x00528E80 forwards + // MovementStruct::params verbatim. This is observable at the player + // input boundary: rebuilding the block from MovementStruct's legacy + // scalar fields loses its speed, hold key, and control flags. + var (mm, h, calls) = MakeFacade(); + int interrupts = 0; + h.Interp.InterruptCurrentMovement = () => interrupts++; + var parameters = new MovementParameters + { + Speed = 0.37f, + SetHoldKey = true, + HoldKeyToApply = HoldKey.Run, + CancelMoveTo = false, + ModifyRawState = true, + ModifyInterpretedState = true, + }; + + var result = mm.PerformMovement(new MovementStruct + { + Type = MovementType.RawCommand, + Motion = MotionCommand.TurnRight, + // Deliberately contradictory legacy fields: Params wins. + Speed = 9f, + ModifyRawState = false, + ModifyInterpretedState = false, + Params = parameters, + }); + + Assert.Equal(WeenieError.None, result); + Assert.Equal(0.37f, h.Interp.RawState.TurnSpeed); + Assert.Equal(HoldKey.Run, h.Interp.RawState.CurrentHoldKey); + Assert.Equal(0, interrupts); + Assert.Equal(0, calls[0]); + } + [Fact] public void PerformMovement_MoveToTypes_LazyCreate_AndRouteToMoveTo() { diff --git a/tests/AcDream.Core.Tests/Physics/Motion/RemoteChaseEndToEndHarnessTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/RemoteChaseEndToEndHarnessTests.cs index a1480435..ef0f3292 100644 --- a/tests/AcDream.Core.Tests/Physics/Motion/RemoteChaseEndToEndHarnessTests.cs +++ b/tests/AcDream.Core.Tests/Physics/Motion/RemoteChaseEndToEndHarnessTests.cs @@ -191,7 +191,8 @@ internal sealed class RemoteChaseHarness { var mtm = new MoveToManager( Interp, - stopCompletely: () => Interp.StopCompletely(), + stopCompletely: () => Movement.PerformMovement( + new MovementStruct { Type = MovementType.StopCompletely }), getPosition: () => new CorePosition(1u, Body.Position, Body.Orientation), getHeading: () => MoveToMath.GetHeading(Body.Orientation), setHeading: (h, _) => Body.Orientation = diff --git a/tests/AcDream.UI.Abstractions.Tests/Input/MmbMouseLookTests.cs b/tests/AcDream.UI.Abstractions.Tests/Input/MmbMouseLookTests.cs index 96f7b74a..8aaccc9c 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Input/MmbMouseLookTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Input/MmbMouseLookTests.cs @@ -12,7 +12,7 @@ namespace AcDream.UI.Abstractions.Tests.Input; /// public sealed class MmbMouseLookTests { - private sealed class YawSink + private sealed class AdjustmentSink { public float Total; public int ApplyCount; @@ -22,7 +22,7 @@ public sealed class MmbMouseLookTests [Fact] public void Press_ActivatesAndCapturesCursorPosition() { - var sink = new YawSink(); + var sink = new AdjustmentSink(); var ml = new MouseLookState(sink.Apply); ml.Press(cursorX: 320f, cursorY: 240f, wantCaptureMouse: false); @@ -35,7 +35,7 @@ public sealed class MmbMouseLookTests [Fact] public void Release_DeactivatesWhenActive() { - var sink = new YawSink(); + var sink = new AdjustmentSink(); var ml = new MouseLookState(sink.Apply); ml.Press(0f, 0f, wantCaptureMouse: false); @@ -50,7 +50,7 @@ public sealed class MmbMouseLookTests // Defense in depth — the dispatcher already filters on // WantCaptureMouse, but if a binding ever fires through the // state machine itself must not turn on. - var sink = new YawSink(); + var sink = new AdjustmentSink(); var ml = new MouseLookState(sink.Apply); ml.Press(0f, 0f, wantCaptureMouse: true); @@ -61,7 +61,7 @@ public sealed class MmbMouseLookTests [Fact] public void OnWantCaptureMouseChanged_TrueWhileActive_Deactivates() { - var sink = new YawSink(); + var sink = new AdjustmentSink(); var ml = new MouseLookState(sink.Apply); ml.Press(0f, 0f, wantCaptureMouse: false); Assert.True(ml.Active); @@ -79,7 +79,7 @@ public sealed class MmbMouseLookTests { // Going from "ImGui captures" to "ImGui doesn't capture" must // NOT auto-reactivate — only an explicit Press does. - var sink = new YawSink(); + var sink = new AdjustmentSink(); var ml = new MouseLookState(sink.Apply); ml.OnWantCaptureMouseChanged(wantCaptureMouse: false); @@ -88,17 +88,17 @@ public sealed class MmbMouseLookTests } [Fact] - public void ApplyDelta_WhileActive_DrivesYawSink() + public void ApplyDelta_WhileActive_DrivesMovementAdjustmentSink() { - var sink = new YawSink(); - var ml = new MouseLookState(sink.Apply) { SensitivityRadiansPerPixel = 0.01f }; + var sink = new AdjustmentSink(); + var ml = new MouseLookState(sink.Apply) { SensitivityPerPixel = 0.01f }; ml.Press(0f, 0f, wantCaptureMouse: false); - ml.ApplyDelta(dx: 10f, extraSensitivity: 1.0f); + for (int i = 0; i < 6; i++) + ml.ApplyDelta(dx: 10f, extraSensitivity: 1.0f); - // Sign: dragging right (positive dx) yaws the character right - // — by the acdream Yaw convention that's negative yaw delta. - // Magnitude: 10 * 0.01 * 1.0 = 0.1 rad. + // Sign: dragging right (positive dx) requests TurnRight, represented + // by a negative adjustment. Magnitude: 10 * 0.01 * 1.0 = 0.1. Assert.Equal(1, sink.ApplyCount); Assert.Equal(-0.1f, sink.Total, 5); } @@ -106,7 +106,7 @@ public sealed class MmbMouseLookTests [Fact] public void ApplyDelta_WhileInactive_DoesNothing() { - var sink = new YawSink(); + var sink = new AdjustmentSink(); var ml = new MouseLookState(sink.Apply); // Never pressed. @@ -119,7 +119,7 @@ public sealed class MmbMouseLookTests [Fact] public void ApplyDelta_AfterRelease_DoesNothing() { - var sink = new YawSink(); + var sink = new AdjustmentSink(); var ml = new MouseLookState(sink.Apply); ml.Press(0f, 0f, wantCaptureMouse: false); ml.Release(); @@ -135,7 +135,7 @@ public sealed class MmbMouseLookTests // Repeated Press calls must not blow away the originally // captured cursor position — the cursor-restore-on-release // path needs the original anchor. - var sink = new YawSink(); + var sink = new AdjustmentSink(); var ml = new MouseLookState(sink.Apply); ml.Press(100f, 200f, wantCaptureMouse: false); @@ -148,19 +148,82 @@ public sealed class MmbMouseLookTests [Fact] public void ApplyDelta_DriverDrivesCharacterAndCameraInOneSink() { - // Combined drive: the test only exposes a single yaw mutator, - // and that's the design — the yaw mutator GameWindow passes - // updates _playerController.Yaw, and the chase camera reads - // the same yaw via ChaseCamera.Update(pos, playerYaw). So a - // single sink in this test models both behaviors. - var sink = new YawSink(); - var ml = new MouseLookState(sink.Apply) { SensitivityRadiansPerPixel = 0.005f }; + // The input state owns only filtering/scaling. GameWindow's one sink + // routes the result to PlayerMovementController, whose turn motion + // updates both the character heading and chase-camera follow source. + var sink = new AdjustmentSink(); + var ml = new MouseLookState(sink.Apply) { SensitivityPerPixel = 0.005f }; ml.Press(0f, 0f, wantCaptureMouse: false); + for (int i = 0; i < 5; i++) + ml.ApplyDelta(dx: 4f, extraSensitivity: 0.5f); ml.ApplyDelta(dx: 4f, extraSensitivity: 0.5f); // -0.01 ml.ApplyDelta(dx: -2f, extraSensitivity: 0.5f); // +0.005 Assert.Equal(2, sink.ApplyCount); Assert.Equal(-0.005f, sink.Total, 5); } + + [Fact] + public void ApplyDelta_ZeroSampleResetsRetailHorizontalExtent() + { + var sink = new AdjustmentSink(); + var ml = new MouseLookState(sink.Apply) { SensitivityPerPixel = 0.01f }; + ml.Press(0f, 0f, wantCaptureMouse: false); + + for (int i = 0; i < 5; i++) + ml.ApplyDelta(dx: 2f, extraSensitivity: 1f); + ml.ApplyDelta(dx: 0f, extraSensitivity: 1f); + for (int i = 0; i < 5; i++) + ml.ApplyDelta(dx: 2f, extraSensitivity: 1f); + + Assert.Equal(0, sink.ApplyCount); + } + + [Fact] + public void TryTakeRawSample_IdleZeroUsesStrictRetailDelayAndResetsExtent() + { + var sink = new AdjustmentSink(); + var ml = new MouseLookState(sink.Apply) { SensitivityPerPixel = 0.01f }; + ml.Press(0f, 0f, wantCaptureMouse: false, nowSeconds: 1f); + + for (int i = 0; i < 5; i++) + { + ml.QueueDelta(2f); + float now = 1.01f + (i * 0.01f); + Assert.True(ml.TryTakeRawSample(now, out float dx, out float dy)); + Assert.Equal(0f, dy); + ml.ApplyDelta(dx, extraSensitivity: 1f); + } + + // Retail uses a strict > 0.2-second boundary; eventless render frames + // before then do not synthesize input at all. + Assert.False(ml.TryTakeRawSample(1.25f, out _, out _)); + Assert.True(ml.TryTakeRawSample(1.2501f, out float idleDx, out float idleDy)); + Assert.Equal(0f, idleDx); + Assert.Equal(0f, idleDy); + ml.ApplyDelta(idleDx, extraSensitivity: 1f); + + ml.QueueDelta(2f); + Assert.True(ml.TryTakeRawSample(1.26f, out float resumedDx, out _)); + ml.ApplyDelta(resumedDx, extraSensitivity: 1f); + + Assert.Equal(0, sink.ApplyCount); + } + + [Fact] + public void TryTakeRawSample_AccumulatesNativeEventsIntoOneFrameSample() + { + var sink = new AdjustmentSink(); + var ml = new MouseLookState(sink.Apply); + ml.Press(0f, 0f, wantCaptureMouse: false, nowSeconds: 2f); + + ml.QueueDelta(2f, 1f); + ml.QueueDelta(3f, -0.25f); + + Assert.True(ml.TryTakeRawSample(2.01f, out float dx, out float dy)); + Assert.Equal(5f, dx); + Assert.Equal(0.75f, dy); + Assert.False(ml.TryTakeRawSample(2.02f, out _, out _)); + } }