fix(gameplay): reconcile wield ownership and target facing

Preserve PlayerDescription inventory/equipment ownership across authoritative manifest replacement, make weapon switching and combat/UI consumers read the same canonical object state, and carry the complete outbound player position frame across landblocks.

Route target-facing and mouse-look through the shared MovementManager and MotionInterpreter completion owner. Match retail input aggregation, toggle ordering, turn/sidestep remapping, per-axis hold keys, and synchronous movement publication without render-only heading state.

Initialize the live streaming origin from the first accepted canonical player Position, defer other projections until that origin exists, and retain logical entity identity through hydration.

Advance the project ledger from completed M2 to active M3, synchronize CLAUDE.md/AGENTS.md and durable memory, and record the next cast-lifecycle, spellbook/enchantment, and two-client portal gates.

Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
Erik 2026-07-15 08:19:23 +02:00
parent b26f84cc69
commit 7b7ffcd278
36 changed files with 3884 additions and 761 deletions

View file

@ -108,17 +108,12 @@ movement queries.
## Current state ## Current state
**Currently working toward: M2 — Kill a drudge** (equip sword → hit a drudge → damage **Currently working toward: M3 — Cast a spell. M2 — Kill a drudge LANDED 2026-07-15**:
in chat → loot → inventory). **M1.5 — Indoor world feels right LANDED 2026-07-10**: the live melee/missile loop covers weapon switching, target-facing, attack/damage/death, loot,
buildings/cellars/multi-floor inns + full dungeon round-trip (enter→navigate→exit) all inventory, and item giving. The M2/M3 projectile + DAT-effect foundation is automated through
user-gated; #138 closed by the round-trip gate; #133/#137/#95/#79/#93/#80 CLOSED. M2 Step 9; single-client missiles/protection effects are user-gated. **Next:** reconcile F.4/L.1d's
first ports = `CombatMath.ComputeDamage` (F.3) + inventory panel (F.2) + combat anim cast lifecycle against that foundation, then F.5 spellbook + active enchantments. **Carried:** final
(L.1c) — see the M2 section in the milestones doc + `docs/research/2026-06-04-combat-math-deep-dive.md`. two-client portal/observer VFX gate, #145-residual, #116, R6/TS-42, and Track MP0. Detail below.
**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.
For canonical state, read in this order: 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 - [`docs/plans/2026-05-12-milestones.md`](docs/plans/2026-05-12-milestones.md) — milestone targets + freeze list per milestone

View file

@ -106,17 +106,12 @@ movement queries.
## Current state ## Current state
**Currently working toward: M2 — Kill a drudge** (equip sword → hit a drudge → damage **Currently working toward: M3 — Cast a spell. M2 — Kill a drudge LANDED 2026-07-15**:
in chat → loot → inventory). **M1.5 — Indoor world feels right LANDED 2026-07-10**: the live melee/missile loop covers weapon switching, target-facing, attack/damage/death, loot,
buildings/cellars/multi-floor inns + full dungeon round-trip (enter→navigate→exit) all inventory, and item giving. The M2/M3 projectile + DAT-effect foundation is automated through
user-gated; #138 closed by the round-trip gate; #133/#137/#95/#79/#93/#80 CLOSED. M2 Step 9; single-client missiles/protection effects are user-gated. **Next:** reconcile F.4/L.1d's
first ports = `CombatMath.ComputeDamage` (F.3) + inventory panel (F.2) + combat anim cast lifecycle against that foundation, then F.5 spellbook + active enchantments. **Carried:** final
(L.1c) — see the M2 section in the milestones doc + `docs/research/2026-06-04-combat-math-deep-dive.md`. two-client portal/observer VFX gate, #145-residual, #116, R6/TS-42, and Track MP0. Detail below.
**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.
For canonical state, read in this order: 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 - [`docs/plans/2026-05-12-milestones.md`](docs/plans/2026-05-12-milestones.md) — milestone targets + freeze list per milestone

View file

@ -46,7 +46,7 @@ Copy this block when adding a new issue:
## #217 — Character windows did not receive live 64-bit experience updates ## #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 **Severity:** HIGH
**Filed:** 2026-07-13 **Filed:** 2026-07-13
**Component:** retained UI / character sheet / net / player state **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 right-aligned total and red progress fill; Skills immediately shows the same
server-authoritative unassigned XP available for raises. 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 ## #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. NonCombat, so the character could not enter combat.
**Root cause:** PlayerDescription recorded equipped entries with their equip **Root cause:** PlayerDescription recorded equipped entries with their equip
mask but no player ownership/container index. Live WieldObject updates did mask but no player ownership/container index. The combat-mode lookup therefore
index the same item under the player. `ToggleLiveCombatMode` reads the player's missed login equipment, defaulted to Melee, and sent a mode incompatible with
ordered contents for retail `GetObjectAtLocation`, so it missed login equipment, ACE's equipped missile weapon. ACE correctly returned authoritative NonCombat.
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 **Resolution:** PlayerDescription preserves login equipment ownership and the
contained-by-wielder projection as live WieldObject while preserving its wire production combat planner selects Missile for the login-equipped crossbow. A
order, equip mask, and layering priority. The production combat planner now 2026-07-14 retail-conformance refinement also corrected live WieldObject: its
selects Missile for the login-equipped crossbow. The connected gate completed payload is exactly `(itemGuid, equipLocation)`, and confirmation produces
NonCombat → Missile → NonCombat → Missile, including a Jump press, without a `ContainerId=0`, `WielderId=player`. Equipment consumers now use
server rejection. `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` **Research:** `docs/research/2026-07-11-combat-default-and-parent-event-pseudocode.md`

View file

@ -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-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-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-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-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` | | ~~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-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-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-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-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-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 | | 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 |

View file

@ -1,11 +1,34 @@
# acdream — strategic roadmap # 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<Setup>` 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 3050×); 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. **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 **[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` gate + #145-residual. See the shipped table + `docs/plans/2026-05-12-milestones.md`
@ -497,16 +520,16 @@ behavior. Estimated 1726 days focused work, 35 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.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. - **✓ 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.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.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 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 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 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.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`. - **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 05 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. - **Missile/portal VFX campaign Steps 05 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 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 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 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`. - **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. - **✓ 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. - **✓ 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. - **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. - **✓ 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.

View file

@ -2,16 +2,13 @@
**Status:** Living document. Created 2026-05-12. **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). **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 **Currently working toward:** **M3 — "Cast a spell." M2 — "Kill a drudge"
world feels right LANDED 2026-07-10** (pinned writeup in the M1.5 section LANDED 2026-07-15.** The complete connected melee/missile, death/loot,
below): the building/cellar demo (prior sessions) AND the full dungeon inventory, and item-giving demo is user-gated. The shared projectile/effect
round-trip (enter via portal → navigate → exit to the outdoor world) are foundation is hardened through Step 9 and its single-client visual gate passes.
both user-gated. Dungeon support (full Phase G.3) shipped — render / stream Next is the F.4/L.1d retail cast-lifecycle gap audit, then F.5 spellbook and
/ teleport-in / collide / light / doors, each individually gated active enchantments. Carried: the two-client portal/observer VFX gate,
(#133/#137/#95/#79/#93/#80 CLOSED; #138 closed by the round-trip gate). #145-residual, #116 slide-response, R6/TS-42, and Track MP0.
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.
--- ---
@ -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 **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 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 play. Drudge dies, drops loot. Pick up the loot. Open the inventory panel
and see it. 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, **First port target when M2 starts (per the M2 combat-math research memo,
`docs/research/2026-06-04-combat-math-deep-dive.md`):** `docs/research/2026-06-04-combat-math-deep-dive.md`):**
`CombatMath.ComputeDamage` — damage-calc + armor-resists are port-ready `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 - **L.1c held-object foundation (implemented and live-gated 2026-07-11)** — exact
equipment-aware default combat mode (bow→Missile, caster→Magic) plus retail equipment-aware default combat mode (bow→Missile, caster→Magic) plus retail
CreateObject/ParentEvent child attachment and per-frame hand-follow rendering. 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 inventory double-click ports retail's two-stage `AutoWield`: return the occupied
primary weapon to the main pack, then any incompatible shield/ammo, waiting primary weapon to the main pack, then any incompatible shield/ammo, waiting
for every server move confirmation before wielding the requested for every server move confirmation before wielding the requested
@ -463,7 +468,7 @@ include dungeons.
acknowledgement independently of rollback bookkeeping, and prevents rapid acknowledgement independently of rollback bookkeeping, and prevents rapid
switches from overlapping or remaining stuck; ACE owns stance-specific motions and its PropertyInt 40 update switches from overlapping or remaining stuck; ACE owns stance-specific motions and its PropertyInt 40 update
now drives the client `CombatState`. 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 a physical inventory drag released over a 3-D creature now ports
`ItemHolder::AttemptPlaceIn3D` through the exact `GiveObjectRequest 0x00CD` `ItemHolder::AttemptPlaceIn3D` through the exact `GiveObjectRequest 0x00CD`
packet, including selected partial-stack quantities and authoritative-only 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 modes retain the billboard path. This restores the four-piece apex of
Armor/protection shield effects. The final two-client visual gate is still Armor/protection shield effects. The final two-client visual gate is still
pending. 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 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 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. 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 `gmCombatUI` LayoutDesc `0x21000073` now pops for Melee/Missile and shares one
press/hold/release attack-request owner across DAT buttons and keybindings. 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 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" — 🔵 (~34 weeks after M2) ### M3 — "Cast a spell" — 🔵 ACTIVE (started 2026-07-15)
**Demo scenario:** Cast Flame Bolt at a drudge. Watch the cast animation, **Demo scenario:** Cast Flame Bolt at a drudge. Watch the cast animation,
the projectile, the impact. Self-cast a buff (Strength Self). See the the projectile, the impact. Self-cast a buff (Strength Self). See the
enchantment in a buff list. Recall to lifestone — full recall animation, enchantment in a buff list. Recall to lifestone — full recall animation,
correct teleport, correct re-spawn. 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:** **Phases to ship:**
- **F.4** — Spell cast state machine (buffs + recalls first, projectile - **F.4** — Spell cast state machine (buffs + recalls first, projectile
spells second). spells second).

View file

@ -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
```

View file

@ -27,6 +27,7 @@ public sealed class CombatAttackController : IDisposable
private readonly CombatState _combat; private readonly CombatState _combat;
private readonly Func<bool> _canStartAttack; private readonly Func<bool> _canStartAttack;
private readonly Action _prepareAttackRequest;
private readonly Func<AttackHeight, float, bool> _sendAttack; private readonly Func<AttackHeight, float, bool> _sendAttack;
private readonly Action _sendCancelAttack; private readonly Action _sendCancelAttack;
private readonly Func<bool> _isDualWield; private readonly Func<bool> _isDualWield;
@ -50,6 +51,7 @@ public sealed class CombatAttackController : IDisposable
CombatState combat, CombatState combat,
Func<bool> canStartAttack, Func<bool> canStartAttack,
Func<AttackHeight, float, bool> sendAttack, Func<AttackHeight, float, bool> sendAttack,
Action? prepareAttackRequest = null,
Action? sendCancelAttack = null, Action? sendCancelAttack = null,
Func<bool>? isDualWield = null, Func<bool>? isDualWield = null,
Func<bool>? playerReadyForAttack = null, Func<bool>? playerReadyForAttack = null,
@ -58,6 +60,7 @@ public sealed class CombatAttackController : IDisposable
{ {
_combat = combat ?? throw new ArgumentNullException(nameof(combat)); _combat = combat ?? throw new ArgumentNullException(nameof(combat));
_canStartAttack = canStartAttack ?? throw new ArgumentNullException(nameof(canStartAttack)); _canStartAttack = canStartAttack ?? throw new ArgumentNullException(nameof(canStartAttack));
_prepareAttackRequest = prepareAttackRequest ?? (() => { });
_sendAttack = sendAttack ?? throw new ArgumentNullException(nameof(sendAttack)); _sendAttack = sendAttack ?? throw new ArgumentNullException(nameof(sendAttack));
_sendCancelAttack = sendCancelAttack ?? (() => { }); _sendCancelAttack = sendCancelAttack ?? (() => { });
_isDualWield = isDualWield ?? (() => false); _isDualWield = isDualWield ?? (() => false);
@ -73,6 +76,7 @@ public sealed class CombatAttackController : IDisposable
public AttackHeight RequestedHeight { get; private set; } = AttackHeight.Medium; public AttackHeight RequestedHeight { get; private set; } = AttackHeight.Medium;
public float DesiredPower { get; private set; } = InitialDesiredPower; public float DesiredPower { get; private set; } = InitialDesiredPower;
public bool AttackRequestInProgress => _attackRequestInProgress; public bool AttackRequestInProgress => _attackRequestInProgress;
public float RequestedAttackPower => _requestedAttackPower;
public bool BuildInProgress => _buildInProgress; public bool BuildInProgress => _buildInProgress;
/// <summary>The level retail publishes to the embedded combat meter.</summary> /// <summary>The level retail publishes to the embedded combat meter.</summary>
@ -257,8 +261,13 @@ public sealed class CombatAttackController : IDisposable
|| !_canStartAttack()) || !_canStartAttack())
return; 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; _attackRequestInProgress = true;
_requestedAttackPower = 1f; _requestedAttackPower = 1f;
_prepareAttackRequest();
_currentBuildIsAutomatic = false; _currentBuildIsAutomatic = false;
AttemptStartBuildingAttack(); AttemptStartBuildingAttack();
} }

View file

@ -67,7 +67,21 @@ public readonly record struct MovementResult(
// = runRate (where ForwardSpeed is 1.0 / null); everything else = 1.0. // = runRate (where ForwardSpeed is 1.0 / null); everything else = 1.0.
bool JustLanded = false, // true on the single frame we transitioned airborne → grounded bool JustLanded = false, // true on the single frame we transitioned airborne → grounded
float? JumpExtent = null, // non-null when a jump was triggered this frame 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);
/// <summary> /// <summary>
/// Portal-space state for the player movement controller. /// Portal-space state for the player movement controller.
@ -99,8 +113,6 @@ public sealed class PlayerMovementController
private readonly MotionInterpreter _motion; private readonly MotionInterpreter _motion;
private readonly PlayerWeenie _weenie; private readonly PlayerWeenie _weenie;
public float MouseTurnSensitivity { get; set; } = 0.003f;
/// <summary> /// <summary>
/// Maximum Z increase per movement step before the move is rejected. /// Maximum Z increase per movement step before the move is rejected.
/// Retail's <c>step_up_height</c> for human characters is ~0.4 m (hip- /// Retail's <c>step_up_height</c> for human characters is ~0.4 m (hip-
@ -138,6 +150,25 @@ public sealed class PlayerMovementController
public uint CellId { get; private set; } public uint CellId { get; private set; }
public AcDream.Core.Physics.Position CellPosition => _body.CellPosition; public AcDream.Core.Physics.Position CellPosition => _body.CellPosition;
/// <summary>
/// Returns retail's canonical outbound <c>Position</c>: the physics body's
/// carried cell id plus its landblock-local frame origin. Retail
/// <c>CommandInterpreter::SendMovementEvent @ 0x006B4680</c> and
/// <c>SendPositionEvent @ 0x006B4770</c> serialize
/// <c>CPhysicsObj::m_position</c> directly; render/streaming origins are not
/// part of the protocol frame.
/// </summary>
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);
}
/// <summary> /// <summary>
/// Local-player entity id used to skip self-collision in the /// Local-player entity id used to skip self-collision in the
/// airborne sweep. GameWindow updates this whenever the local /// airborne sweep. GameWindow updates this whenever the local
@ -223,18 +254,44 @@ public sealed class PlayerMovementController
private bool _prevTurnLeftHeld; private bool _prevTurnLeftHeld;
private bool _prevTurnRightHeld; private bool _prevTurnRightHeld;
private bool _prevRunHeld; private bool _prevRunHeld;
private bool _hasInputSnapshot;
// Heartbeat timer. // Retail CameraSet::ToggleMouseLook / Rotate (0x00457490 / 0x00458310).
// Cadence is 1.0 sec to match holtburger's // Mouse look owns a transient turn command in the SAME MotionInterpreter
// AUTONOMOUS_POSITION_HEARTBEAT_INTERVAL and the retail trace // as keyboard and server turns; it never writes Yaw directly. The latest
// (2026-05-01 motion-trace findings.md): retail sends ~1 Hz at rest, // filtered sample replaces the preceding sample, matching CameraSet's
// not the 5 Hz our pre-fix code used. Sending at 5 Hz was harmless // per-mouse-message MovePlayer call.
// but wasteful and probably looked like jitter to observers. 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;
/// <summary>
/// Retail's mouse-look movement report interval from
/// <c>CameraSet::Rotate</c> and <c>MouseLookHandler</c>.
/// </summary>
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.
/// <summary> /// <summary>
/// 2026-05-16 — retail-faithful AP cadence. Matches retail's /// 2026-05-16 — retail-faithful AP cadence. Matches retail's
/// CommandInterpreter::ShouldSendPositionEvent (acclient_2013_pseudo_c.txt /// CommandInterpreter::ShouldSendPositionEvent (acclient_2013_pseudo_c.txt
/// at address 0x006b45e0) which gates on either (a) position-or-cell /// at address 0x006b45e0). Inside the interval it reacts to cell/contact
/// change since the last send, or (b) at-rest 1 sec heartbeat elapsed. /// changes; after the interval it compares cell plus the complete frame.
/// `time_between_position_events` constant at 0x006b3efb = 1.0 sec. /// `time_between_position_events` constant at 0x006b3efb = 1.0 sec.
/// ///
/// Old model: a 1 Hz idle / 10 Hz active flat accumulator. That /// Old model: a 1 Hz idle / 10 Hz active flat accumulator. That
@ -245,13 +302,11 @@ public sealed class PlayerMovementController
/// </summary> /// </summary>
public const float HeartbeatInterval = 1.0f; // retail 0x006b3efb public const float HeartbeatInterval = 1.0f; // retail 0x006b3efb
private System.Numerics.Vector3 _lastSentPos; private AcDream.Core.Physics.Position _lastSentPosition;
private uint _lastSentCellId;
private System.Numerics.Plane _lastSentContactPlane; private System.Numerics.Plane _lastSentContactPlane;
private float _lastSentTime; private float _lastSentTime;
private bool _lastSentInitialized; private bool _lastSentInitialized;
private float _simTimeSeconds; private float _simTimeSeconds;
public bool HeartbeatDue { get; private set; }
/// <summary>Sim-time accumulator (advanced by dt at the top of Update). /// <summary>Sim-time accumulator (advanced by dt at the top of Update).
/// Exposed for the network outbound layer to stamp NotePositionSent.</summary> /// Exposed for the network outbound layer to stamp NotePositionSent.</summary>
@ -386,6 +441,307 @@ public sealed class PlayerMovementController
_body.LastMoveWasAutonomous = true; _body.LastMoveWasAutonomous = true;
} }
/// <summary>
/// Begins retail instant mouse-look. <c>CameraSet::ToggleMouseLook</c>
/// (0x00457490) sends a movement event on both transitions, even before a
/// horizontal turn sample arrives.
/// </summary>
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;
}
/// <summary>
/// Supplies the latest signed, filtered horizontal mouse adjustment.
/// Negative means TurnRight in acdream's yaw convention; positive means
/// TurnLeft. Retail <c>CameraSet::Rotate</c> doubles the magnitude, applies
/// a 0.02 dead zone, and caps the raw turn speed at 1.5.
/// </summary>
public void SubmitMouseTurnAdjustment(
float signedAdjustment,
MovementInput input)
{
if (!_mouseLookActive || !float.IsFinite(signedAdjustment))
return;
TakeControlFromServer(input);
_mouseTurnAdjustment = signedAdjustment;
_mouseTurnSamplePending = true;
_mouseMovementEventCandidate = true;
}
/// <summary>
/// Retail's delayed zero-input <c>MouseLookHandler</c> calls
/// <c>CommandInterpreter::StopDrift @ 0x006B4510</c> before filtering the
/// zero sample. It stops both turn directions and participates in the
/// same half-second movement-report cadence.
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
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,
};
/// <summary>
/// Retail <c>CommandInterpreter::TakeControlFromServer</c>
/// (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.
/// </summary>
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);
}
/// <summary>
/// Retail <c>ClientCombatSystem::StartAttackRequest</c> (0x0056C040)
/// invokes <c>CommandInterpreter::MaybeStopCompletely</c> before building
/// the attack. This removes a mouse-origin drift immediately and schedules
/// the final authoritative heading ahead of the eventual attack send.
/// </summary>
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) public void SetCharacterSkills(int runSkill, int jumpSkill)
{ {
_weenie.SetSkills(runSkill, jumpSkill); _weenie.SetSkills(runSkill, jumpSkill);
@ -400,6 +756,59 @@ public sealed class PlayerMovementController
/// </summary> /// </summary>
internal MotionInterpreter Motion => _motion; internal MotionInterpreter Motion => _motion;
/// <summary>
/// Retail <c>CPhysicsObj::StopCompletely</c> (0x00510180) enters through
/// <c>MovementManager::PerformMovement</c> (0x005240D0), not directly
/// through <c>CMotionInterp::StopCompletely</c>. The facade's type-5 path
/// performs the required zero-duration animation completion sweep after
/// the interpreter has queued its matching pending-motion node.
/// </summary>
internal WeenieError StopCompletelyAtPhysicsObjectBoundary() =>
Movement.PerformMovement(new MovementStruct
{
Type = MovementType.StopCompletely,
});
/// <summary>
/// Retail <c>CPhysicsObj::DoMotion</c> (0x00510020) constructs a type-1
/// <c>MovementStruct</c> carrying the original parameters pointer and
/// routes it through <c>MovementManager::PerformMovement</c>. That outer
/// boundary is observable: <c>CMotionInterp::PerformMovement</c>
/// (0x00528E80) performs the synchronous completed-animation sweep after
/// the motion dispatch.
/// </summary>
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,
});
}
/// <summary>
/// Retail <c>CPhysicsObj::StopMotion</c> (0x005100D0), the type-3 peer of
/// <see cref="DoMotionAtPhysicsObjectBoundary"/>. Player input must use
/// this facade; MoveToManager's private _DoMotion/_StopMotion path stays
/// directly on CMotionInterp, matching retail.
/// </summary>
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,
});
}
/// <summary>R4-V5: CONTACT transient-state bit (retail /// <summary>R4-V5: CONTACT transient-state bit (retail
/// <c>transient_state &amp; 1</c>) — the <see cref="MoveTo"/> manager's /// <c>transient_state &amp; 1</c>) — the <see cref="MoveTo"/> manager's
/// UseTime tick gate reads exactly this bit (decomp §6a @307781; a /// 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 /// during a server moveto so apply_raw can't clobber the manager's
/// dispatched motions with the idle raw state.</summary> /// dispatched motions with the idle raw state.</summary>
internal void SetLastMoveWasAutonomous(bool autonomous) internal void SetLastMoveWasAutonomous(bool autonomous)
=> _body.LastMoveWasAutonomous = autonomous; {
_body.LastMoveWasAutonomous = autonomous;
_controlledByServer = !autonomous;
}
/// <summary> /// <summary>
/// Wire the player's AnimationSequencer current cycle velocity into /// Wire the player's AnimationSequencer current cycle velocity into
@ -456,28 +868,95 @@ public sealed class PlayerMovementController
} }
/// <summary> /// <summary>
/// 2026-05-16. Called by the network outbound layer after every /// Retail <c>CommandInterpreter::SendMovementEvent</c> (0x006B4680)
/// AutonomousPosition or MoveToState that carries the player's /// stamps only <c>last_sent_position_time</c>. The carried frame and
/// position. Resets the diff-driven heartbeat clock so the next /// contact plane remain those from the last AutonomousPosition packet.
/// `HeartbeatDue` evaluation requires either a fresh state change
/// (cell, contact-plane, or frame) OR another full HeartbeatInterval.
/// Mirrors retail's SendPositionEvent at
/// <c>acclient_2013_pseudo_c.txt:700345-700348</c> which updates
/// `last_sent_position`, `last_sent_position_time`, AND
/// `last_sent_contact_plane` after every send.
/// </summary> /// </summary>
public void NotePositionSent(System.Numerics.Vector3 worldPos, public void NoteMovementSent(float nowSeconds, bool mouseLookEvent = false)
uint cellId,
System.Numerics.Plane contactPlane,
float nowSeconds)
{ {
_lastSentPos = worldPos; _lastSentTime = nowSeconds;
_lastSentCellId = cellId; if (mouseLookEvent)
{
_mouseMovementEventPending = false;
_lastMouseMovementEventTime = nowSeconds;
}
}
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>
/// Retail <c>CommandInterpreter::SendPositionEvent</c> (0x006B4770)
/// stamps the send time, complete cell-local frame (origin and
/// orientation), and contact plane after an AutonomousPosition packet.
/// </summary>
public void NotePositionSent(AcDream.Core.Physics.Position position,
System.Numerics.Plane contactPlane,
float nowSeconds)
{
_lastSentPosition = position;
_lastSentContactPlane = contactPlane; _lastSentContactPlane = contactPlane;
_lastSentTime = nowSeconds; _lastSentTime = nowSeconds;
_lastSentInitialized = true; _lastSentInitialized = true;
} }
/// <summary>
/// Faithful port of retail
/// <c>CommandInterpreter::ShouldSendPositionEvent</c> (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.
/// </summary>
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 // L.2a slice 1 (2026-05-12): centralized CellId mutation so the
// [cell-transit] probe fires from a single chokepoint. Both the // [cell-transit] probe fires from a single chokepoint. Both the
// server-snap path (SetPosition) and the per-frame resolver path // 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 // R3-W6: retail's teleport idle is a FULL stop (StopCompletely
// 0x00527e40: resets fwd/sidestep/turn COMMANDS, zeroes velocity, // 0x00527e40: resets fwd/sidestep/turn COMMANDS, zeroes velocity,
// enqueues the A9 jump-snapshot node) — not a bare DoMotion(Ready). // 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:: // R5-V3 (#171): retail teleport_hook (0x00514ed0) — PositionManager::
// UnStick (@0x00514eee) right after the moveto cancel: a teleport // UnStick (@0x00514eee) right after the moveto cancel: a teleport
// tears down any active stick. (StopInterpolating/UnConstrain have no // tears down any active stick. (StopInterpolating/UnConstrain have no
@ -556,6 +1045,7 @@ public sealed class PlayerMovementController
_prevTurnLeftHeld = false; _prevTurnLeftHeld = false;
_prevTurnRightHeld = false; _prevTurnRightHeld = false;
_prevRunHeld = false; _prevRunHeld = false;
_hasInputSnapshot = false;
// Reset physics clock so any subsequent update_object calls start fresh. // Reset physics clock so any subsequent update_object calls start fresh.
_body.LastUpdateTime = 0.0; _body.LastUpdateTime = 0.0;
@ -732,8 +1222,31 @@ public sealed class PlayerMovementController
// InterruptCurrentMovement → MoveTo.CancelMoveTo(ActionCancelled), // InterruptCurrentMovement → MoveTo.CancelMoveTo(ActionCancelled),
// the retail user-input cancel chain (TS-36 retired; set_hold_run's // the retail user-input cancel chain (TS-36 retired; set_hold_run's
// interrupt:true is the same chain for the Shift edge). // 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(); var p = new AcDream.Core.Physics.Motion.MovementParameters();
// Shift/run edge FIRST — retail's set_hold_run re-applies // 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 // the old level-triggered build, which always reflected the
// currently-held set). // currently-held set).
if (input.Forward && !_prevForwardHeld) if (input.Forward && !_prevForwardHeld)
{ _motion.DoMotion(MotionCommand.WalkForward, p); motionEdgeFired = true; } { DoMotionAtPhysicsObjectBoundary(MotionCommand.WalkForward, p); motionEdgeFired = true; }
else if (input.Backward && !_prevBackwardHeld && !input.Forward) 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.Forward && _prevForwardHeld)
{ {
if (input.Backward) if (input.Backward)
_motion.DoMotion(MotionCommand.WalkBackward, p); DoMotionAtPhysicsObjectBoundary(MotionCommand.WalkBackward, p);
else else
_motion.StopMotion(MotionCommand.WalkForward, p); StopMotionAtPhysicsObjectBoundary(MotionCommand.WalkForward, p);
motionEdgeFired = true; motionEdgeFired = true;
} }
else if (!input.Backward && _prevBackwardHeld && !input.Forward) else if (!input.Backward && _prevBackwardHeld && !input.Forward)
{ _motion.StopMotion(MotionCommand.WalkBackward, p); motionEdgeFired = true; } { StopMotionAtPhysicsObjectBoundary(MotionCommand.WalkBackward, p); motionEdgeFired = true; }
// Sidestep channel. // Sidestep channel. CameraInstantMouseLook remaps held keyboard
if (input.StrafeRight && !_prevStrafeRightHeld) // TurnLeft/TurnRight into this channel while MMB is active.
{ _motion.DoMotion(MotionCommand.SideStepRight, p); motionEdgeFired = true; } (uint? desiredSidestep, bool sidestepUsesRunHold) =
else if (input.StrafeLeft && !_prevStrafeLeftHeld) DesiredInputSidestep(input, _mouseLookActive);
{ _motion.DoMotion(MotionCommand.SideStepLeft, p); motionEdgeFired = true; } if (ApplyInputSidestep(desiredSidestep, sidestepUsesRunHold))
else if (!input.StrafeRight && !input.StrafeLeft motionEdgeFired = true;
&& (_prevStrafeRightHeld || _prevStrafeLeftHeld))
{ _motion.StopMotion(MotionCommand.SideStepRight, p); motionEdgeFired = true; }
// Turn channel. // Everything above is an ordinary input edge and therefore owns
if (input.TurnRight && !_prevTurnRightHeld) // an immediate SendMovementEvent. Mouse-origin turn updates below
{ _motion.DoMotion(MotionCommand.TurnRight, p); motionEdgeFired = true; } // deliberately do not: CameraSet reports them on its separate
else if (input.TurnLeft && !_prevTurnLeftHeld) // half-second cadence.
{ _motion.DoMotion(MotionCommand.TurnLeft, p); motionEdgeFired = true; } movementEventRequested = motionEdgeFired;
else if (!input.TurnRight && !input.TurnLeft
&& (_prevTurnRightHeld || _prevTurnLeftHeld)) // Turn channel. Retail CameraSet::Rotate (0x00458310) feeds mouse
{ _motion.StopMotion(MotionCommand.TurnRight, p); motionEdgeFired = true; } // 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 // Retail stores last_move_was_autonomous = 1 at the CPhysicsObj
// INPUT boundary — CPhysicsObj::DoMotion @00510030 / // INPUT boundary — CPhysicsObj::DoMotion @00510030 /
@ -796,7 +1356,10 @@ public sealed class PlayerMovementController
// routes the per-tick pump's A3 dual dispatch (raw for // routes the per-tick pump's A3 dual dispatch (raw for
// input-driven motion, interpreted for server/manager-driven). // input-driven motion, interpreted for server/manager-driven).
if (motionEdgeFired) if (motionEdgeFired)
{
_body.LastMoveWasAutonomous = true; _body.LastMoveWasAutonomous = true;
_controlledByServer = false;
}
} }
_prevForwardHeld = input.Forward; _prevForwardHeld = input.Forward;
@ -828,14 +1391,14 @@ public sealed class PlayerMovementController
// and TurnToHeading nodes dispatch TurnRight/TurnLeft through // and TurnToHeading nodes dispatch TurnRight/TurnLeft through
// _DoMotion into the SAME interpreted turn state, so this one // _DoMotion into the SAME interpreted turn state, so this one
// integrator rotates the player for both input and moveto turns. // integrator rotates the player for both input and moveto turns.
// Mouse turn stays a direct Yaw delta (deliberately generates no // Mouse-origin turns now enter the same interpreted turn state above.
// turn command — avoids MoveToState spam from mouse jitter). // Packet cadence is carried separately by ShouldSendMovementEvent, so
// local mouse sampling cannot flood MoveToState.
if (_motion.InterpretedState.TurnCommand == MotionCommand.TurnRight) if (_motion.InterpretedState.TurnCommand == MotionCommand.TurnRight)
{ {
Yaw -= (MathF.PI / 2.0f) // BaseTurnRateRadPerSec (AP-9), ex-RemoteMoveToDriver Yaw -= (MathF.PI / 2.0f) // BaseTurnRateRadPerSec (AP-9), ex-RemoteMoveToDriver
* _motion.InterpretedState.TurnSpeed * dt; * _motion.InterpretedState.TurnSpeed * dt;
} }
Yaw -= input.MouseDeltaX * MouseTurnSensitivity;
// Wrap yaw to [-PI, PI] so it doesn't grow unbounded. // 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;
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; outForwardSpeed = 1.0f;
} }
// Strafe: retail uses speed=1.0 for SideStep (see holtburger // Source the outbound axis from the canonical input-owned raw channel.
// common.rs::locomotion_command_for_state). 0.5 was our earlier guess // CameraInstantMouseLook maps keyboard TurnLeft/TurnRight into this
// and made strafing feel lethargic; the retail feel is full-speed // same channel, so reconstructing it from only physical strafe keys
// sidestep matching the walk forward pace. // would send ACE an idle sidestep while the local body moved.
if (input.StrafeRight) if (_activeInputSidestepCommand is { } activeInputSidestep)
{ {
outSidestepCmd = MotionCommand.SideStepRight; outSidestepCmd = activeInputSidestep;
outSidestepSpeed = 1.0f; outSidestepSpeed = _motion.RawState.SidestepSpeed;
}
else if (input.StrafeLeft)
{
outSidestepCmd = MotionCommand.SideStepLeft;
outSidestepSpeed = 1.0f;
} }
// Turn commands from KEYBOARD only (A/D). Mouse turning is applied // Turn commands come from the current user-owned turn channel. This is
// directly to Yaw above and doesn't generate a turn command — if it // keyboard A/D or CameraSet's transient mouse-origin turn, never a
// did, mouse jitter would flip turnCmd between TurnRight/TurnLeft // server-owned MoveTo turn. The raw speed is the exact value passed to
// every frame, causing stateChanged=True on every frame and flooding // MotionInterpreter before hold-run adjustment.
// the server with MoveToState spam. if (_activeInputTurnCommand is { } activeInputTurn)
if (input.TurnRight)
{ {
outTurnCmd = MotionCommand.TurnRight; outTurnCmd = activeInputTurn;
outTurnSpeed = 1.0f; outTurnSpeed = _activeInputTurnSpeed;
}
else if (input.TurnLeft)
{
outTurnCmd = MotionCommand.TurnLeft;
outTurnSpeed = 1.0f;
} }
// ── 7. Detect motion state change ───────────────────────────────────── // ── 7. Detect motion state change ─────────────────────────────────────
@ -1245,6 +1797,15 @@ public sealed class PlayerMovementController
|| !FloatsEqual(outForwardSpeed, _prevForwardSpeed) || !FloatsEqual(outForwardSpeed, _prevForwardSpeed)
|| runHold != _prevRunHold || runHold != _prevRunHold
|| motionEdgeFired; || motionEdgeFired;
bool mouseMovementEventDue = _mouseMovementEventPending
|| (_mouseMovementEventCandidate
&& _simTimeSeconds > _lastMouseMovementEventTime + MouseMovementEventInterval);
_mouseMovementEventCandidate = false;
if (mouseMovementEventDue)
_mouseMovementEventPending = true;
bool shouldSendMovementEvent = movementEventRequested || mouseMovementEventDue;
_prevForwardCmd = outForwardCmd; _prevForwardCmd = outForwardCmd;
_prevSidestepCmd = outSidestepCmd; _prevSidestepCmd = outSidestepCmd;
_prevTurnCmd = outTurnCmd; _prevTurnCmd = outTurnCmd;
@ -1258,63 +1819,11 @@ public sealed class PlayerMovementController
return System.Math.Abs(a.Value - b.Value) < 1e-4f; 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 // R3-W6: the K-fix5 LocalAnimationSpeed synthesis is DELETED — the
// run pacing now comes from the ported machinery itself // run pacing now comes from the ported machinery itself
// (apply_run_to_command scales the interpreted speed by my_run_rate; // (apply_run_to_command scales the interpreted speed by my_run_rate;
// the DefaultSink dispatch plays the cycle at that speed — the same // the DefaultSink dispatch plays the cycle at that speed — the same
// source remotes use). // 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 — // R4-V5: the #69 auto-walk turn-cycle edge synthesizer is DELETED —
// the MoveToManager's own aux-turn steering and TurnToHeading nodes // the MoveToManager's own aux-turn steering and TurnToHeading nodes
// dispatch TurnRight/TurnLeft through _DoMotion (the retail // dispatch TurnRight/TurnLeft through _DoMotion (the retail
@ -1333,34 +1842,36 @@ public sealed class PlayerMovementController
ForwardSpeed: outForwardSpeed, ForwardSpeed: outForwardSpeed,
SidestepSpeed: outSidestepSpeed, SidestepSpeed: outSidestepSpeed,
TurnSpeed: outTurnSpeed, TurnSpeed: outTurnSpeed,
// Run hold-key applies to ANY active directional axis, not just // CurrentHoldKey is a level independent of active axes; every
// forward (per holtburger's build_motion_state_raw_motion_state: // active ordinary axis inherits it during wire projection. This
// "uses the same value for every active per-axis hold key"). The // preserves idle run/walk toggles as well as strafe/backward run.
// pre-fix condition `input.Run && input.Forward` made strafe-run IsRunning: input.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,
JustLanded: justLanded, JustLanded: justLanded,
JumpExtent: outJumpExtent, JumpExtent: outJumpExtent,
JumpVelocity: outJumpVelocity); JumpVelocity: outJumpVelocity,
ShouldSendMovementEvent: shouldSendMovementEvent,
TurnUsesRunHold: _activeInputTurnFromMouse && outTurnCmd.HasValue,
SidestepUsesRunHold: _activeInputSidestepUsesRunHold
&& outSidestepCmd.HasValue,
IsMouseLookMovementEvent: mouseMovementEventDue);
} }
/// <summary> /// <summary>
/// 2026-05-16. Position-equality test for diff-driven AP cadence. /// Retail <c>Frame::is_equal</c> (0x00424C30) compares both the origin
/// Retail uses Frame::is_equal at acclient_2013_pseudo_c.txt:700263 /// and all four quaternion components with a 0.0002-unit epsilon. It does
/// which is essentially exact float comparison after a memcmp of /// not treat the mathematically equivalent <c>q</c>/<c>-q</c> pair as the
/// the frame struct. For floating-point safety we use a tiny epsilon /// same stored frame.
/// — sub-millimeter — that's well below any movement we'd want to
/// suppress sending for.
/// </summary> /// </summary>
private static bool ApproxPositionEqual( private static bool ApproxFrameEqual(CellFrame a, CellFrame b)
System.Numerics.Vector3 a, System.Numerics.Vector3 b)
{ {
const float Epsilon = 0.001f; // 1 mm const float Epsilon = 0.000199999995f;
return MathF.Abs(a.X - b.X) < Epsilon return MathF.Abs(a.Origin.X - b.Origin.X) <= Epsilon
&& MathF.Abs(a.Y - b.Y) < Epsilon && MathF.Abs(a.Origin.Y - b.Origin.Y) <= Epsilon
&& MathF.Abs(a.Z - b.Z) < 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;
} }
/// <summary> /// <summary>
@ -1375,11 +1886,10 @@ public sealed class PlayerMovementController
private static bool ApproxPlaneEqual( private static bool ApproxPlaneEqual(
System.Numerics.Plane a, System.Numerics.Plane b) System.Numerics.Plane a, System.Numerics.Plane b)
{ {
const float NormalEpsilon = 1e-4f; const float Epsilon = 0.000199999995f;
const float DistanceEpsilon = 0.001f; return MathF.Abs(a.Normal.X - b.Normal.X) <= Epsilon
return MathF.Abs(a.Normal.X - b.Normal.X) < NormalEpsilon && MathF.Abs(a.Normal.Y - b.Normal.Y) <= Epsilon
&& MathF.Abs(a.Normal.Y - b.Normal.Y) < NormalEpsilon && MathF.Abs(a.Normal.Z - b.Normal.Z) <= Epsilon
&& MathF.Abs(a.Normal.Z - b.Normal.Z) < NormalEpsilon && MathF.Abs(a.D - b.D) < Epsilon;
&& MathF.Abs(a.D - b.D) < DistanceEpsilon;
} }
} }

View file

@ -570,10 +570,10 @@ public sealed class EquippedChildRenderController : IDisposable
return new PaletteOverride(spawn.BasePaletteId ?? 0, ranges); return new PaletteOverride(spawn.BasePaletteId ?? 0, ranges);
} }
private void OnObjectMoved(ClientObject item, uint _, uint __) private void OnObjectMoved(ClientObjectMove move)
{ {
if (item.CurrentlyEquippedLocation == EquipMask.None) if (move.Current.EquipLocation == EquipMask.None)
Remove(item.ObjectId); Remove(move.ItemId);
} }
private void OnMoveRolledBack(ClientObject item) private void OnMoveRolledBack(ClientObject item)

File diff suppressed because it is too large Load diff

View file

@ -222,13 +222,13 @@ internal sealed class AutoWieldController : IDisposable
return true; return true;
} }
private void OnObjectMoved(ClientObject item, uint _, uint newContainerId) private void OnObjectMoved(ClientObjectMove move)
{ {
if (_pendingSwitch is not { } pending if (_pendingSwitch is not { } pending
|| pending.BlockingItemId == 0 || pending.BlockingItemId == 0
|| item.ObjectId != pending.BlockingItemId || move.ItemId != pending.BlockingItemId
|| newContainerId != _playerGuid() || move.Current.ContainerId != _playerGuid()
|| item.CurrentlyEquippedLocation != EquipMask.None) || move.Current.EquipLocation != EquipMask.None)
return; return;
// Clear before re-entry: the second AutoWield pass must see the newly // 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); TryWield(requested, pending.RequestedMask);
} }
private void OnWieldConfirmed(ClientObject item) private void OnWieldConfirmed(uint itemId)
{ {
if (_pendingSwitch is { BlockingItemId: 0 } pending if (_pendingSwitch is { BlockingItemId: 0 } pending
&& item.ObjectId == pending.RequestedItemId) && itemId == pending.RequestedItemId)
_pendingSwitch = null; _pendingSwitch = null;
} }

View file

@ -170,6 +170,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
// Rebuild on any change to the player's possessions. // Rebuild on any change to the player's possessions.
_objects.ObjectAdded += OnObjectChanged; _objects.ObjectAdded += OnObjectChanged;
_objects.ObjectMoved += OnObjectMoved; _objects.ObjectMoved += OnObjectMoved;
_objects.ContainerContentsReplaced += OnContainerContentsReplaced;
_objects.ObjectRemoved += OnObjectRemoved; _objects.ObjectRemoved += OnObjectRemoved;
_objects.ObjectUpdated += OnObjectChanged; _objects.ObjectUpdated += OnObjectChanged;
_objects.Cleared += OnObjectsCleared; _objects.Cleared += OnObjectsCleared;
@ -239,8 +240,21 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
} }
if (Concerns(o)) Populate(); if (Concerns(o)) Populate();
} }
private void OnObjectMoved(ClientObject o, uint from, uint to) private void OnObjectMoved(ClientObjectMove move)
{ if (Concerns(o) || from == _playerGuid() || to == _playerGuid()) Populate(); } {
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 OnInteractionStateChanged() => ApplyIndicators();
private void OnSelectionChanged(SelectionTransition _) => ApplyIndicators(); private void OnSelectionChanged(SelectionTransition _) => ApplyIndicators();
private void OnObjectsCleared() => Populate(); private void OnObjectsCleared() => Populate();
@ -702,6 +716,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
_disposed = true; _disposed = true;
_objects.ObjectAdded -= OnObjectChanged; _objects.ObjectAdded -= OnObjectChanged;
_objects.ObjectMoved -= OnObjectMoved; _objects.ObjectMoved -= OnObjectMoved;
_objects.ContainerContentsReplaced -= OnContainerContentsReplaced;
_objects.ObjectRemoved -= OnObjectRemoved; _objects.ObjectRemoved -= OnObjectRemoved;
_objects.ObjectUpdated -= OnObjectChanged; _objects.ObjectUpdated -= OnObjectChanged;
_objects.Cleared -= OnObjectsCleared; _objects.Cleared -= OnObjectsCleared;

View file

@ -211,8 +211,16 @@ public sealed class PaperdollController : IItemListDragHandler, IRetainedPanelCo
else if (Concerns(o)) else if (Concerns(o))
Populate(); Populate();
} }
private void OnObjectMoved(ClientObject o, uint from, uint to) private void OnObjectMoved(ClientObjectMove move)
{ if (Concerns(o) || from == _playerGuid() || to == _playerGuid()) Populate(); } {
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 OnSelectionChanged(SelectionTransition _) => ApplySelectionIndicators();
private void OnObjectsCleared() 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 /// 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 /// 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 /// 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 /// WieldItemOptimistic), so the equip-location need not be tested here; OnObjectMoved carries the
/// player backstop for moves that transiently satisfy neither (e.g. unwield into a side bag).</summary> /// complete old/new retail placement for transitions that satisfy neither after mutation.</summary>
private bool Concerns(ClientObject o) private bool Concerns(ClientObject o)
{ {
uint p = _playerGuid(); uint p = _playerGuid();

View file

@ -216,13 +216,17 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
Populate(); Populate();
} }
private void OnRepositoryObjectMoved(ClientObject obj, uint oldContainer, uint newContainer) private void OnRepositoryObjectMoved(ClientObjectMove move)
{ {
uint player = _playerGuid?.Invoke() ?? 0u; uint player = _playerGuid?.Invoke() ?? 0u;
if (obj.ObjectId == _ammoObjectId if (move.ItemId == _ammoObjectId
|| (player != 0 && (oldContainer == player || newContainer == player))) || (player != 0
&& (move.Previous.ContainerId == player
|| move.Current.ContainerId == player
|| move.Previous.WielderId == player
|| move.Current.WielderId == player)))
RefreshAmmo(); RefreshAmmo();
if (IsShortcutGuid(obj.ObjectId)) if (IsShortcutGuid(move.ItemId))
Populate(); Populate();
} }
@ -238,7 +242,7 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
return obj.ObjectId == _ammoObjectId return obj.ObjectId == _ammoObjectId
|| (player != 0 && obj.ObjectId == player) || (player != 0 && obj.ObjectId == player)
|| (player != 0 || (player != 0
&& obj.ContainerId == player && (obj.WielderId == player || obj.ContainerId == player)
&& (obj.CurrentlyEquippedLocation && (obj.CurrentlyEquippedLocation
& (EquipMask.MissileWeapon | EquipMask.MissileAmmo)) != 0); & (EquipMask.MissileWeapon | EquipMask.MissileAmmo)) != 0);
} }
@ -246,13 +250,9 @@ public sealed class ToolbarController : IItemListDragHandler, IRetainedPanelCont
private void RefreshAmmo() private void RefreshAmmo()
{ {
uint player = _playerGuid?.Invoke() ?? 0u; uint player = _playerGuid?.Invoke() ?? 0u;
var placements = new List<ClientObject>(); IReadOnlyList<ClientObject> placements = player != 0
if (player != 0) ? _repo.GetEquippedBy(player)
{ : Array.Empty<ClientObject>();
foreach (uint objectId in _repo.GetContents(player))
if (_repo.Get(objectId) is { } item)
placements.Add(item);
}
ToolbarAmmoPolicy.Result result = ToolbarAmmoPolicy.Resolve(placements); ToolbarAmmoPolicy.Result result = ToolbarAmmoPolicy.Resolve(placements);
_ammoObjectId = result.ObjectId; _ammoObjectId = result.ObjectId;

View file

@ -305,33 +305,29 @@ public static class GameEventWiring
var p = GameEvents.ParseWieldObject(e.Payload.Span); var p = GameEvents.ParseWieldObject(e.Payload.Span);
if (p is null) return; if (p is null) return;
uint wielderGuid = p.Value.WielderGuid != 0u uint wielderGuid = playerGuid?.Invoke() ?? 0u;
? p.Value.WielderGuid items.ApplyConfirmedServerWield(
: (playerGuid?.Invoke() ?? 0u);
items.MoveItem(
p.Value.ItemGuid, p.Value.ItemGuid,
newContainerId: wielderGuid, wielderGuid,
newEquipLocation: (AcDream.Core.Items.EquipMask)p.Value.EquipLoc); (AcDream.Core.Items.EquipMask)p.Value.EquipLoc);
items.ConfirmWield(p.Value.ItemGuid); // Exact IR_WIELD completion + optimistic reconciliation.
}); });
dispatcher.Register(GameEventType.InventoryPutObjInContainer, e => dispatcher.Register(GameEventType.InventoryPutObjInContainer, e =>
{ {
var p = GameEvents.ParsePutObjInContainer(e.Payload.Span); var p = GameEvents.ParsePutObjInContainer(e.Payload.Span);
if (p is null) return; if (p is null) return;
items.MoveItem( items.ApplyConfirmedServerMove(
p.Value.ItemGuid, p.Value.ItemGuid,
p.Value.ContainerGuid, p.Value.ContainerGuid,
newWielderId: 0u,
newSlot: (int)p.Value.Placement, newSlot: (int)p.Value.Placement,
containerTypeHint: p.Value.ContainerType); 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 // 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 // opened (Use 0x0036). Treat it as a full projection-only REPLACE: update membership without
// record the new entries in order (ContainerSlot = index — ACE writes entries // inventing ContainerSlot values, then publish one ContainerContentsReplaced notification so
// OrderBy(PlacementPosition) with no slot field). The container-open UI (InventoryController) // every UI consumer repaints from the same snapshot. Retail: ClientUISystem::OnViewContents.
// repaints the grid off the resulting ObjectAdded/Moved events. Retail: ClientUISystem::OnViewContents.
dispatcher.Register(GameEventType.ViewContents, e => dispatcher.Register(GameEventType.ViewContents, e =>
{ {
var p = GameEvents.ParseViewContents(e.Payload.Span); var p = GameEvents.ParseViewContents(e.Payload.Span);
@ -352,8 +348,10 @@ public static class GameEventWiring
var guid = GameEvents.ParsePutObjectIn3D(e.Payload.Span); var guid = GameEvents.ParsePutObjectIn3D(e.Payload.Span);
if (guid is not null) if (guid is not null)
{ {
items.MoveItem(guid.Value, newContainerId: 0u); items.ApplyConfirmedServerMove(
items.ConfirmMove(guid.Value); guid.Value,
newContainerId: 0u,
newWielderId: 0u);
} }
}); });
@ -596,20 +594,35 @@ public static class GameEventWiring
entries[i] = new ContainerContentEntry( entries[i] = new ContainerContentEntry(
p.Value.Inventory[i].Guid, p.Value.Inventory[i].Guid,
p.Value.Inventory[i].ContainerType); p.Value.Inventory[i].ContainerType);
items.ReplaceContents(ownerGuid, entries); items.InitializeInventoryManifest(ownerGuid, entries);
} }
else else
{ {
foreach (var inv in p.Value.Inventory) foreach (var inv in p.Value.Inventory)
items.RecordMembership(inv.Guid, containerTypeHint: inv.ContainerType); items.RecordMembership(inv.Guid, containerTypeHint: inv.ContainerType);
} }
foreach (var eq in p.Value.Equipped) if (ownerGuid != 0u)
{ {
items.RecordMembership( var equipment = new EquipmentManifestEntry[p.Value.Equipped.Count];
eq.Guid, for (int i = 0; i < equipment.Length; i++)
containerId: ownerGuid, {
equip: (EquipMask)eq.EquipLocation, var eq = p.Value.Equipped[i];
priority: eq.Priority); 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 // D.5.1 Task 4: forward shortcut bar entries to the caller so the

View file

@ -350,19 +350,14 @@ public static class GameEvents
/// <summary>0x0023 WieldObject: server-driven equip.</summary> /// <summary>0x0023 WieldObject: server-driven equip.</summary>
public readonly record struct WieldObject( public readonly record struct WieldObject(
uint ItemGuid, uint ItemGuid,
uint EquipLoc, uint EquipLoc);
uint WielderGuid);
public static WieldObject? ParseWieldObject(ReadOnlySpan<byte> payload) public static WieldObject? ParseWieldObject(ReadOnlySpan<byte> payload)
{ {
if (payload.Length < 8) return null; if (payload.Length < 8) return null;
uint wielderGuid = payload.Length >= 12
? BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(8))
: 0u;
return new WieldObject( return new WieldObject(
BinaryPrimitives.ReadUInt32LittleEndian(payload), BinaryPrimitives.ReadUInt32LittleEndian(payload),
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)), BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)));
wielderGuid);
} }
/// <summary>0x0022 InventoryPutObjInContainer: server puts item into container slot. /// <summary>0x0022 InventoryPutObjInContainer: server puts item into container slot.

View file

@ -100,6 +100,7 @@ public static class WeenieErrorMessages
[0x0498] = "You have moved too far!", // YouHaveMovedTooFar [0x0498] = "You have moved too far!", // YouHaveMovedTooFar
[0x0499] = "That is not a valid destination!", // TeleToInvalidPosition [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.", [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 // Fellowship
[0x0528] = "The fellowship is locked; you cannot open locked fellowships.", [0x0528] = "The fellowship is locked; you cannot open locked fellowships.",

View file

@ -10,6 +10,12 @@ namespace AcDream.Core.Items;
/// </summary> /// </summary>
public readonly record struct ContainerContentEntry(uint Guid, uint ContainerType); public readonly record struct ContainerContentEntry(uint Guid, uint ContainerType);
/// <summary>One login-time PlayerDescription equipment placement.</summary>
public readonly record struct EquipmentManifestEntry(
uint Guid,
EquipMask EquipLocation,
uint Priority);
/// <summary> /// <summary>
/// Server rejection of an inventory move request. <paramref name="RolledBack"/> /// Server rejection of an inventory move request. <paramref name="RolledBack"/>
/// is true when the table restored a locally optimistic move; false for /// is true when the table restored a locally optimistic move; false for
@ -21,6 +27,35 @@ public readonly record struct MoveRequestFailure(
uint WeenieError, uint WeenieError,
bool RolledBack); bool RolledBack);
/// <summary>
/// One side of retail's <c>ServerSaysMoveItem_s</c> 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.
/// </summary>
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);
}
/// <summary>
/// Retail-shaped inventory placement notice. Named retail
/// <c>ACCWeenieObject::ServerSaysMoveItem @ 0x0058DBB0</c> snapshots and
/// publishes both complete placements after applying the new state.
/// </summary>
public readonly record struct ClientObjectMove(
uint ItemId,
ClientObject? Item,
ClientObjectPlacement Previous,
ClientObjectPlacement Current);
public enum ClientObjectRemovalReason public enum ClientObjectRemovalReason
{ {
Ordinary, Ordinary,
@ -72,21 +107,29 @@ public sealed class ClientObjectTable
private readonly ConcurrentDictionary<uint, ClientObject> _objects = new(); private readonly ConcurrentDictionary<uint, ClientObject> _objects = new();
private readonly ConcurrentDictionary<uint, Container> _containers = new(); private readonly ConcurrentDictionary<uint, Container> _containers = new();
private readonly Dictionary<uint, List<uint>> _containerIndex = new(); private readonly Dictionary<uint, List<uint>> _containerIndex = new();
private readonly Dictionary<uint, List<uint>> _equipmentIndex = new();
// B-Drag: pre-move snapshots for optimistic inventory moves. itemId → (container, slot, equip) BEFORE // B-Drag: pre-move snapshots for optimistic inventory moves. itemId → (container, slot, equip) BEFORE
// the optimistic MoveItem; restored by RollbackMove on InventoryServerSaveFailed (0x00A0), // the optimistic MoveItem; restored by RollbackMove on InventoryServerSaveFailed (0x00A0),
// cleared by ConfirmMove on the InventoryPutObjInContainer (0x0022) echo. // cleared by ConfirmMove on the InventoryPutObjInContainer (0x0022) echo.
private readonly Dictionary<uint, (uint container, int slot, EquipMask equip, int outstanding)> _pendingMoves = new(); private readonly Dictionary<uint, (ClientObjectPlacement placement, int outstanding)> _pendingMoves = new();
/// <summary>Fires when an object is first added to the session.</summary> /// <summary>Fires when an object is first added to the session.</summary>
public event Action<ClientObject>? ObjectAdded; public event Action<ClientObject>? ObjectAdded;
/// <summary> /// <summary>
/// Fires when an object's container / slot changes (moved between /// Fires after a complete placement notice (moved between packs,
/// packs, equipped, unequipped, dropped on ground). Old and new /// equipped, unequipped, dropped on ground). <see cref="ClientObjectMove.Item"/>
/// container ids are 0 if origin or destination is "world" / "nowhere". /// is null when retail publishes the notice before CreateObject resolves
/// the GUID; the old/new placement remains authoritative notice data.
/// </summary> /// </summary>
public event Action<ClientObject, uint, uint>? ObjectMoved; public event Action<ClientObjectMove>? ObjectMoved;
/// <summary>
/// Fires after retail ViewContents replaces a container's ordered member
/// lists. This is a list-projection change, not an item ownership move.
/// </summary>
public event Action<uint>? ContainerContentsReplaced;
/// <summary> /// <summary>
/// Fires after an optimistic inventory/equipment move is rejected and /// 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 /// rollback bookkeeping: retail clears IR_WIELD on the matching server
/// response even when separate reconciliation state exists for the item. /// response even when separate reconciliation state exists for the item.
/// </summary> /// </summary>
public event Action<ClientObject>? WieldConfirmed; public event Action<uint>? WieldConfirmed;
/// <summary> /// <summary>
/// Fires for every InventoryServerSaveFailed response, including requests /// Fires for every InventoryServerSaveFailed response, including requests
@ -164,8 +207,12 @@ public sealed class ClientObjectTable
public void AddOrUpdate(ClientObject item) public void AddOrUpdate(ClientObject item)
{ {
ArgumentNullException.ThrowIfNull(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; _objects[item.ObjectId] = item;
UpdateEquipmentIndex(item.ObjectId, previous, ClientObjectPlacement.From(item));
if (!existed) ObjectAdded?.Invoke(item); if (!existed) ObjectAdded?.Invoke(item);
else ObjectUpdated?.Invoke(item); else ObjectUpdated?.Invoke(item);
} }
@ -180,25 +227,156 @@ public sealed class ClientObjectTable
} }
/// <summary> /// <summary>
/// Handle a server-driven move — called from /// Move the local projection of an item without changing authoritative
/// InventoryPutObjInContainer (0x0022) and WieldObject (0x0023) /// WielderId. Optimistic inventory requests use this path because retail
/// handlers. Updates ContainerId / ContainerSlot / CurrentlyEquippedLocation /// UIAttemptWield sends the request without rewriting public weenie fields.
/// 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.
/// </summary> /// </summary>
public bool MoveItem(uint itemId, uint newContainerId, int newSlot = -1, public bool MoveItem(uint itemId, uint newContainerId, int newSlot = -1,
EquipMask newEquipLocation = EquipMask.None, uint? containerTypeHint = null) EquipMask newEquipLocation = EquipMask.None, uint? containerTypeHint = null)
{ {
if (!_objects.TryGetValue(itemId, out var item)) return false; if (!_objects.TryGetValue(itemId, out var item)) return false;
return ApplyPlacement(
item,
new ClientObjectPlacement(
newContainerId,
newSlot,
item.WielderId,
newEquipLocation),
containerTypeHint);
}
uint oldContainer = item.ContainerId; /// <summary>
item.ContainerId = newContainerId; /// Apply an authoritative server move, including the item's wielder
item.ContainerSlot = newSlot; /// ownership. Retail <c>ACCWeenieObject::ServerSaysMoveItem @
item.CurrentlyEquippedLocation = newEquipLocation; /// 0x0058DBB0</c> updates <c>_containerID</c>, <c>_wielderID</c>, and
if (containerTypeHint is { } hint) item.ContainerTypeHint = hint; /// <c>_location</c> in one callback. Keeping the old wielder after an
Reindex(item, oldContainer); /// unwield makes <c>DetermineUseResult @ 0x00588460</c> classify a bow in
ObjectMoved?.Invoke(item, oldContainer, newContainerId); /// the backpack as already wielded, so a later double click sends no
/// request.
/// </summary>
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<uint>? 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));
}
/// <summary>
/// Applies a server-confirmed container/world move. The matching pending
/// request is reconciled before <see cref="ObjectMoved"/> is published,
/// matching retail <c>ServerSaysMoveItem</c>: a reentrant listener may start
/// a new request without the old confirmation consuming it afterward.
/// </summary>
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;
}
/// <summary>
/// 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
/// <c>ACCWeenieObject::ServerSaysMoveItem @ 0x0058DBB0</c>.
/// </summary>
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; return true;
} }
@ -209,10 +387,9 @@ public sealed class ClientObjectTable
private void RecordPending(uint itemId, ClientObject item) private void RecordPending(uint itemId, ClientObject item)
{ {
if (_pendingMoves.TryGetValue(itemId, out var p)) 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 else
_pendingMoves[itemId] = (item.ContainerId, item.ContainerSlot, _pendingMoves[itemId] = (ClientObjectPlacement.From(item), 1);
item.CurrentlyEquippedLocation, 1);
} }
/// <summary> /// <summary>
@ -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 // 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 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. // (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.ContainerId = newContainerId;
item.CurrentlyEquippedLocation = EquipMask.None; item.CurrentlyEquippedLocation = EquipMask.None;
List<uint>? changedContainers = RemoveFromOtherContainerIndexes(
itemId,
newContainerId);
if (oldContainer != 0 && oldContainer != newContainerId if (oldContainer != 0 && oldContainer != newContainerId
&& _containerIndex.TryGetValue(oldContainer, out var srcList)) && _containerIndex.TryGetValue(oldContainer, out var srcList))
@ -253,15 +434,16 @@ public sealed class ClientObjectTable
} }
else item.ContainerSlot = newSlot; else item.ContainerSlot = newSlot;
ObjectMoved?.Invoke(item, oldContainer, newContainerId); PublishPlacementChange(item, previous);
PublishContainerContentsChanges(changedContainers);
return true; return true;
} }
/// <summary>Optimistic (instant) wield: snapshot the pre-wield (container, slot, equip), then set /// <summary>Optimistic (instant) wield: snapshot the pre-wield (container, slot, equip), then set
/// ContainerId = wielderGuid and CurrentlyEquippedLocation = equipMask — EXACTLY what the server's /// ContainerId = wielderGuid and CurrentlyEquippedLocation = equipMask. WielderId remains unchanged
/// WieldObject 0x0023 confirm does via <see cref="MoveItem"/> (acdream models a wielded item as /// until <see cref="ApplyConfirmedServerWield"/> receives the authoritative WieldObject, matching retail's
/// contained-by-the-wielder). Neither path writes WielderId, so the optimistic state equals the /// UIAttemptWield request/confirmation split. The confirmation converts this temporary projection to
/// confirmed state and <see cref="RollbackMove"/> via MoveItem fully restores pre-move state. Fires /// ContainerId zero + authoritative WielderId. <see cref="RollbackMove"/> restores the local projection. Fires
/// ObjectMoved for an immediate repaint. The caller sends GetAndWieldItem; ConfirmMove (on the 0x0023 /// ObjectMoved for an immediate repaint. The caller sends GetAndWieldItem; ConfirmMove (on the 0x0023
/// echo) / RollbackMove (on 0x00A0) reconcile.</summary> /// echo) / RollbackMove (on 0x00A0) reconcile.</summary>
public bool WieldItemOptimistic(uint itemId, uint wielderGuid, EquipMask equipMask) public bool WieldItemOptimistic(uint itemId, uint wielderGuid, EquipMask equipMask)
@ -291,24 +473,10 @@ public sealed class ClientObjectTable
} }
else else
{ {
_pendingMoves[itemId] = (p.container, p.slot, p.equip, p.outstanding - 1); _pendingMoves[itemId] = (p.placement, p.outstanding - 1);
} }
} }
/// <summary>
/// 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.
/// </summary>
public void ConfirmWield(uint itemId)
{
ConfirmMove(itemId);
if (_objects.TryGetValue(itemId, out ClientObject? item))
WieldConfirmed?.Invoke(item);
}
/// <summary>The server rejected a move (InventoryServerSaveFailed 0x00A0) — restore the item to its /// <summary>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 /// pre-move (container, slot) and drop the snapshot entirely (the server's next snapshot reconciles
/// any still-outstanding moves). False if nothing was pending.</summary> /// any still-outstanding moves). False if nothing was pending.</summary>
@ -316,7 +484,13 @@ public sealed class ClientObjectTable
{ {
if (!_pendingMoves.TryGetValue(itemId, out var pre)) return false; if (!_pendingMoves.TryGetValue(itemId, out var pre)) return false;
_pendingMoves.Remove(itemId); _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; return false;
MoveRolledBack?.Invoke(_objects[itemId]); MoveRolledBack?.Invoke(_objects[itemId]);
return true; return true;
@ -357,12 +531,18 @@ public sealed class ClientObjectTable
bool notifyObjectRemoved) bool notifyObjectRemoved)
{ {
if (!_objects.TryRemove(itemId, out var item)) return false; if (!_objects.TryRemove(itemId, out var item)) return false;
if (item.ContainerId != 0 && _containerIndex.TryGetValue(item.ContainerId, out var l)) List<uint>? changedContainers = RemoveFromOtherContainerIndexes(
l.Remove(itemId); 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 _pendingMoves.Remove(itemId); // a destroyed item must not leave a snapshot that mis-rolls-back a recycled guid
if (notifyObjectRemoved) if (notifyObjectRemoved)
ObjectRemoved?.Invoke(item); ObjectRemoved?.Invoke(item);
ObjectRemovalClassified?.Invoke(new ClientObjectRemoval(item, reason, generation)); ObjectRemovalClassified?.Invoke(new ClientObjectRemoval(item, reason, generation));
PublishContainerContentsChanges(changedContainers);
return true; return true;
} }
@ -437,10 +617,13 @@ public sealed class ClientObjectTable
public bool UpdateIntProperty(uint itemId, uint propertyId, int value) public bool UpdateIntProperty(uint itemId, uint propertyId, int value)
{ {
if (!_objects.TryGetValue(itemId, out var item)) return false; if (!_objects.TryGetValue(itemId, out var item)) return false;
ClientObjectPlacement previous = ClientObjectPlacement.From(item);
item.Properties.Ints[propertyId] = value; item.Properties.Ints[propertyId] = value;
if (propertyId == UiEffectsPropertyId) item.Effects = (uint)value; if (propertyId == UiEffectsPropertyId) item.Effects = (uint)value;
if (propertyId == CurrentWieldedLocationPropertyId) if (propertyId == CurrentWieldedLocationPropertyId)
item.CurrentlyEquippedLocation = (EquipMask)(uint)value; item.CurrentlyEquippedLocation = (EquipMask)(uint)value;
if (propertyId == CurrentWieldedLocationPropertyId)
UpdateEquipmentIndex(itemId, previous, ClientObjectPlacement.From(item));
ObjectUpdated?.Invoke(item); ObjectUpdated?.Invoke(item);
return true; return true;
} }
@ -489,6 +672,7 @@ public sealed class ClientObjectTable
_objects[d.Guid] = obj; _objects[d.Guid] = obj;
} }
uint oldContainer = obj.ContainerId; uint oldContainer = obj.ContainerId;
ClientObjectPlacement previous = ClientObjectPlacement.From(obj);
if (!string.IsNullOrEmpty(d.Name)) obj.Name = d.Name!; if (!string.IsNullOrEmpty(d.Name)) obj.Name = d.Name!;
if (!string.IsNullOrEmpty(d.PluralName)) obj.PluralName = d.PluralName!; 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.MaxStructure is { } ms) obj.MaxStructure = ms;
if (d.Workmanship is { } wm) obj.Workmanship = wm; if (d.Workmanship is { } wm) obj.Workmanship = wm;
List<uint>? changedContainers = RemoveFromOtherContainerIndexes(
obj.ObjectId,
obj.ContainerId);
Reindex(obj, oldContainer); Reindex(obj, oldContainer);
UpdateEquipmentIndex(obj.ObjectId, previous, ClientObjectPlacement.From(obj));
if (!existed) ObjectAdded?.Invoke(obj); else ObjectUpdated?.Invoke(obj); if (!existed) ObjectAdded?.Invoke(obj); else ObjectUpdated?.Invoke(obj);
PublishContainerContentsChanges(changedContainers);
return obj; return obj;
} }
/// <summary> /// <summary>
/// PlayerDescription manifest: record that this guid is the player's /// PlayerDescription manifest: record that this guid is the player's
/// (in inventory or equipped at <paramref name="equip"/>), creating an /// (in inventory or equipped at <paramref name="equip"/>), creating an
/// empty entry if CreateObject hasn't arrived yet. Equipped entries may /// empty entry if CreateObject hasn't arrived yet. PlayerDescription may
/// specify the player as <paramref name="containerId"/> so login and live /// use the player as <paramref name="containerId"/> for its login-time
/// WieldObject updates share the same contained-by-wielder projection. /// placement projection; live WieldObject confirmation later establishes
/// canonical ContainerId zero plus WielderId player.
/// Never touches icon/name/type/effects — that data comes from CreateObject. /// Never touches icon/name/type/effects — that data comes from CreateObject.
/// </summary> /// </summary>
public ClientObject RecordMembership(uint guid, uint containerId = 0, public ClientObject RecordMembership(uint guid, uint containerId = 0,
@ -548,6 +738,7 @@ public sealed class ClientObjectTable
_objects[guid] = obj; _objects[guid] = obj;
} }
uint oldContainer = obj.ContainerId; uint oldContainer = obj.ContainerId;
ClientObjectPlacement previous = ClientObjectPlacement.From(obj);
if (containerId != 0) if (containerId != 0)
obj.ContainerId = containerId; obj.ContainerId = containerId;
obj.CurrentlyEquippedLocation = equip; obj.CurrentlyEquippedLocation = equip;
@ -555,11 +746,130 @@ public sealed class ClientObjectTable
obj.ContainerSlot = -1; obj.ContainerSlot = -1;
if (containerTypeHint is { } hint) obj.ContainerTypeHint = hint; if (containerTypeHint is { } hint) obj.ContainerTypeHint = hint;
if (priority is { } p) obj.Priority = p; if (priority is { } p) obj.Priority = p;
List<uint>? changedContainers = RemoveFromOtherContainerIndexes(
obj.ObjectId,
obj.ContainerId);
Reindex(obj, oldContainer); Reindex(obj, oldContainer);
UpdateEquipmentIndex(obj.ObjectId, previous, ClientObjectPlacement.From(obj));
if (!existed) ObjectAdded?.Invoke(obj); else ObjectUpdated?.Invoke(obj); if (!existed) ObjectAdded?.Invoke(obj); else ObjectUpdated?.Invoke(obj);
PublishContainerContentsChanges(changedContainers);
return obj; return obj;
} }
/// <summary>
/// Initialize PlayerDescription equipment in canonical retail form while
/// preserving the packed InventoryPlacement list order exactly. Retail
/// <c>UpdateObjectInventory @ 0x00559550</c> assigns the complete list; it
/// does not replay live head-insertion for each login entry.
/// </summary>
public void InitializeEquipmentManifest(
uint wielderId,
IReadOnlyList<EquipmentManifestEntry> entries)
{
ArgumentNullException.ThrowIfNull(entries);
if (wielderId == 0u) return;
var ordered = new List<uint>(entries.Count);
var notifications = new List<(ClientObject Item, bool Existed)>(entries.Count);
var changedContainers = new List<uint>();
var incoming = new HashSet<uint>();
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<uint>? 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<uint>? RemoveFromOtherContainerIndexes(
uint itemId,
uint exceptContainerId)
{
List<uint>? changed = null;
foreach ((uint containerId, List<uint> members) in _containerIndex)
{
if (containerId == exceptContainerId || !members.Remove(itemId))
continue;
(changed ??= new List<uint>()).Add(containerId);
}
return changed;
}
private void PublishContainerContentsChanges(List<uint>? changed)
{
if (changed is null || changed.Count == 0)
return;
var published = new HashSet<uint>();
foreach (uint containerId in changed)
{
if (!published.Add(containerId))
continue;
ContainerContentsReplaced?.Invoke(containerId);
}
}
private void Reindex(ClientObject obj, uint oldContainerId) private void Reindex(ClientObject obj, uint oldContainerId)
{ {
if (oldContainerId != obj.ContainerId && oldContainerId != 0 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<uint>? emptyOwners = null;
foreach ((uint ownerId, List<uint> placements) in _equipmentIndex)
{
placements.Remove(itemId);
if (placements.Count == 0)
(emptyOwners ??= new List<uint>()).Add(ownerId);
}
if (emptyOwners is null)
return;
foreach (uint ownerId in emptyOwners)
_equipmentIndex.Remove(ownerId);
}
/// <summary>
/// Maintain retail's <c>_invPlacement</c> order. Named retail
/// <c>SetPlayerWieldLocation @ 0x0058D8C0</c> inserts a placement at the
/// head; <c>GetObjectAtLocation @ 0x0058CE00</c> scans head-to-tail.
/// </summary>
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<uint>? 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<uint>? currentList))
_equipmentIndex[currentOwner] = currentList = new List<uint>();
currentList.Remove(itemId);
currentList.Insert(0, itemId);
}
private int SlotOf(uint guid) => private int SlotOf(uint guid) =>
_objects.TryGetValue(guid, out var o) && o.ContainerSlot >= 0 _objects.TryGetValue(guid, out var o) && o.ContainerSlot >= 0
? o.ContainerSlot ? o.ContainerSlot
@ -596,13 +962,25 @@ public sealed class ClientObjectTable
? l.ToArray() : System.Array.Empty<uint>(); ? l.ToArray() : System.Array.Empty<uint>();
/// <summary> /// <summary>
/// Replace a container's entire membership with <paramref name="guids"/> (in order) — the /// Snapshot of items equipped by <paramref name="wielderId"/>. Confirmed
/// authoritative full snapshot the server sends in ViewContents (0x0196) when you open a /// retail equipment has ContainerId zero and WielderId set; the
/// container. Members no longer present are detached (ContainerId → 0); new members are /// ContainerId arm keeps the optimistic pre-confirm projection visible.
/// recorded with ContainerSlot = list index. ACE writes ViewContents entries /// </summary>
/// OrderBy(PlacementPosition) with NO explicit slot field (GameEventViewContents.cs), so the public IReadOnlyList<ClientObject> GetEquippedBy(uint wielderId) =>
/// list order IS the slot order. Fires ObjectAdded/ObjectMoved so bound UI repaints. _equipmentIndex.TryGetValue(wielderId, out List<uint>? placements)
/// Retail consumer: ClientUISystem::OnViewContents. ? placements
.Select(Get)
.Where(item => item is not null
&& item.CurrentlyEquippedLocation != EquipMask.None
&& (item.WielderId == wielderId || item.ContainerId == wielderId))
.Cast<ClientObject>()
.ToArray()
: Array.Empty<ClientObject>();
/// <summary>
/// Replace only a viewed container's ordered membership projection.
/// Retail <c>ACCObjectMaint::ViewObjectContents @ 0x00558A70</c> rebuilds
/// the container lists without mutating child ownership or equip state.
/// </summary> /// </summary>
public void ReplaceContents(uint containerId, IReadOnlyList<uint> guids) public void ReplaceContents(uint containerId, IReadOnlyList<uint> guids)
{ {
@ -616,51 +994,110 @@ public sealed class ClientObjectTable
} }
/// <summary> /// <summary>
/// Replace a container's entire membership with <paramref name="entries"/> in retail order. /// Replace only a viewed container's ordered membership projection.
/// PlayerDescription and ViewContents both carry ContentProfile entries; the order is the /// ContentProfile order is retained in <see cref="GetContents"/> while the
/// dense list order retail feeds to ACCObjectMaint::ViewObjectContents, while ContainerType /// child weenie's canonical placement remains owned by move/wield events.
/// chooses the loose-item list versus the side-pack/container list.
/// </summary> /// </summary>
public void ReplaceContents(uint containerId, IReadOnlyList<ContainerContentEntry> entries) public void ReplaceContents(uint containerId, IReadOnlyList<ContainerContentEntry> entries)
{ {
ArgumentNullException.ThrowIfNull(entries); ArgumentNullException.ThrowIfNull(entries);
if (containerId == 0) return; if (containerId == 0) return;
var keep = new HashSet<uint>(); var ordered = new List<uint>(entries.Count);
foreach (var entry in entries) var added = new List<ClientObject>();
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.
for (int i = 0; i < entries.Count; i++) for (int i = 0; i < entries.Count; i++)
{ {
var entry = entries[i]; var entry = entries[i];
ordered.Add(entry.Guid);
bool existed = _objects.TryGetValue(entry.Guid, out var obj); bool existed = _objects.TryGetValue(entry.Guid, out var obj);
if (!existed || obj is null) if (!existed || obj is null)
{ {
obj = new ClientObject { ObjectId = entry.Guid }; obj = new ClientObject { ObjectId = entry.Guid };
_objects[entry.Guid] = obj; _objects[entry.Guid] = obj;
} }
uint oldContainer = obj.ContainerId; obj.ContainerTypeHint = entry.ContainerType;
obj.ContainerId = containerId; if (!existed) added.Add(obj);
}
_containerIndex[containerId] = ordered;
foreach (ClientObject item in added)
ObjectAdded?.Invoke(item);
ContainerContentsReplaced?.Invoke(containerId);
}
/// <summary>
/// Initialize canonical inventory ownership from PlayerDescription. This
/// login manifest is object-state initialization, not a move transition.
/// </summary>
public void InitializeInventoryManifest(
uint ownerId,
IReadOnlyList<ContainerContentEntry> entries)
{
ArgumentNullException.ThrowIfNull(entries);
if (ownerId == 0u) return;
var ordered = new List<uint>(entries.Count);
var changedContainers = new List<uint>();
var notifications = new List<(ClientObject Item, bool Existed)>(entries.Count);
var incoming = new HashSet<uint>();
for (int i = 0; i < entries.Count; i++)
incoming.Add(entries[i].Guid);
if (_containerIndex.TryGetValue(ownerId, out List<uint>? 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.ContainerSlot = i;
obj.WielderId = 0u;
obj.CurrentlyEquippedLocation = EquipMask.None; obj.CurrentlyEquippedLocation = EquipMask.None;
obj.ContainerTypeHint = entry.ContainerType; obj.ContainerTypeHint = entry.ContainerType;
Reindex(obj, oldContainer); if (RemoveFromOtherContainerIndexes(entry.Guid, ownerId) is { } changed)
if (!existed) ObjectAdded?.Invoke(obj); changedContainers.AddRange(changed);
else ObjectMoved?.Invoke(obj, oldContainer, containerId); 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);
} }
/// <summary> /// <summary>
@ -680,10 +1117,11 @@ public sealed class ClientObjectTable
return total; 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 // optimistically-wielded item (WieldItemOptimistic, before the server confirm) has
// WielderId == 0 and is detected via the ContainerId walk below (ContainerId == wielder). // 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) private bool IsCarriedBy(ClientObject o, uint ownerGuid)
{ {
if (o.WielderId == ownerGuid) return true; if (o.WielderId == ownerGuid) return true;
@ -705,6 +1143,7 @@ public sealed class ClientObjectTable
_objects.Clear(); _objects.Clear();
_containers.Clear(); _containers.Clear();
_containerIndex.Clear(); _containerIndex.Clear();
_equipmentIndex.Clear();
_pendingMoves.Clear(); // B-Drag: drop in-flight optimistic snapshots (a recycled guid must not mis-rollback) _pendingMoves.Clear(); // B-Drag: drop in-flight optimistic snapshots (a recycled guid must not mis-rollback)
Cleared?.Invoke(); Cleared?.Invoke();
} }

View file

@ -952,9 +952,17 @@ public sealed class MotionInterpreter : IMotionDoneSink
/// </summary> /// </summary>
public WeenieError PerformMovement(MovementStruct mvs) 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, Speed = mvs.Speed,
Autonomous = mvs.Autonomous,
ModifyInterpretedState = mvs.ModifyInterpretedState, ModifyInterpretedState = mvs.ModifyInterpretedState,
ModifyRawState = mvs.ModifyRawState, ModifyRawState = mvs.ModifyRawState,
}; };

View file

@ -5,11 +5,10 @@ namespace AcDream.UI.Abstractions.Input;
/// <summary> /// <summary>
/// Phase K.2 — state machine for MMB-hold "instant mouse-look" mode /// Phase K.2 — state machine for MMB-hold "instant mouse-look" mode
/// (retail's <c>CameraInstantMouseLook</c>). While active, mouse-X /// (retail's <c>CameraInstantMouseLook</c>). While active, mouse-X
/// delta drives the character's heading AND the chase camera yaw /// delta produces retail's signed horizontal camera adjustment. The host
/// together (combined drive — the camera "instantly" follows the /// routes that adjustment through the player's TurnLeft/TurnRight movement
/// character because mouse-X moves the character, and the chase /// commands; the chase camera follows the resulting character heading.
/// camera always tracks the character). Mouse-Y is left to the /// Mouse-Y is left to the caller — typically pitches the chase camera only.
/// caller — typically pitches the chase camera only.
/// ///
/// <para> /// <para>
/// The class owns three transitions: /// The class owns three transitions:
@ -27,17 +26,28 @@ namespace AcDream.UI.Abstractions.Input;
/// <para> /// <para>
/// <see cref="ApplyDelta"/> is the per-frame mouse-move hook: when /// <see cref="ApplyDelta"/> is the per-frame mouse-move hook: when
/// active, scales <paramref name="dx"/> by sensitivity and feeds the /// active, scales <paramref name="dx"/> by sensitivity and feeds the
/// caller-supplied yaw mutator. The mutator is the only side-channel — /// caller-supplied movement-adjustment sink. The sink is the only
/// the class doesn't know about <c>PlayerMovementController</c> or /// side-channel — the class doesn't know about
/// <c>ChaseCamera</c>. /// <c>PlayerMovementController</c> or <c>ChaseCamera</c>.
/// </para> /// </para>
/// </summary> /// </summary>
public sealed class MouseLookState public sealed class MouseLookState
{ {
private readonly Action<float> _applyYawDelta; private readonly Action<float> _applyHorizontalAdjustment;
private int _horizontalExtent;
private float _queuedDeltaX;
private float _queuedDeltaY;
private float _lastMouseInputTime;
/// <summary>
/// Retail <c>CInputManager_WIN32::GetInput @ 0x006897A0</c> does not
/// synthesize an idle sample until the cursor has been still for strictly
/// more than 0.2 seconds.
/// </summary>
public const float IdleZeroDelaySeconds = 0.2f;
/// <summary>True while MMB is held AND ImGui isn't capturing the /// <summary>True while MMB is held AND ImGui isn't capturing the
/// mouse. Mouse-X deltas drive yaw only when this is true.</summary> /// mouse. Mouse-X deltas drive turn adjustment only when this is true.</summary>
public bool Active { get; private set; } public bool Active { get; private set; }
/// <summary>Cursor X at the moment <see cref="Press"/> activated. /// <summary>Cursor X at the moment <see cref="Press"/> activated.
@ -48,17 +58,19 @@ public sealed class MouseLookState
public float CapturedCursorY { get; private set; } public float CapturedCursorY { get; private set; }
/// <summary> /// <summary>
/// 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 /// chase-camera sensitivity factor used elsewhere in GameWindow is
/// folded in by the caller; this class only owns the constant /// folded in by the caller; this class only owns the constant
/// scale that converts pixels to radians. Default 0.004 matches /// scale used before CameraSet's ×2 speed and 1.5 cap. Retail
/// the K.1b RMB-orbit factor. /// <c>MouseLookHandler</c> uses exactly 0.0666666701 after the configured
/// input sensitivity.
/// </summary> /// </summary>
public float SensitivityRadiansPerPixel { get; set; } = 0.004f; public float SensitivityPerPixel { get; set; } = 0.0666666701f;
public MouseLookState(Action<float> applyYawDelta) public MouseLookState(Action<float> applyHorizontalAdjustment)
{ {
_applyYawDelta = applyYawDelta ?? throw new ArgumentNullException(nameof(applyYawDelta)); _applyHorizontalAdjustment = applyHorizontalAdjustment
?? throw new ArgumentNullException(nameof(applyHorizontalAdjustment));
} }
/// <summary> /// <summary>
@ -71,13 +83,21 @@ public sealed class MouseLookState
/// <param name="wantCaptureMouse">Mirror of /// <param name="wantCaptureMouse">Mirror of
/// <c>ImGui.GetIO().WantCaptureMouse</c>. When true, the press is /// <c>ImGui.GetIO().WantCaptureMouse</c>. When true, the press is
/// ignored so a hover-over-panel MMB doesn't grab the cursor.</param> /// ignored so a hover-over-panel MMB doesn't grab the cursor.</param>
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 (wantCaptureMouse) return;
if (Active) return; if (Active) return;
Active = true; Active = true;
CapturedCursorX = cursorX; CapturedCursorX = cursorX;
CapturedCursorY = cursorY; CapturedCursorY = cursorY;
_horizontalExtent = 0;
_queuedDeltaX = 0f;
_queuedDeltaY = 0f;
_lastMouseInputTime = nowSeconds;
} }
/// <summary>MMB release transition. Always deactivates if active.</summary> /// <summary>MMB release transition. Always deactivates if active.</summary>
@ -85,6 +105,9 @@ public sealed class MouseLookState
{ {
if (!Active) return; if (!Active) return;
Active = false; Active = false;
_horizontalExtent = 0;
_queuedDeltaX = 0f;
_queuedDeltaY = 0f;
} }
/// <summary> /// <summary>
@ -94,24 +117,83 @@ public sealed class MouseLookState
/// </summary> /// </summary>
public void OnWantCaptureMouseChanged(bool wantCaptureMouse) public void OnWantCaptureMouseChanged(bool wantCaptureMouse)
{ {
if (Active && wantCaptureMouse) Active = false; if (Active && wantCaptureMouse)
{
Active = false;
_horizontalExtent = 0;
_queuedDeltaX = 0f;
_queuedDeltaY = 0f;
}
}
/// <summary>
/// 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.
/// </summary>
public void QueueDelta(float dx, float dy = 0f)
{
if (Active && float.IsFinite(dx) && float.IsFinite(dy))
{
_queuedDeltaX += dx;
_queuedDeltaY += dy;
}
}
/// <summary>
/// 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 <see cref="ApplyDelta"/>.
/// </summary>
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;
} }
/// <summary> /// <summary>
/// Apply a per-frame mouse-X delta. When active, scales by /// Apply a per-frame mouse-X delta. When active, scales by
/// <see cref="SensitivityRadiansPerPixel"/> times the /// <see cref="SensitivityPerPixel"/> times the
/// caller-supplied <paramref name="extraSensitivity"/> (typically /// caller-supplied <paramref name="extraSensitivity"/> (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 /// 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).
/// </summary> /// </summary>
/// <param name="dx">Pixels of horizontal mouse motion since the /// <param name="dx">Pixels of horizontal mouse motion since the
/// last frame.</param> /// last frame.</param>
/// <param name="extraSensitivity">Multiplier (e.g. chase-camera /// <param name="extraSensitivity">Multiplier (e.g. chase-camera
/// sensitivity) applied on top of the radians-per-pixel scale.</param> /// sensitivity) applied on top of the per-pixel scale.</param>
public void ApplyDelta(float dx, float extraSensitivity) public void ApplyDelta(float dx, float extraSensitivity)
{ {
if (!Active) return; if (!Active) return;
ProcessDelta(dx, extraSensitivity);
}
private void ProcessDelta(float dx, float extraSensitivity)
{
// Sign: dragging the mouse RIGHT (dx > 0) should yaw the // Sign: dragging the mouse RIGHT (dx > 0) should yaw the
// character to the right. With the acdream Yaw convention // character to the right. With the acdream Yaw convention
// (where Yaw 0 = +X, increasing to +Y), positive yaw is // (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 // yaw goes DOWN (more clockwise). The dispatcher convention
// for chase YawOffset is `YawOffset -= dx * factor`; we keep // for chase YawOffset is `YawOffset -= dx * factor`; we keep
// the same sign here so character + camera rotate identically. // 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);
} }
} }

View file

@ -179,6 +179,42 @@ public sealed class CombatAttackControllerTests
Assert.Equal(0, cancels); Assert.Equal(0, cancels);
} }
[Fact]
public void StartAttackRequest_PreparesPlayerMovementBeforePowerBuild()
{
var events = new List<string>();
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( private static CombatAttackController Create(
CombatState combat, CombatState combat,
Func<double> now, Func<double> now,

View file

@ -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<CellSurface>(),
Array.Empty<PortalPlane>(),
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);
}
}

View file

@ -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);
}

View file

@ -468,6 +468,37 @@ public sealed class ItemInteractionControllerTests
Assert.Equal(Player, h.Objects.Get(bow)!.ContainerId); 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] [Fact]
public void WeaponReplacement_doesNotOverlapRequestsWhileAwaitingServer() public void WeaponReplacement_doesNotOverlapRequestsWhileAwaitingServer()
{ {
@ -534,10 +565,12 @@ public sealed class ItemInteractionControllerTests
Assert.True(h.Objects.WieldItemOptimistic( Assert.True(h.Objects.WieldItemOptimistic(
bow, Player, EquipMask.MissileWeapon)); bow, Player, EquipMask.MissileWeapon));
// Authoritative WieldObject + ConfirmWield completes AutoWield even // Authoritative WieldObject completes AutoWield even
// though the object table still has another rollback record. // though the object table still has another rollback record.
h.Objects.MoveItem(bow, Player, -1, EquipMask.MissileWeapon); Assert.True(h.Objects.ApplyConfirmedServerWield(
h.Objects.ConfirmWield(bow); bow,
Player,
EquipMask.MissileWeapon));
h.Now += 200; h.Now += 200;
Assert.True(h.Controller.ActivateItem(sword)); Assert.True(h.Controller.ActivateItem(sword));
Assert.Equal( Assert.Equal(

View file

@ -440,6 +440,29 @@ public class PaperdollControllerTests
Assert.Equal(0xF01u, lists[HeadSlot].Cell.ItemId); // the head slot repainted with the helm 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] [Fact]
public void Live_npc_equip_does_not_appear_on_the_doll() // player-scoped: a remote creature's wielded item never leaks public void Live_npc_equip_does_not_appear_on_the_doll() // player-scoped: a remote creature's wielded item never leaks
{ {

View file

@ -477,9 +477,17 @@ public class ToolbarControllerTests
repo.AddOrUpdate(new ClientObject { ObjectId = player, Type = ItemType.Creature }); repo.AddOrUpdate(new ClientObject { ObjectId = player, Type = ItemType.Creature });
repo.AddOrUpdate(new ClientObject { ObjectId = pack, Type = ItemType.Container }); repo.AddOrUpdate(new ClientObject { ObjectId = pack, Type = ItemType.Container });
repo.AddOrUpdate(new ClientObject { ObjectId = bow, StackSize = 1, StackSizeMax = 1 }); 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.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( ToolbarController.Bind(
layout, layout,
@ -492,7 +500,7 @@ public class ToolbarControllerTests
Assert.Equal("42", ammoButton.Label); Assert.Equal("42", ammoButton.Label);
Assert.True(repo.MoveItem(arrows, pack)); Assert.True(repo.ApplyServerMove(arrows, pack, newWielderId: 0u));
Assert.Null(ammoButton.Label); Assert.Null(ammoButton.Label);
} }

View file

@ -104,13 +104,13 @@ public sealed class GameEventWiringTests
[Fact] [Fact]
public void WireAll_WieldObject_RoutesToClientObjectTable() 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 }); 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, 0x1000);
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), (uint)EquipMask.MeleeWeapon); BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), (uint)EquipMask.MeleeWeapon);
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(8), 0x2000);
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.WieldObject, payload)); var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.WieldObject, payload));
d.Dispatch(env!.Value); d.Dispatch(env!.Value);
@ -118,7 +118,8 @@ public sealed class GameEventWiringTests
var item = items.Get(0x1000); var item = items.Get(0x1000);
Assert.NotNull(item); Assert.NotNull(item);
Assert.Equal(EquipMask.MeleeWeapon, item!.CurrentlyEquippedLocation); Assert.Equal(EquipMask.MeleeWeapon, item!.CurrentlyEquippedLocation);
Assert.Equal(0x2000u, item.ContainerId); Assert.Equal(0u, item.ContainerId);
Assert.Equal(player, item.WielderId);
} }
[Fact] [Fact]
@ -130,7 +131,7 @@ public sealed class GameEventWiringTests
items.MoveItem(0x1500, 0x9999u, newSlot: 0); // start in a pack items.MoveItem(0x1500, 0x9999u, newSlot: 0); // start in a pack
items.WieldItemOptimistic(0x1500, player, EquipMask.MeleeWeapon); // optimistic wield → snapshot pending items.WieldItemOptimistic(0x1500, player, EquipMask.MeleeWeapon); // optimistic wield → snapshot pending
var wieldConfirmed = new List<uint>(); var wieldConfirmed = new List<uint>();
items.WieldConfirmed += item => wieldConfirmed.Add(item.ObjectId); items.WieldConfirmed += itemId => wieldConfirmed.Add(itemId);
byte[] payload = new byte[8]; byte[] payload = new byte[8];
BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x1500); BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x1500);
@ -139,7 +140,8 @@ public sealed class GameEventWiringTests
d.Dispatch(env!.Value); // server confirms the wield d.Dispatch(env!.Value); // server confirms the wield
Assert.False(items.RollbackMove(0x1500)); // pending snapshot was cleared by ConfirmMove 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); Assert.Equal(new[] { 0x1500u }, wieldConfirmed);
} }
@ -567,6 +569,7 @@ public sealed class GameEventWiringTests
const uint playerGuid = 0x50000001u; const uint playerGuid = 0x50000001u;
const uint packItemGuid = 0x50000A01u; const uint packItemGuid = 0x50000A01u;
const uint crossbowGuid = 0x50000B01u; const uint crossbowGuid = 0x50000B01u;
const uint ammoGuid = 0x50000B02u;
var dispatcher = new GameEventDispatcher(); var dispatcher = new GameEventDispatcher();
var items = new ClientObjectTable(); var items = new ClientObjectTable();
GameEventWiring.WireAll( GameEventWiring.WireAll(
@ -591,10 +594,13 @@ public sealed class GameEventWiringTests
w.Write(0u); // spellbook_filters w.Write(0u); // spellbook_filters
w.Write(1u); // inventory count w.Write(1u); // inventory count
w.Write(packItemGuid); w.Write(0u); w.Write(packItemGuid); w.Write(0u);
w.Write(1u); // equipped count w.Write(2u); // equipped count
w.Write(crossbowGuid); w.Write(crossbowGuid);
w.Write((uint)EquipMask.MissileWeapon); w.Write((uint)EquipMask.MissileWeapon);
w.Write(7u); // layering priority w.Write(7u); // layering priority
w.Write(ammoGuid);
w.Write((uint)EquipMask.MissileAmmo);
w.Write(8u);
var envelope = GameEventEnvelope.TryParse( var envelope = GameEventEnvelope.TryParse(
WrapEnvelope(GameEventType.PlayerDescription, sb.ToArray())); WrapEnvelope(GameEventType.PlayerDescription, sb.ToArray()));
@ -603,17 +609,19 @@ public sealed class GameEventWiringTests
ClientObject crossbow = items.Get(crossbowGuid)!; ClientObject crossbow = items.Get(crossbowGuid)!;
crossbow.Type = ItemType.MissileWeapon; crossbow.Type = ItemType.MissileWeapon;
crossbow.CombatUse = 2; crossbow.CombatUse = 2;
var orderedPlayerContents = items.GetContents(playerGuid) IReadOnlyList<ClientObject> orderedEquipment = items.GetEquippedBy(playerGuid);
.Select(guid => items.Get(guid)!)
.ToList();
Assert.Equal(new[] { packItemGuid, crossbowGuid }, items.GetContents(playerGuid)); Assert.Equal(new[] { packItemGuid }, items.GetContents(playerGuid));
Assert.Equal(playerGuid, crossbow.ContainerId); 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(EquipMask.MissileWeapon, crossbow.CurrentlyEquippedLocation);
Assert.Equal(7u, crossbow.Priority); Assert.Equal(7u, crossbow.Priority);
Assert.Equal( Assert.Equal(
CombatMode.Missile, CombatMode.Missile,
CombatInputPlanner.GetDefaultCombatMode(orderedPlayerContents)); CombatInputPlanner.GetDefaultCombatMode(orderedEquipment));
} }
[Fact] [Fact]
@ -879,8 +887,8 @@ public sealed class GameEventWiringTests
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.ViewContents, payload)); var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.ViewContents, payload));
d.Dispatch(env!.Value); d.Dispatch(env!.Value);
Assert.Equal(0x500000C9u, items.Get(0x50000A01u)!.ContainerId); Assert.Equal(0u, items.Get(0x50000A01u)!.ContainerId);
Assert.Equal(0x500000C9u, items.Get(0x50000A02u)!.ContainerId); Assert.Equal(0u, items.Get(0x50000A02u)!.ContainerId);
Assert.Equal(new[] { 0x50000A01u, 0x50000A02u }, items.GetContents(0x500000C9u)); Assert.Equal(new[] { 0x50000A01u, 0x50000A02u }, items.GetContents(0x500000C9u));
Assert.Equal(0u, items.Get(0x50000A01u)!.ContainerTypeHint); Assert.Equal(0u, items.Get(0x50000A01u)!.ContainerTypeHint);
Assert.Equal(1u, items.Get(0x50000A02u)!.ContainerTypeHint); Assert.Equal(1u, items.Get(0x50000A02u)!.ContainerTypeHint);
@ -959,6 +967,37 @@ public sealed class GameEventWiringTests
Assert.Equal(1u, items.Get(0x50000B02u)!.ContainerTypeHint); 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] [Fact]
public void WireAll_InventoryPutObjectIn3D_UnparentsFromContainer() public void WireAll_InventoryPutObjectIn3D_UnparentsFromContainer()
{ {

View file

@ -41,16 +41,14 @@ public sealed class AppraiseTests
[Fact] [Fact]
public void ParseWieldObject_RoundTrip() public void ParseWieldObject_RoundTrip()
{ {
byte[] payload = new byte[12]; byte[] payload = new byte[8];
BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x1000u); BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x1000u);
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), 0x00400000u); // MeleeWeapon slot BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), 0x00400000u); // MeleeWeapon slot
BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(8), 0x2000u); // wielder
var parsed = GameEvents.ParseWieldObject(payload); var parsed = GameEvents.ParseWieldObject(payload);
Assert.NotNull(parsed); Assert.NotNull(parsed);
Assert.Equal(0x1000u, parsed!.Value.ItemGuid); Assert.Equal(0x1000u, parsed!.Value.ItemGuid);
Assert.Equal(0x00400000u, parsed.Value.EquipLoc); Assert.Equal(0x00400000u, parsed.Value.EquipLoc);
Assert.Equal(0x2000u, parsed.Value.WielderGuid);
} }
[Fact] [Fact]

View file

@ -207,7 +207,6 @@ public sealed class GameEventDispatcherTests
Assert.NotNull(parsed); Assert.NotNull(parsed);
Assert.Equal(0x50000A01u, parsed!.Value.ItemGuid); Assert.Equal(0x50000A01u, parsed!.Value.ItemGuid);
Assert.Equal((uint)EquipMask.ChestArmor, parsed.Value.EquipLoc); Assert.Equal((uint)EquipMask.ChestArmor, parsed.Value.EquipLoc);
Assert.Equal(0u, parsed.Value.WielderGuid);
} }
[Fact] [Fact]

View file

@ -100,6 +100,7 @@ public sealed class WeenieErrorMessagesTests
[InlineData(0x0036u, "Action cancelled!")] [InlineData(0x0036u, "Action cancelled!")]
[InlineData(0x003Du, "You charged too far!")] [InlineData(0x003Du, "You charged too far!")]
[InlineData(0x004Au, "Ack! You killed yourself!")] [InlineData(0x004Au, "Ack! You killed yourself!")]
[InlineData(0x0550u, "Out of Range!")]
public void Format_CombatMovementErrors(uint code, string expected) public void Format_CombatMovementErrors(uint code, string expected)
=> Assert.Equal(expected, WeenieErrorMessages.Format(code, null)); => Assert.Equal(expected, WeenieErrorMessages.Format(code, null));

View file

@ -1,6 +1,7 @@
using System; using System;
using System.Numerics; using System.Numerics;
using AcDream.App.Input; using AcDream.App.Input;
using AcDream.App.Rendering;
using AcDream.Core.Physics; using AcDream.Core.Physics;
using AcDream.Core.Physics.Motion; using AcDream.Core.Physics.Motion;
using DatReaderWriter.DBObjs; using DatReaderWriter.DBObjs;
@ -27,7 +28,9 @@ namespace AcDream.Core.Tests.Input;
public class PlayerMoveToCutoverTests public class PlayerMoveToCutoverTests
{ {
private const uint NC = 0x8000003Du; private const uint NC = 0x8000003Du;
private const uint MissileCombat = 0x8000003Fu;
private const uint Ready = 0x41000003u; private const uint Ready = 0x41000003u;
private const uint Reload = 0x40000016u;
private const uint Walk = 0x45000005u; private const uint Walk = 0x45000005u;
private const uint Run = 0x44000007u; private const uint Run = 0x44000007u;
private const uint TurnRight = 0x6500000Du; private const uint TurnRight = 0x6500000Du;
@ -73,7 +76,10 @@ public class PlayerMoveToCutoverTests
var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NC }; var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NC };
mt.StyleDefaults[(DRWMotionCommand)NC] = (DRWMotionCommand)Ready; mt.StyleDefaults[(DRWMotionCommand)NC] = (DRWMotionCommand)Ready;
mt.StyleDefaults[(DRWMotionCommand)MissileCombat] = (DRWMotionCommand)Ready;
mt.Cycles[(int)((NC << 16) | (Ready & 0xFFFFFFu))] = MakeMd(0x300u); 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) | (Walk & 0xFFFFFFu))] = MakeMd(0x301u);
mt.Cycles[(int)((NC << 16) | (Run & 0xFFFFFFu))] = MakeMd(0x302u); mt.Cycles[(int)((NC << 16) | (Run & 0xFFFFFFu))] = MakeMd(0x302u);
// The MoveToManager's aux-turn steering / TurnToHeading nodes // The MoveToManager's aux-turn steering / TurnToHeading nodes
@ -82,6 +88,7 @@ public class PlayerMoveToCutoverTests
// _DoMotion error and the manager cancels the moveto (retail // _DoMotion error and the manager cancels the moveto (retail
// dispatch-failure semantics), so the fixture needs one. // dispatch-failure semantics), so the fixture needs one.
mt.Cycles[(int)((NC << 16) | (TurnRight & 0xFFFFFFu))] = MakeMd(0x303u); mt.Cycles[(int)((NC << 16) | (TurnRight & 0xFFFFFFu))] = MakeMd(0x303u);
mt.Cycles[(int)((MissileCombat << 16) | (TurnRight & 0xFFFFFFu))] = MakeMd(0x303u);
return new AnimationSequencer(setup, mt, loader); return new AnimationSequencer(setup, mt, loader);
} }
@ -102,8 +109,27 @@ public class PlayerMoveToCutoverTests
{ {
public required PlayerMovementController Controller; public required PlayerMovementController Controller;
public required MoveToManager MoveTo; public required MoveToManager MoveTo;
public required EntityPhysicsHost PlayerHost;
public required Dictionary<uint, IPhysicsObjHost> Hosts;
public int MoveToCompleteCount; public int MoveToCompleteCount;
public WeenieError LastCompleteError; 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;
}
} }
/// <summary>The EnterPlayerModeNow bind set, verbatim shape: real sink, /// <summary>The EnterPlayerModeNow bind set, verbatim shape: real sink,
@ -116,6 +142,12 @@ public class PlayerMoveToCutoverTests
var controller = new PlayerMovementController(MakeFlatEngine()); var controller = new PlayerMovementController(MakeFlatEngine());
var seq = MakeSequencer(); var seq = MakeSequencer();
seqOut = seq; 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 // Production (EnterPlayerModeNow) order: the sink binds BEFORE the
// initial SetPosition — SetPosition → StopCompletely needs the sink // initial SetPosition — SetPosition → StopCompletely needs the sink
// for its type-5 motion-table dispatch, the completable partner of // 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.SetPosition(new Vector3(96f, 96f, 50f), 0x0001);
controller.Yaw = 0f; // heading 90 = facing +X controller.Yaw = 0f; // heading 90 = facing +X
var rig = new Rig { Controller = controller, MoveTo = null! }; const uint selfGuid = 0x5000000Au;
var hosts = new Dictionary<uint, IPhysicsObjHost>();
EntityPhysicsHost playerHost = null!;
var rig = new Rig
{
Controller = controller,
MoveTo = null!,
PlayerHost = null!,
Hosts = hosts,
};
var moveTo = new MoveToManager( var moveTo = new MoveToManager(
controller.Motion, controller.Motion,
stopCompletely: () => controller.Motion.StopCompletely(), stopCompletely: () => controller.StopCompletelyAtPhysicsObjectBoundary(),
getPosition: () => new Position( getPosition: () => new Position(
controller.CellId, controller.Position, controller.BodyOrientation), controller.CellId, controller.Position, controller.BodyOrientation),
getHeading: () => MoveToMath.HeadingFromYaw(controller.Yaw), getHeading: () => MoveToMath.HeadingFromYaw(controller.Yaw),
@ -143,12 +184,28 @@ public class PlayerMoveToCutoverTests
contact: () => controller.BodyInContact, contact: () => controller.BodyInContact,
isInterpolating: () => false, isInterpolating: () => false,
getVelocity: () => controller.BodyVelocity, getVelocity: () => controller.BodyVelocity,
getSelfId: () => 0x5000000Au, getSelfId: () => selfGuid,
setTarget: (_, _, _, _) => { }, setTarget: (ctx, id, radius, quantum) =>
clearTarget: () => { }, playerHost.SetTarget(ctx, id, radius, quantum),
getTargetQuantum: () => 0.0, clearTarget: () => playerHost.ClearTarget(),
setTargetQuantum: _ => { }, getTargetQuantum: () => playerHost.TargetManager.GetTargetQuantum(),
setTargetQuantum: quantum => playerHost.TargetManager.SetTargetQuantum(quantum),
curTime: () => controller.SimTimeSeconds); 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 => moveTo.MoveToComplete = err =>
{ {
rig.MoveToCompleteCount++; rig.MoveToCompleteCount++;
@ -158,6 +215,7 @@ public class PlayerMoveToCutoverTests
controller.Motion.InterruptCurrentMovement = controller.Motion.InterruptCurrentMovement =
() => moveTo.CancelMoveTo(WeenieError.ActionCancelled); () => moveTo.CancelMoveTo(WeenieError.ActionCancelled);
rig.MoveTo = moveTo; rig.MoveTo = moveTo;
rig.PlayerHost = playerHost;
return rig; return rig;
} }
@ -290,8 +348,6 @@ public class PlayerMoveToCutoverTests
// under the production completion feed. // under the production completion feed.
var rig = MakeRig(out var seq); var rig = MakeRig(out var seq);
var c = rig.Controller; var c = rig.Controller;
seq.MotionDoneTarget = (m, ok) => c.Motion.MotionDone(m, ok);
for (int f = 0; f < 300 && c.Motion.MotionsPending(); f++) for (int f = 0; f < 300 && c.Motion.MotionsPending(); f++)
{ {
c.Update(1f / 60f, new MovementInput()); c.Update(1f / 60f, new MovementInput());
@ -317,7 +373,6 @@ public class PlayerMoveToCutoverTests
// pops; remotes 0/40). // pops; remotes 0/40).
var rig = MakeRig(out var seq); var rig = MakeRig(out var seq);
var c = rig.Controller; 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) var dest = new Vector3(104f, 96f, 50f); // 8 m dead ahead (heading 90)
// A few idle frames first — production always has render ticks // A few idle frames first — production always has render ticks
@ -355,6 +410,130 @@ public class PlayerMoveToCutoverTests
Assert.Equal(1, rig.MoveToCompleteCount); 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] [Fact]
public void JumpRelease_CancelsMoveTo() public void JumpRelease_CancelsMoveTo()
{ {

View file

@ -54,7 +54,11 @@ public sealed class ClientObjectTableTests
repo.AddOrUpdate(item); repo.AddOrUpdate(item);
uint seenOld = 999, seenNew = 999; 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); repo.MoveItem(100, 42, newSlot: 3);
@ -71,6 +75,74 @@ public sealed class ClientObjectTableTests
Assert.False(repo.MoveItem(999, 42)); 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] [Fact]
public void Remove_FiresEventAndRemoves() public void Remove_FiresEventAndRemoves()
{ {
@ -471,53 +543,156 @@ public sealed class ClientObjectTableTests
var table = new ClientObjectTable(); var table = new ClientObjectTable();
table.ReplaceContents(0xC9u, new uint[] { 0xA01u, 0xA02u, 0xA03u }); table.ReplaceContents(0xC9u, new uint[] { 0xA01u, 0xA02u, 0xA03u });
Assert.Equal(new[] { 0xA01u, 0xA02u, 0xA03u }, table.GetContents(0xC9u)); Assert.Equal(new[] { 0xA01u, 0xA02u, 0xA03u }, table.GetContents(0xC9u));
Assert.Equal(0xC9u, table.Get(0xA01u)!.ContainerId); Assert.Equal(0u, table.Get(0xA01u)!.ContainerId);
} }
[Fact] [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(); var table = new ClientObjectTable();
table.ReplaceContents(0xC9u, new uint[] { 0xA01u, 0xA02u, 0xA03u }); table.ReplaceContents(0xC9u, new uint[] { 0xA01u, 0xA02u, 0xA03u });
table.ReplaceContents(0xC9u, new uint[] { 0xA02u }); // A01 + A03 removed server-side table.ReplaceContents(0xC9u, new uint[] { 0xA02u }); // A01 + A03 removed server-side
Assert.Equal(new[] { 0xA02u }, table.GetContents(0xC9u)); 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(0u, table.Get(0xA03u)!.ContainerId);
Assert.Equal(0xC9u, table.Get(0xA02u)!.ContainerId); Assert.Equal(0u, table.Get(0xA02u)!.ContainerId);
} }
[Fact] [Fact]
public void ReplaceContents_listedMemberClearsStaleEquipLocation() public void ReplaceContents_listedMemberPreservesCanonicalPlacement()
{ {
var table = new ClientObjectTable(); var table = new ClientObjectTable();
table.AddOrUpdate(new ClientObject table.AddOrUpdate(new ClientObject
{ {
ObjectId = 0xA10u, ObjectId = 0xA10u,
ContainerId = 0x50000001u, ContainerId = 0u,
WielderId = 0x50000001u,
CurrentlyEquippedLocation = EquipMask.ChestWear | EquipMask.UpperArmWear, CurrentlyEquippedLocation = EquipMask.ChestWear | EquipMask.UpperArmWear,
}); });
table.ReplaceContents(0x50000001u, new uint[] { 0xA10u }); table.ReplaceContents(0x50000001u, new uint[] { 0xA10u });
var item = table.Get(0xA10u)!; var item = table.Get(0xA10u)!;
Assert.Equal(0x50000001u, item.ContainerId); Assert.Equal(0u, item.ContainerId);
Assert.Equal(0, item.ContainerSlot); Assert.Equal(0x50000001u, item.WielderId);
Assert.Equal(EquipMask.None, item.CurrentlyEquippedLocation); Assert.Equal(-1, item.ContainerSlot);
Assert.Equal(
EquipMask.ChestWear | EquipMask.UpperArmWear,
item.CurrentlyEquippedLocation);
} }
[Fact] [Fact]
public void ReplaceContents_detachedMemberClearsStaleEquipLocation() public void ReplaceContents_omittedMemberPreservesCanonicalPlacement()
{ {
var table = new ClientObjectTable(); var table = new ClientObjectTable();
table.AddOrUpdate(new ClientObject { ObjectId = 0xA11u }); table.AddOrUpdate(new ClientObject
{
ObjectId = 0xA11u,
WielderId = 0x50000001u,
});
table.MoveItem(0xA11u, 0x50000001u, newSlot: 0, table.MoveItem(0xA11u, 0x50000001u, newSlot: 0,
newEquipLocation: EquipMask.ChestWear | EquipMask.UpperArmWear); newEquipLocation: EquipMask.ChestWear | EquipMask.UpperArmWear);
table.ReplaceContents(0x50000001u, Array.Empty<uint>()); table.ReplaceContents(0x50000001u, Array.Empty<uint>());
var item = table.Get(0xA11u)!; var item = table.Get(0xA11u)!;
Assert.Equal(0u, item.ContainerId); Assert.Equal(0x50000001u, item.ContainerId);
Assert.Equal(EquipMask.None, item.CurrentlyEquippedLocation); Assert.Equal(0x50000001u, item.WielderId);
Assert.Equal(
EquipMask.ChestWear | EquipMask.UpperArmWear,
item.CurrentlyEquippedLocation);
} }
[Fact] [Fact]
@ -559,11 +734,130 @@ public sealed class ClientObjectTableTests
table.Ingest(FullWeenie(0xA01u, container: player)); table.Ingest(FullWeenie(0xA01u, container: player));
Assert.Equal(new[] { 0xA01u, 0xA02u }, table.GetContents(player)); Assert.Equal(new[] { 0xA01u, 0xA02u }, table.GetContents(player));
Assert.Equal(0, table.Get(0xA01u)!.ContainerSlot); Assert.Equal(-1, table.Get(0xA01u)!.ContainerSlot);
Assert.Equal(1, table.Get(0xA02u)!.ContainerSlot); Assert.Equal(-1, table.Get(0xA02u)!.ContainerSlot);
Assert.Equal(1u, table.Get(0xA02u)!.ContainerTypeHint); 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<EquipmentManifestEntry>());
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<ContainerContentEntry>());
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<EquipmentManifestEntry>());
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] [Fact]
public void Reindex_UnknownSlotAppendsAfterKnownSnapshotSlots() public void Reindex_UnknownSlotAppendsAfterKnownSnapshotSlots()
{ {
@ -692,8 +986,8 @@ public sealed class ClientObjectTableTests
table.WieldItemOptimistic(0x940u, player, EquipMask.HeadWear); table.WieldItemOptimistic(0x940u, player, EquipMask.HeadWear);
var o = table.Get(0x940u)!; var o = table.Get(0x940u)!;
Assert.Equal(EquipMask.HeadWear, o.CurrentlyEquippedLocation); // equipped now (instant) Assert.Equal(EquipMask.HeadWear, o.CurrentlyEquippedLocation); // equipped now (instant)
Assert.Equal(player, o.ContainerId); // contained-by-wielder (matches the WieldObject 0x0023 confirm) Assert.Equal(player, o.ContainerId); // temporary pre-confirm placement projection
Assert.Equal(0u, o.WielderId); // optimistic wield does NOT write WielderId (ContainerId-based model) Assert.Equal(0u, o.WielderId); // authoritative WieldObject later sets WielderId + clears ContainerId
} }
[Fact] [Fact]
@ -731,6 +1025,89 @@ public sealed class ClientObjectTableTests
public void WieldItemOptimistic_unknownItem_false() public void WieldItemOptimistic_unknownItem_false()
=> Assert.False(new ClientObjectTable().WieldItemOptimistic(0xDEADu, 0x1u, EquipMask.HeadWear)); => 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] [Fact]
public void RejectMove_withoutOptimisticMutation_stillPublishesServerFailure() public void RejectMove_withoutOptimisticMutation_stillPublishesServerFailure()
{ {
@ -766,7 +1143,7 @@ public sealed class ClientObjectTableTests
} }
[Fact] [Fact]
public void ConfirmWield_publishesIndependentlyOfRollbackOutstandingCount() public void ApplyConfirmedServerWield_publishesIndependentlyOfRollbackOutstandingCount()
{ {
var table = new ClientObjectTable(); var table = new ClientObjectTable();
const uint item = 0x944u, player = 0x50000001u, pack = 0x40000005u; const uint item = 0x944u, player = 0x50000001u, pack = 0x40000005u;
@ -775,12 +1152,20 @@ public sealed class ClientObjectTableTests
table.WieldItemOptimistic(item, player, EquipMask.MeleeWeapon); table.WieldItemOptimistic(item, player, EquipMask.MeleeWeapon);
table.MoveItemOptimistic(item, pack, 0); table.MoveItemOptimistic(item, pack, 0);
var confirmed = new List<uint>(); var confirmed = new List<uint>();
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(new[] { item }, confirmed);
Assert.Equal(0u, table.Get(item)!.ContainerId);
Assert.Equal(player, table.Get(item)!.WielderId);
Assert.True(table.RollbackMove(item)); 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] [Fact]

View file

@ -92,6 +92,44 @@ public sealed class MovementManagerTests
Assert.Null(mm.MoveTo); 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] [Fact]
public void PerformMovement_MoveToTypes_LazyCreate_AndRouteToMoveTo() public void PerformMovement_MoveToTypes_LazyCreate_AndRouteToMoveTo()
{ {

View file

@ -191,7 +191,8 @@ internal sealed class RemoteChaseHarness
{ {
var mtm = new MoveToManager( var mtm = new MoveToManager(
Interp, Interp,
stopCompletely: () => Interp.StopCompletely(), stopCompletely: () => Movement.PerformMovement(
new MovementStruct { Type = MovementType.StopCompletely }),
getPosition: () => new CorePosition(1u, Body.Position, Body.Orientation), getPosition: () => new CorePosition(1u, Body.Position, Body.Orientation),
getHeading: () => MoveToMath.GetHeading(Body.Orientation), getHeading: () => MoveToMath.GetHeading(Body.Orientation),
setHeading: (h, _) => Body.Orientation = setHeading: (h, _) => Body.Orientation =

View file

@ -12,7 +12,7 @@ namespace AcDream.UI.Abstractions.Tests.Input;
/// </summary> /// </summary>
public sealed class MmbMouseLookTests public sealed class MmbMouseLookTests
{ {
private sealed class YawSink private sealed class AdjustmentSink
{ {
public float Total; public float Total;
public int ApplyCount; public int ApplyCount;
@ -22,7 +22,7 @@ public sealed class MmbMouseLookTests
[Fact] [Fact]
public void Press_ActivatesAndCapturesCursorPosition() public void Press_ActivatesAndCapturesCursorPosition()
{ {
var sink = new YawSink(); var sink = new AdjustmentSink();
var ml = new MouseLookState(sink.Apply); var ml = new MouseLookState(sink.Apply);
ml.Press(cursorX: 320f, cursorY: 240f, wantCaptureMouse: false); ml.Press(cursorX: 320f, cursorY: 240f, wantCaptureMouse: false);
@ -35,7 +35,7 @@ public sealed class MmbMouseLookTests
[Fact] [Fact]
public void Release_DeactivatesWhenActive() public void Release_DeactivatesWhenActive()
{ {
var sink = new YawSink(); var sink = new AdjustmentSink();
var ml = new MouseLookState(sink.Apply); var ml = new MouseLookState(sink.Apply);
ml.Press(0f, 0f, wantCaptureMouse: false); ml.Press(0f, 0f, wantCaptureMouse: false);
@ -50,7 +50,7 @@ public sealed class MmbMouseLookTests
// Defense in depth — the dispatcher already filters on // Defense in depth — the dispatcher already filters on
// WantCaptureMouse, but if a binding ever fires through the // WantCaptureMouse, but if a binding ever fires through the
// state machine itself must not turn on. // state machine itself must not turn on.
var sink = new YawSink(); var sink = new AdjustmentSink();
var ml = new MouseLookState(sink.Apply); var ml = new MouseLookState(sink.Apply);
ml.Press(0f, 0f, wantCaptureMouse: true); ml.Press(0f, 0f, wantCaptureMouse: true);
@ -61,7 +61,7 @@ public sealed class MmbMouseLookTests
[Fact] [Fact]
public void OnWantCaptureMouseChanged_TrueWhileActive_Deactivates() public void OnWantCaptureMouseChanged_TrueWhileActive_Deactivates()
{ {
var sink = new YawSink(); var sink = new AdjustmentSink();
var ml = new MouseLookState(sink.Apply); var ml = new MouseLookState(sink.Apply);
ml.Press(0f, 0f, wantCaptureMouse: false); ml.Press(0f, 0f, wantCaptureMouse: false);
Assert.True(ml.Active); Assert.True(ml.Active);
@ -79,7 +79,7 @@ public sealed class MmbMouseLookTests
{ {
// Going from "ImGui captures" to "ImGui doesn't capture" must // Going from "ImGui captures" to "ImGui doesn't capture" must
// NOT auto-reactivate — only an explicit Press does. // NOT auto-reactivate — only an explicit Press does.
var sink = new YawSink(); var sink = new AdjustmentSink();
var ml = new MouseLookState(sink.Apply); var ml = new MouseLookState(sink.Apply);
ml.OnWantCaptureMouseChanged(wantCaptureMouse: false); ml.OnWantCaptureMouseChanged(wantCaptureMouse: false);
@ -88,17 +88,17 @@ public sealed class MmbMouseLookTests
} }
[Fact] [Fact]
public void ApplyDelta_WhileActive_DrivesYawSink() public void ApplyDelta_WhileActive_DrivesMovementAdjustmentSink()
{ {
var sink = new YawSink(); var sink = new AdjustmentSink();
var ml = new MouseLookState(sink.Apply) { SensitivityRadiansPerPixel = 0.01f }; var ml = new MouseLookState(sink.Apply) { SensitivityPerPixel = 0.01f };
ml.Press(0f, 0f, wantCaptureMouse: false); 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 // Sign: dragging right (positive dx) requests TurnRight, represented
// — by the acdream Yaw convention that's negative yaw delta. // by a negative adjustment. Magnitude: 10 * 0.01 * 1.0 = 0.1.
// Magnitude: 10 * 0.01 * 1.0 = 0.1 rad.
Assert.Equal(1, sink.ApplyCount); Assert.Equal(1, sink.ApplyCount);
Assert.Equal(-0.1f, sink.Total, 5); Assert.Equal(-0.1f, sink.Total, 5);
} }
@ -106,7 +106,7 @@ public sealed class MmbMouseLookTests
[Fact] [Fact]
public void ApplyDelta_WhileInactive_DoesNothing() public void ApplyDelta_WhileInactive_DoesNothing()
{ {
var sink = new YawSink(); var sink = new AdjustmentSink();
var ml = new MouseLookState(sink.Apply); var ml = new MouseLookState(sink.Apply);
// Never pressed. // Never pressed.
@ -119,7 +119,7 @@ public sealed class MmbMouseLookTests
[Fact] [Fact]
public void ApplyDelta_AfterRelease_DoesNothing() public void ApplyDelta_AfterRelease_DoesNothing()
{ {
var sink = new YawSink(); var sink = new AdjustmentSink();
var ml = new MouseLookState(sink.Apply); var ml = new MouseLookState(sink.Apply);
ml.Press(0f, 0f, wantCaptureMouse: false); ml.Press(0f, 0f, wantCaptureMouse: false);
ml.Release(); ml.Release();
@ -135,7 +135,7 @@ public sealed class MmbMouseLookTests
// Repeated Press calls must not blow away the originally // Repeated Press calls must not blow away the originally
// captured cursor position — the cursor-restore-on-release // captured cursor position — the cursor-restore-on-release
// path needs the original anchor. // path needs the original anchor.
var sink = new YawSink(); var sink = new AdjustmentSink();
var ml = new MouseLookState(sink.Apply); var ml = new MouseLookState(sink.Apply);
ml.Press(100f, 200f, wantCaptureMouse: false); ml.Press(100f, 200f, wantCaptureMouse: false);
@ -148,19 +148,82 @@ public sealed class MmbMouseLookTests
[Fact] [Fact]
public void ApplyDelta_DriverDrivesCharacterAndCameraInOneSink() public void ApplyDelta_DriverDrivesCharacterAndCameraInOneSink()
{ {
// Combined drive: the test only exposes a single yaw mutator, // The input state owns only filtering/scaling. GameWindow's one sink
// and that's the design — the yaw mutator GameWindow passes // routes the result to PlayerMovementController, whose turn motion
// updates _playerController.Yaw, and the chase camera reads // updates both the character heading and chase-camera follow source.
// the same yaw via ChaseCamera.Update(pos, playerYaw). So a var sink = new AdjustmentSink();
// single sink in this test models both behaviors. var ml = new MouseLookState(sink.Apply) { SensitivityPerPixel = 0.005f };
var sink = new YawSink();
var ml = new MouseLookState(sink.Apply) { SensitivityRadiansPerPixel = 0.005f };
ml.Press(0f, 0f, wantCaptureMouse: false); 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: 4f, extraSensitivity: 0.5f); // -0.01
ml.ApplyDelta(dx: -2f, extraSensitivity: 0.5f); // +0.005 ml.ApplyDelta(dx: -2f, extraSensitivity: 0.5f); // +0.005
Assert.Equal(2, sink.ApplyCount); Assert.Equal(2, sink.ApplyCount);
Assert.Equal(-0.005f, sink.Total, 5); 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 _));
}
} }